Copyright Manager
Copyright Manager classifies a song as safe, blocked, reported, or unknown when a client requests a status lookup. It also provides account song lists, community reports, and an operator moderation queue. No automatic background copyright check runs.
Architecture
There is no standalone lo-copyright crate - copyright is implemented entirely inside the API app. The moving parts are:
| Layer | Path |
|---|---|
| REST handlers | apps/api/src/routes/copyright.rs |
| GraphQL resolvers | apps/api/src/graphql/copyright.rs |
| DB access + business logic | apps/api/src/db/copyright.rs |
| Inactive live-checking worker implementation | apps/api/src/workers/copyright.rs |
| Curated-playlist → Spotify sync worker | apps/api/src/workers/playlist_sync.rs |
| Spotify integration (metadata + playlist import) | crates/lo-spotify-api |
| Feature flag gate | feature:copyright_detection (checked via require_feature in every handler) |
| Permissions | copyright:read, copyright:edit, copyright:delete, copyright:vote, copyright:report, copyright:recommend, copyright:moderate |
Every request path runs the same sequence: require_permission → require_feature → db::copyright::*. That keeps RBAC and plan-gating consistent across REST and GraphQL, as required by the GraphQL ↔ REST parity rule.
Which permission gates what:
| Surface | Permission |
|---|---|
check, list safe/blocked songs, list playlist syncs, list vote candidates | copyright:read |
| Add a safe/blocked song, import a playlist | copyright:edit |
| Delete a safe/blocked song or a playlist sync | copyright:delete |
| Cast a vote | copyright:vote |
| Approve / dismiss a vote candidate | copyright:moderate (admin scope) |
copyright:read, copyright:edit and copyright:delete also exist as global (system-scope) permissions with the same strings. The admin-side surfaces that read or write the account-less global tables - all-status report review (adminReports), curated-playlist management (create/update/delete/add/remove/syncCuratedPlaylist), and community-list moderation (approveReport/dismissReport and their REST twins POST /v1/copyright/vote-candidates/{track_id}/approve·/dismiss) - are enforced through the admin-only AdminPermissionGuard / require_admin_permission, so an account-scope grant does not reach them (ZAF-734, ZAF-742). copyright:moderate is an admin-only permission (moved out of account scope in ZAF-742): it writes the account-less global safe/blocked catalogue, so it is a platform-operator action, seeded to the system_admin admin role and not assignable to any tenant role. The account-scope resolvers in the table above (own safe/blocked lists, playlist syncs, check, vote candidates) keep the account-OR PermissionGuard.
Lookup pipeline
db::copyright::check_song_status is the single entry point for "is this track OK to play?". GET /v1/copyright/check (REST) and checkSong (GraphQL) call it on demand. The copyright worker implementation also calls it, but no production path starts that worker.
The precedence is by list, not by identifier. Three queries run in order and the first non-empty one wins:
copyright_safe_songs→Safecopyright_blocked_songs→Blocked- pending
copyright_reports→Reported - nothing matched →
Unknown
Safe therefore beats blocked: an explicit safe entry (yours or a global one) shadows a blocked entry for the same track.
Within each query all three identifiers are matched with OR in a single SQL statement, not as a fallback chain:
spotify_track_id- when suppliedisrc- International Standard Recording Code, stable across re-releases; when supplied. Spotify removedexternal_idsfrom track objects for Development Mode apps (every account Spotify app) in its February 2026 Web API changes, so tracks imported from a Spotify playlist usually have no ISRC and match onspotify_track_idor name + artist instead.LOWER(song_name) = LOWER($4) AND LOWER(artist) = LOWER($5)- always evaluated
Each query covers both account-scoped rows (account_id = $1) and global rows (account_id IS NULL). The return value is:
pub enum SongStatus \{ Safe, Blocked, Reported, Unknown \}
Reported means at least one pending copyright report exists and no safe or blocked list entry took precedence.
Data model
PostgreSQL tables (migrations in apps/api/migrations/):
| Table | Purpose | Scope |
|---|---|---|
copyright_safe_songs | Known-safe tracks (whitelist). | Per-account or global (account_id IS NULL). |
copyright_blocked_songs | Known-copyrighted tracks (blacklist). | Per-account or global. |
copyright_reports | Individual community votes (one row per user per track per report type). Formerly copyright_votes. | Global. |
copyright_playlist_syncs | Imported Spotify playlists whose tracks are auto-added to safe-songs. | Per-account. |
copyright_curated_playlists · copyright_curated_playlist_tracks | Admin-curated global safe catalogue, synced out to Spotify. | Global. |
copyright_abuse_reports | Reports of abuse of the voting system itself. | Global. |
Every safe/blocked row carries a source string (manual, playlist, community_vote, …) and, for playlist imports, a source_ref pointing at the copyright_playlist_syncs row. Community submissions alone do not write either global list; an operator must approve a candidate.
Community voting flow
Users with copyright:vote can submit a vote on any song via POST /v1/copyright/vote (castVote mutation):
\{
"spotify_track_id": "3n3Ppam7vgaVa1iaRUc9Lp",
"song_name": "Song Title",
"artist": "Artist Name",
"vote_type": "copyright" | "safe",
"category": "dmca" | "sync_license" | ...,
"recommendation_category": "lofi" | "electronic" | ...,
"vod_url": "https://...",
"vod_timestamp": "01:23:45",
"message": "optional note"
\}
A vote is stored as a copyright_reports row with report_type = 'copyright' (flagging as unsafe) or report_type = 'recommendation' (endorsing as safe).
The submission creates a moderation candidate and leaves the song in the Reported state unless a safe or blocked list entry already takes precedence. Vote counts do not add a song to the global blocked or safe list or dismiss its reports. Only an operator with the admin-scope copyright:moderate permission can approve or dismiss a candidate.
Admin recommendation queue
GET /v1/copyright/vote-candidates and GraphQL voteCandidates share one query: a candidate is a track with at least one pending report. Confirmed and dismissed reports (already moderated via approveReport / dismissReport) neither make a track a candidate nor count towards its tallies. Both return a row per candidate with pending-only counters:
\{
"spotify_track_id": "…",
"song_name": "…",
"artist": "…",
"copyright_votes": 2,
"safe_votes": 1,
"total_votes": 3
\}
Admins act on each candidate via:
POST /v1/copyright/vote-candidates/\{track_id\}/approve- accepts the majority verdict: ifcopyright_votes > safe_votesthe track is added to the global blocked list, otherwise to the global safe list, both withsource = 'community_vote'.dismiss_reportsthen deletes the track's pending reports.POST /v1/copyright/vote-candidates/\{track_id\}/dismiss- deletes the track's pending reports, returning it toUnknownunless a list entry applies. Confirmed and dismissed reports stay. A track without pending reports returns404.
Both endpoints require the admin-scope copyright:moderate (checked against admin_permissions only, via require_admin_permission on REST and AdminPermissionGuard on GraphQL). Moderating a candidate writes the account-less global catalogue, so it is a platform-operator action - not reachable by a tenant Owner/Administrator (ZAF-742).
Approvals emit copyright:moderation_approved; dismissals emit copyright:moderation_dismissed only when a report was actually dismissed. REST and GraphQL emit the same system-scoped audit types after the write. The actor is the operator; account_id is null. Metadata contains spotify_track_id, list (blocked, safe, or null), source, and decision (approved or dismissed), without song title, artist, or reporter identity. See Audit Events.
Playlist import (safe-list bootstrap)
Streamers can seed their account-scoped safe list from a Spotify playlist:
POST /v1/copyright/import-playlist \{ "spotify_playlist_id": "<id>" \}
The handler pulls tracks via lo_spotify_api::SpotifyClient::get_playlist_tracks (up to 100), upserts them into copyright_safe_songs with source = 'playlist', and writes a copyright_playlist_syncs row recording the playlist name, track count, and last_synced_at. GET /v1/copyright/playlist-syncs / DELETE /v1/copyright/playlist-syncs/\{id\} manage existing syncs; deletion also removes every safe-song entry sourced from that sync (see db::copyright::delete_playlist_sync).
Auto-sync (re-importing an account's playlist periodically to pick up new tracks the curator adds) is modelled in the copyright_playlist_syncs.auto_sync column and tracked by last_synced_at, but no worker acts on it today - account playlist syncs are re-run on demand.
The background worker that does exist, apps/api/src/workers/playlist_sync.rs, works the other direction and on a different object: every 5 minutes (after a 30 s startup delay) it pushes the admin-curated global playlists out to Spotify, reconciling each curated_playlists track list against the Spotify playlist using system-level Spotify credentials from integration_configs.
Live checking worker status
No background copyright check runs. WorkerManager::start_copyright_worker exists in apps/api/src/workers/manager.rs, but nothing calls it. The implementation in apps/api/src/workers/copyright.rs would poll the current Spotify track if started:
- It decides whether the account is live by reading the YouTube worker's Redis cache at
lumio:youtube:active_streams:{account_id}- it never calls the YouTube API itself. - A check runs when a broadcast is active or when the worker is in
proactive_mode. - Poll interval: 30 s while a broadcast is active (
ACTIVE_CHECK_INTERVAL_SECS), 60 s otherwise (NO_BROADCAST_POLL_INTERVAL_SECS). - The Spotify token is re-fetched every poll via
crate::oauth::get_fresh_connection_token- never refreshed inline. - It emits one of three event types:
copyright:warning,copyright:blocked,copyright:clear.
Integration with the Music feature
The inactive worker does not annotate spotify:track events with a status field. Its code defines the following events, but it currently emits none because the worker is never started:
| Situation | Event emitted | Extra behaviour |
|---|---|---|
| Track is on a blocked list | copyright:blocked | - |
Track is Unknown and a YouTube restriction was detected | copyright:warning | Auto-learn: the track is added to the account's blocked list |
Track is Safe and a YouTube restriction was detected | copyright:clear | Signals a likely false positive |
| Anything else | none | - |
Every payload carries { song_name, artist, spotify_track_id, restriction_detected }.
The on-demand status lookup does not mute audio or emit these events. See Spotify for the running Now Playing worker.
GraphQL surface
apps/api/src/graphql/copyright.rs covers the REST surface under different names, and adds several capabilities that have no REST counterpart today. Match the names carefully - they are not a mechanical rename of the REST paths.
| REST | GraphQL |
|---|---|
GET /v1/copyright/check | checkSong(spotifyTrackId, isrc, title, artist) |
GET/POST /v1/copyright/safe-songs, DELETE .../{id} | safeSongs, addSafeSong, deleteSafeSong |
GET/POST /v1/copyright/blocked-songs, DELETE .../{id} | blockedSongs, addBlockedSong, deleteBlockedSong |
POST /v1/copyright/import-playlist | importPlaylist(spotifyPlaylistId) |
GET /v1/copyright/playlist-syncs, DELETE .../{id} | playlistSyncs, deletePlaylistSync |
POST /v1/copyright/vote | createCopyrightReport (flag) / createSafeRecommendation (endorse) |
GET /v1/copyright/vote-candidates | voteCandidates |
POST /v1/copyright/vote-candidates/\{track_id\}/approve · /dismiss | approveReport(id, addToPlaylistId) · dismissReport(id) - note these take a report id, not a track id |
The GraphQL surface also includes:
-
Report evidence:
confirmCopyrightReport(reportId, input)adds evidence to an existing report. -
Admin review:
adminReports(status, reportType, limit, offset). -
Curated playlists:
createCuratedPlaylist,updateCuratedPlaylist,deleteCuratedPlaylist,addToCuratedPlaylist,removeFromCuratedPlaylist,syncCuratedPlaylist,curatedPlaylistTracks- the admin-curated global safe catalogue. -
Preview-then-import:
previewPlaylist(spotifyPlaylistId)fetches the tracks, checks each one, and caches the result in Redis for 10 minutes;importSelectedSongs(input)then imports only the chosen tracks from that cache. -
Public (unauthenticated) reads:
publicSafeSongs,publicBlockedSongs,publicReports,publicSystemPlaylists,publicSystemPlaylist,publicSong,publicSongSitemap,publicSongStatus.publicReportsreturns the PII-freePublicCopyrightReportprojection - song name, artist, Spotify track id, report type, category, confirmation count and timestamp only. It never exposes the reporter's identity (voterId), free-textmessage, or VOD evidence (vodUrl/vodTimestamp); those are reachable only on the authenticated admin path (adminReports).The marketing
/music/*pages use server-side GraphQL reads; browser refreshes use the/api/copyright/*proxy, never a direct browser GraphQL call. Song and playlist detail pages callpublicSong(id)andpublicSystemPlaylist(id)once per request throughserverGqlSSR(); page and metadata share the result. Sitemap and public plan reads use the anonymousanonymousGql()helper inside the Next data cache, with no visitor credentials. The song and playlist reads have public REST twins inroutes/songs.rs(/v1/songs,/v1/songs/check,/v1/songs/sitemap,/v1/songs/{id}) androutes/playlists.rs(/v1/playlists,/v1/playlists/{id}), listed in Copyright → Public REST endpoints. Both protocols run the same fail-closedfeature:copyright_detectioncheck:FeatureGuardon GraphQL,require_feature(…, auth.account_id())withOptionalAuthon REST.routes/songs.rsregisters/songs/checkand/songs/sitemapbefore/songs/{id}, whosePath<Uuid>would otherwise match them.publicSongSitemapis keyset-paged onid(page ≤ 5,000,account_id IS NULLon both list branches,lastmod=created_at); the included lists are the server constantsSONG_SITEMAP_INCLUDE_SAFE/SONG_SITEMAP_INCLUDE_BLOCKEDindb/copyright.rs. -
recentlyPlayedSongs(days, limit)- TimescaleDBspotify:trackevents cross-referenced against the copyright database.
Where a capability exists on both protocols, inputs, permission strings, feature-flag guards, and error messages must stay identical - do not change one side without the other in the same PR.
Key files
apps/api/src/routes/copyright.rs- all REST handlers: 14 operations across 12 paths.apps/api/src/graphql/copyright.rs- GraphQL queries + mutations.apps/api/src/db/copyright.rs-SongStatus,check_song_status,add_safe_song/add_blocked_song, playlist import, report counters.apps/api/migrations/-copyright_*table migrations (search forcopyright_safe_songs).crates/lo-spotify-api- Spotify API client used for playlist imports and track metadata.