+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")
+24
-3
backend/src/backend/_internal/moderation.py
+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
+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
+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"),