Files
lidify/backend/prisma/schema.prisma
2025-12-25 18:58:06 -06:00

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])
}