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[] profiles ViewerProfile[] linkCodes DeviceLinkCode[] googlePlayAuditLogs GooglePlayAccessAuditLog[] iptvAddonToken AddonToken? @relation(fields: [iptvAddonTokenId], references: [id], onDelete: SetNull) @@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") 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([]) /** 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 + 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") }