+13
STATUS.md
+13
STATUS.md
···
47
47
48
48
### January 2026
49
49
50
+
#### copyright moderation improvements (PRs #703-704, Jan 2)
51
+
52
+
**per legal advice**, redesigned copyright handling to reduce liability exposure:
53
+
- **disabled auto-labeling** (PR #703): labels are no longer automatically emitted when copyright matches are detected. the system now only flags and notifies, leaving takedown decisions to humans
54
+
- **raised threshold** (PR #703): copyright flag threshold increased from "any match" to configurable score (default 85%). controlled via `MODERATION_COPYRIGHT_SCORE_THRESHOLD` env var
55
+
- **DM notifications** (PR #704): when a track is flagged, both the artist and admin receive BlueSky DMs with details. includes structured error handling for when users have DMs disabled
56
+
- **observability** (PR #704): Logfire spans added to all notification paths (`send_dm`, `copyright_notification`) with error categorization (`dm_blocked`, `network`, `auth`, `unknown`)
57
+
- **notification tracking**: `notified_at` field added to `copyright_scans` table to track which flags have been communicated
58
+
59
+
**why this matters**: DMCA safe harbor requires taking action on notices, not proactively policing. auto-labeling was creating liability by making assertions about copyright status. human review is now required before any takedown action.
60
+
61
+
---
62
+
50
63
#### ATProto OAuth permission sets (PRs #697-698, Jan 1-2)
51
64
52
65
**permission sets enabled** - OAuth now uses `include:fm.plyr.authFullApp` instead of listing individual `repo:` scopes:
+32
backend/alembic/versions/2026_01_02_140513_15472c0b3bb4_add_notified_at_to_copyright_scans.py
+32
backend/alembic/versions/2026_01_02_140513_15472c0b3bb4_add_notified_at_to_copyright_scans.py
···
1
+
"""add notified_at to copyright_scans
2
+
3
+
Revision ID: 15472c0b3bb4
4
+
Revises: 9ee155c078ed
5
+
Create Date: 2026-01-02 14:05:13.306570
6
+
7
+
"""
8
+
9
+
from collections.abc import Sequence
10
+
11
+
import sqlalchemy as sa
12
+
13
+
from alembic import op
14
+
15
+
# revision identifiers, used by Alembic.
16
+
revision: str = "15472c0b3bb4"
17
+
down_revision: str | Sequence[str] | None = "9ee155c078ed"
18
+
branch_labels: str | Sequence[str] | None = None
19
+
depends_on: str | Sequence[str] | None = None
20
+
21
+
22
+
def upgrade() -> None:
23
+
"""Upgrade schema."""
24
+
op.add_column(
25
+
"copyright_scans",
26
+
sa.Column("notified_at", sa.DateTime(timezone=True), nullable=True),
27
+
)
28
+
29
+
30
+
def downgrade() -> None:
31
+
"""Downgrade schema."""
32
+
op.drop_column("copyright_scans", "notified_at")
+30
-3
backend/src/backend/_internal/moderation.py
+30
-3
backend/src/backend/_internal/moderation.py
···
1
1
"""moderation service integration for copyright scanning."""
2
2
3
3
import logging
4
+
from datetime import UTC, datetime
4
5
from typing import Any
5
6
6
7
import logfire
8
+
from sqlalchemy import select
9
+
from sqlalchemy.orm import joinedload
7
10
8
11
from backend._internal.moderation_client import get_moderation_client
12
+
from backend._internal.notifications import notification_service
9
13
from backend.config import settings
10
-
from backend.models import CopyrightScan
14
+
from backend.models import CopyrightScan, Track
11
15
from backend.utilities.database import db_session
12
16
13
17
logger = logging.getLogger(__name__)
···
78
82
match_count=len(scan.matches),
79
83
)
80
84
81
-
# auto-label emission removed - see https://github.com/zzstoatzz/plyr.fm/issues/702
82
-
# labels will be emitted after user notification + grace period (future work)
85
+
# send notification if flagged (see #702)
86
+
if result.is_flagged:
87
+
track = await db.scalar(
88
+
select(Track)
89
+
.options(joinedload(Track.artist))
90
+
.where(Track.id == track_id)
91
+
)
92
+
if track and track.artist:
93
+
(
94
+
artist_result,
95
+
admin_result,
96
+
) = await notification_service.send_copyright_notification(
97
+
track_id=track_id,
98
+
track_title=track.title,
99
+
artist_did=track.artist_did,
100
+
artist_handle=track.artist.handle,
101
+
highest_score=scan.highest_score,
102
+
matches=scan.matches,
103
+
)
104
+
# mark as notified if at least one succeeded
105
+
if (artist_result and artist_result.success) or (
106
+
admin_result and admin_result.success
107
+
):
108
+
scan.notified_at = datetime.now(UTC)
109
+
await db.commit()
83
110
84
111
85
112
async def _emit_copyright_label(
+235
-86
backend/src/backend/_internal/notifications.py
+235
-86
backend/src/backend/_internal/notifications.py
···
1
1
"""notification service for relay events."""
2
2
3
3
import logging
4
+
from dataclasses import dataclass
4
5
6
+
import logfire
5
7
from atproto import AsyncClient, models
6
8
7
9
from backend.config import settings
8
10
from backend.models import Track
9
11
10
12
logger = logging.getLogger(__name__)
13
+
14
+
15
+
@dataclass
16
+
class NotificationResult:
17
+
"""result of a notification attempt."""
18
+
19
+
success: bool
20
+
recipient_did: str
21
+
error: str | None = None
22
+
error_type: str | None = None # "dm_blocked", "network", "auth", "unknown"
11
23
12
24
13
25
class NotificationService:
···
68
80
self.dm_client = None
69
81
self.recipient_did = None
70
82
71
-
async def send_image_flag_notification(
72
-
self,
73
-
image_id: str,
74
-
severity: str,
75
-
categories: list[str],
76
-
context: str,
77
-
):
78
-
"""send notification about a flagged image.
83
+
async def _send_dm_to_did(
84
+
self, recipient_did: str, message_text: str
85
+
) -> NotificationResult:
86
+
"""send a DM to a specific DID.
79
87
80
-
args:
81
-
image_id: R2 storage ID of the flagged image
82
-
severity: severity level (low, medium, high)
83
-
categories: list of violated policy categories
84
-
context: where the image was uploaded (e.g., "track cover", "album cover")
88
+
returns NotificationResult with success status and error details.
85
89
"""
86
-
if not self.dm_client or not self.recipient_did:
87
-
logger.warning(
88
-
"dm client not authenticated or recipient not set, skipping notification"
90
+
if not self.dm_client:
91
+
return NotificationResult(
92
+
success=False,
93
+
recipient_did=recipient_did,
94
+
error="dm client not authenticated",
95
+
error_type="auth",
89
96
)
90
-
return
91
97
92
-
try:
93
-
dm = self.dm_client.chat.bsky.convo
98
+
with logfire.span(
99
+
"send_dm",
100
+
recipient_did=recipient_did,
101
+
message_length=len(message_text),
102
+
) as span:
103
+
try:
104
+
dm = self.dm_client.chat.bsky.convo
94
105
95
-
convo_response = await dm.get_convo_for_members(
96
-
models.ChatBskyConvoGetConvoForMembers.Params(
97
-
members=[self.recipient_did]
106
+
convo_response = await dm.get_convo_for_members(
107
+
models.ChatBskyConvoGetConvoForMembers.Params(
108
+
members=[recipient_did]
109
+
)
98
110
)
99
-
)
100
111
101
-
if not convo_response.convo or not convo_response.convo.id:
102
-
raise ValueError("failed to get conversation ID")
103
-
104
-
convo_id = convo_response.convo.id
112
+
if not convo_response.convo or not convo_response.convo.id:
113
+
span.set_attribute("error_type", "no_convo")
114
+
return NotificationResult(
115
+
success=False,
116
+
recipient_did=recipient_did,
117
+
error="failed to get conversation ID - user may have DMs disabled",
118
+
error_type="dm_blocked",
119
+
)
105
120
106
-
categories_str = ", ".join(categories) if categories else "unspecified"
107
-
message_text = (
108
-
f"🚨 image flagged on {settings.app.name}\n\n"
109
-
f"context: {context}\n"
110
-
f"image_id: {image_id}\n"
111
-
f"severity: {severity}\n"
112
-
f"categories: {categories_str}"
113
-
)
114
-
115
-
await dm.send_message(
116
-
models.ChatBskyConvoSendMessage.Data(
117
-
convo_id=convo_id,
118
-
message=models.ChatBskyConvoDefs.MessageInput(text=message_text),
121
+
await dm.send_message(
122
+
models.ChatBskyConvoSendMessage.Data(
123
+
convo_id=convo_response.convo.id,
124
+
message=models.ChatBskyConvoDefs.MessageInput(
125
+
text=message_text
126
+
),
127
+
)
119
128
)
120
-
)
121
129
122
-
logger.info(f"sent image flag notification for {image_id}")
130
+
span.set_attribute("success", True)
131
+
return NotificationResult(success=True, recipient_did=recipient_did)
123
132
124
-
except Exception:
125
-
logger.exception(f"error sending image flag notification for {image_id}")
133
+
except Exception as e:
134
+
error_str = str(e)
135
+
error_type = "unknown"
126
136
127
-
async def send_track_notification(self, track: Track):
128
-
"""send notification about a new track."""
129
-
if not self.dm_client or not self.recipient_did:
130
-
logger.warning(
131
-
"dm client not authenticated or recipient not set, skipping notification"
132
-
)
133
-
return
137
+
# try to categorize the error
138
+
if "blocked" in error_str.lower() or "not allowed" in error_str.lower():
139
+
error_type = "dm_blocked"
140
+
elif "timeout" in error_str.lower() or "connect" in error_str.lower():
141
+
error_type = "network"
142
+
elif "auth" in error_str.lower() or "401" in error_str:
143
+
error_type = "auth"
134
144
135
-
try:
136
-
# create shortcut to convo methods
137
-
dm = self.dm_client.chat.bsky.convo
145
+
span.set_attribute("error_type", error_type)
146
+
span.set_attribute("error", error_str)
147
+
logger.exception(f"error sending DM to {recipient_did}")
138
148
139
-
# get or create conversation with the target user
140
-
convo_response = await dm.get_convo_for_members(
141
-
models.ChatBskyConvoGetConvoForMembers.Params(
142
-
members=[self.recipient_did]
149
+
return NotificationResult(
150
+
success=False,
151
+
recipient_did=recipient_did,
152
+
error=error_str,
153
+
error_type=error_type,
143
154
)
144
-
)
145
155
146
-
if not convo_response.convo or not convo_response.convo.id:
147
-
raise ValueError("failed to get conversation ID")
156
+
async def send_copyright_notification(
157
+
self,
158
+
track_id: int,
159
+
track_title: str,
160
+
artist_did: str,
161
+
artist_handle: str,
162
+
highest_score: int,
163
+
matches: list[dict],
164
+
) -> tuple[NotificationResult | None, NotificationResult | None]:
165
+
"""send notification about a copyright flag to both artist and admin.
148
166
149
-
convo_id = convo_response.convo.id
167
+
returns (artist_result, admin_result) tuple with details of each attempt.
168
+
"""
169
+
with logfire.span(
170
+
"copyright_notification",
171
+
track_id=track_id,
172
+
track_title=track_title,
173
+
artist_did=artist_did,
174
+
artist_handle=artist_handle,
175
+
highest_score=highest_score,
176
+
match_count=len(matches),
177
+
) as span:
178
+
if not self.dm_client:
179
+
logfire.warn("dm client not authenticated, skipping notification")
180
+
return None, None
150
181
151
-
# format the message with rich information
152
-
artist_handle = track.artist.handle
182
+
# format match info
183
+
match_count = len(matches)
184
+
primary_match = None
185
+
if matches:
186
+
m = matches[0]
187
+
primary_match = (
188
+
f"{m.get('title', 'Unknown')} by {m.get('artist', 'Unknown')}"
189
+
)
153
190
154
-
# only include link if we have a non-localhost frontend URL
191
+
# build track URL if available
155
192
track_url = None
156
193
frontend_url = settings.frontend.url
157
194
if frontend_url and "localhost" not in frontend_url:
158
-
track_url = f"{frontend_url}/track/{track.id}"
195
+
track_url = f"{frontend_url}/track/{track_id}"
159
196
197
+
# message for the artist (uploader)
198
+
artist_message = (
199
+
f"⚠️ copyright notice for your track on {settings.app.name}\n\n"
200
+
f"track: '{track_title}'\n"
201
+
f"match confidence: {highest_score}%\n"
202
+
)
203
+
if primary_match:
204
+
artist_message += f"potential match: {primary_match}\n"
205
+
artist_message += (
206
+
"\nif you believe this is an error, please reply to this message. "
207
+
"otherwise, the track may be removed after review."
208
+
)
209
+
210
+
# message for admin
211
+
admin_message = (
212
+
f"🚨 copyright flag on {settings.app.name}\n\n"
213
+
f"track: '{track_title}'\n"
214
+
f"artist: @{artist_handle}\n"
215
+
f"score: {highest_score}%\n"
216
+
f"matches: {match_count}\n"
217
+
)
218
+
if primary_match:
219
+
admin_message += f"primary: {primary_match}\n"
160
220
if track_url:
161
-
message_text: str = (
162
-
f"🎵 new track on {settings.app.name}!\n\n"
163
-
f"'{track.title}' by @{artist_handle}\n\n"
164
-
f"listen: {track_url}\n"
165
-
f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}"
166
-
)
167
-
else:
168
-
# dev environment - no link
169
-
message_text: str = (
170
-
f"🎵 new track on {settings.app.name}!\n\n"
171
-
f"'{track.title}' by @{artist_handle}\n"
172
-
f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}"
221
+
admin_message += f"\n{track_url}"
222
+
223
+
# send to artist
224
+
artist_result = await self._send_dm_to_did(artist_did, artist_message)
225
+
span.set_attribute("artist_success", artist_result.success)
226
+
if not artist_result.success:
227
+
span.set_attribute("artist_error_type", artist_result.error_type)
228
+
logfire.warn(
229
+
"failed to notify artist",
230
+
artist_handle=artist_handle,
231
+
error_type=artist_result.error_type,
232
+
error=artist_result.error,
173
233
)
174
234
175
-
# send the DM
176
-
await dm.send_message(
177
-
models.ChatBskyConvoSendMessage.Data(
178
-
convo_id=convo_id,
179
-
message=models.ChatBskyConvoDefs.MessageInput(text=message_text),
235
+
# send to admin
236
+
admin_result = None
237
+
if self.recipient_did:
238
+
admin_result = await self._send_dm_to_did(
239
+
self.recipient_did, admin_message
180
240
)
241
+
span.set_attribute("admin_success", admin_result.success)
242
+
if not admin_result.success:
243
+
span.set_attribute("admin_error_type", admin_result.error_type)
244
+
logfire.warn(
245
+
"failed to notify admin",
246
+
error_type=admin_result.error_type,
247
+
error=admin_result.error,
248
+
)
249
+
250
+
# summary
251
+
any_success = artist_result.success or (
252
+
admin_result and admin_result.success
181
253
)
254
+
span.set_attribute("any_success", any_success)
182
255
183
-
logger.info(f"sent notification for track {track.id} to {convo_id}")
256
+
if artist_result.success:
257
+
logfire.info(
258
+
"sent copyright notification to artist",
259
+
artist_handle=artist_handle,
260
+
track_id=track_id,
261
+
)
262
+
if admin_result and admin_result.success:
263
+
logfire.info(
264
+
"sent copyright notification to admin",
265
+
track_id=track_id,
266
+
)
184
267
185
-
except Exception:
186
-
logger.exception(f"error sending notification for track {track.id}")
268
+
return artist_result, admin_result
269
+
270
+
async def send_image_flag_notification(
271
+
self,
272
+
image_id: str,
273
+
severity: str,
274
+
categories: list[str],
275
+
context: str,
276
+
) -> NotificationResult | None:
277
+
"""send notification about a flagged image.
278
+
279
+
args:
280
+
image_id: R2 storage ID of the flagged image
281
+
severity: severity level (low, medium, high)
282
+
categories: list of violated policy categories
283
+
context: where the image was uploaded (e.g., "track cover", "album cover")
284
+
"""
285
+
if not self.recipient_did:
286
+
logger.warning("recipient not set, skipping notification")
287
+
return None
288
+
289
+
categories_str = ", ".join(categories) if categories else "unspecified"
290
+
message_text = (
291
+
f"🚨 image flagged on {settings.app.name}\n\n"
292
+
f"context: {context}\n"
293
+
f"image_id: {image_id}\n"
294
+
f"severity: {severity}\n"
295
+
f"categories: {categories_str}"
296
+
)
297
+
298
+
result = await self._send_dm_to_did(self.recipient_did, message_text)
299
+
if result.success:
300
+
logger.info(f"sent image flag notification for {image_id}")
301
+
return result
302
+
303
+
async def send_track_notification(self, track: Track) -> NotificationResult | None:
304
+
"""send notification about a new track."""
305
+
if not self.recipient_did:
306
+
logger.warning("recipient not set, skipping notification")
307
+
return None
308
+
309
+
artist_handle = track.artist.handle
310
+
311
+
# only include link if we have a non-localhost frontend URL
312
+
track_url = None
313
+
frontend_url = settings.frontend.url
314
+
if frontend_url and "localhost" not in frontend_url:
315
+
track_url = f"{frontend_url}/track/{track.id}"
316
+
317
+
if track_url:
318
+
message_text = (
319
+
f"🎵 new track on {settings.app.name}!\n\n"
320
+
f"'{track.title}' by @{artist_handle}\n\n"
321
+
f"listen: {track_url}\n"
322
+
f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}"
323
+
)
324
+
else:
325
+
# dev environment - no link
326
+
message_text = (
327
+
f"🎵 new track on {settings.app.name}!\n\n"
328
+
f"'{track.title}' by @{artist_handle}\n"
329
+
f"uploaded: {track.created_at.strftime('%b %d at %H:%M UTC')}"
330
+
)
331
+
332
+
result = await self._send_dm_to_did(self.recipient_did, message_text)
333
+
if result.success:
334
+
logger.info(f"sent notification for track {track.id}")
335
+
return result
187
336
188
337
async def shutdown(self):
189
338
"""cleanup resources."""
+7
backend/src/backend/models/copyright_scan.py
+7
backend/src/backend/models/copyright_scan.py
···
56
56
server_default="{}",
57
57
)
58
58
59
+
# notification tracking
60
+
notified_at: Mapped[datetime | None] = mapped_column(
61
+
DateTime(timezone=True),
62
+
nullable=True,
63
+
default=None,
64
+
)
65
+
59
66
__table_args__ = (
60
67
Index("idx_copyright_scans_flagged", "is_flagged"),
61
68
Index("idx_copyright_scans_scanned_at", "scanned_at"),