Files
flimmer/prisma/schema.prisma
T
Florian Wilde dd2b7dd48b
CI / pruefen (pull_request) Canceled after 0s
Jellyfin anbinden: Bestand und Sichtungen uebernehmen
Zugeordnet wird ueber die TMDB-Kennung, die Jellyfin zu jedem Item
mitliefert — dieselbe Kennung, auf der Flimmers Katalog ohnehin aufbaut. Eine
unscharfe Suche ueber Titel und Jahr ist damit nicht noetig: die Zuordnung ist
eindeutig oder sie findet nicht statt. Was Jellyfin ohne Kennung fuehrt,
landet im Bericht statt falsch zugeordnet zu werden.

Angemeldet wird mit Benutzername und Passwort, nicht mit einem API-Schluessel.
Mit einem Schluessel liefert Jellyfin den Bestand ohne UserData, also ohne
Gesehen-Status — und zwar ohne Fehlermeldung. Der Verbindungstest prueft
deshalb ausdruecklich nach und sagt es, statt den Abgleich stumm ins Leere
laufen zu lassen. Das Zugangstoken liegt mit AES-256-GCM verschluesselt in der
Datenbank; abgeleitet aus AUTH_SECRET.

Der Abgleich ist eine Einbahnstrasse: er ergaenzt, was auf dem Server liegt
oder dort gelaufen ist, und loescht nie etwas, das in Flimmer steht. Fehlende
Titel werden nur aufgenommen, wenn das Haekchen gesetzt ist — im taeglichen
Lauf also nie.

Entdoppelt wird zweifach: Episoden ueber (userId, episodeId, run), Filme
ausdruecklich per Nachschlagen. Bei Filmen ist episodeId null, und Postgres
behandelt NULL im eindeutigen Index als jeweils verschieden — der Index greift
dort nicht.

Watch.source haelt fest, was von wo kam. Ohne diese Angabe liesse sich spaeter
nicht mehr sagen, welche Sichtung aus Jellyfin stammt.

Umgesetzt sind Stufe 1 und 2. Stufe 3 (Zurueckschreiben) kommt in der
Abnahmeliste nicht vor und braeuchte eine Versionsweiche, die sich ohne echten
Server nicht verlaesslich pruefen laesst; das Feld writeBack ist angelegt, die
Logik dahinter bleibt leer.

Elf Tests gegen nachgebildete Antworten. Zusaetzlich der ganze Klickweg gegen
einen echten HTTP-Server im Produktionsbau: verbinden, pruefen, abgleichen,
zweiter Lauf ohne Dubletten, und der Warnfall ohne UserData.

Refs #1
2026-09-02 09:24:38 +02:00

631 lines
17 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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[]
passkeys Passkey[]
mediaServers MediaServerConnection[]
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])
}
/// Ein Passkey (WebAuthn) als dritter Anmeldeweg neben Passwort und OIDC.
model Passkey {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Kennung des Authenticators, base64url — die Gegenstelle nennt sie beim Anmelden.
credentialId String @unique
/// Öffentlicher Schlüssel im COSE-Format.
publicKey Bytes
/// Signaturzähler. Zählt er nicht hoch, wurde die Antwort wiedereingespielt.
counter BigInt @default(0)
/// usb, nfc, ble, internal, hybrid — hilft dem Browser bei der Auswahl.
transports String[] @default([])
/// singleDevice oder multiDevice. Ein nicht gesicherter Passkey ist mit dem
/// Gerät verloren — davor warnt die Oberfläche.
deviceType String @default("singleDevice")
backedUp Boolean @default(false)
/// Vom Nutzer vergeben: „iPhone", „Yubikey am Schlüsselbund".
name String
createdAt DateTime @default(now())
lastUsedAt DateTime?
@@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[]
serverItems MediaServerItem[]
@@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[]
serverItems MediaServerItem[]
@@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())
/// Woher die Sichtung stammt. Ohne diese Angabe liesse sich spaeter nicht
/// mehr sagen, was von einem Medienserver kam — und ein Zuruecknehmen des
/// Abgleichs waere unmoeglich.
source WatchSource @default(FLIMMER)
/// 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])
}
// ---------------------------------------------------------------------------
// Medienserver (Jellyfin, Emby)
// ---------------------------------------------------------------------------
enum WatchSource {
FLIMMER
JELLYFIN
}
enum MediaServerKind {
JELLYFIN
EMBY
}
/// Verbindung zu einem Medienserver. Je Nutzer und Server eine.
model MediaServerConnection {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kind MediaServerKind @default(JELLYFIN)
/// Anzeigename des Servers, wie ihn Jellyfin meldet.
name String @default("Jellyfin")
baseUrl String
/// Zugangstoken, mit AUTH_SECRET verschluesselt — im Klartext hat er hier
/// nichts verloren.
token String
/// Kennung des Jellyfin-Nutzers, dessen Bestand und Sichtungen zaehlen.
serverUserId String
/// Anmeldename, nur zur Anzeige.
serverUserName String @default("")
enabled Boolean @default(true)
syncOwned Boolean @default(true)
syncWatched Boolean @default(true)
/// Stufe 3: Haekchen aus Flimmer zurueck nach Jellyfin schreiben.
writeBack Boolean @default(false)
lastSyncAt DateTime?
lastSyncReport Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items MediaServerItem[]
@@unique([userId, baseUrl])
@@index([userId])
}
/// Bereits zugeordnete Items. Macht Folgelaeufe billig und ist die
/// Voraussetzung fuers Zurueckschreiben.
model MediaServerItem {
id String @id @default(cuid())
connectionId String
connection MediaServerConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
/// Item-Kennung auf dem Medienserver.
externalId String
titleId String?
title Title? @relation(fields: [titleId], references: [id], onDelete: Cascade)
episodeId String?
episode Episode? @relation(fields: [episodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([connectionId, externalId])
@@index([connectionId])
}