8c8a34e320
Add Request.force (migration add_request_force): the pipeline's intake now skips the "already in library" dedupe for a forced job, so an owned album is re-downloaded. The import step still keeps the new copy only when it's higher quality, so a forced upgrade can never downgrade what's on disk. - Worker: _is_force_job → dedupe bypassed when Request.force. - POST /api/library/[id]/upgrade finds the matching MonitoredRelease and enqueues a force request (guards against stacking on an in-flight job). - Library route exposes monitoredReleaseId; the album modal shows a "Replace / upgrade" button when a release exists. Worker + web tests added (pipeline force re-acquire; upgrade route). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
6.8 KiB
Plaintext
228 lines
6.8 KiB
Plaintext
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)
|
|
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?
|
|
// 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[]
|
|
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])
|
|
}
|
|
|
|
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])
|
|
}
|