feat: copyright moderation improvements (#480)

* fix: sync copyright flag resolution from labeler to portal

when an admin marks a track as "false positive" in the moderation UI,
the flag now correctly clears from the user's portal. previously the
backend's copyright_scans table was never updated, leaving stale flags.

the fix uses an idempotent approach - the backend now queries the
labeler for the source of truth rather than requiring a sync/backfill:

moderation service:
- add POST /admin/active-labels endpoint
- returns which URIs have active (non-negated) copyright-violation labels

backend:
- add get_active_copyright_labels() to query the labeler
- update get_copyright_info() to check labeler for pending flags
- lazily update resolution field when labeler confirms resolution
- skip labeler call for already-resolved scans (optimization)

behavior:
- existing resolved flags immediately take effect (no backfill needed)
- fails closed: if labeler unreachable, flags remain visible (safe default)
- lazy DB update reduces future labeler calls for same track

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

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

* feat: add copyright attestation checkbox to upload flow

users must now confirm they have distribution rights before uploading.
includes educational copy about "publicly available ≠ licensed" to reduce
good-faith mistakes (per tigers blood incident retrospective).

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

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

---------

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

authored by zzstoatzz.io Claude and committed by GitHub 726d0b11 badc91f0

Changed files
+634 -70
backend
src
backend
_internal
utilities
tests
frontend
src
routes
upload
moderation
+51
backend/src/backend/_internal/moderation.py
··· 192 192 logger.warning("failed to emit copyright label for %s: %s", uri, e) 193 193 194 194 195 + async def get_active_copyright_labels(uris: list[str]) -> set[str]: 196 + """check which URIs have active (non-negated) copyright-violation labels. 197 + 198 + queries the moderation service's labeler to determine which tracks are 199 + still actively flagged. this is the source of truth for flag status. 200 + 201 + args: 202 + uris: list of AT URIs to check 203 + 204 + returns: 205 + set of URIs that are still actively flagged 206 + 207 + note: 208 + fails closed (returns all URIs as active) if moderation service is unreachable 209 + to avoid accidentally hiding real violations. 210 + """ 211 + if not uris: 212 + return set() 213 + 214 + if not settings.moderation.enabled: 215 + logger.debug("moderation disabled, treating all as active") 216 + return set(uris) 217 + 218 + if not settings.moderation.auth_token: 219 + logger.warning("MODERATION_AUTH_TOKEN not set, treating all as active") 220 + return set(uris) 221 + 222 + try: 223 + async with httpx.AsyncClient( 224 + timeout=httpx.Timeout(settings.moderation.timeout_seconds) 225 + ) as client: 226 + response = await client.post( 227 + f"{settings.moderation.labeler_url}/admin/active-labels", 228 + json={"uris": uris}, 229 + headers={"X-Moderation-Key": settings.moderation.auth_token}, 230 + ) 231 + response.raise_for_status() 232 + data = response.json() 233 + active = set(data.get("active_uris", [])) 234 + logfire.debug( 235 + "checked active copyright labels", 236 + total_uris=len(uris), 237 + active_count=len(active), 238 + ) 239 + return active 240 + except Exception as e: 241 + # fail closed: if we can't confirm resolution, treat as active 242 + logger.warning("failed to check active labels, treating all as active: %s", e) 243 + return set(uris) 244 + 245 + 195 246 async def _store_scan_error(track_id: int, error: str) -> None: 196 247 """store a scan error as a clear result. 197 248
+87 -11
backend/src/backend/utilities/aggregations.py
··· 1 1 """aggregation utilities for efficient batch counting.""" 2 2 3 + import logging 3 4 from collections import Counter 4 5 from dataclasses import dataclass 6 + from datetime import UTC, datetime 5 7 from typing import Any 6 8 7 - from sqlalchemy import select 9 + from sqlalchemy import select, update 8 10 from sqlalchemy.ext.asyncio import AsyncSession 9 11 from sqlalchemy.sql import func 10 12 11 - from backend.models import CopyrightScan, Tag, TrackComment, TrackLike, TrackTag 13 + from backend.models import CopyrightScan, Tag, Track, TrackComment, TrackLike, TrackTag 14 + 15 + logger = logging.getLogger(__name__) 12 16 13 17 14 18 @dataclass ··· 71 75 ) -> dict[int, CopyrightInfo]: 72 76 """get copyright scan info for multiple tracks in a single query. 73 77 78 + checks the moderation service's labeler for the true resolution status. 79 + if a track was resolved (negation label exists), treats it as not flagged 80 + and lazily updates the backend's resolution field. 81 + 74 82 args: 75 83 db: database session 76 84 track_ids: list of track IDs to get info for ··· 81 89 if not track_ids: 82 90 return {} 83 91 84 - stmt = select( 85 - CopyrightScan.track_id, CopyrightScan.is_flagged, CopyrightScan.matches 86 - ).where(CopyrightScan.track_id.in_(track_ids)) 92 + # get scans with track AT URIs for labeler lookup 93 + stmt = ( 94 + select( 95 + CopyrightScan.id, 96 + CopyrightScan.track_id, 97 + CopyrightScan.is_flagged, 98 + CopyrightScan.matches, 99 + CopyrightScan.resolution, 100 + Track.atproto_record_uri, 101 + ) 102 + .join(Track, CopyrightScan.track_id == Track.id) 103 + .where(CopyrightScan.track_id.in_(track_ids)) 104 + ) 87 105 88 106 result = await db.execute(stmt) 89 107 rows = result.all() 90 108 109 + # separate flagged scans that need labeler check vs already resolved 110 + needs_labeler_check: list[ 111 + tuple[int, int, str, list] 112 + ] = [] # scan_id, track_id, uri, matches 91 113 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 - ) 114 + 115 + for scan_id, track_id, is_flagged, matches, resolution, uri in rows: 116 + if not is_flagged or resolution is not None: 117 + # not flagged or already resolved - no need to check labeler 118 + copyright_info[track_id] = CopyrightInfo( 119 + is_flagged=False if resolution else is_flagged, 120 + primary_match=_extract_primary_match(matches) 121 + if is_flagged and not resolution 122 + else None, 123 + ) 124 + elif uri: 125 + # flagged with no resolution - need to check labeler 126 + needs_labeler_check.append((scan_id, track_id, uri, matches)) 127 + else: 128 + # flagged but no AT URI - can't check labeler, treat as flagged 129 + copyright_info[track_id] = CopyrightInfo( 130 + is_flagged=True, 131 + primary_match=_extract_primary_match(matches), 132 + ) 133 + 134 + # check labeler for tracks that need it 135 + if needs_labeler_check: 136 + from backend._internal.moderation import get_active_copyright_labels 137 + 138 + uris = [uri for _, _, uri, _ in needs_labeler_check] 139 + active_uris = await get_active_copyright_labels(uris) 140 + 141 + # process results and lazily update DB for resolved tracks 142 + resolved_scan_ids: list[int] = [] 143 + for scan_id, track_id, uri, matches in needs_labeler_check: 144 + if uri in active_uris: 145 + # still actively flagged 146 + copyright_info[track_id] = CopyrightInfo( 147 + is_flagged=True, 148 + primary_match=_extract_primary_match(matches), 149 + ) 150 + else: 151 + # resolved in labeler - treat as not flagged 152 + copyright_info[track_id] = CopyrightInfo( 153 + is_flagged=False, 154 + primary_match=None, 155 + ) 156 + resolved_scan_ids.append(scan_id) 157 + 158 + # lazily update resolution for newly discovered resolved scans 159 + if resolved_scan_ids: 160 + try: 161 + await db.execute( 162 + update(CopyrightScan) 163 + .where(CopyrightScan.id.in_(resolved_scan_ids)) 164 + .values(resolution="dismissed", reviewed_at=datetime.now(UTC)) 165 + ) 166 + await db.commit() 167 + logger.info( 168 + "lazily updated %d copyright scans as dismissed", 169 + len(resolved_scan_ids), 170 + ) 171 + except Exception as e: 172 + logger.warning("failed to lazily update copyright resolutions: %s", e) 173 + await db.rollback() 98 174 99 175 return copyright_info 100 176
+88
backend/tests/test_moderation.py
··· 10 10 from backend._internal.moderation import ( 11 11 _call_moderation_service, 12 12 _store_scan_result, 13 + get_active_copyright_labels, 13 14 scan_track_for_copyright, 14 15 ) 15 16 from backend.models import Artist, CopyrightScan, Track ··· 375 376 376 377 assert scan.is_flagged is True 377 378 assert scan.highest_score == 85 379 + 380 + 381 + # tests for get_active_copyright_labels 382 + 383 + 384 + async def test_get_active_copyright_labels_empty_list() -> None: 385 + """test that empty URI list returns empty set.""" 386 + result = await get_active_copyright_labels([]) 387 + assert result == set() 388 + 389 + 390 + async def test_get_active_copyright_labels_disabled() -> None: 391 + """test that disabled moderation returns all URIs as active (fail closed).""" 392 + uris = ["at://did:plc:test/fm.plyr.track/1", "at://did:plc:test/fm.plyr.track/2"] 393 + 394 + with patch("backend._internal.moderation.settings") as mock_settings: 395 + mock_settings.moderation.enabled = False 396 + 397 + result = await get_active_copyright_labels(uris) 398 + 399 + assert result == set(uris) 400 + 401 + 402 + async def test_get_active_copyright_labels_no_auth_token() -> None: 403 + """test that missing auth token returns all URIs as active (fail closed).""" 404 + uris = ["at://did:plc:test/fm.plyr.track/1"] 405 + 406 + with patch("backend._internal.moderation.settings") as mock_settings: 407 + mock_settings.moderation.enabled = True 408 + mock_settings.moderation.auth_token = "" 409 + 410 + result = await get_active_copyright_labels(uris) 411 + 412 + assert result == set(uris) 413 + 414 + 415 + async def test_get_active_copyright_labels_success() -> None: 416 + """test successful call to labeler returns active URIs.""" 417 + uris = [ 418 + "at://did:plc:test/fm.plyr.track/1", 419 + "at://did:plc:test/fm.plyr.track/2", 420 + "at://did:plc:test/fm.plyr.track/3", 421 + ] 422 + 423 + mock_response = Mock() 424 + mock_response.json.return_value = { 425 + "active_uris": ["at://did:plc:test/fm.plyr.track/1"] # only one is active 426 + } 427 + mock_response.raise_for_status.return_value = None 428 + 429 + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 430 + mock_post.return_value = mock_response 431 + 432 + with patch("backend._internal.moderation.settings") as mock_settings: 433 + mock_settings.moderation.enabled = True 434 + mock_settings.moderation.auth_token = "test-token" 435 + mock_settings.moderation.labeler_url = "https://labeler.example.com" 436 + mock_settings.moderation.timeout_seconds = 10 437 + 438 + result = await get_active_copyright_labels(uris) 439 + 440 + # only the active URI should be in the result 441 + assert result == {"at://did:plc:test/fm.plyr.track/1"} 442 + 443 + # verify correct endpoint was called 444 + call_kwargs = mock_post.call_args 445 + assert "/admin/active-labels" in str(call_kwargs) 446 + assert call_kwargs.kwargs["json"] == {"uris": uris} 447 + 448 + 449 + async def test_get_active_copyright_labels_service_error() -> None: 450 + """test that service errors return all URIs as active (fail closed).""" 451 + uris = ["at://did:plc:test/fm.plyr.track/1", "at://did:plc:test/fm.plyr.track/2"] 452 + 453 + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 454 + mock_post.side_effect = httpx.ConnectError("connection failed") 455 + 456 + with patch("backend._internal.moderation.settings") as mock_settings: 457 + mock_settings.moderation.enabled = True 458 + mock_settings.moderation.auth_token = "test-token" 459 + mock_settings.moderation.labeler_url = "https://labeler.example.com" 460 + mock_settings.moderation.timeout_seconds = 10 461 + 462 + result = await get_active_copyright_labels(uris) 463 + 464 + # should fail closed - all URIs treated as active 465 + assert result == set(uris)
+146 -2
backend/tests/utilities/test_aggregations.py
··· 1 1 """tests for aggregation utilities.""" 2 2 3 + from unittest.mock import AsyncMock, patch 4 + 3 5 import pytest 6 + from sqlalchemy import select 4 7 from sqlalchemy.ext.asyncio import AsyncSession 5 8 6 - from backend.models import Artist, Track, TrackLike 7 - from backend.utilities.aggregations import get_like_counts 9 + from backend.models import Artist, CopyrightScan, Track, TrackLike 10 + from backend.utilities.aggregations import get_copyright_info, get_like_counts 8 11 9 12 10 13 @pytest.fixture ··· 103 106 """test getting like count for a single track.""" 104 107 counts = await get_like_counts(db_session, [test_tracks[0].id]) 105 108 assert counts[test_tracks[0].id] == 2 109 + 110 + 111 + # tests for get_copyright_info 112 + 113 + 114 + @pytest.fixture 115 + async def flagged_track(db_session: AsyncSession) -> Track: 116 + """create a track with a copyright flag.""" 117 + artist = Artist( 118 + did="did:plc:flagged", 119 + handle="flagged.bsky.social", 120 + display_name="Flagged Artist", 121 + ) 122 + db_session.add(artist) 123 + await db_session.flush() 124 + 125 + track = Track( 126 + title="Flagged Track", 127 + artist_did=artist.did, 128 + file_id="flagged_file", 129 + file_type="mp3", 130 + atproto_record_uri="at://did:plc:flagged/fm.plyr.track/abc123", 131 + ) 132 + db_session.add(track) 133 + await db_session.commit() 134 + await db_session.refresh(track) 135 + 136 + # add copyright scan with flag 137 + scan = CopyrightScan( 138 + track_id=track.id, 139 + is_flagged=True, 140 + highest_score=90, 141 + matches=[{"title": "Copyrighted Song", "artist": "Famous Artist", "score": 90}], 142 + ) 143 + db_session.add(scan) 144 + await db_session.commit() 145 + 146 + return track 147 + 148 + 149 + async def test_get_copyright_info_already_resolved( 150 + db_session: AsyncSession, flagged_track: Track 151 + ) -> None: 152 + """test that already resolved scans are treated as not flagged.""" 153 + # update scan to have resolution set 154 + scan = await db_session.scalar( 155 + select(CopyrightScan).where(CopyrightScan.track_id == flagged_track.id) 156 + ) 157 + assert scan is not None 158 + scan.resolution = "dismissed" 159 + await db_session.commit() 160 + 161 + # should NOT call labeler since resolution is already set 162 + with patch( 163 + "backend._internal.moderation.get_active_copyright_labels", 164 + new_callable=AsyncMock, 165 + ) as mock_labeler: 166 + result = await get_copyright_info(db_session, [flagged_track.id]) 167 + 168 + # labeler should not be called for already-resolved scans 169 + mock_labeler.assert_not_called() 170 + 171 + # track should show as not flagged 172 + assert flagged_track.id in result 173 + assert result[flagged_track.id].is_flagged is False 174 + 175 + 176 + async def test_get_copyright_info_checks_labeler_for_pending( 177 + db_session: AsyncSession, flagged_track: Track 178 + ) -> None: 179 + """test that pending flagged scans query the labeler.""" 180 + # mock labeler returning this URI as active (still flagged) 181 + with patch( 182 + "backend._internal.moderation.get_active_copyright_labels", 183 + new_callable=AsyncMock, 184 + ) as mock_labeler: 185 + mock_labeler.return_value = {flagged_track.atproto_record_uri} 186 + 187 + result = await get_copyright_info(db_session, [flagged_track.id]) 188 + 189 + # labeler should be called 190 + mock_labeler.assert_called_once() 191 + call_args = mock_labeler.call_args[0][0] 192 + assert flagged_track.atproto_record_uri in call_args 193 + 194 + # track should still show as flagged 195 + assert flagged_track.id in result 196 + assert result[flagged_track.id].is_flagged is True 197 + assert result[flagged_track.id].primary_match == "Copyrighted Song by Famous Artist" 198 + 199 + 200 + async def test_get_copyright_info_resolved_in_labeler( 201 + db_session: AsyncSession, flagged_track: Track 202 + ) -> None: 203 + """test that labeler resolution clears the flag and updates DB.""" 204 + # mock labeler returning empty set (all resolved) 205 + with patch( 206 + "backend._internal.moderation.get_active_copyright_labels", 207 + new_callable=AsyncMock, 208 + ) as mock_labeler: 209 + mock_labeler.return_value = set() # not active = resolved 210 + 211 + result = await get_copyright_info(db_session, [flagged_track.id]) 212 + 213 + # track should show as not flagged 214 + assert flagged_track.id in result 215 + assert result[flagged_track.id].is_flagged is False 216 + 217 + # verify lazy update: resolution should be set in DB 218 + scan = await db_session.scalar( 219 + select(CopyrightScan).where(CopyrightScan.track_id == flagged_track.id) 220 + ) 221 + assert scan is not None 222 + assert scan.resolution == "dismissed" 223 + assert scan.reviewed_at is not None 224 + 225 + 226 + async def test_get_copyright_info_empty_list(db_session: AsyncSession) -> None: 227 + """test that empty track list returns empty dict.""" 228 + result = await get_copyright_info(db_session, []) 229 + assert result == {} 230 + 231 + 232 + async def test_get_copyright_info_no_scan( 233 + db_session: AsyncSession, test_tracks: list[Track] 234 + ) -> None: 235 + """test that tracks without copyright scans are not included.""" 236 + # test_tracks fixture doesn't create copyright scans 237 + track_ids = [track.id for track in test_tracks] 238 + 239 + with patch( 240 + "backend._internal.moderation.get_active_copyright_labels", 241 + new_callable=AsyncMock, 242 + ) as mock_labeler: 243 + result = await get_copyright_info(db_session, track_ids) 244 + 245 + # labeler should not be called since no flagged tracks 246 + mock_labeler.assert_not_called() 247 + 248 + # no tracks should be in result since none have scans 249 + assert result == {}
+187 -57
frontend/src/routes/upload/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { goto } from '$app/navigation'; 4 - import Header from '$lib/components/Header.svelte'; 5 - import HandleSearch from '$lib/components/HandleSearch.svelte'; 6 - import AlbumSelect from '$lib/components/AlbumSelect.svelte'; 7 - import WaveLoading from '$lib/components/WaveLoading.svelte'; 8 - import TagInput from '$lib/components/TagInput.svelte'; 9 - import type { FeaturedArtist, AlbumSummary } from '$lib/types'; 10 - import { API_URL, getServerConfig } from '$lib/config'; 11 - import { uploader } from '$lib/uploader.svelte'; 12 - import { toast } from '$lib/toast.svelte'; 13 - import { auth } from '$lib/auth.svelte'; 2 + import { onMount } from "svelte"; 3 + import { goto } from "$app/navigation"; 4 + import Header from "$lib/components/Header.svelte"; 5 + import HandleSearch from "$lib/components/HandleSearch.svelte"; 6 + import AlbumSelect from "$lib/components/AlbumSelect.svelte"; 7 + import WaveLoading from "$lib/components/WaveLoading.svelte"; 8 + import TagInput from "$lib/components/TagInput.svelte"; 9 + import type { FeaturedArtist, AlbumSummary } from "$lib/types"; 10 + import { API_URL, getServerConfig } from "$lib/config"; 11 + import { uploader } from "$lib/uploader.svelte"; 12 + import { toast } from "$lib/toast.svelte"; 13 + import { auth } from "$lib/auth.svelte"; 14 14 15 15 // browser-compatible audio formats only 16 - const ACCEPTED_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.m4a']; 17 - const ACCEPTED_AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/wav', 'audio/mp4']; 18 - const FILE_INPUT_ACCEPT = [...ACCEPTED_AUDIO_EXTENSIONS, ...ACCEPTED_AUDIO_MIME_TYPES].join(','); 16 + const ACCEPTED_AUDIO_EXTENSIONS = [".mp3", ".wav", ".m4a"]; 17 + const ACCEPTED_AUDIO_MIME_TYPES = ["audio/mpeg", "audio/wav", "audio/mp4"]; 18 + const FILE_INPUT_ACCEPT = [ 19 + ...ACCEPTED_AUDIO_EXTENSIONS, 20 + ...ACCEPTED_AUDIO_MIME_TYPES, 21 + ].join(","); 19 22 20 23 function isSupportedAudioFile(name: string): boolean { 21 - const dotIndex = name.lastIndexOf('.'); 24 + const dotIndex = name.lastIndexOf("."); 22 25 if (dotIndex === -1) return false; 23 26 const ext = name.slice(dotIndex).toLowerCase(); 24 27 return ACCEPTED_AUDIO_EXTENSIONS.includes(ext); ··· 27 30 let loading = $state(true); 28 31 29 32 // upload form fields 30 - let title = $state(''); 31 - let albumTitle = $state(''); 33 + let title = $state(""); 34 + let albumTitle = $state(""); 32 35 let file = $state<File | null>(null); 33 36 let imageFile = $state<File | null>(null); 34 37 let featuredArtists = $state<FeaturedArtist[]>([]); 35 38 let uploadTags = $state<string[]>([]); 36 39 let hasUnresolvedFeaturesInput = $state(false); 40 + let attestedRights = $state(false); 37 41 38 42 // albums for selection 39 43 let albums = $state<AlbumSummary[]>([]); ··· 41 45 onMount(async () => { 42 46 // wait for auth to finish loading 43 47 while (auth.loading) { 44 - await new Promise(resolve => setTimeout(resolve, 50)); 48 + await new Promise((resolve) => setTimeout(resolve, 50)); 45 49 } 46 50 47 51 if (!auth.isAuthenticated) { 48 - goto('/login'); 52 + goto("/login"); 49 53 return; 50 54 } 51 55 ··· 56 60 async function loadMyAlbums() { 57 61 if (!auth.user) return; 58 62 try { 59 - const response = await fetch(`${API_URL}/albums/${auth.user.handle}`); 63 + const response = await fetch( 64 + `${API_URL}/albums/${auth.user.handle}`, 65 + ); 60 66 if (response.ok) { 61 67 const data = await response.json(); 62 68 albums = data.albums; 63 69 } 64 70 } catch (_e) { 65 - console.error('failed to load albums:', _e); 71 + console.error("failed to load albums:", _e); 66 72 } 67 73 } 68 74 ··· 78 84 const tagsToUpload = [...uploadTags]; 79 85 80 86 const clearForm = () => { 81 - title = ''; 82 - albumTitle = ''; 87 + title = ""; 88 + albumTitle = ""; 83 89 file = null; 84 90 imageFile = null; 85 91 featuredArtists = []; 86 92 uploadTags = []; 93 + attestedRights = false; 87 94 88 - const fileInput = document.getElementById('file-input') as HTMLInputElement; 89 - if (fileInput) fileInput.value = ''; 90 - const imageInput = document.getElementById('image-input') as HTMLInputElement; 91 - if (imageInput) imageInput.value = ''; 95 + const fileInput = document.getElementById( 96 + "file-input", 97 + ) as HTMLInputElement; 98 + if (fileInput) fileInput.value = ""; 99 + const imageInput = document.getElementById( 100 + "image-input", 101 + ) as HTMLInputElement; 102 + if (imageInput) imageInput.value = ""; 92 103 }; 93 104 94 105 uploader.upload( ··· 105 116 onSuccess: () => { 106 117 clearForm(); 107 118 }, 108 - onError: () => {} 109 - } 119 + onError: () => {}, 120 + }, 110 121 ); 111 122 } 112 123 ··· 115 126 if (target.files && target.files[0]) { 116 127 const selected = target.files[0]; 117 128 if (!isSupportedAudioFile(selected.name)) { 118 - toast.error(`unsupported file type. supported: ${ACCEPTED_AUDIO_EXTENSIONS.join(', ')}`); 119 - target.value = ''; 129 + toast.error( 130 + `unsupported file type. supported: ${ACCEPTED_AUDIO_EXTENSIONS.join(", ")}`, 131 + ); 132 + target.value = ""; 120 133 file = null; 121 134 return; 122 135 } ··· 125 138 const config = await getServerConfig(); 126 139 const sizeMB = selected.size / (1024 * 1024); 127 140 if (sizeMB > config.max_upload_size_mb) { 128 - toast.error(`audio file too large (${sizeMB.toFixed(1)}MB). max: ${config.max_upload_size_mb}MB`); 129 - target.value = ''; 141 + toast.error( 142 + `audio file too large (${sizeMB.toFixed(1)}MB). max: ${config.max_upload_size_mb}MB`, 143 + ); 144 + target.value = ""; 130 145 file = null; 131 146 return; 132 147 } 133 148 } catch (_e) { 134 - console.error('failed to validate file size:', _e); 149 + console.error("failed to validate file size:", _e); 135 150 } 136 151 137 152 file = selected; ··· 147 162 const config = await getServerConfig(); 148 163 const sizeMB = selected.size / (1024 * 1024); 149 164 if (sizeMB > config.max_image_size_mb) { 150 - toast.error(`image too large (${sizeMB.toFixed(1)}MB). max: ${config.max_image_size_mb}MB`); 151 - target.value = ''; 165 + toast.error( 166 + `image too large (${sizeMB.toFixed(1)}MB). max: ${config.max_image_size_mb}MB`, 167 + ); 168 + target.value = ""; 152 169 imageFile = null; 153 170 return; 154 171 } 155 172 } catch (_e) { 156 - console.error('failed to validate image size:', _e); 173 + console.error("failed to validate image size:", _e); 157 174 } 158 175 159 176 imageFile = selected; ··· 162 179 163 180 async function logout() { 164 181 await auth.logout(); 165 - window.location.href = '/'; 182 + window.location.href = "/"; 166 183 } 167 184 </script> 168 185 ··· 175 192 <WaveLoading size="lg" message="loading..." /> 176 193 </div> 177 194 {:else} 178 - <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={logout} /> 195 + <Header 196 + user={auth.user} 197 + isAuthenticated={auth.isAuthenticated} 198 + onLogout={logout} 199 + /> 179 200 <main> 180 201 <div class="section-header"> 181 202 <h2>upload track</h2> ··· 195 216 196 217 <div class="form-group"> 197 218 <label for="album">album (optional)</label> 198 - <AlbumSelect 199 - {albums} 200 - bind:value={albumTitle} 201 - /> 219 + <AlbumSelect {albums} bind:value={albumTitle} /> 202 220 </div> 203 221 204 222 <div class="form-group"> ··· 206 224 <HandleSearch 207 225 bind:selected={featuredArtists} 208 226 bind:hasUnresolvedInput={hasUnresolvedFeaturesInput} 209 - onAdd={(artist) => { featuredArtists = [...featuredArtists, artist]; }} 210 - onRemove={(did) => { featuredArtists = featuredArtists.filter(a => a.did !== did); }} 227 + onAdd={(artist) => { 228 + featuredArtists = [...featuredArtists, artist]; 229 + }} 230 + onRemove={(did) => { 231 + featuredArtists = featuredArtists.filter( 232 + (a) => a.did !== did, 233 + ); 234 + }} 211 235 /> 212 236 </div> 213 237 ··· 215 239 <label for="upload-tags">tags (optional)</label> 216 240 <TagInput 217 241 tags={uploadTags} 218 - onAdd={(tag) => { uploadTags = [...uploadTags, tag]; }} 219 - onRemove={(tag) => { uploadTags = uploadTags.filter(t => t !== tag); }} 242 + onAdd={(tag) => { 243 + uploadTags = [...uploadTags, tag]; 244 + }} 245 + onRemove={(tag) => { 246 + uploadTags = uploadTags.filter((t) => t !== tag); 247 + }} 220 248 placeholder="type to search tags..." 221 249 /> 222 250 </div> ··· 232 260 /> 233 261 <p class="format-hint">supported: mp3, wav, m4a</p> 234 262 {#if file} 235 - <p class="file-info">{file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)</p> 263 + <p class="file-info"> 264 + {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) 265 + </p> 236 266 {/if} 237 267 </div> 238 268 ··· 246 276 /> 247 277 <p class="format-hint">supported: jpg, png, webp, gif</p> 248 278 {#if imageFile} 249 - <p class="file-info">{imageFile.name} ({(imageFile.size / 1024 / 1024).toFixed(2)} MB)</p> 279 + <p class="file-info"> 280 + {imageFile.name} ({( 281 + imageFile.size / 282 + 1024 / 283 + 1024 284 + ).toFixed(2)} MB) 285 + </p> 250 286 {/if} 251 287 </div> 252 288 253 - <button type="submit" disabled={!file || hasUnresolvedFeaturesInput} class="upload-btn" title={hasUnresolvedFeaturesInput ? "please select or clear featured artist" : ""}> 289 + <div class="form-group attestation"> 290 + <label class="checkbox-label"> 291 + <input 292 + type="checkbox" 293 + bind:checked={attestedRights} 294 + required 295 + /> 296 + <span class="checkbox-text"> 297 + I have the right to distribute this content, I am not 298 + knowingly infringing on copyright or otherwise stealing 299 + from artists. 300 + </span> 301 + </label> 302 + <p class="attestation-note"> 303 + Content appearing on other platforms (YouTube, SoundCloud, 304 + Internet Archive, etc.) does not mean it's licensed for 305 + redistribution. You should own the rights or have explicit 306 + permission, or the content may be removed to keep plyr.fm in 307 + compliance. For any questions or concerns, please DM 308 + <a 309 + href="https://bsky.app/profile/zzstoatzz.io" 310 + target="_blank" 311 + rel="noopener">@zzstoatzz.io</a 312 + > :) have a nice day! 313 + </p> 314 + </div> 315 + 316 + <button 317 + type="submit" 318 + disabled={!file || 319 + hasUnresolvedFeaturesInput || 320 + !attestedRights} 321 + class="upload-btn" 322 + title={hasUnresolvedFeaturesInput 323 + ? "please select or clear featured artist" 324 + : !attestedRights 325 + ? "please confirm you have distribution rights" 326 + : ""} 327 + > 254 328 <span>upload track</span> 255 329 </button> 256 330 </form> ··· 271 345 main { 272 346 max-width: 800px; 273 347 margin: 0 auto; 274 - padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)); 348 + padding: 0 1rem 349 + calc( 350 + var(--player-height, 120px) + 2rem + 351 + env(safe-area-inset-bottom, 0px) 352 + ); 275 353 } 276 354 277 355 .section-header { ··· 307 385 font-size: 0.9rem; 308 386 } 309 387 310 - input[type='text'] { 388 + input[type="text"] { 311 389 width: 100%; 312 390 padding: 0.75rem; 313 391 background: var(--bg-primary); ··· 319 397 transition: all 0.2s; 320 398 } 321 399 322 - input[type='text']:focus { 400 + input[type="text"]:focus { 323 401 outline: none; 324 402 border-color: var(--accent); 325 403 } 326 404 327 - input[type='file'] { 405 + input[type="file"] { 328 406 width: 100%; 329 407 padding: 0.75rem; 330 408 background: var(--bg-primary); ··· 365 443 button:hover:not(:disabled) { 366 444 background: var(--accent-hover); 367 445 transform: translateY(-1px); 368 - box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); 446 + box-shadow: 0 4px 12px 447 + color-mix(in srgb, var(--accent) 30%, transparent); 369 448 } 370 449 371 450 button:disabled { ··· 385 464 gap: 0.5rem; 386 465 } 387 466 467 + .attestation { 468 + background: var(--bg-primary); 469 + padding: 1rem; 470 + border-radius: 4px; 471 + border: 1px solid var(--border-default); 472 + } 473 + 474 + .checkbox-label { 475 + display: flex; 476 + align-items: flex-start; 477 + gap: 0.75rem; 478 + cursor: pointer; 479 + margin-bottom: 0; 480 + } 481 + 482 + .checkbox-label input[type="checkbox"] { 483 + width: 1.25rem; 484 + height: 1.25rem; 485 + margin-top: 0.1rem; 486 + flex-shrink: 0; 487 + accent-color: var(--accent); 488 + cursor: pointer; 489 + } 490 + 491 + .checkbox-text { 492 + font-size: 0.95rem; 493 + color: var(--text-primary); 494 + line-height: 1.4; 495 + } 496 + 497 + .attestation-note { 498 + margin-top: 0.75rem; 499 + margin-left: 2rem; 500 + font-size: 0.8rem; 501 + color: var(--text-tertiary); 502 + line-height: 1.4; 503 + } 504 + 505 + .attestation-note a { 506 + color: var(--accent); 507 + text-decoration: none; 508 + } 509 + 510 + .attestation-note a:hover { 511 + text-decoration: underline; 512 + } 513 + 388 514 @media (max-width: 768px) { 389 515 main { 390 - padding: 0 0.75rem calc(var(--player-height, 120px) + 1.5rem + env(safe-area-inset-bottom, 0px)); 516 + padding: 0 0.75rem 517 + calc( 518 + var(--player-height, 120px) + 1.5rem + 519 + env(safe-area-inset-bottom, 0px) 520 + ); 391 521 } 392 522 393 523 form {
+33
moderation/src/admin.rs
··· 92 92 pub message: String, 93 93 } 94 94 95 + /// Request to check which URIs have active labels. 96 + #[derive(Debug, Deserialize)] 97 + pub struct ActiveLabelsRequest { 98 + pub uris: Vec<String>, 99 + } 100 + 101 + /// Response with active (non-negated) URIs. 102 + #[derive(Debug, Serialize)] 103 + pub struct ActiveLabelsResponse { 104 + pub active_uris: Vec<String>, 105 + } 106 + 95 107 /// List all flagged tracks - returns JSON for API, HTML for htmx. 96 108 pub async fn list_flagged( 97 109 State(state): State<AppState>, ··· 214 226 html, 215 227 ) 216 228 .into_response()) 229 + } 230 + 231 + /// Get which URIs have active (non-negated) copyright-violation labels. 232 + /// 233 + /// Used by the backend to determine which tracks are still flagged. 234 + pub async fn get_active_labels( 235 + State(state): State<AppState>, 236 + Json(request): Json<ActiveLabelsRequest>, 237 + ) -> Result<Json<ActiveLabelsResponse>, AppError> { 238 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 239 + 240 + tracing::debug!(uri_count = request.uris.len(), "checking active labels"); 241 + 242 + let active_uris = db.get_active_labels(&request.uris).await?; 243 + 244 + tracing::debug!( 245 + active_count = active_uris.len(), 246 + "returning active labels" 247 + ); 248 + 249 + Ok(Json(ActiveLabelsResponse { active_uris })) 217 250 } 218 251 219 252 /// Store context for a label (for backfill without re-emitting labels).
+41
moderation/src/db.rs
··· 469 469 .map(|s| s.unwrap_or(0)) 470 470 } 471 471 472 + /// Get URIs that have active (non-negated) copyright-violation labels. 473 + /// 474 + /// For each URI, checks if there's a negation label. Returns only those 475 + /// that are still actively flagged. 476 + pub async fn get_active_labels(&self, uris: &[String]) -> Result<Vec<String>, sqlx::Error> { 477 + if uris.is_empty() { 478 + return Ok(Vec::new()); 479 + } 480 + 481 + // Get all negated URIs from our input set 482 + let negated_uris: std::collections::HashSet<String> = sqlx::query_scalar::<_, String>( 483 + r#" 484 + SELECT DISTINCT uri 485 + FROM labels 486 + WHERE val = 'copyright-violation' AND neg = true AND uri = ANY($1) 487 + "#, 488 + ) 489 + .bind(uris) 490 + .fetch_all(&self.pool) 491 + .await? 492 + .into_iter() 493 + .collect(); 494 + 495 + // Get URIs that have a positive label and are not negated 496 + let active_uris: Vec<String> = sqlx::query_scalar::<_, String>( 497 + r#" 498 + SELECT DISTINCT uri 499 + FROM labels 500 + WHERE val = 'copyright-violation' AND neg = false AND uri = ANY($1) 501 + "#, 502 + ) 503 + .bind(uris) 504 + .fetch_all(&self.pool) 505 + .await? 506 + .into_iter() 507 + .filter(|uri| !negated_uris.contains(uri)) 508 + .collect(); 509 + 510 + Ok(active_uris) 511 + } 512 + 472 513 /// Get all copyright-violation labels with their resolution status and context. 473 514 /// 474 515 /// A label is resolved if there's a negation label for the same uri+val.
+1
moderation/src/main.rs
··· 83 83 .route("/admin/resolve", post(admin::resolve_flag)) 84 84 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 85 85 .route("/admin/context", post(admin::store_context)) 86 + .route("/admin/active-labels", post(admin::get_active_labels)) 86 87 // Static files (CSS, JS for admin UI) 87 88 .nest_service("/static", ServeDir::new("static")) 88 89 // ATProto XRPC endpoints (public)