Skip to main content

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:

LayerPath
REST handlersapps/api/src/routes/copyright.rs
GraphQL resolversapps/api/src/graphql/copyright.rs
DB access + business logicapps/api/src/db/copyright.rs
Inactive live-checking worker implementationapps/api/src/workers/copyright.rs
Curated-playlist → Spotify sync workerapps/api/src/workers/playlist_sync.rs
Spotify integration (metadata + playlist import)crates/lo-spotify-api
Feature flag gatefeature:copyright_detection (checked via require_feature in every handler)
Permissionscopyright: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:

SurfacePermission
check, list safe/blocked songs, list playlist syncs, list vote candidatescopyright:read
Add a safe/blocked song, import a playlistcopyright:edit
Delete a safe/blocked song or a playlist synccopyright:delete
Cast a votecopyright:vote
Approve / dismiss a vote candidatecopyright: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:

  1. copyright_safe_songs → Safe
  2. copyright_blocked_songs → Blocked
  3. pending copyright_reports → Reported
  4. 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 supplied
  • isrc - International Standard Recording Code, stable across re-releases; when supplied. Spotify removed external_ids from 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 on spotify_track_id or 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/):

TablePurposeScope
copyright_safe_songsKnown-safe tracks (whitelist).Per-account or global (account_id IS NULL).
copyright_blocked_songsKnown-copyrighted tracks (blacklist).Per-account or global.
copyright_reportsIndividual community votes (one row per user per track per report type). Formerly copyright_votes.Global.
copyright_playlist_syncsImported Spotify playlists whose tracks are auto-added to safe-songs.Per-account.
copyright_curated_playlists · copyright_curated_playlist_tracksAdmin-curated global safe catalogue, synced out to Spotify.Global.
copyright_abuse_reportsReports 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: if copyright_votes > safe_votes the track is added to the global blocked list, otherwise to the global safe list, both with source = 'community_vote'. dismiss_reports then deletes the track's pending reports.
  • POST /v1/copyright/vote-candidates/\{track_id\}/dismiss - deletes the track's pending reports, returning it to Unknown unless a list entry applies. Confirmed and dismissed reports stay. A track without pending reports returns 404.

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:

SituationEvent emittedExtra behaviour
Track is on a blocked listcopyright:blocked-
Track is Unknown and a YouTube restriction was detectedcopyright:warningAuto-learn: the track is added to the account's blocked list
Track is Safe and a YouTube restriction was detectedcopyright:clearSignals a likely false positive
Anything elsenone-

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.

RESTGraphQL
GET /v1/copyright/checkcheckSong(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-playlistimportPlaylist(spotifyPlaylistId)
GET /v1/copyright/playlist-syncs, DELETE .../{id}playlistSyncs, deletePlaylistSync
POST /v1/copyright/votecreateCopyrightReport (flag) / createSafeRecommendation (endorse)
GET /v1/copyright/vote-candidatesvoteCandidates
POST /v1/copyright/vote-candidates/\{track_id\}/approve · /dismissapproveReport(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. publicReports returns the PII-free PublicCopyrightReport projection - song name, artist, Spotify track id, report type, category, confirmation count and timestamp only. It never exposes the reporter's identity (voterId), free-text message, 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 call publicSong(id) and publicSystemPlaylist(id) once per request through serverGqlSSR(); page and metadata share the result. Sitemap and public plan reads use the anonymous anonymousGql() helper inside the Next data cache, with no visitor credentials. The song and playlist reads have public REST twins in routes/songs.rs (/v1/songs, /v1/songs/check, /v1/songs/sitemap, /v1/songs/{id}) and routes/playlists.rs (/v1/playlists, /v1/playlists/{id}), listed in Copyright → Public REST endpoints. Both protocols run the same fail-closed feature:copyright_detection check: FeatureGuard on GraphQL, require_feature(…, auth.account_id()) with OptionalAuth on REST. routes/songs.rs registers /songs/check and /songs/sitemap before /songs/{id}, whose Path<Uuid> would otherwise match them. publicSongSitemap is keyset-paged on id (page ≤ 5,000, account_id IS NULL on both list branches, lastmod = created_at); the included lists are the server constants SONG_SITEMAP_INCLUDE_SAFE / SONG_SITEMAP_INCLUDE_BLOCKED in db/copyright.rs.

  • recentlyPlayedSongs(days, limit) - TimescaleDB spotify:track events 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 for copyright_safe_songs).
  • crates/lo-spotify-api - Spotify API client used for playlist imports and track metadata.