+11
-11
backend/src/backend/api/tracks/listing.py
+11
-11
backend/src/backend/api/tracks/listing.py
···
17
17
from backend.schemas import TrackResponse
18
18
from backend.utilities.aggregations import (
19
19
get_comment_counts,
20
-
get_copyright_flags,
20
+
get_copyright_info,
21
21
get_like_counts,
22
22
)
23
23
···
59
59
result = await db.execute(stmt)
60
60
tracks = result.scalars().all()
61
61
62
-
# batch fetch like, comment counts and copyright flags for all tracks
62
+
# batch fetch like, comment counts and copyright info for all tracks
63
63
track_ids = [track.id for track in tracks]
64
-
like_counts, comment_counts, copyright_flags = await asyncio.gather(
64
+
like_counts, comment_counts, copyright_info = await asyncio.gather(
65
65
get_like_counts(db, track_ids),
66
66
get_comment_counts(db, track_ids),
67
-
get_copyright_flags(db, track_ids),
67
+
get_copyright_info(db, track_ids),
68
68
)
69
69
70
70
# use cached PDS URLs with fallback on failure
···
162
162
liked_track_ids,
163
163
like_counts,
164
164
comment_counts,
165
-
copyright_flags,
165
+
copyright_info,
166
166
)
167
167
for track in tracks
168
168
]
···
187
187
result = await db.execute(stmt)
188
188
tracks = result.scalars().all()
189
189
190
-
# batch fetch copyright flags
190
+
# batch fetch copyright info
191
191
track_ids = [track.id for track in tracks]
192
-
copyright_flags = await get_copyright_flags(db, track_ids)
192
+
copyright_info = await get_copyright_info(db, track_ids)
193
193
194
194
# fetch all track responses concurrently
195
195
track_responses = await asyncio.gather(
196
196
*[
197
-
TrackResponse.from_track(track, copyright_flags=copyright_flags)
197
+
TrackResponse.from_track(track, copyright_info=copyright_info)
198
198
for track in tracks
199
199
]
200
200
)
···
231
231
result = await db.execute(stmt)
232
232
tracks = result.scalars().all()
233
233
234
-
# batch fetch copyright flags
234
+
# batch fetch copyright info
235
235
track_ids = [track.id for track in tracks]
236
-
copyright_flags = await get_copyright_flags(db, track_ids)
236
+
copyright_info = await get_copyright_info(db, track_ids)
237
237
238
238
# fetch all track responses concurrently
239
239
track_responses = await asyncio.gather(
240
240
*[
241
-
TrackResponse.from_track(track, copyright_flags=copyright_flags)
241
+
TrackResponse.from_track(track, copyright_info=copyright_info)
242
242
for track in tracks
243
243
]
244
244
)
+9
-4
backend/src/backend/schemas.py
+9
-4
backend/src/backend/schemas.py
···
5
5
from pydantic import BaseModel
6
6
7
7
from backend.models import Album, Track
8
+
from backend.utilities.aggregations import CopyrightInfo
8
9
9
10
10
11
class AlbumSummary(BaseModel):
···
66
67
copyright_flagged: bool | None = (
67
68
None # None = not scanned, False = clear, True = flagged
68
69
)
70
+
copyright_match: str | None = None # "Title by Artist" of primary match
69
71
70
72
@classmethod
71
73
async def from_track(
···
75
77
liked_track_ids: set[int] | None = None,
76
78
like_counts: dict[int, int] | None = None,
77
79
comment_counts: dict[int, int] | None = None,
78
-
copyright_flags: dict[int, bool] | None = None,
80
+
copyright_info: dict[int, CopyrightInfo] | None = None,
79
81
) -> "TrackResponse":
80
82
"""build track response from Track model.
81
83
···
85
87
liked_track_ids: optional set of liked track IDs for this user
86
88
like_counts: optional dict of track_id -> like_count
87
89
comment_counts: optional dict of track_id -> comment_count
88
-
copyright_flags: optional dict of track_id -> is_flagged (None if not scanned)
90
+
copyright_info: optional dict of track_id -> CopyrightInfo
89
91
"""
90
92
# check if user has liked this track
91
93
is_liked = liked_track_ids is not None and track.id in liked_track_ids
···
121
123
f"&rkey={rkey}"
122
124
)
123
125
124
-
# get copyright flag status (None if not in dict = not scanned)
125
-
copyright_flagged = copyright_flags.get(track.id) if copyright_flags else None
126
+
# get copyright info (None if not in dict = not scanned)
127
+
track_copyright = copyright_info.get(track.id) if copyright_info else None
128
+
copyright_flagged = track_copyright.is_flagged if track_copyright else None
129
+
copyright_match = track_copyright.primary_match if track_copyright else None
126
130
127
131
return cls(
128
132
id=track.id,
···
144
148
comment_count=comment_count,
145
149
album=album_data,
146
150
copyright_flagged=copyright_flagged,
151
+
copyright_match=copyright_match,
147
152
)
+59
-9
backend/src/backend/utilities/aggregations.py
+59
-9
backend/src/backend/utilities/aggregations.py
···
1
1
"""aggregation utilities for efficient batch counting."""
2
2
3
+
from collections import Counter
4
+
from dataclasses import dataclass
5
+
from typing import Any
6
+
3
7
from sqlalchemy import select
4
8
from sqlalchemy.ext.asyncio import AsyncSession
5
9
from sqlalchemy.sql import func
6
10
7
11
from backend.models import CopyrightScan, TrackComment, TrackLike
12
+
13
+
14
+
@dataclass
15
+
class CopyrightInfo:
16
+
"""copyright scan result with match details."""
17
+
18
+
is_flagged: bool
19
+
primary_match: str | None = None # "Title by Artist" for most frequent match
8
20
9
21
10
22
async def get_like_counts(db: AsyncSession, track_ids: list[int]) -> dict[int, int]:
···
54
66
return dict(result.all()) # type: ignore
55
67
56
68
57
-
async def get_copyright_flags(
69
+
async def get_copyright_info(
58
70
db: AsyncSession, track_ids: list[int]
59
-
) -> dict[int, bool]:
60
-
"""get copyright flag status for multiple tracks in a single query.
71
+
) -> dict[int, CopyrightInfo]:
72
+
"""get copyright scan info for multiple tracks in a single query.
61
73
62
74
args:
63
75
db: database session
64
-
track_ids: list of track IDs to get flags for
76
+
track_ids: list of track IDs to get info for
65
77
66
78
returns:
67
-
dict mapping track_id -> is_flagged (only includes scanned tracks)
79
+
dict mapping track_id -> CopyrightInfo (only includes scanned tracks)
68
80
"""
69
81
if not track_ids:
70
82
return {}
71
83
72
-
stmt = select(CopyrightScan.track_id, CopyrightScan.is_flagged).where(
73
-
CopyrightScan.track_id.in_(track_ids)
74
-
)
84
+
stmt = select(
85
+
CopyrightScan.track_id, CopyrightScan.is_flagged, CopyrightScan.matches
86
+
).where(CopyrightScan.track_id.in_(track_ids))
75
87
76
88
result = await db.execute(stmt)
77
-
return dict(result.all()) # type: ignore
89
+
rows = result.all()
90
+
91
+
copyright_info: dict[int, CopyrightInfo] = {}
92
+
for track_id, is_flagged, matches in rows:
93
+
primary_match = _extract_primary_match(matches) if is_flagged else None
94
+
copyright_info[track_id] = CopyrightInfo(
95
+
is_flagged=is_flagged,
96
+
primary_match=primary_match,
97
+
)
98
+
99
+
return copyright_info
100
+
101
+
102
+
def _extract_primary_match(matches: list[dict[str, Any]]) -> str | None:
103
+
"""extract the most frequent match from copyright scan results.
104
+
105
+
args:
106
+
matches: list of match dicts with 'title' and 'artist' keys
107
+
108
+
returns:
109
+
"Title by Artist" string for the most common match, or None
110
+
"""
111
+
if not matches:
112
+
return None
113
+
114
+
# count occurrences of each (title, artist) pair
115
+
match_counts: Counter[tuple[str, str]] = Counter()
116
+
for match in matches:
117
+
title = match.get("title", "").strip()
118
+
artist = match.get("artist", "").strip()
119
+
if title and artist:
120
+
match_counts[(title, artist)] += 1
121
+
122
+
if not match_counts:
123
+
return None
124
+
125
+
# get the most common match
126
+
(title, artist), _ = match_counts.most_common(1)[0]
127
+
return f"{title} by {artist}"
+1
frontend/src/lib/types.ts
+1
frontend/src/lib/types.ts
+3
-2
frontend/src/routes/portal/+page.svelte
+3
-2
frontend/src/routes/portal/+page.svelte
···
980
980
<div class="track-title">
981
981
{track.title}
982
982
{#if track.copyright_flagged}
983
+
{@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'}
983
984
{#if track.atproto_record_url}
984
985
<a
985
986
href={track.atproto_record_url}
986
987
target="_blank"
987
988
rel="noopener noreferrer"
988
989
class="copyright-flag"
989
-
title="potential copyright match - click to view record"
990
+
title="{matchText}"
990
991
>
991
992
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
992
993
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
···
995
996
</svg>
996
997
</a>
997
998
{:else}
998
-
<span class="copyright-flag" title="potential copyright match detected">
999
+
<span class="copyright-flag" title={matchText}>
999
1000
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1000
1001
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
1001
1002
<line x1="12" y1="9" x2="12" y2="13"></line>