stremio/apps/master-api/prisma/schema.prisma
Jos Vooges | STH 826e95aaab Fix Recent sorting with stable discoveredAt on media files.
Backfill from file mtime and set discoveredAt on first index (and size changes) so new titles rise to the top instead of bulk-import path order (W/Z).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 01:00:18 +02:00

619 lines
22 KiB
Text

generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum NodeStatus {
ONLINE
OFFLINE
DEGRADED
REVOKED
UPDATING
SCANNING
ERROR
}
enum PlaybackSessionStatus {
ACTIVE
IDLE
EXPIRED
REVOKED
COMPLETED
}
enum LocationType {
LOCAL
REMOTE
}
enum LibraryShelfKind {
MOVIE
SERIES
}
enum DownloadJobStatus {
DOWNLOADING
FINISHED
IMPORTING
IMPORTED
FAILED
}
enum GooglePlaySyncStatus {
NOT_ENABLED
PENDING_ADD
ACTIVE
PENDING_REMOVE
REMOVED
ERROR
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
name String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
addonTokens AddonToken[]
sessions AdminSession[]
@@map("users")
}
/** App-kijker (Android TV / phone) — apart van admin User. */
model ViewerUser {
id String @id @default(uuid())
email String @unique
passwordHash String @map("password_hash")
name String?
enabled Boolean @default(true)
appAccess Boolean @default(true) @map("app_access")
/** Live TV in Android-app (Xtream via gekoppelde addon-token). */
iptvAccess Boolean @default(false) @map("iptv_access")
iptvAddonTokenId String? @map("iptv_addon_token_id")
/** Google-account voor Play Closed Testing; fallback = email */
googlePlayEmail String? @map("google_play_email")
/** Gewenste Play-distributietoegang (sync met Google Group) */
googlePlayAccess Boolean @default(false) @map("google_play_access")
googlePlayMembershipId String? @map("google_play_membership_id")
googlePlaySyncStatus GooglePlaySyncStatus @default(NOT_ENABLED) @map("google_play_sync_status")
googlePlayLastSyncedAt DateTime? @map("google_play_last_synced_at")
googlePlaySyncError String? @map("google_play_sync_error") @db.Text
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
devices ViewerDevice[]
progress WatchProgress[]
shelfPrefs ViewerShelfPreference[]
linkCodes DeviceLinkCode[]
googlePlayAuditLogs GooglePlayAccessAuditLog[]
iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
@@map("viewer_users")
}
model ViewerDevice {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
name String
platform String @default("android_tv")
refreshTokenHash String @unique @map("refresh_token_hash")
lastSeenAt DateTime? @map("last_seen_at")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@index([viewerUserId])
@@map("viewer_devices")
}
/** Kortstondige koppelcode (TV toont code → gebruiker claimt op /link). */
model DeviceLinkCode {
id String @id @default(uuid())
codeHash String @unique @map("code_hash")
/** Display-code (alleen tot claim; daarna null) */
codeDisplay String? @map("code_display")
deviceName String @map("device_name")
platform String @default("android_tv")
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
viewerUserId String? @map("viewer_user_id")
/** Eenmalig token voor device poll na claim */
claimTokenHash String? @unique @map("claim_token_hash")
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser? @relation(fields: [viewerUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
@@map("device_link_codes")
}
enum WatchMediaType {
MOVIE
EPISODE
}
model WatchProgress {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
mediaType WatchMediaType @map("media_type")
movieId String? @map("movie_id")
episodeId String? @map("episode_id")
seriesId String? @map("series_id")
positionMs Int @default(0) @map("position_ms")
durationMs Int @default(0) @map("duration_ms")
completed Boolean @default(false)
hiddenFromContinue Boolean @default(false) @map("hidden_from_continue")
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, movieId])
@@unique([viewerUserId, episodeId])
@@index([viewerUserId, updatedAt])
@@index([viewerUserId, hiddenFromContinue, completed])
@@map("watch_progress")
}
model ViewerShelfPreference {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
shelfId String @map("shelf_id")
visible Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@unique([viewerUserId, shelfId])
@@index([viewerUserId, sortOrder])
@@map("viewer_shelf_preferences")
}
model AdminSession {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("admin_sessions")
}
model AddonToken {
id String @id @default(uuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
label String?
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
iptvLine IptvLine?
playbackSessions PlaybackSession[]
iptvViewers ViewerUser[]
@@map("addon_tokens")
}
/** Xtream/XUI credentials bound to one Stremio addon install (token). */
model IptvLine {
id String @id @default(uuid())
addonTokenId String @unique @map("addon_token_id")
label String?
baseUrl String @map("base_url")
username String
/** AES-GCM ciphertext (base64) */
passwordEnc String @map("password_enc")
enableLive Boolean @default(true) @map("enable_live")
enableVod Boolean @default(true) @map("enable_vod")
enableSeries Boolean @default(true) @map("enable_series")
lastOkAt DateTime? @map("last_ok_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
addonToken AddonToken @relation(fields: [addonTokenId], references: [id], onDelete: Cascade)
@@map("iptv_lines")
}
model Location {
id String @id @default(uuid())
name String @unique
description String?
priority Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
nodes Node[]
@@map("locations")
}
model Node {
id String @id @default(uuid())
name String
hostname String?
locationId String? @map("location_id")
locationType LocationType @default(LOCAL) @map("location_type")
status NodeStatus @default(OFFLINE)
version String?
architecture String?
publicStreamUrl String @map("public_stream_url")
lastSeen DateTime? @map("last_seen")
totalStorage BigInt @default(0) @map("total_storage")
freeStorage BigInt @default(0) @map("free_storage")
activeStreams Int @default(0) @map("active_streams")
currentBandwidth BigInt @default(0) @map("current_bandwidth")
libraryFileCount Int @default(0) @map("library_file_count")
lastRevision Int @default(0) @map("last_revision")
/** Node scanner: "off" or duration like "24h" / "6h" */
fullScanInterval String @default("off") @map("full_scan_interval")
/** Nightly catch-up time HH:MM in node local TZ */
fullScanAt String @default("03:30") @map("full_scan_at")
/** Host/container scan roots for movies (e.g. /mnt/user/NL Films) — legacy; prefer scanRoots */
moviesPaths String[] @default([]) @map("movies_paths")
/** Host/container scan roots for series — legacy; prefer scanRoots */
seriesPaths String[] @default([]) @map("series_paths")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
location Location? @relation(fields: [locationId], references: [id])
credentials NodeCredential?
mediaFiles MediaFile[]
playbackSessions PlaybackSession[]
scanRoots NodeScanRoot[]
synologyDsConfig SynologyDsConfig?
downloadJobs DownloadJob[]
@@map("nodes")
}
/** Named Stremio catalog / shelf shared across all nodes (e.g. Kids Films). */
model LibraryShelf {
id String @id @default(uuid())
name String
homepageName String? @map("homepage_name")
slug String @unique
kind LibraryShelfKind
sortOrder Int @default(0) @map("sort_order")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
scanRoots NodeScanRoot[]
mediaFiles MediaFile[]
@@map("library_shelves")
}
/** One filesystem root on a node bound to a library shelf. */
model NodeScanRoot {
id String @id @default(uuid())
nodeId String @map("node_id")
path String
shelfId String @map("shelf_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
shelf LibraryShelf @relation(fields: [shelfId], references: [id], onDelete: Restrict)
@@unique([nodeId, path])
@@index([shelfId])
@@map("node_scan_roots")
}
model NodeCredential {
id String @id @default(uuid())
nodeId String @unique @map("node_id")
apiKeyHash String @map("api_key_hash")
createdAt DateTime @default(now()) @map("created_at")
rotatedAt DateTime? @map("rotated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@map("node_credentials")
}
model NodeEnrollmentToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
name String?
locationId String? @map("location_id")
expiresAt DateTime @map("expires_at")
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("node_enrollment_tokens")
}
model Movie {
id String @id @default(uuid())
title String
originalTitle String? @map("original_title")
year Int?
overview String?
posterUrl String? @map("poster_url")
backdropUrl String? @map("backdrop_url")
logoUrl String? @map("logo_url")
genres String[] @default([])
runtime Int?
imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
mediaFiles MediaFile[]
matches LibraryMatch[]
subtitles LibrarySubtitle[]
@@map("movies")
}
model Series {
id String @id @default(uuid())
title String
year Int?
overview String?
posterUrl String? @map("poster_url")
backdropUrl String? @map("backdrop_url")
logoUrl String? @map("logo_url")
genres String[] @default([])
imdbId String? @unique @map("imdb_id")
tmdbId Int? @unique @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
seasons Season[]
episodes Episode[]
matches LibraryMatch[]
@@map("series")
}
model Season {
id String @id @default(uuid())
seriesId String @map("series_id")
seasonNumber Int @map("season_number")
series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade)
episodes Episode[]
@@unique([seriesId, seasonNumber])
@@map("seasons")
}
model Episode {
id String @id @default(uuid())
seriesId String @map("series_id")
seasonId String @map("season_id")
seasonNumber Int @map("season_number")
episodeNumber Int @map("episode_number")
title String?
overview String?
stillUrl String? @map("still_url")
runtime Int?
airDate DateTime? @map("air_date")
imdbId String? @map("imdb_id")
tmdbId Int? @map("tmdb_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
series Series @relation(fields: [seriesId], references: [id], onDelete: Cascade)
season Season @relation(fields: [seasonId], references: [id], onDelete: Cascade)
mediaFiles MediaFile[]
matches LibraryMatch[]
@@unique([seriesId, seasonNumber, episodeNumber])
@@map("episodes")
}
model MediaFile {
id String @id @default(uuid())
nodeId String @map("node_id")
movieId String? @map("movie_id")
episodeId String? @map("episode_id")
shelfId String? @map("shelf_id")
localFileId String @map("local_file_id")
sizeBytes BigInt @map("size_bytes")
container String?
resolution String?
videoCodec String? @map("video_codec")
videoBitrate Int? @map("video_bitrate")
hdrType String? @map("hdr_type")
audioCodec String? @map("audio_codec")
audioChannels String? @map("audio_channels")
audioLanguage String? @map("audio_language")
releaseName String @map("release_name")
available Boolean @default(true)
lastSeen DateTime @default(now()) @map("last_seen")
/// Eerste keer dat dit bestand in de library kwam (wijzigt niet bij rematch/upsert).
discoveredAt DateTime @default(now()) @map("discovered_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
movie Movie? @relation(fields: [movieId], references: [id])
episode Episode? @relation(fields: [episodeId], references: [id])
shelf LibraryShelf? @relation(fields: [shelfId], references: [id], onDelete: SetNull)
playbackSessions PlaybackSession[]
@@unique([nodeId, localFileId])
@@index([shelfId])
@@index([discoveredAt])
@@map("media_files")
}
model LibraryMatch {
id String @id @default(uuid())
movieId String? @map("movie_id")
seriesId String? @map("series_id")
episodeId String? @map("episode_id")
releaseName String @map("release_name")
ignored Boolean @default(false)
manual Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
movie Movie? @relation(fields: [movieId], references: [id])
series Series? @relation(fields: [seriesId], references: [id])
episode Episode? @relation(fields: [episodeId], references: [id])
@@map("library_matches")
}
model PlaybackSession {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String? @map("user_id")
addonTokenId String? @map("addon_token_id")
nodeId String @map("node_id")
mediaFileId String @map("media_file_id")
status PlaybackSessionStatus @default(ACTIVE)
idleTimeoutSeconds Int @map("idle_timeout_seconds")
absoluteExpiresAt DateTime @map("absolute_expires_at")
lastActivity DateTime @default(now()) @map("last_activity")
bytesSent BigInt @default(0) @map("bytes_sent")
lastByteOffset BigInt @default(0) @map("last_byte_offset")
fileSizeBytes BigInt @default(0) @map("file_size_bytes")
revoked Boolean @default(false)
createdAt DateTime @default(now()) @map("created_at")
node Node @relation(fields: [nodeId], references: [id])
mediaFile MediaFile @relation(fields: [mediaFileId], references: [id])
addonToken AddonToken? @relation(fields: [addonTokenId], references: [id], onDelete: SetNull)
@@index([status, lastActivity])
@@index([addonTokenId])
@@map("playback_sessions")
}
/** Synology Download Station credentials + fixed 4K import paths (per node). */
model SynologyDsConfig {
id String @id @default(uuid())
nodeId String @unique @map("node_id")
/** DSM base URL, e.g. https://nas.local:5001 */
baseUrl String @map("base_url")
username String
passwordEnc String @map("password_enc")
/** DS task destination (share-relative), e.g. Downloads/complete */
downloadDestination String @map("download_destination")
/** Absolute host path of that destination on the media-node */
downloadHostPath String @map("download_host_path")
/** Absolute host path for 4K movies library folder */
fourKHostPath String @map("four_k_host_path")
enabled Boolean @default(true)
lastOkAt DateTime? @map("last_ok_at")
lastError String? @map("last_error")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@map("synology_ds_configs")
}
model DownloadJob {
id String @id @default(uuid())
nodeId String @map("node_id")
status DownloadJobStatus @default(DOWNLOADING)
sourceUri String @map("source_uri")
sourceTitle String @map("source_title")
dsTaskId String? @map("ds_task_id")
tmdbId Int? @map("tmdb_id")
imdbId String? @map("imdb_id")
movieTitle String @map("movie_title")
movieYear Int? @map("movie_year")
posterUrl String? @map("poster_url")
destFolderName String? @map("dest_folder_name")
sourcePath String? @map("source_path")
importedPath String? @map("imported_path")
releaseTags String? @map("release_tags")
subtitleFileId Int? @map("subtitle_file_id")
subtitleLang String? @map("subtitle_lang")
subtitleSource String? @map("subtitle_source")
/** Cached subtitle body (downloaded at job create) */
subtitleContent String? @map("subtitle_content") @db.Text
subtitleError String? @map("subtitle_error")
subtitlePath String? @map("subtitle_path")
error String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
@@index([status, updatedAt])
@@index([nodeId, status])
@@map("download_jobs")
}
/** Singleton-style integration credentials (OpenSubtitles, etc.). */
model IntegrationSetting {
id String @id
apiKeyEnc String? @map("api_key_enc")
username String?
passwordEnc String? @map("password_enc")
/** Must match OpenSubtitles.com API Consumer / User-Agent exactly */
userAgent String? @map("user_agent")
/** Non-secret JSON config (e.g. Google Play group/project fields) */
configJson String? @map("config_json") @db.Text
enabled Boolean @default(true)
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("integration_settings")
}
/** Sidecar / OpenSubtitles text stored for Stremio stream.subtitles. */
model LibrarySubtitle {
id String @id @default(uuid())
movieId String? @map("movie_id")
tmdbId Int? @map("tmdb_id")
imdbId String? @map("imdb_id")
language String @default("nl")
format String @default("srt")
content String
fileName String? @map("file_name")
downloadJobId String? @map("download_job_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
movie Movie? @relation(fields: [movieId], references: [id], onDelete: SetNull)
@@index([movieId])
@@index([tmdbId])
@@index([imdbId])
@@map("library_subtitles")
}
/** Audit trail voor Google Play group-lidmaatschap (geen secrets). */
model GooglePlayAccessAuditLog {
id String @id @default(uuid())
adminUserId String? @map("admin_user_id")
adminEmail String? @map("admin_email")
viewerUserId String @map("viewer_user_id")
targetEmail String @map("target_email")
action String
result String
membershipId String? @map("membership_id")
errorMessage String? @map("error_message") @db.Text
createdAt DateTime @default(now()) @map("created_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
@@index([viewerUserId, createdAt])
@@map("google_play_access_audit_logs")
}