stremio/apps/master-api/prisma/schema.prisma
Jos Vooges | STH bd21b01956 Add full series download flow with episode and season import.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 02:30:40 +02:00

706 lines
25 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-gebruiker (Android/iOS/Tizen) — huishouden met optioneel IPTV + Stremio-addons. */
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 de apps (Xtream-lijn op deze gebruiker). */
iptvAccess Boolean @default(false) @map("iptv_access")
/** @deprecated Legacy: IPTV via addon-token; voorkeur = iptvLine op deze user. */
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[]
profiles ViewerProfile[]
linkCodes DeviceLinkCode[]
googlePlayAuditLogs GooglePlayAccessAuditLog[]
/** Legacy koppeling — behouden voor migratie. */
iptvAddonToken AddonToken? @relation("ViewerLegacyIptvToken", fields: [iptvAddonTokenId], references: [id], onDelete: SetNull)
iptvLine IptvLine?
addonTokens AddonToken[] @relation("ViewerAddonTokens")
@@map("viewer_users")
}
/** Netflix-stijl kijkprofiel onder één ViewerUser (voortgang/prefs per profiel). */
model ViewerProfile {
id String @id @default(uuid())
viewerUserId String @map("viewer_user_id")
name String
avatarId String @default("fox") @map("avatar_id")
isOwner Boolean @default(false) @map("is_owner")
isKids Boolean @default(false) @map("is_kids")
pinHash String? @map("pin_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
viewer ViewerUser @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
devices ViewerDevice[]
progress WatchProgress[]
shelfPrefs ViewerShelfPreference[]
favorites ViewerFavorite[]
@@unique([viewerUserId, name])
@@index([viewerUserId])
@@map("viewer_profiles")
}
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")
activeProfileId String? @map("active_profile_id")
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)
activeProfile ViewerProfile? @relation(fields: [activeProfileId], references: [id], onDelete: SetNull)
@@index([viewerUserId])
@@index([activeProfileId])
@@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())
profileId String @map("profile_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")
profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([profileId, movieId])
@@unique([profileId, episodeId])
@@index([profileId, updatedAt])
@@index([profileId, hiddenFromContinue, completed])
@@map("watch_progress")
}
model ViewerShelfPreference {
id String @id @default(uuid())
profileId String @map("profile_id")
shelfId String @map("shelf_id")
visible Boolean @default(true)
sortOrder Int @default(0) @map("sort_order")
profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([profileId, shelfId])
@@index([profileId, sortOrder])
@@map("viewer_shelf_preferences")
}
enum FavoriteMediaType {
MOVIE
SERIES
}
/** Favorieten/bookmarks per kijkprofiel — gesynchroniseerd over devices. */
model ViewerFavorite {
id String @id @default(uuid())
profileId String @map("profile_id")
mediaType FavoriteMediaType @map("media_type")
mediaId String @map("media_id")
createdAt DateTime @default(now()) @map("created_at")
profile ViewerProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([profileId, mediaType, mediaId])
@@index([profileId, createdAt])
@@map("viewer_favorites")
}
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")
/** Gebruiker (huishouden) waaraan deze Stremio-addon hangt. */
viewerUserId String? @map("viewer_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)
viewerUser ViewerUser? @relation("ViewerAddonTokens", fields: [viewerUserId], references: [id], onDelete: SetNull)
iptvLine IptvLine?
playbackSessions PlaybackSession[]
legacyIptvViewers ViewerUser[] @relation("ViewerLegacyIptvToken")
@@index([viewerUserId])
@@map("addon_tokens")
}
/** Xtream/XUI credentials — bij voorkeur per ViewerUser; legacy optioneel per AddonToken. */
model IptvLine {
id String @id @default(uuid())
/** Primair: IPTV-lijn van één app-gebruiker. */
viewerUserId String? @unique @map("viewer_user_id")
/** Legacy: IPTV direct op Stremio-addon (pre-merge). */
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")
viewerUser ViewerUser? @relation(fields: [viewerUserId], references: [id], onDelete: Cascade)
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([])
/** TMDB adult-flag; kids-profielen filteren hierop. */
adult Boolean @default(false)
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[]
@@index([adult])
@@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([])
/** TMDB adult-flag; kids-profielen filteren hierop. */
adult Boolean @default(false)
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[]
@@index([adult])
@@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 + library 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 1080p movies (e.g. …/Media/Films/Films) */
filmsHostPath String @default("") @map("films_host_path")
/** Absolute host path for 4K movies (e.g. …/Media/Films/4K) */
fourKHostPath String @map("four_k_host_path")
/** Absolute host path for series (e.g. …/Media/Series/Series) */
seriesHostPath String @default("") @map("series_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")
}
enum DownloadLibraryTarget {
FILMS
FOUR_K
SERIES
}
enum DownloadSeriesMode {
EPISODE
SEASON
}
model DownloadJob {
id String @id @default(uuid())
nodeId String @map("node_id")
status DownloadJobStatus @default(DOWNLOADING)
/** Where to import after DS finishes */
libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target")
/** Series only: single episode vs whole season pack */
seriesMode DownloadSeriesMode? @map("series_mode")
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")
seasonNumber Int? @map("season_number")
episodeNumber Int? @map("episode_number")
episodeTitle String? @map("episode_title")
/** Season pack tracking JSON */
episodesJson String? @map("episodes_json") @db.Text
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")
}