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 and shares the Spotify / YouTube clients that already power the Music feature. 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 |
| 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 |
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.
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.
Matching priority is deterministic:
spotify_track_id— exact match (most reliable).isrc— International Standard Recording Code, stable across re-releases.song_name+artist— case-insensitive exact match on both fields (fallback when no IDs are available).
For each layer, the query checks both account-scoped rows (account_id = <acc>) 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
Four 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 vote type). | Global. |
copyright_playlist_syncs | Imported Spotify playlists whose tracks are auto-added to safe-songs. | Per-account. |
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, 0 safe votes → song is added to the global blocked list with
source = 'community_vote'(unless it is already in a global safe playlist). Votes are cleared. - ≥3 safe votes and
safe > copyright→ votes are auto-dismissed (the song was not actually copyrighted). - Otherwise → song remains in the
Reportedstate 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. Reports are then cleared.POST /v1/copyright/vote-candidates/\{track_id\}/dismiss— deletes all reports for the track, returning it toUnknown.
Both endpoints require copyright:edit.
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 periodically to pick up new tracks the curator adds) is modelled in the auto_sync column and tracked by last_synced_at; the sync worker lives under apps/api/src/workers/.
Integration with the Music feature
The Music feature (features/spotify) calls check_song_status on each track change and emits an event whose raw.copyright_status carries safe / blocked / reported / unknown. Overlays and automations use that field to drive behaviour (mute an audio source, hide the now-playing widget, post a warning to chat, …). This is the only downstream consumer of copyright data today — the detection subsystem does not itself mute audio; it only classifies.
REST ↔ GraphQL parity
Every endpoint listed above has a 1:1 GraphQL counterpart in apps/api/src/graphql/copyright.rs (e.g. copyrightCheck, addSafeSong, castVote, approveVoteCandidate). Inputs, permission strings, feature-flag guards, and error messages are identical on both sides — do not add new behaviour to one protocol without updating the other in the same PR.
Key files
apps/api/src/routes/copyright.rs— all REST handlers (13 endpoints).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.