generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum RequestStatus { pending completed needs_attention } enum JobState { requested matching matched downloading tagging imported needs_attention } enum MonitoredReleaseState { wanted grabbed fulfilled } model Request { id String @id @default(cuid()) artist String album String status RequestStatus @default(pending) createdAt DateTime @default(now()) // Force re-acquisition: skip the "already in library" dedupe so an owned album is // re-downloaded on demand. The import step still keeps the copy only if it's higher // quality, so a forced upgrade never downgrades. Set by the Library "Replace / upgrade". force Boolean @default(false) job Job? libraryItem LibraryItem? monitoredReleaseId String? monitoredRelease MonitoredRelease? @relation(fields: [monitoredReleaseId], references: [id], onDelete: SetNull) } model Job { id String @id @default(cuid()) request Request @relation(fields: [requestId], references: [id], onDelete: Cascade) requestId String @unique state JobState @default(requested) currentStage String @default("intake") attempts Int @default(0) error String? claimedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt downloadProgress Float @default(0) // Estimated seconds until the current download finishes, from measured throughput (slskd byte // transfer rate). Null when unknown (no speed sample yet, or a source that doesn't report bytes). downloadEtaSeconds Int? paused Boolean @default(false) candidates Candidate[] } model Candidate { id String @id @default(cuid()) job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) jobId String source String format String qualityClass Int trackCount Int confidence Float sourceRef String chosen Boolean @default(false) createdAt DateTime @default(now()) } model LibraryItem { id String @id @default(cuid()) request Request? @relation(fields: [requestId], references: [id], onDelete: Cascade) requestId String? @unique artist String album String path String source String format String qualityClass Int importedAt DateTime @default(now()) // MusicBrainz release-group MBID when known (resolved at manual import) — gives the album // cover art without depending on a matching MonitoredRelease row. rgMbid String? // MusicBrainz ARTIST MBID when known (captured at import + scan). Own-state ("in library") // prefers this over the fragile exact artist-name match; null rows fall back to name-match // (re-scan to populate). artistMbid String? // The album's on-disk audio filenames ("## Title.ext"), captured at import + scan, so the // Library modal can show the REAL tracks on disk (revealing incomplete/edition mismatches) // instead of MusicBrainz's canonical listing. Empty for items imported before this existed // (re-scan to populate). trackNames String[] @default([]) @@unique([artist, album]) } model Config { key String @id value String secret Boolean @default(false) updatedAt DateTime @updatedAt } model WatchedArtist { id String @id @default(cuid()) mbid String @unique name String autoMonitorFuture Boolean @default(false) showAllTypes Boolean @default(false) monitorFrom DateTime @default(now()) lastPolledAt DateTime? lastDiscoveredAt DateTime? createdAt DateTime @default(now()) releases MonitoredRelease[] } model MonitoredRelease { id String @id @default(cuid()) watchedArtist WatchedArtist? @relation(fields: [watchedArtistId], references: [id], onDelete: Cascade) watchedArtistId String? artistMbid String artistName String rgMbid String @unique album String primaryType String? secondaryTypes String[] firstReleaseDate String? monitored Boolean @default(false) // User marked this release "do not monitor" — the monitor skips it (never enqueued as // wanted/upgrade) and it drops off the Wanted list, independent of `monitored`, so // auto-monitor-future can't silently re-grab it. Reversible from the discography page. ignored Boolean @default(false) state MonitoredReleaseState @default(wanted) currentQualityClass Int? firstGrabbedAt DateTime? lastSearchedAt DateTime? createdAt DateTime @default(now()) requests Request[] } enum DiscoveryKind { artist album } enum SuggestionStatus { pending followed wanted dismissed } model DiscoverySuggestion { id String @id @default(cuid()) kind DiscoveryKind artistMbid String artistName String rgMbid String? album String? primaryType String? secondaryTypes String[] firstReleaseDate String? score Float seedCount Int sources String[] // Why this album is suggested (kind='album' only): "newest" (the artist's newest un-owned // release), "popular" (their most-played per Last.fm), or "newest,popular" when they're the // same release. Null for artist suggestions / rows from before this column existed. albumReason String? status SuggestionStatus @default(pending) dedupeKey String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model DiscoverySeedContribution { id String @id @default(cuid()) candidateMbid String candidateName String seedMbid String score Float sources String[] updatedAt DateTime @updatedAt @@unique([candidateMbid, seedMbid]) @@index([candidateMbid]) @@index([seedMbid]) } // Throttle registry for discovery seeds that are NOT WatchedArtist rows — i.e. artists you own // albums by (LibraryItem.artistMbid) but don't follow. Mirrors WatchedArtist.lastDiscoveredAt so // each library-derived seed is swept once per interval, not every sweep. model DiscoverySeed { mbid String @id lastDiscoveredAt DateTime? } model ScanWorkItem { id String @id @default(cuid()) scanId String artist String album String path String done Boolean @default(false) @@unique([scanId, path]) @@index([scanId, done, artist, album]) } // Album folders on disk that the library scan could not resolve to a MusicBrainz release // (no match, or an unreadable/corrupt file). Surfaced on /library so the user can fix the // metadata or add them manually instead of them being silently skipped. Keyed by path; a // row is removed when the album later matches, and stale rows (a prior scan's, or a folder // since removed) are pruned when a scan completes. model UnmatchedAlbum { id String @id @default(cuid()) artist String album String path String @unique reason String scanId String scannedAt DateTime @default(now()) } // Generic read-through cache for external API responses shared by web + worker (keyed on // the LOGICAL operation, not the request URL, so both processes share entries). TTL is // applied at read time from fetchedAt in code (no expiresAt column → tunable, no migration). // Named generically so the Last.fm browse cache can ride the same table. model ApiCache { key String @id json Json fetchedAt DateTime @default(now()) @@index([fetchedAt]) }