Initial release v1.0.0
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
-- Add updatedAt column to Track if it doesn't exist
|
||||
-- This handles databases that were created before this column was added to the schema
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'Track' AND column_name = 'updatedAt'
|
||||
) THEN
|
||||
ALTER TABLE "Track" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,926 @@
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user