feat: send DM notifications when tracks are copyright flagged

- add send_copyright_notification() to NotificationService
- DM both the artist (uploader) and admin when a track is flagged
- add notified_at field to CopyrightScan model to track notification status
- artist message explains the flag and invites dispute
- admin message includes match details and track link

related to #702

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+165 -3
backend
+24 -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 + notified = await notification_service.send_copyright_notification( 94 + track_id=track_id, 95 + track_title=track.title, 96 + artist_did=track.artist_did, 97 + artist_handle=track.artist.handle, 98 + highest_score=scan.highest_score, 99 + matches=scan.matches, 100 + ) 101 + if notified: 102 + scan.notified_at = datetime.now(UTC) 103 + await db.commit() 83 104 84 105 85 106 async def _emit_copyright_label(
+102
backend/src/backend/_internal/notifications.py
··· 68 68 self.dm_client = None 69 69 self.recipient_did = None 70 70 71 + async def _send_dm_to_did(self, recipient_did: str, message_text: str) -> bool: 72 + """send a DM to a specific DID. 73 + 74 + returns True if sent successfully, False otherwise. 75 + """ 76 + if not self.dm_client: 77 + logger.warning("dm client not authenticated, skipping notification") 78 + return False 79 + 80 + try: 81 + dm = self.dm_client.chat.bsky.convo 82 + 83 + convo_response = await dm.get_convo_for_members( 84 + models.ChatBskyConvoGetConvoForMembers.Params(members=[recipient_did]) 85 + ) 86 + 87 + if not convo_response.convo or not convo_response.convo.id: 88 + raise ValueError("failed to get conversation ID") 89 + 90 + await dm.send_message( 91 + models.ChatBskyConvoSendMessage.Data( 92 + convo_id=convo_response.convo.id, 93 + message=models.ChatBskyConvoDefs.MessageInput(text=message_text), 94 + ) 95 + ) 96 + return True 97 + 98 + except Exception: 99 + logger.exception(f"error sending DM to {recipient_did}") 100 + return False 101 + 102 + async def send_copyright_notification( 103 + self, 104 + track_id: int, 105 + track_title: str, 106 + artist_did: str, 107 + artist_handle: str, 108 + highest_score: int, 109 + matches: list[dict], 110 + ) -> bool: 111 + """send notification about a copyright flag to both artist and admin. 112 + 113 + returns True if at least one notification was sent successfully. 114 + """ 115 + if not self.dm_client: 116 + logger.warning("dm client not authenticated, skipping notification") 117 + return False 118 + 119 + # format match info 120 + match_count = len(matches) 121 + primary_match = None 122 + if matches: 123 + m = matches[0] 124 + primary_match = ( 125 + f"{m.get('title', 'Unknown')} by {m.get('artist', 'Unknown')}" 126 + ) 127 + 128 + # build track URL if available 129 + track_url = None 130 + frontend_url = settings.frontend.url 131 + if frontend_url and "localhost" not in frontend_url: 132 + track_url = f"{frontend_url}/track/{track_id}" 133 + 134 + # message for the artist (uploader) 135 + artist_message = ( 136 + f"⚠️ copyright notice for your track on {settings.app.name}\n\n" 137 + f"track: '{track_title}'\n" 138 + f"match confidence: {highest_score}%\n" 139 + ) 140 + if primary_match: 141 + artist_message += f"potential match: {primary_match}\n" 142 + artist_message += ( 143 + "\nif you believe this is an error, please reply to this message. " 144 + "otherwise, the track may be removed after review." 145 + ) 146 + 147 + # message for admin 148 + admin_message = ( 149 + f"🚨 copyright flag on {settings.app.name}\n\n" 150 + f"track: '{track_title}'\n" 151 + f"artist: @{artist_handle}\n" 152 + f"score: {highest_score}%\n" 153 + f"matches: {match_count}\n" 154 + ) 155 + if primary_match: 156 + admin_message += f"primary: {primary_match}\n" 157 + if track_url: 158 + admin_message += f"\n{track_url}" 159 + 160 + # send to both 161 + artist_sent = await self._send_dm_to_did(artist_did, artist_message) 162 + admin_sent = False 163 + if self.recipient_did: 164 + admin_sent = await self._send_dm_to_did(self.recipient_did, admin_message) 165 + 166 + if artist_sent: 167 + logger.info(f"sent copyright notification to artist {artist_handle}") 168 + if admin_sent: 169 + logger.info(f"sent copyright notification to admin for track {track_id}") 170 + 171 + return artist_sent or admin_sent 172 + 71 173 async def send_image_flag_notification( 72 174 self, 73 175 image_id: str,
+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"),