Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57151a770e | ||
|
|
09625c89ef | ||
|
|
f8d5566865 | ||
|
|
6847d08011 | ||
|
|
7f890155ae | ||
|
|
b560ee9c51 |
@@ -7,6 +7,99 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [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
|
||||
|
||||
Zwei größere Bausteine: **Anmeldung per Passkey** und die **Jellyfin-Anbindung**.
|
||||
|
||||
@@ -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.
|
||||
- ✅ **Issue-Runde** (v0.8.0) — Passkeys, Jellyfin-Anbindung, Standardreihenfolge,
|
||||
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,
|
||||
Zeiterfassung in Gitea, Freigabe durch Florian. Details im DEVLOG.
|
||||
|
||||
@@ -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
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
@@ -158,8 +193,9 @@ selbst — Changelog, Screenshots, Release.
|
||||
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
|
||||
| Nacharbeiten — Docker verifiziert, eigene Sammlung | 0.7.0 | 51 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
|
||||
|
||||
|
||||
@@ -237,6 +237,46 @@ Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit.
|
||||
|
||||
---
|
||||
|
||||
## Errungenschaften
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
@@ -370,6 +410,38 @@ Beim Abgleich passiert dreierlei:
|
||||
Danach läuft der Abgleich im täglichen Hintergrundlauf mit — dort allerdings
|
||||
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,
|
||||
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.
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.8.0",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"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;
|
||||
@@ -42,6 +42,9 @@ model User {
|
||||
publicProfile Boolean @default(false)
|
||||
profileSlug String? @unique
|
||||
|
||||
/// Errungenschaften auf der öffentlichen Profilseite zeigen
|
||||
showAchievements Boolean @default(true)
|
||||
|
||||
// Benachrichtigungswünsche
|
||||
notifyNewEpisode Boolean @default(true)
|
||||
notifyRelease Boolean @default(true)
|
||||
@@ -55,6 +58,7 @@ model User {
|
||||
oidcAccounts OidcAccount[]
|
||||
passkeys Passkey[]
|
||||
mediaServers MediaServerConnection[]
|
||||
achievements Achievement[]
|
||||
entries LibraryEntry[]
|
||||
watches Watch[]
|
||||
tags Tag[]
|
||||
@@ -628,3 +632,27 @@ model MediaServerItem {
|
||||
@@unique([connectionId, externalId])
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -43,6 +43,11 @@ async function ausschnitt(page, name, ueberschrift) {
|
||||
}
|
||||
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();
|
||||
await bereich.screenshot({ path: `${OUT}/${name}.png` });
|
||||
console.log(" ", name);
|
||||
@@ -199,6 +204,16 @@ async function shot(page, name, url, opts = {}) {
|
||||
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 ---
|
||||
{
|
||||
const { ctx, page } = await session(1400, 900, "light");
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type WatchResult,
|
||||
} from "@/lib/library";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { achievementName, evaluateAchievements } from "@/lib/achievements";
|
||||
|
||||
/**
|
||||
* Server-Aktionen rund um die Bibliothek.
|
||||
@@ -38,11 +39,14 @@ export type ActionResult = {
|
||||
message?: string;
|
||||
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
|
||||
watchIds?: string[];
|
||||
/** Gerade erreichte Errungenschaften, fertig benannt. */
|
||||
achievements?: string[];
|
||||
};
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
|
||||
revalidate: string[] = [],
|
||||
{ pruefeErrungenschaften = false }: { pruefeErrungenschaften?: boolean } = {},
|
||||
): Promise<ActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
@@ -50,7 +54,25 @@ async function run(
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
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) {
|
||||
if (error instanceof LibraryError) return { ok: false, message: error.message };
|
||||
console.error("Bibliotheksaktion fehlgeschlagen:", error);
|
||||
@@ -75,7 +97,7 @@ export async function addTitleAction(titleId: string, status: string = "WATCHLIS
|
||||
message:
|
||||
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
|
||||
};
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function trashEntryAction(entryId: string) {
|
||||
@@ -130,7 +152,7 @@ export async function startRewatchAction(entryId: string) {
|
||||
return run(async (userId) => {
|
||||
const entry = await startRewatch(userId, cuid.parse(entryId));
|
||||
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)]);
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
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));
|
||||
return { message: "Staffel zurückgesetzt" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function markUpToAction(entryId: string, episodeId: string) {
|
||||
return run(
|
||||
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
|
||||
PATHS,
|
||||
{ pruefeErrungenschaften: true },
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
return run(async (userId) => {
|
||||
await unmarkMovie(userId, cuid.parse(entryId));
|
||||
return { message: "Markierung entfernt" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
export async function undoAction(entryId: string, watchIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
|
||||
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" };
|
||||
}, PATHS);
|
||||
}, PATHS, { pruefeErrungenschaften: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,7 @@ import { runSync } from "@/lib/sync";
|
||||
import {
|
||||
connectJellyfin,
|
||||
deleteConnection,
|
||||
importUnassigned,
|
||||
syncConnection,
|
||||
testConnection,
|
||||
updateConnection,
|
||||
@@ -64,6 +65,7 @@ const profileSchema = z.object({
|
||||
name: z.string().trim().min(1).max(80),
|
||||
region: z.string().trim().length(2).toUpperCase(),
|
||||
publicProfile: z.boolean(),
|
||||
showAchievements: z.boolean(),
|
||||
profileSlug: z
|
||||
.string()
|
||||
.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(
|
||||
connectionId: string,
|
||||
data: { enabled?: boolean; syncOwned?: boolean; syncWatched?: boolean },
|
||||
|
||||
@@ -24,6 +24,7 @@ export default async function SettingsPage() {
|
||||
region: true,
|
||||
publicProfile: true,
|
||||
profileSlug: true,
|
||||
showAchievements: true,
|
||||
notifyNewEpisode: true,
|
||||
notifyRelease: true,
|
||||
notifyAvailability: true,
|
||||
@@ -89,6 +90,14 @@ export default async function SettingsPage() {
|
||||
syncOwned: verbindung.syncOwned,
|
||||
syncWatched: verbindung.syncWatched,
|
||||
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>
|
||||
|
||||
@@ -16,6 +16,8 @@ import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
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" };
|
||||
|
||||
@@ -42,6 +44,13 @@ export default async function StatsPage({
|
||||
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) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
@@ -205,6 +214,48 @@ export default async function StatsPage({
|
||||
emptyText="Noch nichts bewertet."
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { listUnlockedAchievements } from "@/lib/achievements";
|
||||
import { AchievementSymbol } from "@/components/AchievementBadge";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
|
||||
import { formatMinutes, formatRelative } from "@/lib/labels";
|
||||
@@ -35,10 +37,12 @@ export default async function PublicProfilePage({ params }: { params: Params })
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
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();
|
||||
|
||||
const abzeichen = user.showAchievements ? await listUnlockedAchievements(user.id) : [];
|
||||
|
||||
const [totals, mostWatched, topRated, recent] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMostWatched(user.id, 6),
|
||||
@@ -89,6 +93,24 @@ export default async function PublicProfilePage({ params }: { params: Params })
|
||||
</div>
|
||||
</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 && (
|
||||
<section className="mt-6">
|
||||
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library";
|
||||
type Toast = {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: "info" | "error";
|
||||
tone: "info" | "error" | "erfolg";
|
||||
undo?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
show(result.message, {
|
||||
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],
|
||||
);
|
||||
@@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
||||
borderColor:
|
||||
toast.tone === "error"
|
||||
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
|
||||
: toast.tone === "erfolg"
|
||||
? "color-mix(in oklab, var(--fl-success) 50%, transparent)"
|
||||
: "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>
|
||||
|
||||
@@ -100,6 +100,7 @@ export function ProfileForm({
|
||||
email: string;
|
||||
region: string;
|
||||
publicProfile: boolean;
|
||||
showAchievements: boolean;
|
||||
profileSlug: string | null;
|
||||
notifyNewEpisode: boolean;
|
||||
notifyRelease: boolean;
|
||||
@@ -121,6 +122,7 @@ export function ProfileForm({
|
||||
name: state.name,
|
||||
region: state.region,
|
||||
publicProfile: state.publicProfile,
|
||||
showAchievements: state.showAchievements,
|
||||
profileSlug: state.profileSlug || null,
|
||||
notifyNewEpisode: state.notifyNewEpisode,
|
||||
notifyRelease: state.notifyRelease,
|
||||
@@ -190,6 +192,7 @@ export function ProfileForm({
|
||||
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||
/>
|
||||
{state.publicProfile && (
|
||||
<>
|
||||
<Field
|
||||
id="profileSlug"
|
||||
label="Adresse"
|
||||
@@ -198,6 +201,13 @@ export function ProfileForm({
|
||||
placeholder="dein-name"
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
connectMediaServerAction,
|
||||
deleteMediaServerAction,
|
||||
importMediaServerTitlesAction,
|
||||
syncMediaServerAction,
|
||||
testMediaServerAction,
|
||||
updateMediaServerAction,
|
||||
@@ -26,6 +28,14 @@ import { formatDate } from "@/lib/labels";
|
||||
* Bibliothek werden.
|
||||
*/
|
||||
|
||||
export type UnassignedItem = {
|
||||
tmdbId: number | null;
|
||||
typ: "SERIES" | "MOVIE" | null;
|
||||
name: string;
|
||||
grund: string;
|
||||
dateien: number;
|
||||
};
|
||||
|
||||
export type Verbindung = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -35,6 +45,9 @@ export type Verbindung = {
|
||||
syncOwned: boolean;
|
||||
syncWatched: boolean;
|
||||
lastSyncAt: string | null;
|
||||
/** Aus dem letzten Abgleich — Grundlage der Auswahlliste. */
|
||||
offen: UnassignedItem[];
|
||||
weitereOffen: number;
|
||||
};
|
||||
|
||||
type Pruefung = NonNullable<MediaServerResult["pruefung"]>;
|
||||
@@ -171,7 +184,7 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
|
||||
Verbindung prüfen
|
||||
</button>
|
||||
<Haken
|
||||
label="Fehlende Titel dabei aufnehmen"
|
||||
label="Bekannte Titel dabei in die Bibliothek aufnehmen"
|
||||
checked={aufnehmen}
|
||||
onChange={setAufnehmen}
|
||||
/>
|
||||
@@ -184,6 +197,26 @@ export function MediaServerSettings({ verbindungen }: { verbindungen: Verbindung
|
||||
{pruefung && <Pruefbericht pruefung={pruefung} />}
|
||||
{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 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
@@ -324,3 +357,144 @@ function Haken({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "./db";
|
||||
import { env } from "./env";
|
||||
import { ensureTitle } from "./catalog";
|
||||
import { decryptSecret, encryptSecret } from "./secrets";
|
||||
import {
|
||||
authenticate,
|
||||
@@ -33,12 +35,33 @@ import {
|
||||
|
||||
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 = {
|
||||
gelesen: number;
|
||||
bestandGesetzt: number;
|
||||
sichtungenAngelegt: 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[];
|
||||
};
|
||||
|
||||
@@ -47,12 +70,25 @@ const LEERER_BERICHT = (): SyncReport => ({
|
||||
bestandGesetzt: 0,
|
||||
sichtungenAngelegt: 0,
|
||||
aufgenommen: 0,
|
||||
nachgeladen: 0,
|
||||
nichtZugeordnet: [],
|
||||
weitereNichtZugeordnet: 0,
|
||||
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
|
||||
@@ -209,6 +245,8 @@ export async function syncConnection(
|
||||
});
|
||||
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 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);
|
||||
|
||||
if (tmdbId == null) {
|
||||
merkeUnzugeordnet(bericht, item, "keine TMDB-Kennung am Item");
|
||||
merkeUnzugeordnet(bericht, gesammelt, item, null, "keine TMDB-Kennung am Item");
|
||||
continue;
|
||||
}
|
||||
|
||||
const treffer = titelNachTmdb.get(tmdbId);
|
||||
if (!treffer) {
|
||||
merkeUnzugeordnet(bericht, item, `TMDB ${tmdbId} nicht im Katalog`);
|
||||
merkeUnzugeordnet(bericht, gesammelt, item, tmdbId, "noch nicht im Katalog");
|
||||
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({
|
||||
where: { id: connectionId },
|
||||
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;
|
||||
}
|
||||
|
||||
function merkeUnzugeordnet(bericht: SyncReport, item: JellyfinItem, grund: string) {
|
||||
if (bericht.nichtZugeordnet.length >= BERICHT_GRENZE) return;
|
||||
const name =
|
||||
item.Type === "Episode" && item.SeriesName
|
||||
? `${item.SeriesName} — ${item.Name}`
|
||||
: (item.Name ?? "Ohne Titel");
|
||||
bericht.nichtZugeordnet.push({ name, grund });
|
||||
/**
|
||||
* Merkt sich einen nicht zuzuordnenden Titel — zusammengefasst.
|
||||
*
|
||||
* Der Schlüssel ist die TMDB-Kennung, sonst der Name. Jellyfin führt oft
|
||||
* mehrere Dateien zum selben Film; in der Auswahlliste soll er einmal stehen,
|
||||
* mit dem Vermerk, wie viele Dateien darauf zeigen.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@ import { env } from "./env";
|
||||
import { importSeries, importMovie, SPECIALS_SEASON } from "./catalog";
|
||||
import { notifyUser } from "./notify";
|
||||
import { syncAllConnections } from "./mediaserver";
|
||||
import { evaluateAllAchievements } from "./achievements";
|
||||
import { episodeCode } from "./labels";
|
||||
|
||||
/**
|
||||
@@ -29,6 +30,7 @@ export type SyncStats = {
|
||||
sessionsPurged: number;
|
||||
mediaServersSynced: number;
|
||||
mediaServerWatches: number;
|
||||
achievementsUnlocked: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
@@ -237,6 +239,7 @@ export async function runSync({ limit = 60 }: { limit?: number } = {}): Promise<
|
||||
sessionsPurged: 0,
|
||||
mediaServersSynced: 0,
|
||||
mediaServerWatches: 0,
|
||||
achievementsUnlocked: 0,
|
||||
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();
|
||||
stats.entriesPurged = purged.entries;
|
||||
stats.sessionsPurged = purged.sessions;
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
});
|
||||
@@ -77,6 +77,17 @@ export const items = {
|
||||
ProviderIds: { Tmdb: "424242" },
|
||||
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;
|
||||
|
||||
export const seriesItem = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { decryptSecret, encryptSecret } from "../src/lib/secrets";
|
||||
import {
|
||||
connectJellyfin,
|
||||
deleteConnection,
|
||||
importUnassigned,
|
||||
syncConnection,
|
||||
testConnection,
|
||||
} from "../src/lib/mediaserver";
|
||||
@@ -140,7 +141,7 @@ test("Bestand wird zugeordnet, Unzuordenbares landet im Bericht", async () => {
|
||||
|
||||
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.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")),
|
||||
"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 {
|
||||
jf.restore();
|
||||
}
|
||||
@@ -315,3 +322,148 @@ test("fremde Verbindungen sind unerreichbar", async () => {
|
||||
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 }[];
|
||||
};
|
||||
}
|
||||
|
||||