Streaming-Verfügbarkeit beantwortet nur die halbe Frage: manche Filme laufen nirgends mehr, die hat man gekauft und liegen zu Hause — in Jellyfin, Plex, Emby, auf dem NAS oder als Scheibe. Bewusst getrennt vom Feld „wo schaue ich das": das eine sagt, wo ein Titel gerade läuft, und das ändert sich ständig; das andere sagt, was einem gehört, und das bleibt. Auf der Detailseite steht die Sammlung deshalb vor der Verfügbarkeit — was man hat, ist die verlässlichere Antwort. Durchgezogen bis in Filter, Export, CSV-Import und REST-API.
519 lines
13 KiB
Plaintext
519 lines
13 KiB
Plaintext
// Flimmer — Datenmodell
|
||
//
|
||
// Aufbau in vier Schichten:
|
||
// 1. Konten & Zugang (User, Session, ApiKey, OidcAccount, AppSetting)
|
||
// 2. Katalog (Title, Season, Episode, WatchProvider …)
|
||
// — geteilt zwischen allen Nutzern, dedupliziert über die TMDB-ID
|
||
// 3. Persönliches Tracking (LibraryEntry, Watch, Tag …)
|
||
// 4. Universen (Universe, UniverseItem, UniverseOrder …)
|
||
|
||
generator client {
|
||
provider = "prisma-client"
|
||
output = "../src/generated/prisma"
|
||
runtime = "nodejs"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Konten & Zugang
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum Role {
|
||
ADMIN
|
||
USER
|
||
}
|
||
|
||
model User {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
name String
|
||
passwordHash String?
|
||
role Role @default(USER)
|
||
isActive Boolean @default(true)
|
||
|
||
/// Region für die Streaming-Verfügbarkeit (ISO-3166-1, z. B. "DE")
|
||
region String @default("DE")
|
||
locale String @default("de")
|
||
|
||
/// Öffentliche Profilseite unter /u/<profileSlug>
|
||
publicProfile Boolean @default(false)
|
||
profileSlug String? @unique
|
||
|
||
// Benachrichtigungswünsche
|
||
notifyNewEpisode Boolean @default(true)
|
||
notifyRelease Boolean @default(true)
|
||
notifyAvailability Boolean @default(false)
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
sessions Session[]
|
||
apiKeys ApiKey[]
|
||
oidcAccounts OidcAccount[]
|
||
entries LibraryEntry[]
|
||
watches Watch[]
|
||
tags Tag[]
|
||
universes Universe[] @relation("UniverseOwner")
|
||
memberships UniverseMember[]
|
||
channels NotificationChannel[]
|
||
notifications NotificationLog[]
|
||
|
||
@@index([createdAt])
|
||
}
|
||
|
||
model Session {
|
||
id String @id @default(cuid())
|
||
|
||
/// SHA-256 des Cookie-Tokens — das Klartext-Token verlässt den Server nie erneut
|
||
tokenHash String @unique
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
expiresAt DateTime
|
||
userAgent String?
|
||
ip String?
|
||
createdAt DateTime @default(now())
|
||
lastSeen DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
@@index([expiresAt])
|
||
}
|
||
|
||
model ApiKey {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
name String
|
||
/// Sichtbares Präfix zur Wiedererkennung, z. B. "flm_a1b2c3d4"
|
||
prefix String @unique
|
||
keyHash String @unique
|
||
|
||
lastUsedAt DateTime?
|
||
revokedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
model OidcAccount {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
issuer String
|
||
subject String
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([issuer, subject])
|
||
@@index([userId])
|
||
}
|
||
|
||
/// Instanzweite Einstellungen (Registrierung offen/zu, Instanzname …)
|
||
model AppSetting {
|
||
key String @id
|
||
value Json
|
||
updatedAt DateTime @updatedAt
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Katalog
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum TitleType {
|
||
SERIES
|
||
MOVIE
|
||
}
|
||
|
||
model Title {
|
||
id String @id @default(cuid())
|
||
type TitleType
|
||
|
||
/// TMDB-ID; null bei manuell angelegten Titeln ohne TMDB-Treffer
|
||
tmdbId Int?
|
||
imdbId String?
|
||
|
||
title String
|
||
originalTitle String?
|
||
overview String?
|
||
tagline String?
|
||
posterPath String?
|
||
backdropPath String?
|
||
|
||
/// Erstausstrahlung (Serie) bzw. Kinostart (Film)
|
||
releaseDate DateTime?
|
||
lastAirDate DateTime?
|
||
|
||
/// Laufzeit in Minuten: bei Filmen die Filmlänge, bei Serien die
|
||
/// durchschnittliche Episodenlänge (Rückfallwert für Episoden ohne Angabe)
|
||
runtime Int?
|
||
|
||
/// TMDB-Status: "Returning Series", "Ended", "Released", "Planned" …
|
||
status String?
|
||
inProduction Boolean @default(false)
|
||
|
||
genres String[]
|
||
originalLang String?
|
||
homepage String?
|
||
voteAverage Float?
|
||
|
||
seasonCount Int @default(0)
|
||
episodeCount Int @default(0)
|
||
|
||
/// Offizielle TMDB-Collection (z. B. „Spider-Man Collection")
|
||
tmdbCollectionId Int?
|
||
tmdbCollectionName String?
|
||
|
||
/// Zeitpunkt des letzten TMDB-Abgleichs
|
||
syncedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
seasons Season[]
|
||
episodes Episode[]
|
||
entries LibraryEntry[]
|
||
watches Watch[]
|
||
providers TitleProvider[]
|
||
universeItems UniverseItem[]
|
||
|
||
@@unique([type, tmdbId])
|
||
@@index([type])
|
||
@@index([releaseDate])
|
||
@@index([title])
|
||
@@index([tmdbCollectionId])
|
||
}
|
||
|
||
model Season {
|
||
id String @id @default(cuid())
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
|
||
tmdbId Int?
|
||
seasonNumber Int
|
||
name String
|
||
overview String?
|
||
posterPath String?
|
||
airDate DateTime?
|
||
episodeCount Int @default(0)
|
||
|
||
episodes Episode[]
|
||
|
||
@@unique([titleId, seasonNumber])
|
||
@@index([titleId])
|
||
}
|
||
|
||
model Episode {
|
||
id String @id @default(cuid())
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
seasonId String
|
||
season Season @relation(fields: [seasonId], references: [id], onDelete: Cascade)
|
||
|
||
tmdbId Int?
|
||
seasonNumber Int
|
||
episodeNumber Int
|
||
name String
|
||
overview String?
|
||
stillPath String?
|
||
airDate DateTime?
|
||
runtime Int?
|
||
|
||
watches Watch[]
|
||
|
||
@@unique([titleId, seasonNumber, episodeNumber])
|
||
@@index([titleId])
|
||
@@index([seasonId])
|
||
@@index([airDate])
|
||
}
|
||
|
||
/// Streamingdienst laut TMDB/JustWatch
|
||
model WatchProvider {
|
||
id String @id @default(cuid())
|
||
tmdbId Int @unique
|
||
name String
|
||
logoPath String?
|
||
/// Akzentfarbe für Badges im UI (Hex); fällt sonst auf einen Standardton zurück
|
||
color String?
|
||
|
||
titles TitleProvider[]
|
||
}
|
||
|
||
enum ProviderKind {
|
||
FLATRATE
|
||
RENT
|
||
BUY
|
||
ADS
|
||
FREE
|
||
}
|
||
|
||
model TitleProvider {
|
||
id String @id @default(cuid())
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
providerId String
|
||
provider WatchProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||
region String
|
||
kind ProviderKind
|
||
/// Für die Benachrichtigung „ist jetzt verfügbar"
|
||
firstSeenAt DateTime @default(now())
|
||
|
||
@@unique([titleId, providerId, region, kind])
|
||
@@index([titleId, region])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Persönliches Tracking
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum EntryStatus {
|
||
WATCHING
|
||
PAUSED
|
||
COMPLETED
|
||
DROPPED
|
||
WATCHLIST
|
||
}
|
||
|
||
model LibraryEntry {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
|
||
status EntryStatus @default(WATCHLIST)
|
||
|
||
/// Eigene Bewertung 1–10
|
||
rating Int?
|
||
notes String?
|
||
|
||
/// „Wo schaue ich das" — Vorschlag aus der Verfügbarkeit, überschreibbar
|
||
whereToWatch String?
|
||
|
||
/// Liegt der Titel in der eigenen Sammlung? Unabhängig davon, ob ihn gerade
|
||
/// ein Streamingdienst führt — gekaufte Filme laufen irgendwann nirgends mehr.
|
||
owned Boolean @default(false)
|
||
|
||
/// Wo genau: „Jellyfin", „Blu-ray", „NAS", „Plex" …
|
||
ownedSource String?
|
||
|
||
favorite Boolean @default(false)
|
||
|
||
/// Aktueller Durchlauf: 0 = Erstsichtung, 1 = erster Rewatch …
|
||
currentRun Int @default(0)
|
||
|
||
startedAt DateTime?
|
||
finishedAt DateTime?
|
||
|
||
/// Papierkorb: 30 Tage aufbewahren statt hart löschen
|
||
deletedAt DateTime?
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
tags TagOnEntry[]
|
||
watches Watch[]
|
||
|
||
@@unique([userId, titleId])
|
||
@@index([userId, status])
|
||
@@index([userId, updatedAt])
|
||
@@index([userId, owned])
|
||
@@index([deletedAt])
|
||
}
|
||
|
||
model Tag {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
name String
|
||
color String?
|
||
|
||
entries TagOnEntry[]
|
||
|
||
@@unique([userId, name])
|
||
}
|
||
|
||
model TagOnEntry {
|
||
tagId String
|
||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||
entryId String
|
||
entry LibraryEntry @relation(fields: [entryId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([tagId, entryId])
|
||
@@index([entryId])
|
||
}
|
||
|
||
/// Ein Sichtungsvorgang: entweder eine Episode oder ein Film.
|
||
/// Rewatches werden über `run` gezählt (0 = Erstsichtung).
|
||
model Watch {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
entryId String?
|
||
entry LibraryEntry? @relation(fields: [entryId], references: [id], onDelete: SetNull)
|
||
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
episodeId String?
|
||
episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||
|
||
run Int @default(0)
|
||
watchedAt DateTime @default(now())
|
||
|
||
/// Tatsächlich angerechnete Minuten — friert die Laufzeit zum Zeitpunkt der
|
||
/// Sichtung ein, damit spätere TMDB-Korrekturen die Statistik nicht verbiegen.
|
||
minutes Int @default(0)
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([userId, episodeId, run])
|
||
@@index([userId, watchedAt])
|
||
@@index([userId, titleId])
|
||
@@index([entryId])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Universen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
model Universe {
|
||
id String @id @default(cuid())
|
||
ownerId String
|
||
owner User @relation("UniverseOwner", fields: [ownerId], references: [id], onDelete: Cascade)
|
||
name String
|
||
slug String
|
||
description String?
|
||
coverPath String?
|
||
|
||
deletedAt DateTime?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
items UniverseItem[]
|
||
orders UniverseOrder[]
|
||
members UniverseMember[]
|
||
|
||
@@unique([ownerId, slug])
|
||
@@index([ownerId])
|
||
}
|
||
|
||
enum SharePermission {
|
||
READ
|
||
WRITE
|
||
}
|
||
|
||
model UniverseMember {
|
||
id String @id @default(cuid())
|
||
universeId String
|
||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
permission SharePermission @default(READ)
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([universeId, userId])
|
||
@@index([userId])
|
||
}
|
||
|
||
model UniverseItem {
|
||
id String @id @default(cuid())
|
||
universeId String
|
||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||
titleId String
|
||
title Title @relation(fields: [titleId], references: [id], onDelete: Cascade)
|
||
note String?
|
||
createdAt DateTime @default(now())
|
||
|
||
positions UniverseOrderItem[]
|
||
|
||
@@unique([universeId, titleId])
|
||
@@index([universeId])
|
||
}
|
||
|
||
enum OrderKind {
|
||
/// Automatisch aus den Erscheinungsdaten berechnet
|
||
RELEASE
|
||
/// Frei sortierbar (Story-Chronologie, Einsteiger-Reihenfolge …)
|
||
CUSTOM
|
||
}
|
||
|
||
model UniverseOrder {
|
||
id String @id @default(cuid())
|
||
universeId String
|
||
universe Universe @relation(fields: [universeId], references: [id], onDelete: Cascade)
|
||
name String
|
||
kind OrderKind @default(CUSTOM)
|
||
isDefault Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
items UniverseOrderItem[]
|
||
|
||
@@index([universeId])
|
||
}
|
||
|
||
model UniverseOrderItem {
|
||
id String @id @default(cuid())
|
||
orderId String
|
||
order UniverseOrder @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||
itemId String
|
||
item UniverseItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
position Int
|
||
|
||
@@unique([orderId, itemId])
|
||
@@index([orderId, position])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Benachrichtigungen & Hintergrundläufe
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum ChannelKind {
|
||
EMAIL
|
||
NTFY
|
||
GOTIFY
|
||
WEBHOOK
|
||
}
|
||
|
||
model NotificationChannel {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
kind ChannelKind
|
||
name String
|
||
/// Kanalspezifische Einstellungen (URL, Topic, Token …)
|
||
config Json
|
||
enabled Boolean @default(true)
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([userId])
|
||
}
|
||
|
||
model NotificationLog {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
/// Verhindert Doppelversand, z. B. "episode:<episodeId>"
|
||
dedupeKey String
|
||
subject String
|
||
body String
|
||
sentAt DateTime @default(now())
|
||
ok Boolean @default(true)
|
||
error String?
|
||
|
||
@@unique([userId, dedupeKey])
|
||
@@index([userId, sentAt])
|
||
}
|
||
|
||
model SyncRun {
|
||
id String @id @default(cuid())
|
||
kind String
|
||
startedAt DateTime @default(now())
|
||
finishedAt DateTime?
|
||
ok Boolean @default(false)
|
||
message String?
|
||
stats Json?
|
||
|
||
@@index([kind, startedAt])
|
||
}
|