6 Commits
Author SHA1 Message Date
florian.wilde 57151a770e Merge #19: Version 0.9.0
CI / pruefen (push) Canceled after 0s
Release / veroeffentlichen (push) Canceled after 0s
2026-09-02 12:47:52 +00:00
Florian Wilde 09625c89ef Version 0.9.0
CI / pruefen (pull_request) Canceled after 0s
Errungenschaften und die Auswahlliste fuer unbekannte Jellyfin-Titel.
Changelog, Release-Notiz, DEVLOG und Projektgedaechtnis nachgezogen.

Die Anleitung hat ihre beiden Abschnitte schon mit den jeweiligen Issues
bekommen — Errungenschaften mit Screenshot, „Titel, die Flimmer noch nicht
kennt" im Jellyfin-Kapitel.
2026-09-02 14:47:23 +02:00
florian.wilde f8d5566865 Merge #18: Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (push) Canceled after 0s
Closes #16
2026-09-02 11:27:26 +00:00
florian.wilde 6847d08011 Merge #17: Errungenschaften — Abzeichen fuer erreichte Meilensteine
CI / pruefen (push) Canceled after 0s
Closes #15
2026-09-02 11:27:11 +00:00
Florian Wilde 7f890155ae Unbekannte Jellyfin-Titel zur Auswahl stellen und nachladen
CI / pruefen (pull_request) Canceled after 0s
Der Abgleich ordnete nur gegen Titel zu, die Flimmer schon kannte. Auf einer
frisch aufgesetzten Instanz meldet Jellyfin damit tausend Eintraege und
Flimmer ordnet keinen einzigen zu — der Bericht konnte nur aufzaehlen, was er
nicht einsortieren konnte.

Aus dem Bericht wird eine Auswahlliste: anhaken, was uebernommen werden soll,
und nur das aus TMDB nachladen. Ein pauschales Haekchen waere bei tausend
Eintraegen keine Wahl gewesen — in einer Mediathek liegen Konzertmitschnitte
neben dem, was man wirklich verfolgt.

Der Bericht ist dafuer umgebaut:

- Zusammengefasst je TMDB-Kennung. Jellyfin fuehrt oft mehrere Dateien zum
  selben Film; in der Liste steht er einmal, mit der Zahl der Dateien.
- Die Grenze stand bei 50 und war bei ueber tausend gelesenen Eintraegen ein
  Blick durchs Schluesselloch. Jetzt 1000, und was darueber hinausgeht, wird
  gezaehlt statt stillschweigend abgeschnitten.
- Der Typ kommt aus dem Item statt aus dem Umkehrschluss "keine Episode, also
  Film". Eine Probe zeigte sonst eine Serie als Film.

Nachgeladen wird nur auf Klick, hoechstens hundert je Lauf, und ein einzelner
Fehlschlag beendet den Lauf nicht. Aus der Liste verschwindet nur, was
tatsaechlich ankam: ein Fehlschlag laesst den Eintrag stehen, damit man es
erneut versuchen kann, ohne den ganzen Abgleich zu wiederholen. Mein erster
Entwurf strich auch Gescheiterte — der Test hielt das falsche Verhalten fest
und wurde mitkorrigiert.

Eintraege ohne TMDB-Kennung bleiben sichtbar, aber nicht auswaehlbar: dort
fehlt die Kennung in Jellyfin selbst, da kann Flimmer nichts holen.

Fuenf Tests dazu. Der Importschritt selbst laesst sich lokal nur ueber die
Domaenenschicht pruefen — der Entwicklungs-Stub speist sich aus dem Katalog
und kann per Definition nichts liefern, was dort fehlt. Der Klickweg belegt
Liste, Entdopplung, Suche, Auswahl, die gesperrten Zeilen und die ehrliche
Fehlermeldung.

Refs #16
2026-09-02 13:12:19 +02:00
Florian Wilde b560ee9c51 Errungenschaften: Abzeichen fuer erreichte Meilensteine
CI / pruefen (pull_request) Canceled after 0s
Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen
und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und
bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene
Sichtung soll kein Abzeichen kosten.

Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst
stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden
zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen
und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine
gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest.

Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst,
und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie
"Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag,
greift der generische Name.

Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten
TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis
geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name
nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch
abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche
Abzeichen zu geben.

Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die
formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde
"03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt.

Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie
bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt.

Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem
gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch
Farbe, sondern auch im Text.

