Merge pull request #704 from zzstoatzz/feat/copyright-notifications

feat: send DM notifications when tracks are copyright flagged

authored by nate nowack and committed by GitHub 31c33167 df0c0dae

+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:
+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
··· 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
··· 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"),