feat: show primary copyright match in portal tooltip (#389)

- add CopyrightInfo dataclass with is_flagged and primary_match
- extract most frequent match from copyright scan results
- add copyright_match field to TrackResponse API schema
- update tooltip to show "potential copyright violation: [match]"

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

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub d3ecaf81 31d747e1

Changed files
+83 -26
backend
src
backend
api
tracks
utilities
frontend
src
lib
routes
portal
+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
··· 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
··· 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
··· 45 45 image_url?: string; 46 46 is_liked?: boolean; 47 47 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 48 + copyright_match?: string | null; // "Title by Artist" of primary match 48 49 } 49 50 50 51 export interface User {
+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>