Refs #15
2026-09-02 12:09:51 +02:00
30 changed files with 2295 additions and 38 deletions
+93
View File
@@ -7,6 +7,99 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.9.0] — 2026-09-02
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
gewachsenen Mediathek kaum etwas ausrichtete.
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
> Sicherung an.
### Hinzugefügt
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
unten auf der Statistikseite.
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
komplett — …".
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
Profil lassen sich die Abzeichen zeigen oder ausblenden.
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
weil der Katalog sie nicht kennt.
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
mit „hab ich · Jellyfin" in die Bibliothek auf.
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
nichts holen.
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
nur, was tatsächlich ankam**.
### Geändert
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
und was darüber hinausgeht, wird gezählt statt stillschweigend
abgeschnitten.
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
nichts bewirkte.
- Einblendungen kennen einen dritten Ton für Erfolge.
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
auf; sie legte sich sonst über die Überschrift.
### Behoben
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
### Getestet
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
Zusammenfassung, Fehlschlägen und Rechteprüfung.
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
daraus nichts. Das Abzeichen wäre nie vergeben worden.
### Anleitung
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
## [0.8.0] — 2026-09-02 ## [0.8.0] — 2026-09-02
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**. Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
+2
View File
@@ -115,6 +115,8 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min. - ✅ **Nacharbeiten** (v0.7.0/0.7.1) — Docker verifiziert, „hab ich", `update.sh`. 51 min.
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge, - ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min. Rückmeldung beim Tippen, Titelraster, Filmsichtung zurücknehmen. 77 min.
- ✅ **Zweite Runde** (v0.9.0) — Errungenschaften, Auswahlliste für unbekannte
Jellyfin-Titel. 37 min.
Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue, Ab v0.8.0 wird **issue-getrieben** gearbeitet: eigener Branch je Issue,
Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG. Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG.
+37 -1
View File
@@ -146,6 +146,41 @@ selbst — Changelog, Screenshots, Release.
--- ---
## Zweite Issue-Runde (v0.9.0)
| # | Issue | Dauer | Was passiert ist |
|---|---|---|---|
| 9.1 | #15 Errungenschaften | 22 min | Regelkatalog auf einem gemeinsamen Datensatz; Filmreihen über die TMDB-Zugehörigkeit statt einer gepflegten Liste |
| 9.2 | #16 Titel nachladen | 15 min | Aus dem Bericht wird eine Auswahlliste; was Flimmer nicht kennt, kommt auf Wunsch aus TMDB |
**Issues gesamt: 37 min** (2026-09-02, 09:47 – 11:13)
Dazu ohne Zeiterfassung: zwei Issues angelegt (#15 und #16), diese Version
selbst und die Nachträge an der Anleitung.
### Was dabei auffiel
1. **Ein Test hat eine Formatierungsfalle aufgedeckt.** Die Regel „zwischen
drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette; auf
Deutsch liefert `Intl` dafür „03 Uhr", und `Number("03 Uhr")` ist `NaN`.
Das Abzeichen wäre nie vergeben worden.
2. **Ich hatte ein fragwürdiges Verhalten in einem Test festgeschrieben.**
Der erste Entwurf strich auch *gescheiterte* Titel aus der Auswahlliste,
und der Test hielt das fest. Erst beim Durchklicken fiel auf, wie ärgerlich
das wäre — Code und Test wurden korrigiert.
3. **Ein Klickweg hat eine falsche Beschriftung gezeigt.** Eine Serie stand
als „Film" in der Liste, weil der Typ aus dem Umkehrschluss „keine Episode,
also Film" kam.
4. **Der Entwicklungs-Stub kann nur suchen.** Er speist sich aus dem lokalen
Katalog und kann deshalb per Definition nichts liefern, was dort fehlt —
der Importpfad ließ sich nur über Fixtures belegen, nicht über die
Oberfläche.
5. **Zwei parallele Branches, kein Konflikt.** Beide zweigten von `main` ab
und fassten dieselben drei Dateien an. Der Merge lief glatt; geprüft wurde
trotzdem die Kombination auf `main`, nicht nur die einzelnen Zweige.
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -158,8 +193,9 @@ selbst — Changelog, Screenshots, Release.
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min | | 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min | | Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 min |
| Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min | | Issue-Runde — Passkeys, Jellyfin, vier Nacharbeiten | 0.8.0 | 77 min |
| Zweite Runde — Errungenschaften, Titel nachladen | 0.9.0 | 37 min |
**Gesamtentwicklungszeit: 4 h 24 min** (264 min, 2026-09-01 18:09 – 2026-09-02 07:25) **Gesamtentwicklungszeit: 5 h 01 min** (301 min, 2026-09-01 18:09 – 2026-09-02 11:13)
### Womit die Zeit verging ### Womit die Zeit verging
+72
View File
@@ -237,6 +237,46 @@ Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit.
--- ---
## Errungenschaften
![Errungenschaften](screenshots/23-errungenschaften.webp)
Unten auf der Statistikseite sammeln sich **Abzeichen**. Sie tauchen auf, wenn
etwas erreicht ist — mehr passiert nicht. Keine Serien, die reißen, wenn du
eine Woche nichts schaust, keine Balken, die dich antreiben.
Ein paar davon:
- **Abgehakt** — die erste Serie vollständig durchgeschaut
- **Der lange Atem** — eine Serie mit mindestens hundert Episoden komplett
- **Treu geblieben** — über ein Jahr an demselben Titel drangeblieben
- **Durchgemacht** — zwischen drei und fünf Uhr morgens etwas abgehakt
- **Zeitreisender** — Titel aus sieben Jahrzehnten gesehen
- **Strenges Urteil** — einmal die Note 1 vergeben
Dazu kommen Abzeichen für **ganze Filmreihen**: Wer alle Spider-Man-Filme
gesehen hat, bekommt sie als solche angerechnet. Flimmer erkennt das an der
TMDB-Kennung, die zu jedem Film mitgeliefert wird — es braucht keine gepflegte
Liste. Für bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht
„Reihe komplett — …". Genauso zählen **eigene Universen**, sobald alle Titel
darin abgeschlossen sind.
> **Einmal erreicht, bleibt erreicht.**
> Nimmst du eine Sichtung zurück oder entfernst einen Titel, verlierst du kein
> Abzeichen. Es wieder einzukassieren wäre kleinlich — und ein Fehlklick soll
> nichts kaputt machen.
Beim ersten Öffnen der Seite schaut Flimmer einmal deine ganze Historie durch.
Wenn du Flimmer schon länger nutzt, sind deshalb sofort mehrere Abzeichen da —
ohne dass du dafür eine Flut von Meldungen bekommst. Erst danach wird gemeldet,
was neu dazukommt.
Wenn du ein öffentliches Profil hast, kannst du die Abzeichen dort zeigen oder
ausblenden — der Schalter steht in den Einstellungen direkt bei der
Profiladresse.
---
## Einstellungen ## Einstellungen
![Einstellungen](screenshots/11-einstellungen.webp) ![Einstellungen](screenshots/11-einstellungen.webp)
@@ -370,6 +410,38 @@ Beim Abgleich passiert dreierlei:
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
ohne automatisches Aufnehmen. ohne automatisches Aufnehmen.
### Titel, die Flimmer noch nicht kennt
Beim ersten Abgleich einer gewachsenen Mediathek passiert oft etwas
Ernüchterndes: Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen
einzigen** zu. Das ist richtig so — Flimmer kennt nur, was du selbst
aufgenommen oder über die Suche importiert hast.
Deshalb steht unter dem Bericht eine **Auswahlliste**: alles, was auf dem
Server liegt und Flimmer unbekannt ist. Hake an, was übernommen werden soll,
und klicke auf Übernehmen — die Daten holt Flimmer dann von TMDB, bei Serien
samt Staffeln und Episoden.
Zwei Dinge, die dabei helfen:
- **Zusammengefasst.** Führt Jellyfin drei Dateien zum selben Film (Extended
Edition, zweite Fassung), steht er trotzdem nur einmal in der Liste — mit
dem Vermerk, wie viele Dateien darauf zeigen.
- **Es werden hundert auf einmal geholt.** Wer mehr auswählt, bekommt den Rest
beim nächsten Klick. Jeder Titel bedeutet einen Abruf bei TMDB, bei Serien
einen je Staffel.
Manche Einträge lassen sich **nicht** anhaken: dort fehlt die TMDB-Kennung in
Jellyfin selbst. Das trifft oft Extended Editions, Konzertmitschnitte oder
Dateien mit eigenwilliger Benennung. Meist hilft es, in Jellyfin die Erkennung
für diesen Titel nachzuziehen — danach taucht er beim nächsten Abgleich mit
Kennung auf.
> **Der Unterschied zwischen den beiden Häkchen**
> „Bekannte Titel dabei in die Bibliothek aufnehmen" beim Abgleich nimmt auf,
> was Flimmer **kennt**, aber noch nicht in deiner Bibliothek steht.
> Die Auswahlliste holt, was Flimmer **nicht kennt**.
**Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht, **Der Abgleich ist eine Einbahnstraße.** Er ergänzt, was auf dem Server steht,
und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer und löscht nie etwas, das in Flimmer steht. Markierst du einen Film in Flimmer
als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten. als gesehen und löschst ihn in Jellyfin, bleibt deine Sichtung erhalten.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.8.0", "version": "0.9.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
@@ -0,0 +1,22 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "showAchievements" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "Achievement" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT,
"unlockedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Achievement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Achievement_userId_unlockedAt_idx" ON "Achievement"("userId", "unlockedAt");
-- CreateIndex
CREATE UNIQUE INDEX "Achievement_userId_key_key" ON "Achievement"("userId", "key");
-- AddForeignKey
ALTER TABLE "Achievement" ADD CONSTRAINT "Achievement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+28
View File
@@ -42,6 +42,9 @@ model User {
publicProfile Boolean @default(false) publicProfile Boolean @default(false)
profileSlug String? @unique profileSlug String? @unique
/// Errungenschaften auf der öffentlichen Profilseite zeigen
showAchievements Boolean @default(true)
// Benachrichtigungswünsche // Benachrichtigungswünsche
notifyNewEpisode Boolean @default(true) notifyNewEpisode Boolean @default(true)
notifyRelease Boolean @default(true) notifyRelease Boolean @default(true)
@@ -55,6 +58,7 @@ model User {
oidcAccounts OidcAccount[] oidcAccounts OidcAccount[]
passkeys Passkey[] passkeys Passkey[]
mediaServers MediaServerConnection[] mediaServers MediaServerConnection[]
achievements Achievement[]
entries LibraryEntry[] entries LibraryEntry[]
watches Watch[] watches Watch[]
tags Tag[] tags Tag[]
@@ -628,3 +632,27 @@ model MediaServerItem {
@@unique([connectionId, externalId]) @@unique([connectionId, externalId])
@@index([connectionId]) @@index([connectionId])
} }
// ---------------------------------------------------------------------------
// Errungenschaften
// ---------------------------------------------------------------------------
/// Ein erreichtes Abzeichen. Einmal vergeben, bleibt es — auch wenn die
/// Bedingung später nicht mehr erfuellt waere.
model Achievement {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Stabiler Schluessel: "serientaeter-1000", "reihe:556", "universum:<id>".
key String
/// Bei dynamisch benannten Abzeichen der Name zum Zeitpunkt des Erreichens.
/// Eingefroren: ein umbenanntes Universum soll das Abzeichen nicht aendern.
label String?
unlockedAt DateTime @default(now())
@@unique([userId, key])
@@index([userId, unlockedAt])
}
+91
View File
@@ -0,0 +1,91 @@
# Flimmer 0.9.0
**Errungenschaften** — Abzeichen für erreichte Meilensteine. Dazu die
**Auswahlliste** beim Jellyfin-Abgleich, ohne die die Anbindung auf einer
gewachsenen Mediathek kaum etwas ausrichtete.
> ⚠️ **Beim Aktualisieren kommt eine Migration mit** (`errungenschaften`). Der
> Container spielt sie beim Start selbst ein; `./update.sh` legt vorher eine
> Sicherung an.
### Hinzugefügt
- **Errungenschaften.** Zwanzig feste Abzeichen, dazu dynamische für
vollständig gesehene Filmreihen und abgeschlossene Universen. Zu finden
unten auf der Statistikseite.
Sie tauchen auf, wenn etwas erreicht ist, und bleiben dann — **keine
Streaks**, die reißen, wenn man eine Woche nichts schaut, keine
Fortschrittsbalken, die drängeln. Wer eine Sichtung zurücknimmt, verliert
kein Abzeichen; es wieder einzukassieren wäre kleinlich.
Ein paar davon: *Der lange Atem* (eine Serie mit hundert Episoden komplett),
*Treu geblieben* (über ein Jahr an demselben Titel), *Durchgemacht*
(zwischen drei und fünf Uhr morgens abgehakt), *Zeitreisender* (Titel aus
sieben Jahrzehnten), *Strenges Urteil* (einmal die Note 1 vergeben).
Für **ganze Filmreihen** braucht es keine gepflegte Liste: TMDB gruppiert
sie selbst, und die Zugehörigkeit steht beim Import schon am Titel. Wer alle
Spider-Man-Filme gesehen hat, bekommt sie als Reihe angerechnet. Für
bekannte Reihen gibt es einen eigenen Namen, sonst heißt es schlicht „Reihe
komplett — …".
Beim ersten Öffnen wird die ganze Historie einmal **stumm** ausgewertet —
eine gewachsene Bibliothek soll nicht zwei Dutzend Meldungen auf einmal
auslösen. Erst danach wird gemeldet, was neu dazukommt. Auf dem öffentlichen
Profil lassen sich die Abzeichen zeigen oder ausblenden.
- **Auswahlliste für Jellyfin-Titel, die Flimmer nicht kennt.** Beim ersten
Abgleich einer gewachsenen Mediathek passierte bisher etwas Ernüchterndes:
Jellyfin meldet tausend Einträge, und Flimmer ordnet **keinen einzigen** zu,
weil der Katalog sie nicht kennt.
Jetzt steht unter dem Bericht eine Liste zum Anhaken. Was ausgewählt ist,
holt Flimmer von TMDB — bei Serien samt Staffeln und Episoden — und nimmt es
mit „hab ich · Jellyfin" in die Bibliothek auf.
Die Liste fasst zusammen: Führt Jellyfin drei Dateien zum selben Film
(Extended Edition, zweite Fassung), steht er einmal da, mit dem Vermerk, wie
viele Dateien darauf zeigen. Einträge, denen in Jellyfin die TMDB-Kennung
fehlt, bleiben sichtbar, sind aber nicht auswählbar — dort kann Flimmer
nichts holen.
Geholt werden hundert je Lauf; der Rest bleibt für den nächsten Klick. Ein
einzelner Fehlschlag beendet den Lauf nicht, und **aus der Liste verschwindet
nur, was tatsächlich ankam**.
### Geändert
- Der Bericht des Medienserver-Abgleichs war auf **50 Einträge** gekürzt —
bei über tausend gelesenen ein Blick durchs Schlüsselloch. Jetzt bis 1000,
und was darüber hinausgeht, wird gezählt statt stillschweigend
abgeschnitten.
- Das Häkchen beim Abgleich heißt jetzt „**Bekannte** Titel dabei in die
Bibliothek aufnehmen". Es nimmt auf, was Flimmer *kennt*; die neue
Auswahlliste holt, was es *nicht kennt*. Der Unterschied stand vorher
nirgends — und war der Grund, warum das Häkchen bei einer frischen Instanz
nichts bewirkte.
- Einblendungen kennen einen dritten Ton für Erfolge.
- `npm run screenshots` hebt für Ausschnittaufnahmen die klebende Kopfleiste
auf; sie legte sich sonst über die Überschrift.
### Behoben
- Im Bericht stand eine Serie als **Film**, weil der Typ aus dem Umkehrschluss
„keine Episode, also Film" abgeleitet wurde. Er kommt jetzt aus dem Item.
### Getestet
93 Tests (vorher 78). Neu: der Regelkatalog der Errungenschaften samt
Stummschaltung, Entdopplung und Zeitzonenrechnung; die Auswahlliste samt
Zusammenfassung, Fehlschlägen und Rechteprüfung.
Ein Test hat dabei einen Fehler gefunden, den man leicht übersieht: Die Regel
„zwischen drei und fünf Uhr" las die Stunde aus der formatierten Zeichenkette,
und auf Deutsch liefert das System dafür **„03 Uhr"** — als Zahl gelesen wird
daraus nichts. Das Abzeichen wäre nie vergeben worden.
### Anleitung
`docs/ANLEITUNG.md` hat zwei neue Abschnitte: **Errungenschaften** mit
Screenshot und **Titel, die Flimmer noch nicht kennt** im Jellyfin-Kapitel.
+15
View File
@@ -43,6 +43,11 @@ async function ausschnitt(page, name, ueberschrift) {
} }
await page.waitForTimeout(400); await page.waitForTimeout(400);
// Die Kopfleiste klebt oben und legt sich sonst über die Überschrift des
// Ausschnitts. Fürs Bild kurz aufheben.
await page.addStyleTag({ content: "header { position: static !important; }" }).catch(() => {});
await page.waitForTimeout(200);
const bereich = page.locator("section", { hasText: ueberschrift }).first(); const bereich = page.locator("section", { hasText: ueberschrift }).first();
await bereich.screenshot({ path: `${OUT}/${name}.png` }); await bereich.screenshot({ path: `${OUT}/${name}.png` });
console.log(" ", name); console.log(" ", name);
@@ -199,6 +204,16 @@ async function shot(page, name, url, opts = {}) {
await jellyfin.close(); await jellyfin.close();
} }
// --- Errungenschaften ---
{
const { ctx, page } = await session(1400, 1200, "dark");
await login(page);
await page.goto(`${BASE}/statistik`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await ausschnitt(page, "23-errungenschaften", "Errungenschaften");
await ctx.close();
}
// --- Hell --- // --- Hell ---
{ {
const { ctx, page } = await session(1400, 900, "light"); const { ctx, page } = await session(1400, 900, "light");
+33 -10
View File
@@ -24,6 +24,7 @@ import {
type WatchResult, type WatchResult,
} from "@/lib/library"; } from "@/lib/library";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { achievementName, evaluateAchievements } from "@/lib/achievements";
/** /**
* Server-Aktionen rund um die Bibliothek. * Server-Aktionen rund um die Bibliothek.
@@ -38,11 +39,14 @@ export type ActionResult = {
message?: string; message?: string;
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */ /** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
watchIds?: string[]; watchIds?: string[];
/** Gerade erreichte Errungenschaften, fertig benannt. */
achievements?: string[];
}; };
async function run( async function run(
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>, fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
revalidate: string[] = [], revalidate: string[] = [],
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
): Promise<ActionResult> { ): Promise<ActionResult> {
const user = await getSessionUser(); const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." }; if (!user) return { ok: false, message: "Bitte melde dich an." };
@@ -50,7 +54,25 @@ async function run(
try { try {
const result = (await fn(user.id)) ?? {}; const result = (await fn(user.id)) ?? {};
for (const path of revalidate) revalidatePath(path, "layout"); for (const path of revalidate) revalidatePath(path, "layout");
return { ok: true, message: result.message, watchIds: result.watchIds };
// Nur nach Sichtungsaktionen nachsehen — eine Tag-Änderung kann kein
// Abzeichen auslösen, und die Auswertung liest die ganze Sichtungshistorie.
let achievements: string[] | undefined;
if (pruefeErrungenschaften) {
try {
const ergebnis = await evaluateAchievements(user.id);
if (ergebnis.neu.length > 0) {
achievements = ergebnis.neu.map((eintrag) =>
achievementName(eintrag.key, eintrag.label),
);
}
} catch (error) {
// Ein Abzeichen ist die Kür. Es darf das Abhaken nie scheitern lassen.
console.error("Errungenschaften konnten nicht ausgewertet werden:", error);
}
}
return { ok: true, message: result.message, watchIds: result.watchIds, achievements };
} catch (error) { } catch (error) {
if (error instanceof LibraryError) return { ok: false, message: error.message }; if (error instanceof LibraryError) return { ok: false, message: error.message };
console.error("Bibliotheksaktion fehlgeschlagen:", error); console.error("Bibliotheksaktion fehlgeschlagen:", error);
@@ -75,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
message: message:
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt", parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
}; };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function trashEntryAction(entryId: string) { export async function trashEntryAction(entryId: string) {
@@ -130,7 +152,7 @@ export async function startRewatchAction(entryId: string) {
return run(async (userId) => { return run(async (userId) => {
const entry = await startRewatch(userId, cuid.parse(entryId)); const entry = await startRewatch(userId, cuid.parse(entryId));
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` }; return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -148,7 +170,7 @@ export async function toggleEpisodeAction(entryId: string, episodeId: string, wa
} }
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]); await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
return { message: "Markierung entfernt" }; return { message: "Markierung entfernt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) { export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
@@ -158,36 +180,37 @@ export async function toggleSeasonAction(entryId: string, seasonId: string, watc
} }
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId)); await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
return { message: "Staffel zurückgesetzt" }; return { message: "Staffel zurückgesetzt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function markUpToAction(entryId: string, episodeId: string) { export async function markUpToAction(entryId: string, episodeId: string) {
return run( return run(
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))), async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
PATHS, PATHS,
{ pruefeErrungenschaften: true },
); );
} }
export async function watchNextAction(entryId: string) { export async function watchNextAction(entryId: string) {
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS); return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true });
} }
export async function markMovieAction(entryId: string) { export async function markMovieAction(entryId: string) {
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS, { pruefeErrungenschaften: true });
} }
export async function unmarkMovieAction(entryId: string) { export async function unmarkMovieAction(entryId: string) {
return run(async (userId) => { return run(async (userId) => {
await unmarkMovie(userId, cuid.parse(entryId)); await unmarkMovie(userId, cuid.parse(entryId));
return { message: "Markierung entfernt" }; return { message: "Markierung entfernt" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
export async function undoAction(entryId: string, watchIds: string[]) { export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => { return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
return { message: "Rückgängig gemacht" }; return { message: "Rückgängig gemacht" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -234,7 +257,7 @@ export async function setTagsAction(entryId: string, tagNames: string[]) {
]); ]);
return { message: "Tags gespeichert" }; return { message: "Tags gespeichert" };
}, PATHS); }, PATHS, { pruefeErrungenschaften: true });
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+41
View File
@@ -18,6 +18,7 @@ import { runSync } from "@/lib/sync";
import { import {
connectJellyfin, connectJellyfin,
deleteConnection, deleteConnection,
importUnassigned,
syncConnection, syncConnection,
testConnection, testConnection,
updateConnection, updateConnection,
@@ -64,6 +65,7 @@ const profileSchema = z.object({
name: z.string().trim().min(1).max(80), name: z.string().trim().min(1).max(80),
region: z.string().trim().length(2).toUpperCase(), region: z.string().trim().length(2).toUpperCase(),
publicProfile: z.boolean(), publicProfile: z.boolean(),
showAchievements: z.boolean(),
profileSlug: z profileSlug: z
.string() .string()
.trim() .trim()
@@ -402,6 +404,45 @@ export async function syncMediaServerAction(
} }
} }
const auswahlSchema = z
.array(
z.object({
tmdbId: z.number().int().positive(),
typ: z.enum(["SERIES", "MOVIE"]),
}),
)
.min(1)
.max(1000);
/** Holt die ausgewählten Titel aus TMDB nach. */
export async function importMediaServerTitlesAction(
connectionId: string,
auswahl: unknown,
): Promise<MediaServerResult & { uebrig?: number }> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
const daten = auswahlSchema.safeParse(auswahl);
if (!daten.success) return { ok: false, message: "Es war nichts ausgewählt." };
try {
const ergebnis = await importUnassigned(user.id, connectionId, daten.data);
revalidatePath("/einstellungen", "layout");
revalidatePath("/bibliothek", "layout");
const teile = [`${ergebnis.nachgeladen} Titel nachgeladen`];
if (ergebnis.fehler.length > 0) teile.push(`${ergebnis.fehler.length} fehlgeschlagen`);
if (ergebnis.uebrig > 0) teile.push(`${ergebnis.uebrig} bleiben für den nächsten Lauf`);
return { ok: true, message: teile.join(", "), uebrig: ergebnis.uebrig };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
export async function updateMediaServerAction( export async function updateMediaServerAction(
connectionId: string, connectionId: string,
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean }, data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
+9
View File
@@ -24,6 +24,7 @@ export default async function SettingsPage() {
region: true, region: true,
publicProfile: true, publicProfile: true,
profileSlug: true, profileSlug: true,
showAchievements: true,
notifyNewEpisode: true, notifyNewEpisode: true,
notifyRelease: true, notifyRelease: true,
notifyAvailability: true, notifyAvailability: true,
@@ -89,6 +90,14 @@ export default async function SettingsPage() {
syncOwned: verbindung.syncOwned, syncOwned: verbindung.syncOwned,
syncWatched: verbindung.syncWatched, syncWatched: verbindung.syncWatched,
lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null, lastSyncAt: verbindung.lastSyncAt?.toISOString() ?? null,
// Der letzte Bericht liegt als Json in der Datenbank; daraus
// speist sich die Auswahlliste, damit sie einen Seitenwechsel
// übersteht.
offen: (verbindung.lastSyncReport as { nichtZugeordnet?: unknown[] } | null)
?.nichtZugeordnet as never ?? [],
weitereOffen:
((verbindung.lastSyncReport as { weitereNichtZugeordnet?: number } | null)
?.weitereNichtZugeordnet ?? 0),
}))} }))}
/> />
</section> </section>
+51
View File
@@ -16,6 +16,8 @@ import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster"; import { Poster } from "@/components/Poster";
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts"; import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
import { AchievementBadge } from "@/components/AchievementBadge";
import { GRUPPEN, ensureAchievementsEvaluated, listAchievements } from "@/lib/achievements";
export const metadata: Metadata = { title: "Statistik" }; export const metadata: Metadata = { title: "Statistik" };
@@ -42,6 +44,13 @@ export default async function StatsPage({
getYearReview(user.id, year), getYearReview(user.id, year),
]); ]);
// Beim allerersten Aufruf einmal auswerten: sonst bliebe die Liste leer,
// bis nachts der Hintergrundlauf kommt — und wer sie öffnet, will sehen,
// was er hat, nicht auf morgen vertröstet werden. Der Lauf ist stumm und
// passiert genau einmal je Konto.
await ensureAchievementsEvaluated(user.id);
const abzeichen = await listAchievements(user.id);
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) { if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
return ( return (
<AppShell user={user} instanceName={settings.instanceName}> <AppShell user={user} instanceName={settings.instanceName}>
@@ -205,6 +214,48 @@ export default async function StatsPage({
emptyText="Noch nichts bewertet." emptyText="Noch nichts bewertet."
/> />
</div> </div>
<section className="fl-card mt-6 p-5" aria-labelledby="errungenschaften">
<h2 id="errungenschaften" className="text-lg font-semibold">
Errungenschaften
</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
{abzeichen.filter((eintrag) => eintrag.unlockedAt).length} von {abzeichen.length}{" "}
erreicht. Was hier steht, bleibt stehen — auch wenn du eine Sichtung zurücknimmst.
</p>
<div className="space-y-5">
{GRUPPEN.map((gruppe) => {
const eintraege = abzeichen.filter((eintrag) => eintrag.gruppe === gruppe.key);
if (eintraege.length === 0) return null;
// Erreichte zuerst — man will sehen, was man hat, nicht was fehlt.
const sortiert = [...eintraege].sort((a, b) => {
if (Boolean(a.unlockedAt) !== Boolean(b.unlockedAt)) return a.unlockedAt ? -1 : 1;
return a.name.localeCompare(b.name, "de-DE");
});
return (
<div key={gruppe.key}>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{gruppe.label}
</h3>
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{sortiert.map((eintrag) => (
<AchievementBadge
key={eintrag.key}
name={eintrag.name}
beschreibung={eintrag.beschreibung}
symbol={eintrag.symbol}
unlockedAt={eintrag.unlockedAt?.toISOString() ?? null}
/>
))}
</ul>
</div>
);
})}
</div>
</section>
</AppShell> </AppShell>
); );
} }
+23 -1
View File
@@ -2,6 +2,8 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { listUnlockedAchievements } from "@/lib/achievements";
import { AchievementSymbol } from "@/components/AchievementBadge";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats"; import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
import { formatMinutes, formatRelative } from "@/lib/labels"; import { formatMinutes, formatRelative } from "@/lib/labels";
@@ -35,10 +37,12 @@ export default async function PublicProfilePage({ params }: { params: Params })
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: true }, where: { profileSlug: slug, publicProfile: true, isActive: true },
select: { id: true, name: true, createdAt: true }, select: { id: true, name: true, createdAt: true, showAchievements: true },
}); });
if (!user) notFound(); if (!user) notFound();
const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : [];
const [totals, mostWatched, topRated, recent] = await Promise.all([ const [totals, mostWatched, topRated, recent] = await Promise.all([
getTotals(user.id), getTotals(user.id),
getMostWatched(user.id, 6), getMostWatched(user.id, 6),
@@ -89,6 +93,24 @@ export default async function PublicProfilePage({ params }: { params: Params })
</div> </div>
</header> </header>
{abzeichen.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Errungenschaften</h2>
<ul className="flex flex-wrap gap-2">
{abzeichen.map((eintrag) => (
<li
key={eintrag.key}
title={eintrag.beschreibung}
className="inline-flex items-center gap-2 rounded-full border border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_8%,transparent)] px-3 py-1.5 text-sm"
>
<AchievementSymbol symbol={eintrag.symbol} className="h-4 w-4 text-[var(--fl-accent)]" />
{eintrag.name}
</li>
))}
</ul>
</section>
)}
{recent.length > 0 && ( {recent.length > 0 && (
<section className="mt-6"> <section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2> <h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
+203
View File
@@ -0,0 +1,203 @@
import { clsx } from "clsx";
import type { SymbolName } from "@/lib/achievements";
/**
* Abzeichen mit Symbol.
*
* Die Symbole sind eigene SVG statt Emoji: Emoji sehen auf jedem System
* anders aus, lassen sich nicht einfärben und passen nicht zur Formsprache
* des Logos. Alle liegen auf demselben 24er-Raster, zeichnen nur mit
* `currentColor` und tragen keine Farbe für sich — die kommt vom Zustand.
*
* Erreicht und offen unterscheiden sich **nicht nur durch Farbe**: das offene
* Abzeichen ist blass *und* trägt den Hinweis „noch offen" im Text, das
* erreichte nennt sein Datum. Farbe allein wäre für Screenreader und bei
* Farbsehschwäche nichts wert.
*/
const PFADE: Record<SymbolName, React.ReactNode> = {
spross: (
<>
<path d="M12 21v-8" />
<path d="M12 13c0-3 2-5 5-5 0 3-2 5-5 5Z" />
<path d="M12 16c0-2.5-1.8-4.5-4.5-4.5 0 2.5 1.8 4.5 4.5 4.5Z" />
</>
),
haken: <path d="m4 12.5 5 5L20 6.5" />,
schluessel: (
<>
<circle cx="8" cy="12" r="3.5" />
<path d="M11.5 12H21m-3 0v3.5m-3-3.5v2.5" />
</>
),
haus: <path d="M3.5 11 12 4l8.5 7M6 9.5V20h12V9.5M10 20v-5.5h4V20" />,
klappe: (
<>
<rect x="3" y="9" width="18" height="11" rx="2" />
<path d="m3.5 9 3.5-4.5 3.5 4.5m0 0L14 4.5 17.5 9" />
</>
),
planet: (
<>
<circle cx="12" cy="12" r="6" />
<path d="M3.5 14.5c5 2.5 12 2.5 17 0" />
</>
),
kompass: (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="m15.5 8.5-2 5.5-5.5 2 2-5.5Z" />
</>
),
bildschirm: (
<>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 21h8m-4-4v4" />
</>
),
filmrolle: (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="8.5" r="1.6" />
<circle cx="15.4" cy="13.8" r="1.6" />
<circle cx="8.6" cy="13.8" r="1.6" />
</>
),
sonne: (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4" />
</>
),
kalender: (
<>
<rect x="3.5" y="5" width="17" height="15" rx="2" />
<path d="M3.5 10h17M8 3v4m8-4v4" />
</>
),
laeufer: (
<>
<circle cx="14" cy="5" r="2" />
<path d="m8 21 3-6 3 2 1 5M11 15l-2-4 4-3 3 3 3 .5" />
</>
),
berg: <path d="m2.5 19 6-11 4 6.5 2.5-4 6.5 8.5Z" />,
herz: <path d="M12 20s-7.5-4.6-7.5-9.4A4.1 4.1 0 0 1 12 8a4.1 4.1 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z" />,
kreis: (
<>
<path d="M20 12a8 8 0 1 1-3.2-6.4" />
<path d="M20 4v5h-5" />
</>
),
maske: (
<>
<path d="M4 6h7v6a3.5 3.5 0 0 1-7 0Z" />
<path d="M13 6h7v6a3.5 3.5 0 0 1-7 0Z" />
<path d="M6.5 9h2m7 0h2" />
</>
),
sanduhr: (
<>
<path d="M7 3h10M7 21h10" />
<path d="M8 3v3.5L12 12l4-5.5V3M8 21v-3.5L12 12l4 5.5V21" />
</>
),
stern: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z" />,
salz: (
<>
<path d="M8.5 9h7l1 11h-9Z" />
<path d="M9.5 9V5.5h5V9M11 3h2" />
</>
),
scheibe: (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="2.5" />
</>
),
besen: (
<>
<path d="M14 3 8.5 12.5" />
<path d="m5 14 6-3.5 4 6.5-6 3.5Z" />
<path d="m9 20.5 2.5 0.5m2-2 2.5.5m-.5-3.5 2.5.5" />
</>
),
mond: <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />,
spinne: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 9V5m0 10v4m-3-7H5m14 0h-4M9.5 9.5 6.5 6.5m8 3 3-3m-8 8-3 3m8-3 3 3" />
</>
),
};
export function AchievementSymbol({
symbol,
className,
}: {
symbol: SymbolName;
className?: string;
}) {
return (
<svg
viewBox="0 0 24 24"
className={clsx("h-6 w-6", className)}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{PFADE[symbol]}
</svg>
);
}
export function AchievementBadge({
name,
beschreibung,
symbol,
unlockedAt,
}: {
name: string;
beschreibung: string;
symbol: SymbolName;
unlockedAt: string | null;
}) {
const erreicht = unlockedAt !== null;
return (
<li
className={clsx(
"flex items-start gap-3 rounded-xl border p-3 transition-colors",
erreicht
? "border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_7%,transparent)]"
: "border-[var(--fl-border)]",
)}
>
<span
className={clsx("mt-0.5 shrink-0", erreicht ? "text-[var(--fl-accent)]" : "text-[var(--fl-faint)]")}
style={erreicht ? { filter: "drop-shadow(0 0 6px color-mix(in oklab, var(--fl-accent) 40%, transparent))" } : undefined}
>
<AchievementSymbol symbol={symbol} />
</span>
<div className="min-w-0">
<p className={clsx("font-medium", !erreicht && "text-[var(--fl-muted)]")}>{name}</p>
<p className="text-xs text-[var(--fl-faint)]">{beschreibung}</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{erreicht ? `erreicht am ${formatiere(unlockedAt)}` : "noch offen"}
</p>
</div>
</li>
);
}
function formatiere(wert: string): string {
return new Date(wert).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
+15 -2
View File
@@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library";
type Toast = { type Toast = {
id: number; id: number;
message: string; message: string;
tone: "info" | "error"; tone: "info" | "error" | "erfolg";
undo?: () => Promise<void> | void; undo?: () => Promise<void> | void;
}; };
@@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
show(result.message, { show(result.message, {
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined, undo: undo && ids.length > 0 ? () => void undo(ids) : undefined,
}); });
// Errungenschaften bekommen eine eigene Einblendung — sie sollen nicht
// in der Meldung über das Abhaken untergehen.
for (const name of result.achievements ?? []) {
show(`Errungenschaft: ${name}`, { tone: "erfolg" });
}
}, },
[show], [show],
); );
@@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
borderColor: borderColor:
toast.tone === "error" toast.tone === "error"
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)" ? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
: toast.tone === "erfolg"
? "color-mix(in oklab, var(--fl-success) 50%, transparent)"
: "var(--fl-border)", : "var(--fl-border)",
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)", color:
toast.tone === "error"
? "var(--fl-danger)"
: toast.tone === "erfolg"
? "var(--fl-success)"
: "var(--fl-text)",
}} }}
> >
<span className="flex-1">{toast.message}</span> <span className="flex-1">{toast.message}</span>
+10
View File
@@ -100,6 +100,7 @@ export function ProfileForm({
email: string; email: string;
region: string; region: string;
publicProfile: boolean; publicProfile: boolean;
showAchievements: boolean;
profileSlug: string | null; profileSlug: string | null;
notifyNewEpisode: boolean; notifyNewEpisode: boolean;
notifyRelease: boolean; notifyRelease: boolean;
@@ -121,6 +122,7 @@ export function ProfileForm({
name: state.name, name: state.name,
region: state.region, region: state.region,
publicProfile: state.publicProfile, publicProfile: state.publicProfile,
showAchievements: state.showAchievements,
profileSlug: state.profileSlug || null, profileSlug: state.profileSlug || null,
notifyNewEpisode: state.notifyNewEpisode, notifyNewEpisode: state.notifyNewEpisode,
notifyRelease: state.notifyRelease, notifyRelease: state.notifyRelease,
@@ -190,6 +192,7 @@ export function ProfileForm({
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen." hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
/> />
{state.publicProfile && ( {state.publicProfile && (
<>
<Field <Field
id="profileSlug" id="profileSlug"
label="Adresse" label="Adresse"
@@ -198,6 +201,13 @@ export function ProfileForm({
placeholder="dein-name" placeholder="dein-name"
hint="Erreichbar unter /u/<adresse>" hint="Erreichbar unter /u/<adresse>"
/> />
<Toggle
checked={state.showAchievements}
onChange={(value) => setState({ ...state, showAchievements: value })}
label="Errungenschaften zeigen"
hint="Die erreichten Abzeichen erscheinen auf der Profilseite."
/>
</>
)} )}
</fieldset> </fieldset>
)} )}
+175 -1
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { clsx } from "clsx";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
connectMediaServerAction, connectMediaServerAction,
deleteMediaServerAction, deleteMediaServerAction,
importMediaServerTitlesAction,
syncMediaServerAction, syncMediaServerAction,
testMediaServerAction, testMediaServerAction,
updateMediaServerAction, updateMediaServerAction,
@@ -26,6 +28,14 @@ import { formatDate } from "@/lib/labels";
* Bibliothek werden. * Bibliothek werden.
*/ */
export type UnassignedItem = {
tmdbId: number | null;
typ: "SERIES" | "MOVIE" | null;
name: string;
grund: string;
dateien: number;
};
export type Verbindung = { export type Verbindung = {
id: string; id: string;
name: string; name: string;
@@ -35,6 +45,9 @@ export type Verbindung = {
syncOwned: boolean; syncOwned: boolean;
syncWatched: boolean; syncWatched: boolean;
lastSyncAt: string | null; lastSyncAt: string | null;
/** Aus dem letzten Abgleich — Grundlage der Auswahlliste. */
offen: UnassignedItem[];
weitereOffen: number;
}; };
type Pruefung = NonNullable<MediaServerResult["pruefung"]>; type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
@@ -171,7 +184,7 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
Verbindung prüfen Verbindung prüfen
</button> </button>
<Haken <Haken
label="Fehlende Titel dabei aufnehmen" label="Bekannte Titel dabei in die Bibliothek aufnehmen"
checked={aufnehmen} checked={aufnehmen}
onChange={setAufnehmen} onChange={setAufnehmen}
/> />
@@ -184,6 +197,26 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
{pruefung && <Pruefbericht pruefung={pruefung} />} {pruefung && <Pruefbericht pruefung={pruefung} />}
{bericht && <Abgleichbericht bericht={bericht} />} {bericht && <Abgleichbericht bericht={bericht} />}
{verbindungen.map((verbindung) =>
verbindung.offen.length > 0 ? (
<Nachladeliste
key={`offen-${verbindung.id}`}
verbindung={verbindung}
laeuft={laeuft || pending}
onImport={async (auswahl) => {
setLaeuft(true);
try {
const ergebnis = await importMediaServerTitlesAction(verbindung.id, auswahl);
show(ergebnis.message ?? "", { tone: ergebnis.ok ? "info" : "error" });
startTransition(() => router.refresh());
} finally {
setLaeuft(false);
}
}}
/>
) : null,
)}
{verbindungen.length === 0 && ( {verbindungen.length === 0 && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-[var(--fl-muted)]"> <p className="text-sm text-[var(--fl-muted)]">
@@ -324,3 +357,144 @@ function Haken({
</label> </label>
); );
} }
/**
* Auswahlliste für Titel, die Flimmer noch nicht kennt.
*
* Der Unterschied, der in der Oberfläche sichtbar sein muss: Das Häkchen oben
* nimmt auf, was Flimmer **kennt**. Diese Liste holt, was es **nicht kennt** —
* und zwar nur das, was hier angehakt wird. Bei über tausend Einträgen in
* einer Mediathek wäre „alles oder nichts" keine Wahl.
*/
function Nachladeliste({
verbindung,
laeuft,
onImport,
}: {
verbindung: Verbindung;
laeuft: boolean;
onImport: (auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[]) => Promise<void>;
}) {
const [suche, setSuche] = useState("");
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set());
const gefiltert = verbindung.offen.filter((eintrag) =>
eintrag.name.toLocaleLowerCase("de-DE").includes(suche.trim().toLocaleLowerCase("de-DE")),
);
// Ohne TMDB-Kennung lässt sich nichts holen — die Zeilen bleiben sichtbar,
// aber nicht anklickbar. Sie zu verstecken würde die Frage aufwerfen, wo
// sie geblieben sind.
const holbar = gefiltert.filter((eintrag) => eintrag.tmdbId != null);
const ohneKennung = verbindung.offen.length - verbindung.offen.filter((e) => e.tmdbId != null).length;
function umschalten(tmdbId: number) {
setGewaehlt((vorher) => {
const neu = new Set(vorher);
if (neu.has(tmdbId)) neu.delete(tmdbId);
else neu.add(tmdbId);
return neu;
});
}
const alleGewaehlt = holbar.length > 0 && holbar.every((e) => gewaehlt.has(e.tmdbId!));
return (
<div className="rounded-xl border border-[var(--fl-border)] p-4">
<h3 className="font-medium">Noch nicht in Flimmer</h3>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{verbindung.offen.length} Titel liegen auf {verbindung.name}, sind Flimmer aber unbekannt.
Wähle aus, was übernommen werden soll — die Daten kommen dann von TMDB.
{verbindung.weitereOffen > 0 && ` (${verbindung.weitereOffen} weitere nicht aufgeführt)`}
</p>
{ohneKennung > 0 && (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Bei {ohneKennung} davon fehlt die TMDB-Kennung in Jellyfin selbst — die lassen sich nicht
automatisch holen. Meist hilft es, die Erkennung in Jellyfin nachzuziehen.
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
type="search"
value={suche}
onChange={(event) => setSuche(event.target.value)}
placeholder="In der Liste suchen …"
aria-label="Liste durchsuchen"
className="fl-input w-56 py-1.5 text-sm"
/>
<button
type="button"
onClick={() =>
setGewaehlt(alleGewaehlt ? new Set() : new Set(holbar.map((e) => e.tmdbId!)))
}
disabled={holbar.length === 0}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
{alleGewaehlt ? "Auswahl aufheben" : `Alle ${holbar.length} auswählen`}
</button>
</div>
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto pr-1">
{gefiltert.map((eintrag) => {
const holbarEintrag = eintrag.tmdbId != null;
return (
<li key={eintrag.tmdbId ?? `name-${eintrag.name}`}>
<label
className={clsx(
"flex items-start gap-2 rounded-lg px-2 py-1.5 text-sm",
holbarEintrag ? "hover:bg-[var(--fl-surface-2)]" : "opacity-60",
)}
>
<input
type="checkbox"
disabled={!holbarEintrag}
checked={holbarEintrag && gewaehlt.has(eintrag.tmdbId!)}
onChange={() => holbarEintrag && umschalten(eintrag.tmdbId!)}
className="mt-0.5 h-4 w-4 shrink-0 rounded border-[var(--fl-border-strong)] accent-[var(--fl-accent)]"
/>
<span className="min-w-0">
<span className="block truncate">{eintrag.name}</span>
<span className="block text-xs text-[var(--fl-faint)]">
{holbarEintrag
? `${eintrag.typ === "SERIES" ? "Serie" : "Film"} · TMDB ${eintrag.tmdbId}`
: "ohne TMDB-Kennung"}
{eintrag.dateien > 1 && ` · ${eintrag.dateien} Dateien`}
</span>
</span>
</label>
</li>
);
})}
{gefiltert.length === 0 && (
<li className="px-2 py-1.5 text-sm text-[var(--fl-faint)]">Nichts gefunden.</li>
)}
</ul>
<button
type="button"
disabled={laeuft || gewaehlt.size === 0}
onClick={() =>
onImport(
verbindung.offen
.filter((eintrag) => eintrag.tmdbId != null && gewaehlt.has(eintrag.tmdbId))
.map((eintrag) => ({ tmdbId: eintrag.tmdbId!, typ: eintrag.typ ?? "MOVIE" })),
)
}
className="fl-btn fl-btn-primary mt-3"
>
{laeuft
? "Läuft …"
: gewaehlt.size === 0
? "Nichts ausgewählt"
: `${gewaehlt.size} ${gewaehlt.size === 1 ? "Titel" : "Titel"} übernehmen`}
</button>
{gewaehlt.size > 100 && (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Es werden 100 auf einmal geholt; der Rest bleibt ausgewählt für den nächsten Lauf.
</p>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Eigennamen für bekannte Filmreihen.
*
* Die Grundlage ist generisch: „Reihe komplett — Spider-Man" funktioniert für
* jede Reihe, die TMDB kennt, und braucht keine Pflege. Diese Datei setzt
* obendrauf: wo ein Eintrag passt, bekommt das Abzeichen einen Namen mit
* Augenzwinkern. Fehlt er, greift der generische — nichts hängt davon ab.
*
* Die Schlüssel sind **TMDB-Collection-Kennungen**, nachzuschlagen unter
* `themoviedb.org/collection/<id>`.
*
* ## Warum jeder Eintrag ein `erwartet` hat
*
* Eine falsch abgeschriebene Kennung wäre schlimmer als gar kein Eintrag: Wer
* die Ocean's-Reihe durchhat, bekäme „Spider Bro". Deshalb trägt jeder Eintrag
* ein Stück des Namens, den TMDB für diese Reihe führt. Passt es nicht zum
* tatsächlichen Namen, wird der Eintrag ignoriert und der generische Name
* greift. Ein Tippfehler bleibt damit folgenlos.
*
* Erweitern ist ausdrücklich erwünscht — eine Zeile hier, sonst nichts.
*/
import type { SymbolName } from "./achievements";
export type KuratierteReihe = {
name: string;
symbol: SymbolName;
/** Muss im TMDB-Namen der Reihe vorkommen, sonst gilt der Eintrag nicht. */
erwartet: string;
};
const REIHEN: Record<number, KuratierteReihe> = {
556: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" },
531241: { name: "Spider Bro", symbol: "spinne", erwartet: "Spider-Man" },
119: { name: "Ringträger", symbol: "kreis", erwartet: "Herr der Ringe" },
121938: { name: "Auf unerwarteter Reise", symbol: "berg", erwartet: "Hobbit" },
10: { name: "Möge die Macht", symbol: "stern", erwartet: "Star Wars" },
264: { name: "Wo wir hinfahren", symbol: "sanduhr", erwartet: "Zurück in die Zukunft" },
2344: { name: "Aufgewacht", symbol: "kreis", erwartet: "Matrix" },
84: { name: "Gehört ins Museum", symbol: "kompass", erwartet: "Indiana Jones" },
8091: { name: "Im All hört dich niemand", symbol: "planet", erwartet: "Alien" },
328: { name: "Leben findet einen Weg", symbol: "spross", erwartet: "Jurassic Park" },
295: { name: "Freibeuter", symbol: "kompass", erwartet: "Fluch der Karibik" },
1241: { name: "Ganze sieben Jahre", symbol: "schluessel", erwartet: "Harry Potter" },
528: { name: "Ich komme wieder", symbol: "kreis", erwartet: "Terminator" },
230: { name: "Ein Angebot", symbol: "maske", erwartet: "Pate" },
10194: { name: "Bis zur Unendlichkeit", symbol: "planet", erwartet: "Toy Story" },
2150: { name: "Aus dem Sumpf", symbol: "spross", erwartet: "Shrek" },
};
/**
* Der kuratierte Eintrag — aber nur, wenn er zum tatsächlichen Namen passt.
*
* `tmdbName` ist der Name, den TMDB für diese Reihe liefert. Fehlt er, lässt
* sich nichts prüfen; dann gilt der Eintrag im Zweifel nicht.
*/
export function kuratierteReihe(
tmdbCollectionId: number,
tmdbName: string | null | undefined,
): KuratierteReihe | undefined {
const eintrag = REIHEN[tmdbCollectionId];
if (!eintrag || !tmdbName) return undefined;
const passt = tmdbName.toLocaleLowerCase("de-DE").includes(eintrag.erwartet.toLocaleLowerCase("de-DE"));
return passt ? eintrag : undefined;
}
/** Nur für Tests und Übersichten. */
export const ALLE_KURATIERTEN_REIHEN = REIHEN;
+668
View File
@@ -0,0 +1,668 @@
import "server-only";
import { prisma } from "./db";
import { kuratierteReihe } from "./achievement-names";
/**
* Errungenschaften.
*
* Grundhaltung: **still belohnen, nicht antreiben.** Keine Streaks, die einen
* bestrafen, wenn eine Woche nichts läuft. Keine Fortschrittsbalken, die
* drängeln. Ein Abzeichen taucht auf, wenn etwas erreicht ist — und bleibt.
*
* Zwei Entscheidungen, die das Verhalten prägen:
*
* - **Einmal erreicht, bleibt erreicht.** Wer eine Sichtung zurücknimmt,
* verliert kein Abzeichen. Es wieder einzukassieren wäre kleinlich, und die
* Datenlage ändert sich schon durch einen Fehlklick.
* - **Alle Regeln arbeiten auf *einem* Datensatz**, der einmal geholt wird.
* Sonst stellt jede Regel ihre eigene Datenbankabfrage, und aus zwanzig
* Abzeichen werden zwanzig Abfragen bei jedem Abhaken.
*/
export type AchievementKey = string;
/** Ein Abzeichen, wie es die Oberfläche braucht. */
export type AchievementView = {
key: string;
name: string;
beschreibung: string;
symbol: SymbolName;
gruppe: Gruppe;
unlockedAt: Date | null;
};
export type Gruppe = "anfaenge" | "reihen" | "ausdauer" | "breite" | "ordnung";
export const GRUPPEN: { key: Gruppe; label: string }[] = [
{ key: "anfaenge", label: "Anfänge" },
{ key: "reihen", label: "Reihen und Universen" },
{ key: "ausdauer", label: "Ausdauer" },
{ key: "breite", label: "Breite" },
{ key: "ordnung", label: "Ordnung" },
];
export type SymbolName =
| "spross"
| "haken"
| "schluessel"
| "haus"
| "klappe"
| "planet"
| "kompass"
| "bildschirm"
| "filmrolle"
| "sonne"
| "kalender"
| "laeufer"
| "berg"
| "herz"
| "kreis"
| "maske"
| "sanduhr"
| "stern"
| "salz"
| "scheibe"
| "besen"
| "mond"
| "spinne";
// ---------------------------------------------------------------------------
// Der Datensatz, auf dem alle Regeln arbeiten
// ---------------------------------------------------------------------------
type Datensatz = {
/** Zeitzone des Nutzers — „nachts um vier" ist seine Uhrzeit, nicht die des Servers. */
zeitzone: string;
eintraege: number;
minutenGesamt: number;
episodenGesehen: number;
filmeGesehen: number;
bewertungen: number;
niedrigsteBewertung: number | null;
besitzt: number;
watchlist: number;
serienAbgeschlossen: { titleId: string; episoden: number }[];
hoechsterDurchlauf: number;
genres: Set<string>;
jahrzehnte: Set<number>;
/** Größter Abstand zwischen erster und letzter Sichtung derselben Serie, in Tagen. */
laengsteTreue: number;
/** Meiste Minuten an einem Kalendertag (Zeitzone des Nutzers). */
minutenBesterTag: number;
/** Gab es eine Sichtung zwischen 3 und 5 Uhr? */
nachtsGesehen: boolean;
hatPasskey: boolean;
hatMedienserverLauf: boolean;
/** Vollständig gesehene TMDB-Reihen: Kennung → Name der Reihe. */
reihen: Map<number, string>;
/** Vollständig abgeschlossene Universen: Kennung → Name. */
universen: Map<string, string>;
/** Universen, die über eine eigene Reihenfolge abgeschlossen wurden. */
eigeneReihenfolge: boolean;
};
async function sammle(userId: string): Promise<Datensatz> {
// Zeitzone: Flimmer speichert sie nicht je Nutzer, also gilt die der Instanz
// (TZ in der .env). An einer Stelle, damit ein späterer Wechsel auf eine
// Zeitzone je Konto nur hier ansetzen muss.
const zeitzone = process.env.TZ || "Europe/Berlin";
const [eintraege, watches, serien, universen] = await Promise.all([
prisma.libraryEntry.findMany({
where: { userId, deletedAt: null },
select: {
id: true,
titleId: true,
status: true,
rating: true,
owned: true,
currentRun: true,
title: { select: { type: true, genres: true, releaseDate: true, tmdbCollectionId: true } },
},
}),
prisma.watch.findMany({
where: { userId },
select: { titleId: true, episodeId: true, watchedAt: true, minutes: true },
orderBy: { watchedAt: "asc" },
}),
prisma.title.findMany({
where: { entries: { some: { userId, deletedAt: null } }, type: "SERIES" },
select: { id: true, episodeCount: true, title: true, tmdbCollectionId: true },
}),
prisma.universe.findMany({
where: { ownerId: userId, deletedAt: null },
select: {
id: true,
name: true,
items: { select: { titleId: true } },
orders: { select: { kind: true, isDefault: true } },
},
}),
]);
const [passkeys, medienserver] = await Promise.all([
prisma.passkey.count({ where: { userId } }),
prisma.mediaServerConnection.count({ where: { userId, lastSyncAt: { not: null } } }),
]);
// --- Aus den Sichtungen ableiten ---
const minutenGesamt = watches.reduce((summe, watch) => summe + watch.minutes, 0);
const episodenGesehen = watches.filter((watch) => watch.episodeId !== null).length;
const filmeGesehen = watches.filter((watch) => watch.episodeId === null).length;
const proTag = new Map<string, number>();
const spanneProTitel = new Map<string, { erste: Date; letzte: Date }>();
let nachtsGesehen = false;
for (const watch of watches) {
const tag = tagIn(watch.watchedAt, zeitzone);
proTag.set(tag, (proTag.get(tag) ?? 0) + watch.minutes);
const stunde = stundeIn(watch.watchedAt, zeitzone);
if (stunde >= 3 && stunde < 5) nachtsGesehen = true;
const spanne = spanneProTitel.get(watch.titleId);
if (spanne) spanne.letzte = watch.watchedAt;
else spanneProTitel.set(watch.titleId, { erste: watch.watchedAt, letzte: watch.watchedAt });
}
const minutenBesterTag = Math.max(0, ...proTag.values());
const laengsteTreue = Math.max(
0,
...[...spanneProTitel.values()].map(
(spanne) => (spanne.letzte.getTime() - spanne.erste.getTime()) / 86_400_000,
),
);
// --- Aus den Einträgen ableiten ---
const genres = new Set<string>();
const jahrzehnte = new Set<number>();
const gesehenTitel = new Set(watches.map((watch) => watch.titleId));
let bewertungen = 0;
let niedrigsteBewertung: number | null = null;
let besitzt = 0;
let watchlist = 0;
let hoechsterDurchlauf = 0;
for (const eintrag of eintraege) {
if (eintrag.rating != null) {
bewertungen += 1;
niedrigsteBewertung =
niedrigsteBewertung == null ? eintrag.rating : Math.min(niedrigsteBewertung, eintrag.rating);
}
if (eintrag.owned) besitzt += 1;
if (eintrag.status === "WATCHLIST") watchlist += 1;
hoechsterDurchlauf = Math.max(hoechsterDurchlauf, eintrag.currentRun);
// Genres und Jahrzehnte zählen nur, was tatsächlich gesehen wurde.
if (gesehenTitel.has(eintrag.titleId)) {
for (const genre of eintrag.title.genres) genres.add(genre);
if (eintrag.title.releaseDate) {
jahrzehnte.add(Math.floor(eintrag.title.releaseDate.getFullYear() / 10) * 10);
}
}
}
// --- Abgeschlossene Serien ---
const episodenProSerie = new Map<string, number>();
for (const watch of watches) {
if (watch.episodeId === null) continue;
episodenProSerie.set(watch.titleId, (episodenProSerie.get(watch.titleId) ?? 0) + 1);
}
const serienAbgeschlossen = serien
.filter(
(serie) =>
serie.episodeCount > 0 && (episodenProSerie.get(serie.id) ?? 0) >= serie.episodeCount,
)
.map((serie) => ({ titleId: serie.id, episoden: serie.episodeCount }));
// --- Vollständig gesehene TMDB-Reihen ---
// Eine Reihe zählt, wenn alle Filme der Reihe, die im Katalog stehen,
// gesehen sind — und es mindestens zwei sind. Ein einzelner Film ist keine
// Reihe.
const reihen = new Map<number, string>();
const reihenIds = [
...new Set(
eintraege
.map((eintrag) => eintrag.title.tmdbCollectionId)
.filter((id): id is number => id != null),
),
];
if (reihenIds.length > 0) {
const reihenTitel = await prisma.title.findMany({
where: { tmdbCollectionId: { in: reihenIds } },
select: { id: true, tmdbCollectionId: true, tmdbCollectionName: true },
});
const proReihe = new Map<number, { id: string; name: string | null }[]>();
for (const titel of reihenTitel) {
const liste = proReihe.get(titel.tmdbCollectionId!) ?? [];
liste.push({ id: titel.id, name: titel.tmdbCollectionName });
proReihe.set(titel.tmdbCollectionId!, liste);
}
for (const [id, titel] of proReihe) {
if (titel.length < 2) continue;
if (!titel.every((eintrag) => gesehenTitel.has(eintrag.id))) continue;
reihen.set(id, titel.find((eintrag) => eintrag.name)?.name ?? "Reihe");
}
}
// --- Abgeschlossene Universen ---
const abgeschlossene = new Map<string, string>();
let eigeneReihenfolge = false;
for (const universum of universen) {
if (universum.items.length === 0) continue;
if (!universum.items.every((item) => gesehenTitel.has(item.titleId))) continue;
abgeschlossene.set(universum.id, universum.name);
// „Chronologe": abgeschlossen, und die führende Reihenfolge ist eine
// selbst gebaute — nicht die berechnete Erscheinungsreihenfolge.
if (universum.orders.some((order) => order.kind === "CUSTOM" && order.isDefault)) {
eigeneReihenfolge = true;
}
}
return {
zeitzone,
eintraege: eintraege.length,
minutenGesamt,
episodenGesehen,
filmeGesehen,
bewertungen,
niedrigsteBewertung,
besitzt,
watchlist,
serienAbgeschlossen,
hoechsterDurchlauf,
genres,
jahrzehnte,
laengsteTreue,
minutenBesterTag,
nachtsGesehen,
hatPasskey: passkeys > 0,
hatMedienserverLauf: medienserver > 0,
reihen,
universen: abgeschlossene,
eigeneReihenfolge,
};
}
/**
* Kalendertag und Stunde in der Zeitzone des Nutzers.
*
* Wichtig genug für eigene Helfer: beim Monatsdiagramm hat ein
* Zeitzonenversatz schon einmal für einen leeren Chart gesorgt. „Acht Stunden
* an einem Tag" und „nachts um vier" wären in der Serverzeitzone schlicht
* falsch.
*/
function tagIn(zeitpunkt: Date, zeitzone: string): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: zeitzone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(zeitpunkt);
}
function stundeIn(zeitpunkt: Date, zeitzone: string): number {
// Über die Bestandteile statt über die formatierte Zeichenkette: auf Deutsch
// liefert Intl für eine einzelne Stunde „03 Uhr", und daraus wird beim
// Umwandeln in eine Zahl NaN. Ein Test hat genau das aufgedeckt.
const teile = new Intl.DateTimeFormat("de-DE", {
timeZone: zeitzone,
hour: "2-digit",
hour12: false,
}).formatToParts(zeitpunkt);
const stunde = teile.find((teil) => teil.type === "hour")?.value ?? "";
const zahl = Number.parseInt(stunde, 10);
return Number.isFinite(zahl) ? zahl : -1;
}
// ---------------------------------------------------------------------------
// Der Katalog
// ---------------------------------------------------------------------------
type Regel = {
key: string;
name: string;
beschreibung: string;
symbol: SymbolName;
gruppe: Gruppe;
erfuellt: (daten: Datensatz) => boolean;
};
const KATALOG: Regel[] = [
// --- Anfänge ---
{
key: "erstkontakt",
name: "Erstkontakt",
beschreibung: "Den ersten Titel in die Bibliothek aufgenommen",
symbol: "spross",
gruppe: "anfaenge",
erfuellt: (d) => d.eintraege >= 1,
},
{
key: "abgehakt",
name: "Abgehakt",
beschreibung: "Die erste Serie vollständig durchgeschaut",
symbol: "haken",
gruppe: "anfaenge",
erfuellt: (d) => d.serienAbgeschlossen.length >= 1,
},
{
key: "schluesselmeister",
name: "Schlüsselmeister",
beschreibung: "Einen Passkey angelegt",
symbol: "schluessel",
gruppe: "anfaenge",
erfuellt: (d) => d.hatPasskey,
},
{
key: "heimkehrer",
name: "Heimkehrer",
beschreibung: "Den eigenen Medienserver angebunden und abgeglichen",
symbol: "haus",
gruppe: "anfaenge",
erfuellt: (d) => d.hatMedienserverLauf,
},
// --- Reihen und Universen (dynamische kommen weiter unten dazu) ---
{
key: "chronologe",
name: "Chronologe",
beschreibung: "Ein Universum in einer selbst gebauten Reihenfolge durchlaufen",
symbol: "kompass",
gruppe: "reihen",
erfuellt: (d) => d.eigeneReihenfolge,
},
// --- Ausdauer ---
{
key: "serientaeter-1000",
name: "Serientäter",
beschreibung: "1000 Episoden gesehen",
symbol: "bildschirm",
gruppe: "ausdauer",
erfuellt: (d) => d.episodenGesehen >= 1000,
},
{
key: "vielseher-100",
name: "Vielseher",
beschreibung: "100 Filme gesehen",
symbol: "filmrolle",
gruppe: "ausdauer",
erfuellt: (d) => d.filmeGesehen >= 100,
},
{
key: "ein-ganzer-tag",
name: "Ein ganzer Tag",
beschreibung: "24 Stunden Schauzeit insgesamt",
symbol: "sonne",
gruppe: "ausdauer",
erfuellt: (d) => d.minutenGesamt >= 24 * 60,
},
{
key: "eine-ganze-woche",
name: "Eine ganze Woche",
beschreibung: "168 Stunden Schauzeit insgesamt",
symbol: "kalender",
gruppe: "ausdauer",
erfuellt: (d) => d.minutenGesamt >= 7 * 24 * 60,
},
{
key: "marathon",
name: "Marathon",
beschreibung: "Acht Stunden an einem Tag",
symbol: "laeufer",
gruppe: "ausdauer",
erfuellt: (d) => d.minutenBesterTag >= 8 * 60,
},
{
key: "langer-atem",
name: "Der lange Atem",
beschreibung: "Eine Serie mit mindestens 100 Episoden komplett gesehen",
symbol: "berg",
gruppe: "ausdauer",
erfuellt: (d) => d.serienAbgeschlossen.some((serie) => serie.episoden >= 100),
},
{
key: "treu-geblieben",
name: "Treu geblieben",
beschreibung: "Über ein Jahr an demselben Titel drangeblieben",
symbol: "herz",
gruppe: "ausdauer",
erfuellt: (d) => d.laengsteTreue >= 365,
},
{
key: "wiederholungstaeter",
name: "Wiederholungstäter",
beschreibung: "Einen Titel im dritten Durchlauf",
symbol: "kreis",
gruppe: "ausdauer",
erfuellt: (d) => d.hoechsterDurchlauf >= 2,
},
// --- Breite ---
{
key: "feinschmecker",
name: "Feinschmecker",
beschreibung: "Titel aus zehn verschiedenen Genres gesehen",
symbol: "maske",
gruppe: "breite",
erfuellt: (d) => d.genres.size >= 10,
},
{
key: "zeitreisender",
name: "Zeitreisender",
beschreibung: "Titel aus sieben Jahrzehnten gesehen",
symbol: "sanduhr",
gruppe: "breite",
erfuellt: (d) => d.jahrzehnte.size >= 7,
},
{
key: "kritiker-50",
name: "Kritiker",
beschreibung: "50 Bewertungen abgegeben",
symbol: "stern",
gruppe: "breite",
erfuellt: (d) => d.bewertungen >= 50,
},
{
key: "strenges-urteil",
name: "Strenges Urteil",
beschreibung: "Einmal die Note 1 vergeben",
symbol: "salz",
gruppe: "breite",
erfuellt: (d) => d.niedrigsteBewertung === 1,
},
// --- Ordnung ---
{
key: "sammler-25",
name: "Sammler",
beschreibung: "25 Titel in der eigenen Sammlung",
symbol: "scheibe",
gruppe: "ordnung",
erfuellt: (d) => d.besitzt >= 25,
},
{
key: "aufgeraeumt",
name: "Aufgeräumt",
beschreibung: "Die Watchlist vollständig abgearbeitet — bei mindestens 20 Titeln",
symbol: "besen",
gruppe: "ordnung",
// Nur sinnvoll, wenn überhaupt etwas da war: die Bibliothek muss die 20
// hergeben, die Watchlist aber leer sein.
erfuellt: (d) => d.watchlist === 0 && d.eintraege >= 20,
},
{
key: "durchgemacht",
name: "Durchgemacht",
beschreibung: "Zwischen drei und fünf Uhr morgens etwas abgehakt",
symbol: "mond",
gruppe: "ordnung",
erfuellt: (d) => d.nachtsGesehen,
},
];
/** Alle Abzeichen, die dieser Datensatz hergibt — feste und dynamische. */
function erreichte(daten: Datensatz): { key: string; label?: string }[] {
const treffer: { key: string; label?: string }[] = KATALOG.filter((regel) =>
regel.erfuellt(daten),
).map((regel) => ({ key: regel.key }));
// Reihen: je vollständig gesehener TMDB-Reihe ein eigenes Abzeichen. Der
// Name wird eingefroren, damit er später nicht wandert.
// Eingefroren wird der **TMDB-Name** der Reihe, nicht der Anzeigename. Der
// kuratierte Name wird erst beim Anzeigen aufgesetzt — so lässt er sich
// später ändern, ohne gespeicherte Abzeichen anzufassen.
for (const [id, name] of daten.reihen) {
treffer.push({ key: `reihe:${id}`, label: name });
}
for (const [id, name] of daten.universen) {
treffer.push({ key: `universum:${id}`, label: name });
}
return treffer;
}
// ---------------------------------------------------------------------------
// Auswerten
// ---------------------------------------------------------------------------
export type Ergebnis = {
/** Neu vergebene Abzeichen — leer, wenn sich nichts geändert hat. */
neu: { key: string; label: string | null }[];
/** Beim allerersten Lauf eines Kontos wird nichts gemeldet. */
stumm: boolean;
};
/**
* Wertet aus und vergibt, was fehlt.
*
* Der **erste Lauf eines Kontos ist stumm**: bestehende Bibliotheken bringen
* Jahre an Daten mit, und zwei Dutzend Benachrichtigungen auf einmal wären
* keine Freude, sondern Lärm.
*/
export async function evaluateAchievements(userId: string): Promise<Ergebnis> {
const [daten, vorhanden] = await Promise.all([
sammle(userId),
prisma.achievement.findMany({ where: { userId }, select: { key: true } }),
]);
const bekannt = new Set(vorhanden.map((eintrag) => eintrag.key));
const erster = vorhanden.length === 0;
const fehlend = erreichte(daten).filter((eintrag) => !bekannt.has(eintrag.key));
if (fehlend.length === 0) return { neu: [], stumm: erster };
await prisma.achievement.createMany({
data: fehlend.map((eintrag) => ({ userId, key: eintrag.key, label: eintrag.label ?? null })),
skipDuplicates: true,
});
return {
neu: erster ? [] : fehlend.map((eintrag) => ({ key: eintrag.key, label: eintrag.label ?? null })),
stumm: erster,
};
}
/**
* Wertet aus, falls für dieses Konto noch nie ausgewertet wurde.
*
* Gedacht für den ersten Aufruf der Übersicht: ohne diesen Anstoß bliebe sie
* bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt. Ein
* Konto durchläuft das genau einmal — danach kostet es eine Zählabfrage.
*/
export async function ensureAchievementsEvaluated(userId: string): Promise<void> {
const vorhanden = await prisma.achievement.count({ where: { userId } });
if (vorhanden > 0) return;
await evaluateAchievements(userId);
}
/** Für den täglichen Lauf: alle Konten durchgehen. */
export async function evaluateAllAchievements(): Promise<{ konten: number; vergeben: number }> {
const konten = await prisma.user.findMany({ where: { isActive: true }, select: { id: true } });
let vergeben = 0;
for (const konto of konten) {
try {
const ergebnis = await evaluateAchievements(konto.id);
vergeben += ergebnis.neu.length;
} catch (error) {
console.error("Errungenschaften konnten nicht ausgewertet werden:", error);
}
}
return { konten: konten.length, vergeben };
}
// ---------------------------------------------------------------------------
// Anzeigen
// ---------------------------------------------------------------------------
/**
* Alles für die Übersicht: erreichte Abzeichen mit Datum, dazu die offenen
* mit ihrer Bedingung. Die offenen zu zeigen ist Absicht — sie sind die
* Landkarte, nicht die Peitsche.
*/
export async function listAchievements(userId: string): Promise<AchievementView[]> {
const vergeben = await prisma.achievement.findMany({
where: { userId },
select: { key: true, label: true, unlockedAt: true },
orderBy: { unlockedAt: "desc" },
});
const nachKey = new Map(vergeben.map((eintrag) => [eintrag.key, eintrag]));
const feste: AchievementView[] = KATALOG.map((regel) => ({
key: regel.key,
name: regel.name,
beschreibung: regel.beschreibung,
symbol: regel.symbol,
gruppe: regel.gruppe,
unlockedAt: nachKey.get(regel.key)?.unlockedAt ?? null,
}));
// Dynamische gibt es nur, wenn sie erreicht wurden — eine Liste aller
// denkbaren Filmreihen wäre sinnlos lang.
const dynamisch: AchievementView[] = vergeben
.filter((eintrag) => eintrag.key.includes(":"))
.map((eintrag) => {
const istReihe = eintrag.key.startsWith("reihe:");
const id = Number(eintrag.key.slice("reihe:".length));
const kuratiert = istReihe ? kuratierteReihe(id, eintrag.label) : undefined;
return {
key: eintrag.key,
name: kuratiert?.name ?? eintrag.label ?? (istReihe ? "Reihe komplett" : "Universum vollendet"),
beschreibung: istReihe
? `Alle Filme der Reihe „${eintrag.label ?? "…"}" gesehen`
: `Alle Titel des Universums „${eintrag.label ?? "…"}" abgeschlossen`,
symbol: (kuratiert?.symbol ?? (istReihe ? "klappe" : "planet")) as SymbolName,
gruppe: "reihen" as Gruppe,
unlockedAt: eintrag.unlockedAt,
};
});
return [...feste, ...dynamisch];
}
/**
* Anzeigename eines Abzeichens aus Schlüssel und eingefrorenem Namen.
* Wird für die Einblendung direkt nach dem Erreichen gebraucht, wo noch keine
* vollständige Liste geholt wurde.
*/
export function achievementName(key: string, label: string | null): string {
if (key.startsWith("reihe:")) {
const id = Number(key.slice("reihe:".length));
return kuratierteReihe(id, label)?.name ?? `Reihe komplett — ${label ?? "…"}`;
}
if (key.startsWith("universum:")) return `Universum vollendet — ${label ?? "…"}`;
return KATALOG.find((regel) => regel.key === key)?.name ?? key;
}
/** Nur die erreichten — für das öffentliche Profil. */
export async function listUnlockedAchievements(userId: string): Promise<AchievementView[]> {
return (await listAchievements(userId)).filter((abzeichen) => abzeichen.unlockedAt !== null);
}
+195 -12
View File
@@ -1,6 +1,8 @@
import "server-only"; import "server-only";
import { prisma } from "./db"; import { prisma } from "./db";
import { env } from "./env";
import { ensureTitle } from "./catalog";
import { decryptSecret, encryptSecret } from "./secrets"; import { decryptSecret, encryptSecret } from "./secrets";
import { import {
authenticate, authenticate,
@@ -33,12 +35,33 @@ import {
export class MediaServerError extends Error {} export class MediaServerError extends Error {}
/**
* Ein Titel, den der Abgleich nicht zuordnen konnte.
*
* Zusammengefasst je TMDB-Kennung: Jellyfin führt oft mehrere Dateien zum
* selben Film (Extended Edition, zweite Fassung), und in einer Auswahlliste
* will niemand denselben Titel dreimal anhaken.
*/
export type UnassignedItem = {
/** Fehlt sie, lässt sich der Titel nicht automatisch holen. */
tmdbId: number | null;
typ: "SERIES" | "MOVIE" | null;
name: string;
grund: string;
/** Wie viele Jellyfin-Dateien auf diesen Titel zeigen. */
dateien: number;
};
export type SyncReport = { export type SyncReport = {
gelesen: number; gelesen: number;
bestandGesetzt: number; bestandGesetzt: number;
sichtungenAngelegt: number; sichtungenAngelegt: number;
aufgenommen: number; aufgenommen: number;
nichtZugeordnet: { name: string; grund: string }[]; /** Aus TMDB nachgeladen — nur über die Auswahl, nie von selbst. */
nachgeladen: number;
nichtZugeordnet: UnassignedItem[];
/** Wurde die Liste gekürzt? Dann steht hier, wie viele fehlen. */
weitereNichtZugeordnet: number;
fehler: string[]; fehler: string[];
}; };
@@ -47,12 +70,25 @@ const LEERER_BERICHT = (): SyncReport => ({
bestandGesetzt: 0, bestandGesetzt: 0,
sichtungenAngelegt: 0, sichtungenAngelegt: 0,
aufgenommen: 0, aufgenommen: 0,
nachgeladen: 0,
nichtZugeordnet: [], nichtZugeordnet: [],
weitereNichtZugeordnet: 0,
fehler: [], fehler: [],
}); });
/** Wie viele unzuordenbare Titel im Bericht landen — der Rest wäre nur Lärm. */ /**
const BERICHT_GRENZE = 50; * Obergrenze der Auswahlliste.
*
* Früher standen hier 50 — gedacht als Schutz vor Lärm, tatsächlich ein Blick
* durchs Schlüsselloch: bei über tausend gelesenen Einträgen sah man den
* Bruchteil. Jetzt großzügig, aber nicht unbegrenzt: ein Server, der
* Unsinn liefert, soll den Bericht nicht ins Unermessliche treiben. Was
* darüber hinausgeht, wird gezählt und benannt.
*/
const BERICHT_GRENZE = 1000;
/** Wie viele Titel ein Importlauf höchstens holt. */
const IMPORT_GRENZE = 100;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Verbindung einrichten // Verbindung einrichten
@@ -209,6 +245,8 @@ export async function syncConnection(
}); });
const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag])); const eintragNachTitel = new Map(eintraege.map((eintrag) => [eintrag.titleId, eintrag]));
// Zusammengefasst je Titel, nicht je Datei.
const gesammelt = new Map<string, UnassignedItem>();
const bestandTitelIds = new Set<string>(); const bestandTitelIds = new Set<string>();
const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = []; const gesehen: { titleId: string; episodeNumber: number | null; seasonNumber: number | null; wann: Date; externalId: string }[] = [];
@@ -217,13 +255,13 @@ export async function syncConnection(
const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item); const tmdbId = istEpisode ? tmdbIdOfEpisode(item, serien) : tmdbIdOf(item);
if (tmdbId == null) { if (tmdbId == null) {
merkeUnzugeordnet(bericht, item, "keine TMDB-Kennung am Item"); merkeUnzugeordnet(bericht, gesammelt, item, null, "keine TMDB-Kennung am Item");
continue; continue;
} }
const treffer = titelNachTmdb.get(tmdbId); const treffer = titelNachTmdb.get(tmdbId);
if (!treffer) { if (!treffer) {
merkeUnzugeordnet(bericht, item, `TMDB ${tmdbId} nicht im Katalog`); merkeUnzugeordnet(bericht, gesammelt, item, tmdbId, "noch nicht im Katalog");
continue; continue;
} }
@@ -278,6 +316,11 @@ export async function syncConnection(
); );
} }
// Namen zuerst, damit die Auswahlliste durchsuchbar wirkt statt zufällig.
bericht.nichtZugeordnet = [...gesammelt.values()].sort((a, b) =>
a.name.localeCompare(b.name, "de-DE"),
);
await prisma.mediaServerConnection.update({ await prisma.mediaServerConnection.update({
where: { id: connectionId }, where: { id: connectionId },
data: { lastSyncAt: new Date(), lastSyncReport: bericht as object }, data: { lastSyncAt: new Date(), lastSyncReport: bericht as object },
@@ -314,13 +357,49 @@ function tmdbIdOfEpisode(item: JellyfinItem, serien: Map<string, JellyfinItem>):
return serie ? tmdbIdOf(serie) : null; return serie ? tmdbIdOf(serie) : null;
} }
function merkeUnzugeordnet(bericht: SyncReport, item: JellyfinItem, grund: string) { /**
if (bericht.nichtZugeordnet.length >= BERICHT_GRENZE) return; * Merkt sich einen nicht zuzuordnenden Titel — zusammengefasst.
const name = *
item.Type === "Episode" && item.SeriesName * Der Schlüssel ist die TMDB-Kennung, sonst der Name. Jellyfin führt oft
? `${item.SeriesName} — ${item.Name}` * mehrere Dateien zum selben Film; in der Auswahlliste soll er einmal stehen,
: (item.Name ?? "Ohne Titel"); * mit dem Vermerk, wie viele Dateien darauf zeigen.
bericht.nichtZugeordnet.push({ name, grund }); */
function merkeUnzugeordnet(
bericht: SyncReport,
gesammelt: Map<string, UnassignedItem>,
item: JellyfinItem,
tmdbId: number | null,
grund: string,
) {
// Bei Episoden zählt die Serie: einzelne Folgen einer unbekannten Serie
// ergeben keine sinnvolle Auswahl, die Serie schon.
const istEpisode = item.Type === "Episode";
// Der Typ kommt aus dem Item, nicht aus dem Umkehrschluss „keine Episode,
// also Film". Jellyfin liefert bei dieser Abfrage zwar nur Movie und
// Episode, aber ein Server, der auch Series mitschickt, bekäme sonst
// „Film" ans Ohr geheftet.
const istSerie = istEpisode || item.Type === "Series";
const name = istEpisode ? (item.SeriesName ?? item.Name ?? "Ohne Titel") : (item.Name ?? "Ohne Titel");
const schluessel = tmdbId != null ? `tmdb:${tmdbId}` : `name:${name.toLocaleLowerCase("de-DE")}`;
const vorhanden = gesammelt.get(schluessel);
if (vorhanden) {
vorhanden.dateien += 1;
return;
}
if (gesammelt.size >= BERICHT_GRENZE) {
bericht.weitereNichtZugeordnet += 1;
return;
}
gesammelt.set(schluessel, {
tmdbId,
typ: tmdbId == null ? null : istSerie ? "SERIES" : "MOVIE",
name,
grund,
dateien: 1,
});
} }
/** /**
@@ -429,6 +508,110 @@ async function uebernehmeSichtungen(
return angelegt; return angelegt;
} }
/**
* Holt ausgewählte Titel aus TMDB nach und nimmt sie in die Bibliothek auf.
*
* Bewusst getrennt vom Abgleich: der läuft nachts und soll nie ungefragt
* hunderte Titel importieren. Das hier passiert nur auf Klick, mit einer
* Auswahl, die jemand getroffen hat.
*
* Ein einzelner Fehlschlag beendet den Lauf nicht — TMDB kennt nicht jede
* Kennung, die in einer Mediathek steht, und ein unbekannter Film soll die
* anderen neunundneunzig nicht mitreißen.
*/
export async function importUnassigned(
userId: string,
connectionId: string,
auswahl: { tmdbId: number; typ: "SERIES" | "MOVIE" }[],
): Promise<{ nachgeladen: number; fehler: string[]; uebrig: number }> {
await requireConnection(userId, connectionId);
if (!env.tmdb.isConfigured) {
throw new MediaServerError(
"Ohne TMDB-Schlüssel lassen sich keine Titel nachladen. Trage TMDB_API_KEY in der .env ein.",
);
}
// Obergrenze je Lauf: 100 Serien bedeuten mit Staffeln und Episoden schnell
// mehrere hundert TMDB-Abrufe. Der Rest kommt beim nächsten Mal.
const stapel = auswahl.slice(0, IMPORT_GRENZE);
const uebrig = Math.max(0, auswahl.length - stapel.length);
const fehler: string[] = [];
const geholt: number[] = [];
for (const eintrag of stapel) {
try {
const titel = await ensureTitle(eintrag.typ, eintrag.tmdbId, { region: env.tmdb.region });
// Aufnehmen und gleich als vorhanden markieren — er liegt ja auf dem
// Server. Ein bestehender Eintrag wird dabei nicht überschrieben.
const vorhanden = await prisma.libraryEntry.findUnique({
where: { userId_titleId: { userId, titleId: titel.id } },
select: { id: true, owned: true },
});
if (!vorhanden) {
await prisma.libraryEntry.create({
data: { userId, titleId: titel.id, status: "WATCHLIST", owned: true, ownedSource: "Jellyfin" },
});
} else if (!vorhanden.owned) {
await prisma.libraryEntry.update({
where: { id: vorhanden.id },
data: { owned: true, ownedSource: "Jellyfin" },
});
}
geholt.push(eintrag.tmdbId);
} catch (error) {
fehler.push(
`TMDB ${eintrag.tmdbId}: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
}
// Nur was tatsächlich ankam, verschwindet aus der Auswahlliste. Ein
// Fehlschlag — TMDB kurz nicht erreichbar, Kennung unbekannt — soll den
// Eintrag stehen lassen, damit man es noch einmal versuchen kann, ohne
// vorher den ganzen Abgleich zu wiederholen.
await entferneAusBericht(connectionId, geholt);
return { nachgeladen: geholt.length, fehler, uebrig };
}
/**
* Streicht die geholten Titel aus dem gespeicherten Bericht.
*
* Ohne das stünden sie nach dem Import weiter in der Auswahlliste, und man
* würde sie beim nächsten Mal erneut anhaken.
*/
async function entferneAusBericht(connectionId: string, tmdbIds: number[]) {
if (tmdbIds.length === 0) return;
const verbindung = await prisma.mediaServerConnection.findUnique({
where: { id: connectionId },
select: { lastSyncReport: true },
});
const bericht = verbindung?.lastSyncReport as SyncReport | null;
if (!bericht?.nichtZugeordnet) return;
const geholt = new Set(tmdbIds);
const rest = bericht.nichtZugeordnet.filter(
(eintrag) => eintrag.tmdbId == null || !geholt.has(eintrag.tmdbId),
);
await prisma.mediaServerConnection.update({
where: { id: connectionId },
data: {
lastSyncReport: {
...bericht,
nichtZugeordnet: rest,
nachgeladen: (bericht.nachgeladen ?? 0) + tmdbIds.length,
} as object,
},
});
}
/** /**
* Alle fälligen Verbindungen abgleichen — für den täglichen Lauf. * Alle fälligen Verbindungen abgleichen — für den täglichen Lauf.
* *
+15
View File
@@ -4,6 +4,7 @@ import { env } from "./env";
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog"; import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
import { notifyUser } from "./notify"; import { notifyUser } from "./notify";
import { syncAllConnections } from "./mediaserver"; import { syncAllConnections } from "./mediaserver";
import { evaluateAllAchievements } from "./achievements";
import { episodeCode } from "./labels"; import { episodeCode } from "./labels";
/** /**
@@ -29,6 +30,7 @@ export type SyncStats = {
sessionsPurged: number; sessionsPurged: number;
mediaServersSynced: number; mediaServersSynced: number;
mediaServerWatches: number; mediaServerWatches: number;
achievementsUnlocked: number;
errors: string[]; errors: string[];
}; };
@@ -237,6 +239,7 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
sessionsPurged: 0, sessionsPurged: 0,
mediaServersSynced: 0, mediaServersSynced: 0,
mediaServerWatches: 0, mediaServerWatches: 0,
achievementsUnlocked: 0,
errors: [], errors: [],
}; };
@@ -303,6 +306,18 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
); );
} }
// Errungenschaften: einmal täglich vollständig. Die Regeln, die nicht an
// einer Sichtung hängen (Zeitspannen, abgeschlossene Reihen), finden hier
// ihren Weg — inline waeren sie zu teuer.
try {
const abzeichen = await evaluateAllAchievements();
stats.achievementsUnlocked = abzeichen.vergeben;
} catch (error) {
stats.errors.push(
`Errungenschaften: ${error instanceof Error ? error.message : "unbekannter Fehler"}`,
);
}
const purged = await purgeExpired(); const purged = await purgeExpired();
stats.entriesPurged = purged.entries; stats.entriesPurged = purged.entries;
stats.sessionsPurged = purged.sessions; stats.sessionsPurged = purged.sessions;
+254
View File
@@ -0,0 +1,254 @@
import "./setup";
import assert from "node:assert/strict";
import test, { after, before, beforeEach } from "node:test";
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID } from "./fixtures/tmdb";
import { prisma } from "../src/lib/db";
import { importMovie, importSeries } from "../src/lib/catalog";
import { addToLibrary, markMovieWatched, markEpisodesWatched } from "../src/lib/library";
import {
achievementName,
evaluateAchievements,
listAchievements,
listUnlockedAchievements,
} from "../src/lib/achievements";
import { kuratierteReihe } from "../src/lib/achievement-names";
/**
* Prüft die Errungenschaften: Vergabe, Entdopplung, die Stummschaltung beim
* ersten Lauf und die Absicherung gegen falsch abgeschriebene Reihen.
*/
const tmdb = installTmdbStub();
const TEST_EMAIL = "test-achievements@flimmer.invalid";
let userId: string;
let seriesTitleId: string;
let movieTitleId: string;
async function aufraeumen() {
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
}
before(async () => {
await aufraeumen();
userId = (
await prisma.user.create({
data: { email: TEST_EMAIL, name: "Sammlerin" },
select: { id: true },
})
).id;
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
await importMovie(MOVIE_2_ID, "DE");
});
beforeEach(async () => {
await prisma.achievement.deleteMany({ where: { userId } });
await prisma.watch.deleteMany({ where: { userId } });
await prisma.libraryEntry.deleteMany({ where: { userId } });
});
after(async () => {
await aufraeumen();
tmdb.restore();
await prisma.$disconnect();
});
// ---------------------------------------------------------------------------
test("der erste Lauf eines Kontos ist stumm", async () => {
// Bestandskonten bringen Jahre an Daten mit. Zwei Dutzend Meldungen auf
// einmal waeren Laerm, kein Erfolgserlebnis.
await addToLibrary(userId, movieTitleId, "WATCHLIST");
const erster = await evaluateAchievements(userId);
assert.equal(erster.stumm, true, "der erste Lauf meldet nichts");
assert.deepEqual(erster.neu, [], "auch wenn er Abzeichen vergibt");
// Vergeben wurde trotzdem.
const vergeben = await prisma.achievement.count({ where: { userId } });
assert.ok(vergeben > 0, "Erstkontakt ist erreicht");
});
test("ab dem zweiten Lauf wird gemeldet", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId); // stummer Erstlauf
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: movieTitleId },
select: { id: true },
});
await markMovieWatched(userId, eintrag.id);
await prisma.libraryEntry.update({ where: { id: eintrag.id }, data: { currentRun: 2 } });
const zweiter = await evaluateAchievements(userId);
assert.equal(zweiter.stumm, false);
assert.ok(
zweiter.neu.some((abzeichen) => abzeichen.key === "wiederholungstaeter"),
"der dritte Durchlauf wird gemeldet",
);
});
test("ein zweiter Lauf vergibt nichts doppelt", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
const nachErstem = await prisma.achievement.count({ where: { userId } });
const zweiter = await evaluateAchievements(userId);
assert.deepEqual(zweiter.neu, []);
assert.equal(await prisma.achievement.count({ where: { userId } }), nachErstem);
});
test("einmal erreicht bleibt erreicht — auch wenn die Bedingung wegfällt", async () => {
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
assert.ok(
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
);
// Titel wieder entfernen: die Bedingung ist nicht mehr erfüllt.
await prisma.libraryEntry.delete({ where: { id: eintrag.id } });
await evaluateAchievements(userId);
assert.ok(
(await prisma.achievement.findFirst({ where: { userId, key: "erstkontakt" } })) != null,
"das Abzeichen bleibt stehen",
);
});
test("Durchgemacht rechnet in der Zeitzone der Instanz", async () => {
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
const episoden = await prisma.episode.findMany({
where: { titleId: seriesTitleId },
take: 1,
select: { id: true },
});
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
// 03:30 Uhr in Europe/Berlin ist im Sommer 01:30 UTC. Wer in UTC rechnet,
// verpasst das Abzeichen — genau der Fehler, der schon einmal das
// Monatsdiagramm leergefegt hat.
await prisma.watch.updateMany({
where: { userId },
data: { watchedAt: new Date("2026-07-15T01:30:00.000Z") },
});
await evaluateAchievements(userId);
const nachts = await prisma.achievement.findFirst({ where: { userId, key: "durchgemacht" } });
assert.ok(nachts, "03:30 Ortszeit zählt als Nacht");
});
test("Tagsueber gesehen loest Durchgemacht nicht aus", async () => {
const eintrag = await addToLibrary(userId, seriesTitleId, "WATCHING");
const episoden = await prisma.episode.findMany({
where: { titleId: seriesTitleId },
take: 1,
select: { id: true },
});
await markEpisodesWatched(userId, eintrag.id, [episoden[0]!.id]);
await prisma.watch.updateMany({
where: { userId },
data: { watchedAt: new Date("2026-07-15T12:00:00.000Z") },
});
await evaluateAchievements(userId);
assert.equal(await prisma.achievement.count({ where: { userId, key: "durchgemacht" } }), 0);
});
test("die Übersicht zeigt Erreichtes und Offenes", async () => {
await addToLibrary(userId, movieTitleId, "WATCHLIST");
await evaluateAchievements(userId);
const alle = await listAchievements(userId);
assert.ok(alle.length > 15, "der Katalog ist vollständig sichtbar");
const erreicht = alle.filter((abzeichen) => abzeichen.unlockedAt !== null);
const offen = alle.filter((abzeichen) => abzeichen.unlockedAt === null);
assert.ok(erreicht.length >= 1);
assert.ok(offen.length >= 1, "auch das noch nicht Erreichte steht da");
assert.deepEqual(
await listUnlockedAchievements(userId),
erreicht,
"das öffentliche Profil zeigt nur Erreichtes",
);
// Jedes Abzeichen trägt Name und Beschreibung — Farbe allein reicht nicht.
assert.ok(alle.every((abzeichen) => abzeichen.name && abzeichen.beschreibung));
});
test("ein kuratierter Name gilt nur, wenn er zur Reihe passt", async () => {
// Richtige Kennung mit passendem Namen: greift.
assert.equal(kuratierteReihe(556, "Spider-Man Filmreihe")?.name, "Spider Bro");
// Dieselbe Kennung, aber TMDB führt etwas ganz anderes darunter — dann darf
// der Eintrag nicht greifen. Genau so bleibt eine falsch abgeschriebene
// Kennung folgenlos.
assert.equal(kuratierteReihe(556, "Ocean's Filmreihe"), undefined);
// Unbekannte Kennung: nichts.
assert.equal(kuratierteReihe(999999, "Irgendwas"), undefined);
// Ohne Namen lässt sich nichts prüfen — im Zweifel generisch.
assert.equal(kuratierteReihe(556, null), undefined);
});
test("Anzeigenamen entstehen aus Schlüssel und eingefrorenem Namen", () => {
assert.equal(achievementName("marathon", null), "Marathon");
assert.equal(achievementName("reihe:556", "Spider-Man Filmreihe"), "Spider Bro");
assert.equal(
achievementName("reihe:12345", "Nordlicht Filmreihe"),
"Reihe komplett — Nordlicht Filmreihe",
"ohne kuratierten Eintrag der generische Name",
);
assert.equal(
achievementName("universum:abc", "Sternenpfad"),
"Universum vollendet — Sternenpfad",
);
});
test("ein abgeschlossenes Universum wird zum Abzeichen", async () => {
const eintrag = await addToLibrary(userId, movieTitleId, "WATCHLIST");
await markMovieWatched(userId, eintrag.id);
const universum = await prisma.universe.create({
data: {
ownerId: userId,
name: "Testuniversum",
slug: `test-achievements-${Date.now()}`,
items: { create: [{ titleId: movieTitleId }] },
orders: { create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }] },
},
select: { id: true },
});
try {
await evaluateAchievements(userId);
const abzeichen = await prisma.achievement.findFirst({
where: { userId, key: `universum:${universum.id}` },
select: { label: true },
});
assert.ok(abzeichen, "das vollendete Universum zählt");
assert.equal(abzeichen.label, "Testuniversum", "der Name wird eingefroren");
// Umbenennen ändert das Abzeichen nicht mehr.
await prisma.universe.update({
where: { id: universum.id },
data: { name: "Anders benannt" },
});
await evaluateAchievements(userId);
const danach = await prisma.achievement.findFirstOrThrow({
where: { userId, key: `universum:${universum.id}` },
select: { label: true },
});
assert.equal(danach.label, "Testuniversum");
} finally {
await prisma.universe.delete({ where: { id: universum.id } });
}
});
+11
View File
@@ -77,6 +77,17 @@ export const items = {
ProviderIds: { Tmdb: "424242" }, ProviderIds: { Tmdb: "424242" },
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
}, },
/**
* Zweite Datei zu demselben fremden Film — so führt Jellyfin Extended
* Editions. In der Auswahlliste muss der Titel trotzdem einmal stehen.
*/
fremdZweiteFassung: {
Id: "jf-movie-5",
Name: "Ein fremder Film (Extended)",
Type: "Movie",
ProviderIds: { Tmdb: "424242" },
UserData: { Played: false, PlayCount: 0, LastPlayedDate: null },
},
} as const; } as const;
export const seriesItem = { export const seriesItem = {
+153 -1
View File
@@ -12,6 +12,7 @@ import { decryptSecret, encryptSecret } from "../src/lib/secrets";
import { import {
connectJellyfin, connectJellyfin,
deleteConnection, deleteConnection,
importUnassigned,
syncConnection, syncConnection,
testConnection, testConnection,
} from "../src/lib/mediaserver"; } from "../src/lib/mediaserver";
@@ -140,7 +141,7 @@ test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
const bericht = await syncConnection(userId, verbindung.id); const bericht = await syncConnection(userId, verbindung.id);
assert.equal(bericht.gelesen, 6, "alle Items wurden gelesen"); assert.equal(bericht.gelesen, 7, "alle Items wurden gelesen");
assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen"); assert.equal(bericht.aufgenommen, 0, "ohne ausdrückliche Erlaubnis wird nichts aufgenommen");
assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“"); assert.equal(bericht.bestandGesetzt, 1, "nur der vorhandene Eintrag bekommt „hab ich“");
@@ -160,6 +161,12 @@ test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
gruende.some((grund) => grund.includes("nicht im Katalog")), gruende.some((grund) => grund.includes("nicht im Katalog")),
"der fremde Film steht im Bericht", "der fremde Film steht im Bericht",
); );
// Die zwei Dateien zum fremden Film stehen als ein Eintrag da.
assert.equal(
bericht.nichtZugeordnet.length,
2,
"zusammengefasst: ein fremder Film und ein Video ohne Kennung",
);
} finally { } finally {
jf.restore(); jf.restore();
} }
@@ -315,3 +322,148 @@ test("fremde Verbindungen sind unerreichbar", async () => {
jf.restore(); jf.restore();
} }
}); });
// --- Nachladen unbekannter Titel (#16) ---------------------------------------
test("der Bericht fasst Dubletten zusammen und nennt Typ und Kennung", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const bericht = await syncConnection(userId, verbindung.id);
// Zwei Jellyfin-Dateien zeigen auf denselben fremden Film.
const fremd = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === 424242);
assert.equal(fremd.length, 1, "derselbe Titel steht nur einmal in der Liste");
assert.equal(fremd[0]!.dateien, 2, "beide Dateien werden gezaehlt");
assert.equal(fremd[0]!.typ, "MOVIE");
// Das Urlaubsvideo hat keine Kennung — es steht drin, aber ohne Typ.
const ohneKennung = bericht.nichtZugeordnet.filter((eintrag) => eintrag.tmdbId === null);
assert.equal(ohneKennung.length, 1);
assert.equal(ohneKennung[0]!.typ, null, "ohne Kennung laesst sich nichts holen");
assert.equal(bericht.weitereNichtZugeordnet, 0, "nichts abgeschnitten");
} finally {
jf.restore();
}
});
test("ausgewaehlte Titel werden aus TMDB nachgeladen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await syncConnection(userId, verbindung.id);
// MOVIE_2_ID kennt der TMDB-Stub, steht aber in diesem Lauf nicht in der
// Bibliothek — genau der Fall, den #16 loest.
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
const ergebnis = await importUnassigned(userId, verbindung.id, [
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
]);
assert.equal(ergebnis.nachgeladen, 1);
assert.deepEqual(ergebnis.fehler, []);
assert.equal(ergebnis.uebrig, 0);
const titel = await prisma.title.findFirst({ where: { tmdbId: MOVIE_2_ID } });
assert.ok(titel, "der Titel steht jetzt im Katalog");
const eintrag = await prisma.libraryEntry.findFirstOrThrow({
where: { userId, titleId: titel.id },
select: { status: true, owned: true, ownedSource: true },
});
assert.equal(eintrag.status, "WATCHLIST");
assert.equal(eintrag.owned, true, "was auf dem Server liegt, ist vorhanden");
assert.equal(eintrag.ownedSource, "Jellyfin");
} finally {
jf.restore();
}
});
test("ein einzelner Fehlschlag bricht den Import nicht ab", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
const ergebnis = await importUnassigned(userId, verbindung.id, [
// Diese Kennung kennt der Stub nicht — sie muss scheitern.
{ tmdbId: 424242, typ: "MOVIE" },
{ tmdbId: MOVIE_2_ID, typ: "MOVIE" },
]);
assert.equal(ergebnis.nachgeladen, 1, "der zweite Titel kommt trotzdem an");
assert.equal(ergebnis.fehler.length, 1);
assert.ok(ergebnis.fehler[0]!.includes("424242"));
} finally {
jf.restore();
}
});
test("nachgeladene Titel verschwinden aus der Auswahlliste", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
await syncConnection(userId, verbindung.id);
await prisma.title.deleteMany({ where: { tmdbId: MOVIE_2_ID } });
const vorher = await gespeicherterBericht(verbindung.id);
assert.ok(vorher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
await importUnassigned(userId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]);
// Der fremde Film wurde nicht ausgewaehlt und bleibt stehen …
const nachher = await gespeicherterBericht(verbindung.id);
assert.ok(nachher.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242));
// Ein Fehlschlag laesst den Eintrag dagegen stehen: TMDB kennt 424242
// nicht, und wer es spaeter erneut versuchen will, soll ihn noch finden.
const misslungen = await importUnassigned(userId, verbindung.id, [
{ tmdbId: 424242, typ: "MOVIE" },
]);
assert.equal(misslungen.nachgeladen, 0);
assert.equal(misslungen.fehler.length, 1);
const zuletzt = await gespeicherterBericht(verbindung.id);
assert.ok(
zuletzt.nichtZugeordnet.some((eintrag) => eintrag.tmdbId === 424242),
"was nicht geholt werden konnte, bleibt in der Liste",
);
} finally {
jf.restore();
}
});
test("fremde Verbindungen lassen sich nicht befuellen", async () => {
const jf = installJellyfinStub();
try {
const verbindung = await verbinden();
const fremderId = (
await prisma.user.create({
data: { email: "test-mediaserver-import@flimmer.invalid", name: "Fremd" },
select: { id: true },
})
).id;
try {
await assert.rejects(
() => importUnassigned(fremderId, verbindung.id, [{ tmdbId: MOVIE_2_ID, typ: "MOVIE" }]),
/gibt es nicht/,
);
} finally {
await prisma.user.delete({ where: { id: fremderId } });
}
} finally {
jf.restore();
}
});
async function gespeicherterBericht(connectionId: string) {
const verbindung = await prisma.mediaServerConnection.findUniqueOrThrow({
where: { id: connectionId },
select: { lastSyncReport: true },
});
return verbindung.lastSyncReport as unknown as {
nichtZugeordnet: { tmdbId: number | null }[];
};
}