Copyright Detection
Lumio's copyright-detection subsystem classifies songs played on stream as safe, blocked, reported, or unknown so that the Music feature and overlays can react (mute, swap, warn) before a DMCA strike lands. This guide describes the data model, the lookup pipeline, the community-voting loop, and the admin moderation queue.
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 |
| Live checking worker | 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?" and is called by:
GET /v1/copyright/check(REST) /copyrightCheck(GraphQL) — explicit lookup from the dashboard or an overlay.- The Spotify now-playing / YouTube-music watchers, before the track is written into the active-playlist history.
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 suppliedLOWER(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 "≥1 pending community vote exists but no auto-promote threshold has been hit yet".
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. Playlist-sourced global safe entries are immune to auto-promote — if a track is in a curated global playlist, community votes cannot push it onto the global blocked list (see auto_promote_check in db/copyright.rs).
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).
After every vote, db::copyright::auto_promote_check runs:
- ≥3 copyright votes and exactly 0 safe votes → song is added to the global blocked list with
source = 'community_vote', andauto_promote_checkreturnsBlocked. The immunity check is "does any row exist incopyright_safe_songswithaccount_id IS NULLfor this track" — any global safe entry blocks the promotion, not only a playlist-sourced one. Thecopyright_reportsrows are not deleted on this path; they stay until an admin approves or dismisses the candidate. - ≥3 safe votes and
safe > copyright→dismiss_reportsdeletes every report for the track and the check returnsSafe. - Otherwise → returns
None; the song remains in theReportedstate until an admin resolves it.
This keeps the moderation queue short while still letting genuinely contested tracks fall through to human review.
Admin recommendation queue
Candidates that did not auto-resolve surface to admins (permission copyright:read) via GET /v1/copyright/vote-candidates, which returns one row per reported track with running 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 clears the reports.POST /v1/copyright/vote-candidates/\{track_id\}/dismiss— deletes all reports for the track, returning it toUnknown.
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).
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.
The live checking worker
apps/api/src/workers/copyright.rs polls the account's current Spotify track and classifies it:
- 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 copyright worker does not annotate spotify:track events with a status field. It emits its own event types, and overlays and automations trigger on those:
| 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 detection subsystem never mutes audio itself — it only classifies and emits. Reacting (mute an audio source, hide the now-playing widget, post a warning to chat) is the job of an automation or overlay bound to those event types. See features/spotify.
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 |
GraphQL-only surfaces:
-
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,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). ZAF-742.These five public reads are a deliberate GraphQL-only surface with no REST twin (CTO decision, ZAF-1055) — not an oversight or a pending gap. Their only consumer is the webapp's marketing
/music/*pages, which reach them server-side viaserverGqlSSR/ the/api/copyright/*proxy (the browser never calls GraphQL directly). The three-protocol rule is satisfied at the feature level by the authenticated surface — each of these is a PII-safe public projection of data whose authenticated twin already exposes GraphQL + REST + WS. Adding an unauthenticated REST twin would widen the public attack surface for zero consumer benefit; it will be added only if a public REST / developer-API consumer materializes. Feature-gate parity holds — theFeatureGuardon these queries mirrorsrequire_feature("feature:copyright_detection")on the authenticated REST routes. -
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,auto_promote_check, 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.