927 lines
31 KiB
Plaintext
927 lines
31 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
username String @unique
|
|
passwordHash String
|
|
role String @default("user") // user, admin
|
|
onboardingComplete Boolean @default(false) // Tracks if user completed setup
|
|
enrichmentSettings Json? // JSON settings for metadata enrichment
|
|
twoFactorEnabled Boolean @default(false) // 2FA enabled flag
|
|
twoFactorSecret String? // TOTP secret (encrypted)
|
|
twoFactorRecoveryCodes String? // Recovery codes (encrypted, comma-separated hashed codes)
|
|
moodMixParams Json? // Saved mood mix parameters for "Your Mood Mix"
|
|
createdAt DateTime @default(now())
|
|
|
|
plays Play[]
|
|
playlists Playlist[]
|
|
listeningState ListeningState[]
|
|
downloadJobs DownloadJob[]
|
|
spotifyImportJobs SpotifyImportJob[]
|
|
settings UserSettings?
|
|
playbackState PlaybackState?
|
|
likedTracks LikedTrack[]
|
|
dislikedEntities DislikedEntity[]
|
|
cachedTracks CachedTrack[]
|
|
audiobookProgress AudiobookProgress[]
|
|
podcastSubscriptions PodcastSubscription[]
|
|
podcastProgress PodcastProgress[]
|
|
podcastDownloads PodcastDownload[]
|
|
discoveryAlbums DiscoveryAlbum[]
|
|
discoverExclusions DiscoverExclusion[]
|
|
discoverConfig UserDiscoverConfig?
|
|
unavailableAlbums UnavailableAlbum[]
|
|
apiKeys ApiKey[]
|
|
deviceLinkCodes DeviceLinkCode[]
|
|
hiddenPlaylists HiddenPlaylist[]
|
|
notifications Notification[]
|
|
}
|
|
|
|
model UserSettings {
|
|
userId String @id
|
|
playbackQuality String @default("original") // original, high, medium, low
|
|
wifiOnly Boolean @default(false)
|
|
offlineEnabled Boolean @default(false)
|
|
maxCacheSizeMb Int @default(10240) // 10GB default
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model PlaybackState {
|
|
userId String @id
|
|
playbackType String // track, audiobook, podcast
|
|
trackId String? // For music tracks
|
|
audiobookId String? // For audiobooks (audiobookshelfId)
|
|
podcastId String? // For podcasts (format: podcastId:episodeId)
|
|
queue Json? // JSON array of track IDs
|
|
currentIndex Int @default(0)
|
|
isShuffle Boolean @default(false)
|
|
updatedAt DateTime @updatedAt
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
}
|
|
|
|
model SystemSettings {
|
|
id String @id @default("default")
|
|
|
|
// === Download Services ===
|
|
// Lidarr
|
|
lidarrEnabled Boolean @default(true)
|
|
lidarrUrl String? @default("http://localhost:8686")
|
|
lidarrApiKey String? // Encrypted
|
|
|
|
// === AI Services ===
|
|
// OpenAI (for future AI features)
|
|
openaiEnabled Boolean @default(false)
|
|
openaiApiKey String? // Encrypted
|
|
openaiModel String? @default("gpt-4")
|
|
openaiBaseUrl String? // For custom endpoints
|
|
|
|
// Fanart.tv (for high-quality images - optional)
|
|
fanartEnabled Boolean @default(false)
|
|
fanartApiKey String? // Encrypted
|
|
|
|
// === Media Services ===
|
|
// Audiobookshelf
|
|
audiobookshelfEnabled Boolean @default(false)
|
|
audiobookshelfUrl String? @default("http://localhost:13378")
|
|
audiobookshelfApiKey String? // Encrypted
|
|
|
|
// Soulseek (direct connection via slsk-client)
|
|
soulseekUsername String? // Soulseek network username
|
|
soulseekPassword String? // Soulseek network password - Encrypted
|
|
|
|
// Spotify (for playlist import via URL)
|
|
spotifyClientId String?
|
|
spotifyClientSecret String? // Encrypted
|
|
|
|
// === Storage Paths ===
|
|
musicPath String? @default("/music")
|
|
downloadPath String? @default("/downloads")
|
|
|
|
// === Feature Flags ===
|
|
autoSync Boolean @default(true)
|
|
autoEnrichMetadata Boolean @default(true)
|
|
|
|
// === Advanced Settings ===
|
|
maxConcurrentDownloads Int @default(3)
|
|
downloadRetryAttempts Int @default(3)
|
|
transcodeCacheMaxGb Int @default(10) // Transcode cache size limit in GB
|
|
|
|
// === Download Preferences ===
|
|
// Primary download source: "soulseek" (per-track) or "lidarr" (full albums)
|
|
downloadSource String @default("soulseek")
|
|
// When soulseek is primary and fails: "none" (skip) or "lidarr" (download full album)
|
|
soulseekFallback String @default("none")
|
|
|
|
updatedAt DateTime @updatedAt
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
model Artist {
|
|
id String @id @default(cuid())
|
|
mbid String @unique
|
|
name String
|
|
normalizedName String @default("") // Lowercase version for case-insensitive matching
|
|
summary String? @db.Text
|
|
heroUrl String?
|
|
genres Json? // Array of genre strings from Last.fm/MusicBrainz
|
|
lastSynced DateTime @default(now())
|
|
lastEnriched DateTime?
|
|
enrichmentStatus String @default("pending") // pending, enriching, completed, failed
|
|
searchVector Unsupported("tsvector")?
|
|
|
|
albums Album[]
|
|
similarFrom SimilarArtist[] @relation("FromArtist")
|
|
similarTo SimilarArtist[] @relation("ToArtist")
|
|
ownedAlbums OwnedAlbum[]
|
|
|
|
@@index([name])
|
|
@@index([normalizedName])
|
|
@@index([searchVector], type: Gin)
|
|
}
|
|
|
|
model Album {
|
|
id String @id @default(cuid())
|
|
rgMbid String @unique // release group MBID
|
|
artistId String
|
|
title String
|
|
year Int?
|
|
coverUrl String?
|
|
primaryType String // Album, EP, Single, Live, Compilation
|
|
label String? // Record label (from MusicBrainz)
|
|
genres Json? // Array of genre strings from Last.fm
|
|
lastSynced DateTime @default(now())
|
|
location AlbumLocation @default(LIBRARY) // LIBRARY or DISCOVER
|
|
searchVector Unsupported("tsvector")?
|
|
|
|
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
|
tracks Track[]
|
|
|
|
@@index([artistId])
|
|
@@index([location])
|
|
@@index([title])
|
|
@@index([searchVector], type: Gin)
|
|
}
|
|
|
|
model Track {
|
|
id String @id @default(cuid())
|
|
albumId String
|
|
title String
|
|
trackNo Int
|
|
duration Int // seconds
|
|
mime String?
|
|
searchVector Unsupported("tsvector")?
|
|
|
|
// Native file system fields (required for self-contained system)
|
|
filePath String @unique // Relative path: "Artist/Album/track.flac"
|
|
fileModified DateTime // mtime for change detection
|
|
fileSize Int // File size in bytes
|
|
|
|
// === Audio Analysis (Essentia) ===
|
|
// Rhythm
|
|
bpm Float? // Beats per minute (e.g., 120.5)
|
|
beatsCount Int? // Total beats in track
|
|
|
|
// Tonality
|
|
key String? // Musical key (e.g., "C", "F#", "Bb")
|
|
keyScale String? // "major" or "minor"
|
|
keyStrength Float? // Confidence 0-1
|
|
|
|
// Energy & Dynamics
|
|
energy Float? // Overall energy 0-1
|
|
loudness Float? // Average loudness in dB
|
|
dynamicRange Float? // Dynamic range in dB
|
|
|
|
// Mood & Character (basic/estimated)
|
|
danceability Float? // 0-1 how suitable for dancing
|
|
valence Float? // 0 (sad) to 1 (happy) - ML in Enhanced mode
|
|
arousal Float? // 0 (calm) to 1 (energetic) - ML in Enhanced mode
|
|
|
|
// Instrumentation
|
|
instrumentalness Float? // 0-1 (1 = no vocals) - ML in Enhanced mode
|
|
acousticness Float? // 0-1 (1 = acoustic)
|
|
speechiness Float? // 0-1 (1 = spoken word)
|
|
|
|
// === Enhanced Mode: ML Mood Predictions ===
|
|
moodHappy Float? // ML prediction 0-1 (probability of happy)
|
|
moodSad Float? // ML prediction 0-1 (probability of sad)
|
|
moodRelaxed Float? // ML prediction 0-1 (probability of relaxed)
|
|
moodAggressive Float? // ML prediction 0-1 (probability of aggressive)
|
|
moodParty Float? // ML prediction 0-1 (probability of party/upbeat)
|
|
moodAcoustic Float? // ML prediction 0-1 (probability of acoustic)
|
|
moodElectronic Float? // ML prediction 0-1 (probability of electronic)
|
|
danceabilityMl Float? // ML-based danceability (more accurate than basic)
|
|
|
|
// Mood Tags (derived from ML or heuristics)
|
|
moodTags String[] // ["aggressive", "happy", "sad", "relaxed"]
|
|
|
|
// Genre (ML classification from Essentia, backup to Last.fm)
|
|
essentiaGenres String[] // ["rock", "electronic", "jazz"]
|
|
|
|
// Last.fm Tags (user-generated mood/vibe tags)
|
|
lastfmTags String[] // ["chill", "workout", "sad", "90s"]
|
|
|
|
// Analysis Metadata
|
|
analysisStatus String @default("pending") // pending, processing, completed, failed
|
|
analysisVersion String? // Essentia version used
|
|
analysisMode String? // 'standard' or 'enhanced'
|
|
analyzedAt DateTime?
|
|
analysisError String? // Error message if failed
|
|
analysisRetryCount Int @default(0) // Number of retry attempts
|
|
updatedAt DateTime @updatedAt
|
|
|
|
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
|
plays Play[]
|
|
playlistItems PlaylistItem[]
|
|
trackGenres TrackGenre[]
|
|
likedBy LikedTrack[]
|
|
cachedBy CachedTrack[]
|
|
transcodedFiles TranscodedFile[]
|
|
|
|
@@index([albumId])
|
|
@@index([fileModified])
|
|
@@index([title])
|
|
@@index([searchVector], type: Gin)
|
|
@@index([analysisStatus])
|
|
@@index([bpm])
|
|
@@index([energy])
|
|
@@index([valence])
|
|
@@index([danceability])
|
|
}
|
|
|
|
// Transcoded file cache for audio streaming
|
|
model TranscodedFile {
|
|
id String @id @default(cuid())
|
|
trackId String
|
|
quality String // original, high, medium, low
|
|
cachePath String @unique // Relative path in transcode cache
|
|
cacheSize Int // File size in bytes
|
|
sourceModified DateTime // For invalidation
|
|
lastAccessed DateTime @default(now()) // For LRU eviction
|
|
createdAt DateTime @default(now())
|
|
|
|
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([trackId, quality])
|
|
@@index([trackId, quality])
|
|
@@index([lastAccessed])
|
|
}
|
|
|
|
model Play {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
trackId String
|
|
playedAt DateTime @default(now())
|
|
source ListenSource @default(LIBRARY) // LIBRARY, DISCOVERY, or DISCOVERY_KEPT
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId, playedAt])
|
|
@@index([trackId])
|
|
@@index([source])
|
|
}
|
|
|
|
model Playlist {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
mixId String?
|
|
name String
|
|
isPublic Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
// Spotify import metadata
|
|
spotifyPlaylistId String? // Original Spotify playlist ID
|
|
spotifyPlaylistUrl String? // Original Spotify URL for re-import
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
items PlaylistItem[]
|
|
pendingTracks PlaylistPendingTrack[]
|
|
hiddenByUsers HiddenPlaylist[]
|
|
|
|
@@unique([userId, mixId])
|
|
@@index([userId])
|
|
@@index([spotifyPlaylistId])
|
|
}
|
|
|
|
// Track which users have hidden which shared playlists
|
|
model HiddenPlaylist {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
playlistId String
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, playlistId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model PlaylistItem {
|
|
id String @id @default(cuid())
|
|
playlistId String
|
|
trackId String
|
|
sort Int
|
|
|
|
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
|
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([playlistId, trackId])
|
|
@@index([playlistId, sort])
|
|
}
|
|
|
|
// Tracks from Spotify imports that haven't been matched to local library yet
|
|
// These are automatically added to the playlist when the matching track is downloaded
|
|
model PlaylistPendingTrack {
|
|
id String @id @default(cuid())
|
|
playlistId String
|
|
spotifyArtist String // Original artist name from Spotify
|
|
spotifyTitle String // Original track title from Spotify
|
|
spotifyAlbum String // Original album name from Spotify
|
|
albumMbid String? // MusicBrainz album ID if resolved
|
|
artistMbid String? // MusicBrainz artist ID if resolved
|
|
deezerPreviewUrl String? // Deezer 30s preview URL for playback while pending
|
|
sort Int // Position in original playlist
|
|
createdAt DateTime @default(now())
|
|
|
|
playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([playlistId, spotifyArtist, spotifyTitle]) // Prevent duplicates
|
|
@@index([playlistId])
|
|
@@index([albumMbid])
|
|
@@index([artistMbid])
|
|
}
|
|
|
|
// Spotify Import Jobs - tracks import progress and state
|
|
model SpotifyImportJob {
|
|
id String @id
|
|
userId String
|
|
spotifyPlaylistId String
|
|
playlistName String
|
|
status String // pending, downloading, scanning, creating_playlist, completed, failed, cancelled
|
|
progress Int @default(0) // 0-100
|
|
albumsTotal Int
|
|
albumsCompleted Int @default(0)
|
|
tracksTotal Int
|
|
tracksMatched Int @default(0)
|
|
tracksDownloadable Int @default(0)
|
|
createdPlaylistId String?
|
|
error String?
|
|
pendingTracks Json // Array of pending track objects
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@index([status])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
model Genre {
|
|
id String @id @default(cuid())
|
|
name String @unique
|
|
trackGenres TrackGenre[]
|
|
}
|
|
|
|
model TrackGenre {
|
|
trackId String
|
|
genreId String
|
|
|
|
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
|
genre Genre @relation(fields: [genreId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([trackId, genreId])
|
|
@@index([genreId])
|
|
}
|
|
|
|
model SimilarArtist {
|
|
fromArtistId String
|
|
toArtistId String
|
|
weight Float @default(1.0)
|
|
|
|
fromArtist Artist @relation("FromArtist", fields: [fromArtistId], references: [id], onDelete: Cascade)
|
|
toArtist Artist @relation("ToArtist", fields: [toArtistId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([fromArtistId, toArtistId])
|
|
@@index([fromArtistId])
|
|
}
|
|
|
|
model OwnedAlbum {
|
|
artistId String
|
|
rgMbid String
|
|
source String // lidarr, manual, native_scan
|
|
|
|
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([artistId, rgMbid])
|
|
@@index([artistId])
|
|
}
|
|
|
|
model DownloadJob {
|
|
id String @id @default(cuid())
|
|
correlationId String? @unique // UUID for reliable webhook matching
|
|
userId String
|
|
subject String // artist name or album title
|
|
type String // artist, album
|
|
targetMbid String // artist MBID or release group MBID
|
|
status String // pending, processing, completed, failed, exhausted
|
|
error String? // error message if failed
|
|
lidarrRef String? // Lidarr's downloadId from webhook
|
|
lidarrAlbumId Int? // Lidarr's internal album ID for retry/cleanup
|
|
metadata Json? // additional metadata (downloadType, rootFolderPath, etc.)
|
|
attempts Int @default(0) // Number of download attempts
|
|
startedAt DateTime? // When download was initiated (for timeout tracking)
|
|
completedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
discoveryBatchId String? // Links to Discovery Weekly batch if part of discovery
|
|
|
|
// Release iteration tracking (for exhaustive retry)
|
|
triedReleases String[] @default([]) // GUIDs of releases we've tried
|
|
releaseIndex Int @default(0) // Current position in release list
|
|
artistMbid String? // Artist MBID for same-artist fallback
|
|
cleared Boolean @default(false) // User dismissed from history
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
discoveryBatch DiscoveryBatch? @relation(fields: [discoveryBatchId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([userId, status])
|
|
@@index([status])
|
|
@@index([discoveryBatchId])
|
|
@@index([correlationId])
|
|
@@index([startedAt])
|
|
@@index([lidarrRef])
|
|
@@index([artistMbid])
|
|
}
|
|
|
|
model ListeningState {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
kind String // music, book
|
|
entityId String // artist/album/book ID
|
|
trackId String? // current track/chapter
|
|
positionMs Int @default(0)
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, kind, entityId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model DiscoveryAlbum {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
rgMbid String
|
|
artistName String
|
|
artistMbid String?
|
|
albumTitle String
|
|
lidarrAlbumId Int?
|
|
downloadedAt DateTime?
|
|
folderPath String @default("")
|
|
weekStartDate DateTime // When it was added to discovery
|
|
weekEndDate DateTime @default(now()) // When it expires
|
|
status DiscoverStatus @default(ACTIVE)
|
|
likedAt DateTime? // When user liked it
|
|
similarity Float? // Similarity score from Last.fm (0-1)
|
|
tier String? // high, medium, low, wild
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
tracks DiscoveryTrack[]
|
|
|
|
@@unique([userId, weekStartDate, rgMbid])
|
|
@@index([userId, weekStartDate])
|
|
@@index([downloadedAt])
|
|
@@index([status])
|
|
}
|
|
|
|
model DiscoveryTrack {
|
|
id String @id @default(cuid())
|
|
discoveryAlbumId String
|
|
trackId String?
|
|
fileName String
|
|
filePath String
|
|
inPlaylistCount Int @default(0)
|
|
userKept Boolean @default(false)
|
|
lastPlayedAt DateTime?
|
|
|
|
discoveryAlbum DiscoveryAlbum @relation(fields: [discoveryAlbumId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([discoveryAlbumId])
|
|
@@index([userKept])
|
|
@@index([lastPlayedAt])
|
|
}
|
|
|
|
model LikedTrack {
|
|
userId String
|
|
trackId String
|
|
likedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userId, trackId])
|
|
@@index([userId])
|
|
@@index([likedAt])
|
|
}
|
|
|
|
model DislikedEntity {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
entityType String // track, album, artist
|
|
entityId String // trackId, albumId, artistId
|
|
dislikedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, entityType, entityId])
|
|
@@index([userId, entityType])
|
|
}
|
|
|
|
model CachedTrack {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
trackId String
|
|
localPath String
|
|
quality String // original, high, medium, low
|
|
fileSizeMb Float
|
|
cachedAt DateTime @default(now())
|
|
lastAccessedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, trackId, quality])
|
|
@@index([userId])
|
|
@@index([lastAccessedAt])
|
|
}
|
|
|
|
model AudiobookProgress {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
audiobookshelfId String // The audiobook ID from Audiobookshelf
|
|
title String // Cached for display
|
|
author String? // Cached for display
|
|
coverUrl String? // Cached for display
|
|
currentTime Float @default(0) // Current playback position in seconds
|
|
duration Float @default(0) // Total duration in seconds
|
|
isFinished Boolean @default(false)
|
|
lastPlayedAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
// Note: No foreign key to Audiobook - progress can exist before audiobook is cached
|
|
|
|
@@unique([userId, audiobookshelfId])
|
|
@@index([userId, lastPlayedAt])
|
|
}
|
|
|
|
// ============================================
|
|
// Cached Audiobooks (from Audiobookshelf)
|
|
// ============================================
|
|
|
|
model Audiobook {
|
|
id String @id // Audiobookshelf item ID
|
|
title String
|
|
author String?
|
|
narrator String?
|
|
description String? @db.Text
|
|
publishedYear Int?
|
|
publisher String?
|
|
|
|
// Series info
|
|
series String?
|
|
seriesSequence String?
|
|
|
|
// Media info
|
|
duration Float? // seconds
|
|
numTracks Int?
|
|
numChapters Int?
|
|
size BigInt? // bytes
|
|
|
|
// Metadata
|
|
isbn String?
|
|
asin String?
|
|
language String?
|
|
genres String[] // array of genres
|
|
tags String[] // array of tags
|
|
|
|
// Files
|
|
localCoverPath String? // local cached cover image path
|
|
coverUrl String? // original Audiobookshelf URL
|
|
audioUrl String // Audiobookshelf streaming URL
|
|
libraryId String? // Audiobookshelf library ID
|
|
|
|
// Timestamps
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
lastSyncedAt DateTime @default(now())
|
|
|
|
@@index([title])
|
|
@@index([author])
|
|
@@index([series])
|
|
@@index([lastSyncedAt])
|
|
}
|
|
|
|
model PodcastRecommendation {
|
|
id String @id @default(cuid())
|
|
podcastId String // The source podcast ID (from Audiobookshelf)
|
|
recommendedId String // Unique ID for the recommended podcast
|
|
title String
|
|
author String?
|
|
description String? @db.Text
|
|
coverUrl String?
|
|
episodeCount Int @default(0)
|
|
feedUrl String?
|
|
itunesId String?
|
|
score Float @default(0) // Relevance score
|
|
cachedAt DateTime @default(now())
|
|
expiresAt DateTime // When this recommendation expires (30 days from cache)
|
|
|
|
@@index([podcastId, expiresAt])
|
|
@@index([expiresAt]) // For cleanup of expired recommendations
|
|
@@map("podcast_recommendations")
|
|
}
|
|
|
|
// ============================================
|
|
// NEW: Independent Podcast System (RSS-based)
|
|
// ============================================
|
|
|
|
model Podcast {
|
|
id String @id @default(cuid())
|
|
feedUrl String @unique
|
|
title String
|
|
author String?
|
|
description String? @db.Text
|
|
imageUrl String? // Original feed image URL
|
|
localCoverPath String? // Local cached cover image path
|
|
itunesId String? @unique
|
|
language String?
|
|
explicit Boolean @default(false)
|
|
episodeCount Int @default(0)
|
|
lastRefreshed DateTime @default(now())
|
|
refreshInterval Int @default(3600) // seconds (1 hour default)
|
|
autoRefresh Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
episodes PodcastEpisode[]
|
|
subscriptions PodcastSubscription[]
|
|
|
|
@@index([itunesId])
|
|
@@index([lastRefreshed])
|
|
}
|
|
|
|
model PodcastEpisode {
|
|
id String @id @default(cuid())
|
|
podcastId String
|
|
guid String // RSS GUID (unique per feed)
|
|
title String
|
|
description String? @db.Text
|
|
audioUrl String // Direct MP3/audio URL from RSS
|
|
duration Int @default(0) // seconds
|
|
publishedAt DateTime
|
|
episodeNumber Int?
|
|
season Int?
|
|
imageUrl String? // Episode-specific image URL
|
|
localCoverPath String? // Local cached episode cover
|
|
fileSize Int? // bytes
|
|
mimeType String? @default("audio/mpeg")
|
|
createdAt DateTime @default(now())
|
|
|
|
podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
|
|
progress PodcastProgress[]
|
|
downloads PodcastDownload[]
|
|
|
|
@@unique([podcastId, guid])
|
|
@@index([podcastId, publishedAt])
|
|
}
|
|
|
|
// User podcast subscriptions
|
|
model PodcastSubscription {
|
|
userId String
|
|
podcastId String
|
|
subscribedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
podcast Podcast @relation(fields: [podcastId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userId, podcastId])
|
|
@@index([userId])
|
|
@@index([podcastId])
|
|
}
|
|
|
|
// Listening progress for podcast episodes
|
|
model PodcastProgress {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
episodeId String
|
|
currentTime Float @default(0) // seconds
|
|
duration Float @default(0) // seconds
|
|
isFinished Boolean @default(false)
|
|
lastPlayedAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, episodeId])
|
|
@@index([userId, lastPlayedAt])
|
|
}
|
|
|
|
// Downloaded episodes for offline playback
|
|
model PodcastDownload {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
episodeId String
|
|
localPath String // Where the file is stored locally
|
|
fileSizeMb Float
|
|
downloadedAt DateTime @default(now())
|
|
lastAccessedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
episode PodcastEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, episodeId])
|
|
@@index([userId])
|
|
@@index([lastAccessedAt]) // For cache cleanup
|
|
}
|
|
|
|
// ============================================
|
|
// Discover Weekly System
|
|
// ============================================
|
|
|
|
// Album exclusion tracking - prevents suggesting the same album for 6 months
|
|
model DiscoverExclusion {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
albumMbid String // MusicBrainz release group ID
|
|
artistName String? // For display purposes
|
|
albumTitle String? // For display purposes
|
|
lastSuggestedAt DateTime @default(now())
|
|
expiresAt DateTime // 6 months from lastSuggestedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, albumMbid])
|
|
@@index([userId, expiresAt])
|
|
}
|
|
|
|
// User configuration for Discover Weekly
|
|
model UserDiscoverConfig {
|
|
id String @id @default(cuid())
|
|
userId String @unique
|
|
playlistSize Int @default(40) // 5-50, increments of 5
|
|
maxRetryAttempts Int @default(3) // 1-10, how many times to retry finding replacements
|
|
exclusionMonths Int @default(6) // 0-12, months to exclude albums after download (0 = no exclusion)
|
|
downloadRatio Float @default(1.3) // 1.0-2.0, multiplier for albums to request vs target songs
|
|
enabled Boolean @default(true)
|
|
lastGeneratedAt DateTime?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
// Unavailable albums (recommended but not found in Lidarr)
|
|
model UnavailableAlbum {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
artistName String
|
|
albumTitle String
|
|
albumMbid String
|
|
artistMbid String?
|
|
similarity Float // Similarity score from Last.fm (0-1)
|
|
tier String // high, medium, low, wild
|
|
weekStartDate DateTime // When it was recommended
|
|
previewUrl String? // 30-second preview from Deezer
|
|
deezerTrackId String? // Deezer track ID for preview
|
|
deezerAlbumId String? // Deezer album ID for preview
|
|
attemptNumber Int @default(0) // 0 = original, 1 = first replacement, 2 = second replacement, etc.
|
|
originalAlbumId String? // References the original album's ID if this is a replacement
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, weekStartDate, albumMbid])
|
|
@@index([userId, weekStartDate])
|
|
@@index([userId, weekStartDate, attemptNumber])
|
|
@@index([originalAlbumId])
|
|
}
|
|
|
|
// Batch tracking for Discovery Weekly generation
|
|
model DiscoveryBatch {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
weekStart DateTime // Week this batch is for
|
|
targetSongCount Int // Target number of songs to find
|
|
status String @default("downloading") // downloading, scanning, completed, failed
|
|
totalAlbums Int @default(0) // Total albums queued for download
|
|
completedAlbums Int @default(0) // Albums successfully downloaded
|
|
failedAlbums Int @default(0) // Albums that failed to download
|
|
finalSongCount Int @default(0) // Final number of songs in playlist
|
|
logs Json? // Structured logs for debugging [{timestamp, level, message}]
|
|
errorMessage String? // Summary error message if failed
|
|
createdAt DateTime @default(now())
|
|
completedAt DateTime?
|
|
|
|
jobs DownloadJob[]
|
|
|
|
@@index([userId, weekStart])
|
|
@@index([status])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
// ============================================
|
|
// API Keys for Mobile/External Authentication
|
|
// ============================================
|
|
|
|
model ApiKey {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
key String @unique // 64-character hex string
|
|
name String // Device name: "iPhone 14", "Android Tablet"
|
|
lastUsed DateTime @default(now())
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([key])
|
|
@@index([userId])
|
|
@@index([lastUsed])
|
|
}
|
|
|
|
// Temporary device link codes for QR login
|
|
model DeviceLinkCode {
|
|
id String @id @default(cuid())
|
|
code String @unique // 6-digit alphanumeric code
|
|
userId String // User who generated this code
|
|
expiresAt DateTime // 5 minutes from creation
|
|
usedAt DateTime? // When the code was used
|
|
deviceName String? // Name of device that used the code
|
|
apiKeyId String? // The API key created when code was used
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([code, expiresAt])
|
|
@@index([userId])
|
|
}
|
|
|
|
// ============================================
|
|
// Enums
|
|
// ============================================
|
|
|
|
enum DiscoverStatus {
|
|
ACTIVE // Currently in discover folder
|
|
LIKED // User liked, will be moved to permanent
|
|
MOVED // Already moved to permanent library
|
|
DELETED // Week ended, was not liked
|
|
}
|
|
|
|
enum ListenSource {
|
|
LIBRARY // From permanent /music folder
|
|
DISCOVERY // From /music/Discover, not yet liked
|
|
DISCOVERY_KEPT // Was discovery, user liked it
|
|
}
|
|
|
|
enum AlbumLocation {
|
|
LIBRARY // In /music
|
|
DISCOVER // In /music/Discover
|
|
}
|
|
|
|
// ============================================
|
|
// Notifications System
|
|
// ============================================
|
|
|
|
model Notification {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
type String // system, download_complete, playlist_ready, error, import_complete
|
|
title String
|
|
message String?
|
|
metadata Json? // { playlistId, albumId, artistId, etc. }
|
|
read Boolean @default(false)
|
|
cleared Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId, cleared])
|
|
@@index([userId, read])
|
|
@@index([createdAt])
|
|
}
|