Compare commits

..
13 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 fbf4a29461 Der Bildname kommt nicht mehr vom Repositoriumsnamen
Image bauen und veröffentlichen / image (push) Canceled after 0s
Das Repo heisst auf Gitea noch „halo", das Programm aber Flaro. Der
Workflow leitete den Bildnamen aus `gitea.repository` ab — das Image hiesse
`git.scarriffle.com/scarriffle/halo` und wäre die eine Stelle, an der der
alte Name überlebt. Das Skript daneben schreibt `flaro`; beides zusammen
ergäbe zwei Images für dieselbe Sache.

Gitea legt Pakete am **Besitzer** ab, nicht am Repository — sie dürfen
deshalb anders heissen als das Repo. Jetzt nehmen beide Wege
`<besitzer>/flaro`, überschreibbar über die Variable `IMAGE_NAME`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:10:41 +02:00
ScarriffleandClaude Opus 5 c12748e262 Ein Weg vom Quelltext in die Registry — und von dort aufs NAS
Damit das Image dort landet, wo das NAS es holen kann.

**`.gitea/workflows/image.yml`** — Gitea baut selbst. Bei jedem Push auf
`main` entsteht das Image für **beide** Architekturen und landet in der
Registry desselben Gitea. Niemand muss daran denken, niemand muss angemeldet
sein, und niemand kann die Architektur vergessen.

Voraussetzung ist ein registrierter Act-Runner. Hat deiner keinen, läuft die
Datei nie an — dafür gibt es den zweiten Weg.

**`Tools/image-veroeffentlichen.sh`** — ein Befehl vom Arbeitsrechner, baut
amd64 und arm64 und lädt hoch. Angemeldet wird einmalig von Hand; das
Passwort tippt nur, wer es kennt.

Beides legt `:latest` ab, dazu den Kurz-Hash und auf einem Git-Tag dessen
Namen — damit sich eine Fassung festnageln lässt, statt immer „latest" zu
ziehen.

Die Anleitung bekommt vier neue Abschnitte: Gitea baut selbst, von Hand
bauen, das Paket öffentlich stellen, und **die Registry im NAS eintragen** —
also genau der Dialog „Quellname des Images", mit Alias `gitea` und Link
`https://git.scarriffle.com/`. Für ein privates Paket ein Zugriffstoken mit
`read:package`, nicht das Kontopasswort: ein Token lässt sich einzeln
widerrufen.

Die Beispiel-Compose zeigte auf `ghcr.io/scarriffle/flaro` — dort liegt
nichts und lag nie etwas. Jetzt auf die Gitea-Registry.

---

**Ein Fehler im eigenen Skript, gefunden beim ersten Lauf.** Die Prüfung
„läuft Docker?" war `docker info | grep -q .` — und `grep -q` beendet die
Pipe beim ersten Treffer, Docker bekommt SIGPIPE, und mit `set -o pipefail`
gilt das als Fehlschlag. Das Skript meldete „Docker läuft nicht", während
Docker lief und gerade ein Image gebaut hatte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:08:22 +02:00
ScarriffleandClaude Opus 5 f2b5ee633b Dialoge statt Blöcke, bessere Filter, ein Dashboard — und eine NAS-Anleitung
**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular
zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg,
das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch
aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die
Metadaten-Einstellungen.

Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape
schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt
gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das
selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das
Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe
wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht.

**Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort
Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt
noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben
steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas
zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen.

Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch
nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7".

---

**„Wird abgespielt" — wer gerade was schaut.**

Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung
am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn Sekunden,
wo er steht. Wer das als Lebenszeichen liest, braucht kein zweites
Verfahren, das ein Client vergessen könnte — bleibt die Meldung aus, läuft
der Eintrag nach einer Minute von selbst ab.

Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine
Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand",
und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt.

Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf
dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen.
Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und
nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone".

**Nur für Administratoren.** Wer wann welchen Film anschaut, ist eine
Auskunft über Menschen, nicht über den Server. In einem Haushalt mag das
harmlos sein; die Vorgabe eines offenen Projekts darf es trotzdem nicht sein.

Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde
ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den
Spieler zugemacht hat, sähe sich selbst beim Schauen zu.

---

**`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist
ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom
Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine
Datenbank daneben.

Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft —
nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der
Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die
Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64,
und `exec format error` sagt das nicht deutlich.

Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt
nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt.

378 Tests grün, sechs davon neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:03:07 +02:00
ScarriffleandClaude Opus 5 a7ec3a73ba Angemeldet bleiben — und eine 401 reisst nicht mehr die Sitzung ab
Erst die ehrliche Hälfte: dass es heute **andauernd** abgemeldet hat, geht
auf mein Konto. Ich habe die Datenbank dreimal geleert, um mit frischen
Metadaten zu bauen; jedes Mal verfallen alle Token. Kein Schalter der Welt
hätte das verhindert.

Die andere Hälfte ist ein echter Fehler, und er macht aus einem Aussetzer
eine Abmeldung:

**Eine einzelne 401 warf die Anmeldung weg** — und rief dabei
`/auth/logout`, der Server zog das Token also *aktiv zurück*. War die
Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit absichtlich
zerstört, und zwar unwiderruflich. Jetzt wird einmal nachgefragt: gilt das
Token wirklich nicht mehr, ist die Sitzung vorbei; antwortet der Server, war
es diese eine Anfrage. Mehrere gleichzeitige Anfragen laufen in dieselbe
Prüfung — sonst stellte eine Startseite mit acht Abfragen achtmal dieselbe
Frage. Antwortet der Server **gar nicht**, ist das kein Beleg gegen das
Token: jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.

**Abmelden scheitert nie mehr.** Vorher verlangte der Weg eine gültige
Anmeldung — und genau dann, wenn man ihn am dringendsten braucht, gibt es
keine. Nach dem Leeren des Datenbestands zeigte das Cookie im Browser auf
ein Token, das es nicht mehr gab; es ging bei jedem Bild erneut hinaus, und
der Server lehnte es jedes Mal ab. Achtzig Ablehnungen in einer Stunde, und
nichts davon liess sich von aussen beenden. Jetzt wird das Cookie immer
gelöscht; widerrufen wird, was sich widerrufen lässt.

---

**Und der Schalter, um den es ging.** „Angemeldet bleiben" steht jetzt im
Anmeldefeld, mit Haken als Vorgabe — am eigenen Rechner will sich niemand
täglich neu anmelden.

Umgesetzt über die beiden Speicher, die der Browser ohnehin richtig
aufräumt: mit Haken `localStorage` und ein Cookie für ein Jahr, ohne Haken
`sessionStorage` und ein Sitzungscookie, das mit dem Fenster verschwindet.
Ein eigenes Verfallsdatum zu verwalten wäre die schlechtere Variante — eine
Uhr, die man stellen kann, gegen zwei Mechanismen, die es schon gibt.

Das Token selbst läuft weiterhin **nie** ab; es wird widerrufen, nicht
vergessen. Was hier entschieden wird, ist allein, wie lange der Browser sich
erinnert.

372 Tests grün, vier davon neu. Am Container nachgemessen: mit Haken
`max-age=31536000`, ohne Haken kein `max-age`, und ein Abmelden mit
ungültigem Cookie antwortet 204 statt 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 07:40:19 +02:00
ScarriffleandClaude Opus 5 8c395ac051 Die Oberfläche nach Plex' Vorbild — Hintergrund, Schriftzug, Besetzung
Nach den Bildern deiner Plex-Installation umgebaut. Übernommen ist der
Aufbau, nicht die Farbe: Plex ist bernsteingelb, Flaro bleibt Orchidee.

**Kopfleiste oben** mit Marke, Suche und Konto. Die Suche gehört dorthin und
nicht in jede Mediathek: wer einen Titel sucht, weiss oft gar nicht, in
welcher er liegt. Sie geht quer durch alles.

**Seitenleiste mit Icons** — echte SVG, keine Zeichen aus einer Schriftart.
Ein ▶ aus Unicode sieht auf jedem System anders aus, lässt sich nicht
einfärben und springt in der Grundlinie. Die aktive Zeile bekommt einen
schmalen Balken links statt eines leuchtenden Blocks; das liest sich in
einer langen Liste ruhiger.

**Die Kachel steht jetzt an einer Stelle** statt an fünfen. Sie trägt den
Haken für Gesehenes in der Ecke — wie bei Plex, ein Blick über das Raster
genügt —, die Zahl offener Folgen an derselben Stelle, den Fortschrittsbalken
unten und beim Überfahren einen Abspielknopf. Folgen sind dreizeilig: Serie,
Folgentitel, „S14 · F22".

**Reihen haben Pfeile.** Zwanzig Kacheln passen nicht in den Bildschirm, und
quer zu scrollen geht mit dem Rad nur mit Umschalttaste. Die Überschrift
nennt die Mediathek beim Namen: „Zuletzt in Dokumentationen hinzugefügt".

**Mediatheken haben drei Reiter** — Empfehlungen, Mediathek, Kategorien.
Darunter eine Filterzeile mit Genre, Sortierung und der Anzahl. Die
Kategorien sind farbige Kacheln; die Farbe kommt aus dem Namen, nicht aus
einer festen Liste, die jedes Genre kennen müsste, das TMDB je erfindet.

**Die Detailseite** hat jetzt den unscharfen Hintergrund über die ganze
Seite, den **Schriftzug des Films** statt der Systemschrift, eine Zeile mit
Jahr, Genres, Laufzeit, Altersfreigabe und Bewertung, runde Knöpfe für
gesehen/Favorit/weglegen, die Medienangaben als Tabelle — Datei, Video, Ton,
Untertitel — und zuletzt Besetzung & Stab mit runden Porträts.

**Dazu am Server:** ein Name. Ohne ihn zeigten die Clients den Hostnamen,
und die App schrieb „localhost" über die Anmeldung. Vorgabe ist der
Rechnername des Containers.

---

**Der Fehler, den das erste Bild zeigte:** die Lupe in der Suchleiste war
dreihundert Bildpunkte gross und lag über der halben Seite. Ein rohes
`<svg>` ohne Breite füllt seinen Container; die Regel galt nur für `.icon`.
Jetzt für jedes.

368 Tests grün. Am Container nachgesehen: „Der Untergang" mit eigenem
Schriftzug, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm, beide
Tonspuren in der Tabelle, 22 Porträts darunter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 01:04:27 +02:00
ScarriffleandClaude Opus 5 ede4069cd4 Der Server weiss jetzt mehr als Titel und Plakat
Bisher standen zu einem Film vier Angaben in der Datenbank: Titel, Jahr,
Beschreibung, zwei Bilder. Damit lässt sich eine Kachel zeichnen und sonst
nichts — keine Detailseite, die mehr sagt als der Dateiname, keine
Vorschläge, kein Filter nach Genre. Alles drei hing daran.

Jetzt kommen dazu: **Laufzeit, Bewertung, Werbezeile, Altersfreigabe,
Genres, Besetzung, Stab — und der Schriftzug des Titels.** Letzterer ist
das, was eine Startseite teuer aussehen lässt: „Blade Runner 2049" in seiner
eigenen Schrift über dem Hintergrundbild statt in der Systemschrift.

**Alles in einer Anfrage.** `append_to_response` hängt Besetzung,
Fremdkennungen, Freigaben und Bilder an den Einzelabruf. Vier getrennte
Anfragen je Titel wären bei vierhundert Filmen sechzehnhundert, und TMDB
begrenzt, wie oft man fragen darf. Der eigene Abruf der IMDb-Kennung ist
dabei entfallen — sie hängt jetzt mit dran.

Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den USA,
und TMDB führt beide nebeneinander. Genommen wird die des eingestellten
Landes. Beim Schriftzug entscheidet zuerst die Sprache und erst danach die
Bildbewertung — ein deutscher Titel in deutscher Schrift schlägt den besser
bewerteten englischen.

Vom Stab nur Regie und Drehbuch. TMDB führt dort auch Beleuchter und
Caterer; eine Detailseite, die alle nennt, nennt nichts.

**Genres und Personen als eigene Tabellen**, nicht als Text im Film. Sonst:
filtern über `LIKE`, Tippfehler als eigenes Genre, und bei einer Umbenennung
vierhundert Zeilen anfassen. Personen über die Kennung der Quelle geführt,
nicht über den Namen — es gibt zwei Schauspieler namens „Chris Evans".

Darauf sitzen zwei neue Endpunkte: ein Filter nach Genre (nur die, die in
der Mediathek auch vorkommen — ein Knopf zu einer leeren Liste ist schlimmer
als kein Knopf) und **Vorschläge** über die Genres des zuletzt Gesehenen.
Keine Empfehlungsmaschine: ein Haushaltsserver hat keine Millionen
Sehverläufe, aus denen sich etwas lernen liesse. Eine schlichte Regel ist
nachvollziehbar, was eine undurchschaubare Empfehlung nie ist.

---

**Zwei Fehler, beide fand erst der echte Lauf.**

`UNIQUE constraint failed: Genres.Name`, und damit scheiterte der ganze
Scan — in der Mediathek erschien kein einziger Titel. Vier Bibliotheken
scannen gleichzeitig, jede mit eigenem Datenbankkontext; zwei stossen auf
„Drama", keine sieht die ungespeicherte Zeile der anderen. Statt die Läufe
zu serialisieren — das kostete bei zehn Bibliotheken das Zehnfache an Zeit —
wird der Fehlschlag hingenommen und die Zeile des anderen benutzt. Sie steht
ja genau dafür da.

Und: **eine geänderte Sprache erreichte vorhandene Titel nie.** Ein zweiter
Scan fragt nichts erneut, das ist Absicht. Wer aber von Englisch auf Deutsch
stellt, bekam deutsche Titel erst beim nächsten neuen Film. Dafür gibt es
jetzt „Metadaten auffrischen" je Bibliothek — geholt über die bekannte
Kennung, nicht über eine neue Suche, damit eine Zuordnung von Hand
stehenbleibt.

368 Tests grün, zehn davon neu. Am Container nachgewiesen: „Der Untergang",
156 Minuten, FSK 12, Bewertung 7,9, Genres Drama/Historie/Kriegsfilm,
22 Mitwirkende mit Rollennamen, Schriftzug und Hintergrund da — und nach dem
Umstellen auf de-DE heisst er nicht mehr „Downfall".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 23:12:35 +02:00
ScarriffleandClaude Opus 5 230beb9664 Mehrere Mediatheken, jede für sich
Bisher gab es in der Seitenleiste „Filme" und „Serien" — zwei feste Zeilen,
hinter denen alles zusammenfiel, was der Server kannte. Wer „Filme" und
„Dokumentationen" getrennt angelegt hatte, sah sie wieder in einem Topf.
Das nahm die Entscheidung zurück, sie zu trennen.

Jetzt führt die Seitenleiste **jede Mediathek einzeln**, mit Namen und Zahl,
wie bei Plex und Jellyfin. Die Startseite bekommt je Mediathek eine Reihe
mit dem zuletzt Hinzugefügten; steht mehr drin, als die Reihe zeigt, führt
„Alle zeigen" dorthin. Die Mediathekenseite trägt den Namen im Titel, sucht
innerhalb der Mediathek und lässt sich sortieren.

**Am Server dafür:**

`?sort=` auf `/movies` und `/series` — `title`, `added`, `year`. Der Titel
immer als letzter Vergleich, sonst hätten zwei Filme aus demselben Jahr keine
feste Reihenfolge und Seite 2 zeigte denselben Film noch einmal oder gar
nicht.

Die Liste der Bibliotheken nennt je Zeile einen Zählwert. „Dokumentationen ·
0" sagt mehr als ein Eintrag, hinter dem sich nach dem Klick nichts findet —
da ist der Unterschied zwischen „noch nicht gescannt" und „falscher Pfad"
wenigstens sichtbar. `null` heisst „nicht gezählt", nicht „leer".

Umbenennen und Entfernen, beides nur für Administratoren. **Umbenennen, mehr
nicht:** die Art zu ändern hiesse, Filme in Serien zu verwandeln; den Pfad
zu ändern hiesse, einen Bestand gegen einen anderen zu tauschen, während die
Einträge stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue
Bibliothek.

**Beim Entfernen bleiben die Mediendateien liegen.** Weg sind der Eintrag,
die Zuordnungen und der Sehfortschritt aller Benutzer dazu; die Oberfläche
sagt das vorher, und eine Prüfung hält fest, dass die Datei danach noch da
ist. Ein Server, der beim Aufräumen in der Oberfläche fremde Filme löscht,
ist einer, den man nicht noch einmal benutzt.

---

**Der Fehler, den der Browser zeigte, und er betrifft jeden Betrieb:** nach
einem Server-Update lief weiter die alte Oberfläche.

Die Dateien unter `assets/` tragen eine Prüfsumme im Namen — ändert sich der
Inhalt, ändert sich der Name. `index.html` trägt keine; sie nennt nur deren
Namen. Lag sie im Zwischenspeicher, bekam der Browser weiter die alte App:
nicht kaputt, sondern veraltet, und das fällt niemandem auf. Nachgemessen:
geladen war `index-C0K3V8eg.js`, ausgeliefert wurde längst
`index-DomHN4_b.js`.

Jetzt zwei Lebensdauern — `no-cache` für die Seite, ein Jahr und `immutable`
für die Bausteine. Mit Prüfung, und am Container nachgemessen.

Der Beispielbestand bekommt zwei weitere Mediatheken, „Dokumentationen" und
„Konzerte". Mit nur Filme und Serien liesse sich gar nicht prüfen, ob
mehrere wirklich getrennt bleiben — zwei Bestände **derselben Art** sind der
Fall, in dem es auffällt.

358 Tests grün. Am Container nachgewiesen: vier Mediatheken in der
Seitenleiste mit Zählwerten, je eine Reihe auf der Startseite, und
„Dokumentationen" zeigt zwei Titel statt sieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 22:57:25 +02:00
ScarriffleandClaude Opus 5 172b38da06 Die Apps können jetzt beides: Jellyfin oder Flaro
`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem
Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich
trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige
Ansicht musste dafür neu geschrieben werden.

`ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil
ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es
wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht
beide nebeneinander.

**Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort
bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der
ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran.

---

**Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein
Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt;
es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link.
Flaro tut das nicht, und deshalb legt der Client sein Token selbst im
gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem
Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie
setzen könnte.

`URLSession` schickt es von allein mit; **AVFoundation nicht**. Der
Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine
liegen, ist die Liste leer.

**`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins
Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins
Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum
selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer
einzigen Spur entfällt gleich mit.

**Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig
begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen
fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport.
Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall
nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen
unverändert gegen die verschobene Fassung; das ist der Beweis.

**Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der
Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie
die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der
Schlüssel heisst jetzt so neutral wie der Text.

---

**Der Fehler, den erst der Simulator zeigte:** „Stelle wird
wiederhergestellt", 0:00, für immer.

Muss der Server umpacken, antwortet er auf den Download mit 202 und fängt
an. AVPlayer bekommt darauf kein Video, sondern eine leere Antwort — und
wartet, unbegrenzt, ohne Fehler. Die Weboberfläche fragt in einer Schleife
nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und
einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt.

413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse
eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart
ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst
umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s,
gesehen, ein Durchlauf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 22:00:02 +02:00
ScarriffleandClaude Opus 5 6d5824b889 Weiterschauen, sichtbar gemacht
Die Oberfläche zeigt jetzt, was der Server seit Stufe 4 weiss.

Auf der Startseite zwei neue Reihen, ganz oben: **Weiterschauen** und
**Nächste Folge**. Sie überschneiden sich nicht — was angefangen ist, steht
links, was ansteht, rechts. Eine leere Reihe wird gar nicht erst gezeigt.

Auf jeder Kachel ein Balken, und zwar **ab der ersten Sekunde**. Bei einem
Zweieinhalbstünder sind zwei Minuten 1,3 % — anderthalb Bildpunkte. Der
Balken hat deshalb eine Mindestbreite, sonst wäre das Versprechen „wir merken
uns jede Sekunde" ausgerechnet dort unsichtbar, wo es zählt. Serien tragen
die Zahl offener Folgen als Plakette in der Ecke.

Auf der Detailseite stehen **beide Knöpfe nebeneinander**: „Weiterschauen ab
0:42" und „Von vorn". Auch bei einem gesehenen Film — der Server wirft die
Stelle nicht weg, wenn das Häkchen kommt, und sie hier zu verschweigen hiesse,
genau den Unterschied wegzuwerfen, um dessentwillen er sie behält. Dazu ein
Schalter für gesehen/ungesehen.

Der Spieler meldet alle zehn Sekunden, beim Pausieren, am Ende und beim
Schliessen. Nicht bei jedem `timeupdate`: das feuert viermal je Sekunde, und
daraus Anfragen zu machen hiesse, den Server mit Meldungen zu bewerfen, die
sich um 250 ms unterscheiden. Eine verlorene Meldung kostet ein paar Sekunden
Fortschritt — dafür mitten im Film einen Fehler einzublenden wäre die
schlechtere Hälfte des Tauschs.

**Nebenbei ein Fehler beseitigt, der nur mit grosser Mediathek aufgefallen
wäre:** die Detailseite holte 200 Titel und suchte darin nach der Kennung.
Bei 201 Filmen hätte sie vom letzten behauptet, es gebe ihn nicht mehr. Jetzt
gibt es den Einzelabruf, und sie benutzt ihn.

Im Container durchgespielt: „Der Untergang" von vorn, die MKV wird umgepackt,
läuft durch — danach steht „Gesehen" unter dem Titel, der Balken ist voll,
und der Knopf bietet beides an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:33:56 +02:00
ScarriffleandClaude Opus 5 67cf0fd1d9 HEAD auf den Download, und /api verschluckt nichts mehr
Zwei Fehler, derselbe Grund — und beide zeigte erst der Browser.

**Der Spieler bekam die Webseite statt des Films.** „MEDIA_ELEMENT_ERROR:
Format error", 531 Byte, `Content-Type: text/html`. Das ist genau die Grösse
von `index.html`.

`MapGet` nimmt HEAD nicht an. Die Oberfläche fragt aber mit HEAD nach, ob das
Umpacken fertig ist — einen halbfertigen Film herunterzuladen, nur um das zu
erfahren, wäre absurd. Die Anfrage fiel durch den Methodenabgleich hindurch
bis zum SPA-Rückfall, und der nimmt jeden Pfad ohne Dateiendung. Antwort:
200, mit der Startseite im Rumpf. Der Client hielt das Umpacken für fertig
und reichte HTML an `<video>` weiter.

Jetzt `MapMethods` mit GET und HEAD. Kestrel lässt den Rumpf bei HEAD selbst
weg.

**Und ein vertippter Aufruf sah aus wie ein Erfolg.** `GET /api/v1/gibtsnicht`
gab 200 und die Webseite zurück — derselbe Rückfall. Ein Fehler, der erst
dort auffällt, wo jemand HTML durch einen JSON-Parser schickt.

`app.Map("/api/{*rest}", 404)` davor. Buchstaben schlagen Platzhalter, also
gewinnt jeder echte Endpunkt; was übrig bleibt, ist nicht da und sagt das
auch.

350 Tests grün, die zwei neuen eingerechnet. Am Container nachgemessen: HEAD
auf den Download gibt 200 `video/mp4` mit 71 920 Byte, `/api/v1/gibtsnicht`
gibt 404, und ein Neuladen auf `/verwaltung` bekommt weiterhin die Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:33:56 +02:00
ScarriffleandClaude Opus 5 c4081ff251 Stufe 4: Fortschritt ab der ersten Sekunde, ohne Schwelle
Der Anlass für diesen Server, jetzt als Code. Fortschritt, Gesehen-Status,
Weiterschauen, Nächste Folge, Favoriten und Bewertungen — alles je Benutzer.

**Die Jellyfin-Werte sind jetzt belegt statt behauptet.** Das Entwurfsdokument
hatte das ausdrücklich offengelassen („aus dem Gedächtnis"); hier stehen sie
aus `MediaBrowser.Model/Configuration/ServerConfiguration.cs` und
`Emby.Server.Implementations/Library/UserDataManager.cs`:

| | Vorgabe | Was passiert |
|---|---|---|
| `MinResumePct` | 5 | darunter `positionTicks = 0` — Kommentar im Quelltext: „ignore progress during the beginning" |
| `MaxResumePct` | 90 | darüber gesehen, Position **auf 0** |
| `MinResumeDurationSeconds` | 300 | alles unter fünf Minuten gilt nach dem ersten Lebenszeichen als gesehen |

Bei einem Zweistünder sind die ersten sechs Minuten damit weg. Eine
vierminütige Folge gilt nach dreissig Sekunden als vollständig gesehen. Und
fehlt die Laufzeit, nimmt Jellyfin an, der Titel sei durch — wörtlich:
„we'll just have to assume it was fully played".

**Flaro legt jede Meldung ab und wirft nie eine Position weg.** Eine Grenze
fürs Häkchen gibt es trotzdem — 90 %, sonst stünde jeder zu Ende geschaute
Film für immer bei 98 % in Weiterschauen. Der Unterschied ist nicht, *dass*
es die Grenze gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt,
dort zusätzlich die Stelle vernichtet. Wer einen gesehenen Film noch einmal
anfängt, hat beides — das Häkchen und den Punkt, an dem er steht.

Ohne bekannte Laufzeit wird nichts vermutet. Das ist der Fehler, den man
nicht zurücknehmen kann, ohne ihn zu bemerken.

---

**Eine Spalte statt einer zweiten Regel.** „Gehört in Weiterschauen" vergleicht
die Stelle mit einem Anteil der Laufzeit, und diese Rechnung lässt sich nicht
nach SQL übersetzen. Die Regel daneben noch einmal als Abfrage zu schreiben
wäre die Variante, bei der beide irgendwann auseinanderlaufen — unbemerkt,
weil keine von beiden dabei falsch aussieht. Stattdessen hält `InProgress`
das Ergebnis fest, geschrieben an genau einer Stelle. Eine Prüfung hält Reihe
und Regel über vier Stände gegeneinander.

**„Weiterschauen" und „Nächste Folge" überschneiden sich nicht.** Was
angefangen ist, steht links; was ansteht, rechts. Jellyfin zeigt beides
doppelt, und auf einer Startseite mit zwei Reihen nebeneinander sieht das
nach einem Fehler aus.

Dazu, weil die App sie braucht: Einzelabruf für Film und Serie, die nächste
Folge nach einer gegebenen, und der Zustand hängt an jeder Liste mit dran —
in einer Abfrage je Seite, nicht in einer je Titel.

Ein Titel wird über seine Kennung allein angesprochen, ohne dass der Aufrufer
sagen muss, ob es ein Film oder eine Folge ist. Die App hat diese Auskunft an
der Stelle nicht.

348 Tests grün. Im Container nachgewiesen: fünf Minuten „Der Untergang" von
2:36 bleiben stehen, bei Minute 150 kommt das Häkchen und die Stelle bleibt,
beim zweiten Anlauf steht der Film mit beidem wieder in der Reihe — und nach
Folge 1 von Dark zeigt „Nächste Folge" S01E02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:25:55 +02:00
ScarriffleandClaude Opus 5 705ec87416 Ein lokaler Server mit Beispielmedien, zum Ausprobieren
`Tools/demo-medien.sh` legt unter `~/.flaro-medien` einen kleinen Bestand
an: Der Untergang, Matrix, Blade Runner 2049, Inception, Dark mit drei
Folgen, Notruf Hafenkante. Echte Namen, damit TMDB etwas zu finden hat —
aber jede Datei ist zwei Sekunden Testbild mit Ton und wiegt ein paar
Kilobyte.

**Eine davon hat zwei Tonspuren**, Deutsch und Audiodeskription, mit
gesetzter Sprache und gesetztem Namen. Das ist der Fall, um dessentwillen
der ganze Server existiert: Jellyfins Transcode-Endpunkt bildet eine
Tonspur je Sitzung ab, und einmal hat die App dabei still die
Audiodeskription gewählt — drei Gigabyte Mobilfunk für eine unbrauchbare
Datei. Wer das nachstellen will, braucht eine Datei, die es kann.

`docker-compose.local.yml` daneben: benannte Volumes statt NAS-Pfaden,
Port 18080 statt 8080, Beiwerk eingeschaltet.

**`user: "0:0"` ist eine Mac-Eigenheit und steht deshalb kommentiert da.**
Colimas Dateifreigabe reicht die Besitzverhältnisse des Wirts nicht in die
VM durch — eingehängte Ordner erscheinen im Container als root, und ein
Server, der als 1000 läuft, kann dort keine NFO ablegen. Auf einem
Linux-NAS gehört die Zeile weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:07:44 +02:00
ScarriffleandClaude Opus 5 91eb934048 Die Weboberfläche — im Zuschnitt von Plex, vom Server selbst ausgeliefert
Einrichtung, Anmeldung, Mediathek, Detailseite mit Abspielen und
Herunterladen, Zuordnung korrigieren, Verwaltung. Die Oberfläche aus der
Jellyfin-Zeit ist dabei vollständig ersetzt: `types.ts`, `prefs.ts`,
`contrast.ts`, `views/cards.ts`, `views/player.ts` und `views/settings.ts`
sprachen Jellyfins Schnittstelle und hatten hier nichts mehr zu suchen.

Seitenleiste links, waagerechte Reihen auf der Startseite, Plakate im
Verhältnis 2:3, unscharfer Hintergrund hinter dem Kopf der Detailseite —
das ist der Zuschnitt von Plex, weil er gefällt. **Die Farbe bleibt Flaros
Orchidee**, nicht Plex' Orange; der Aufbau ist das Vorbild, nicht das
Aussehen.

Welche Bibliothek gezeigt wird, entscheidet die Seitenleiste. Eine zweite
Umschaltung innerhalb der Seite wäre dieselbe Entscheidung an zwei Orten.

---

**Der Zugang liegt jetzt auch in einem Cookie.** Ein `<img>`, ein `<video>`
und ein Download-Link senden keine eigenen Kopfzeilen — eine App kann ihr
Token in jede Anfrage legen, ein Browser nicht. Das Token stattdessen in
die Adresse zu schreiben wäre der naheliegende Ausweg und stünde danach in
jedem Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen
Link.

`HttpOnly`, damit kein eingeschlichenes Skript herankommt. `SameSite=Strict`
gegen Anfragen von fremden Seiten. `Secure` nur über https — sonst liesse
sich die Oberfläche im Heimnetz über http gar nicht benutzen. Die Apps
benutzen weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein
zweites Verfahren.

---

**Der Fehler, den erst der echte Container zeigte:** eine weisse Seite,
ohne Hinweis worauf.

Die Rückfallregel verlangt für jede Anfrage ohne eigenen Endpunkt einen
angemeldeten Benutzer, und `/assets/index.js` hat keinen. Die statischen
Dateien standen unter `UseAuthorization` — ein neuer Besucher bekam 401 auf
genau das Skript, das ihm die Anmeldemaske zeichnen sollte. `/` kam durch,
weil der SPA-Rückfall `AllowAnonymous` trägt; alles mit Dateiendung nicht.
Jetzt liegen sie davor. Mit Prüfung.

Beim Bau desselben Images der zweite: seit der Kontext die Projektwurzel
ist, überschrieb `COPY server/` die `obj/`-Verzeichnisse aus dem Container
mit denen vom Arbeitsrechner — die zeigen auf osx-arm64, gebaut wird
linux-arm64, `dotnet publish` brach mit NETSDK1047 ab. Dafür gibt es jetzt
eine `.dockerignore` in der Wurzel; die unter `server/` sucht Docker seit
dem Umzug nicht mehr.

---

Vier Prüfungen schlugen danach fehl, und zwar zu Recht: sie hiessen „ohne
Anmeldung gibt es nichts", benutzten aber den Client, der nach dem Anmelden
ein Cookie hält. Sie prüften also den Angemeldeten. Jetzt sagen sie
ausdrücklich, dass sie ohne Zugang fragen.

313 Tests grün. Im Container nachgewiesen: Anmelden, Plakate, Abspielen,
Staffeln und Folgen, Verwaltung — und ein Neuladen auf `/verwaltung`, das
nicht in einem 404 endet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 21:07:29 +02:00
97 changed files with 12815 additions and 2543 deletions
+26
View File
@@ -0,0 +1,26 @@
# Der Bau-Kontext ist die Wurzel des Projekts, seit die Oberfläche
# mitgebaut wird. Diese Datei gehört deshalb hierher und nicht nach server/ —
# Docker sucht sie neben dem Kontext, nicht neben dem Dockerfile.
# **Der Grund für diese Datei.** Ohne sie überschreibt `COPY server/ ./server/`
# die obj/-Verzeichnisse, die `dotnet restore` im Container gerade angelegt
# hat, mit denen vom Arbeitsrechner. Die zeigen auf osx-arm64, der Bau will
# linux-arm64, und `dotnet publish` bricht mit NETSDK1047 ab.
**/bin/
**/obj/
**/*.user
# Gebaute Oberfläche und ihre Abhängigkeiten: der web-Abschnitt baut beides neu.
**/node_modules/
web/app/dist/
# Laufzeitdaten eines lokalen Starts.
server/data/
server/cache/
server/transcode/
# Was im Server-Image nichts verloren hat.
.git/
.build/
DerivedData/
*.xcodeproj/
+80
View File
@@ -0,0 +1,80 @@
# Baut das Flaro-Image und legt es in die Registry dieses Gitea.
#
# **Warum hier und nicht auf dem Arbeitsrechner.** Ein Bau von Hand heisst:
# jemand muss daran denken, muss die richtige Architektur angeben und muss
# angemeldet sein. Hier passiert es bei jedem Push von selbst, immer für
# beide Architekturen, und die Anmeldung besorgt Gitea.
#
# **Voraussetzung ist ein Runner.** Ohne einen registrierten Act-Runner
# läuft diese Datei nie an — dann bleibt der Weg von Hand aus
# `Tools/image-veroeffentlichen.sh`.
name: Image bauen und veröffentlichen
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
jobs:
image:
runs-on: ubuntu-latest
steps:
- name: Quelltext holen
uses: actions/checkout@v4
# **buildx, nicht das eingebaute `docker build`.** Nur damit lassen
# sich zwei Architekturen in einem Manifest ablegen; ohne das bekommt
# ein Intel-NAS ein arm64-Image und meldet „exec format error".
- name: buildx vorbereiten
uses: docker/setup-buildx-action@v3
- name: QEMU für die fremde Architektur
uses: docker/setup-qemu-action@v3
- name: An der Registry anmelden
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY || 'git.scarriffle.com' }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Marken bestimmen
id: marken
run: |
REGISTRY="${{ vars.REGISTRY || 'git.scarriffle.com' }}"
# **Der Name des Pakets, nicht der des Repos.** Gitea legt Pakete
# am Besitzer ab, nicht am Repository — sie dürfen deshalb anders
# heissen. Hier ist das nötig: das Repo heisst noch „halo", das
# Programm aber Flaro, und ein Image namens `halo` wäre die eine
# Stelle, an der der alte Name überlebt.
#
# Kleinbuchstaben, weil die Registry nichts anderes annimmt.
BESITZER=$(echo "${{ gitea.repository_owner }}" | tr '[:upper:]' '[:lower:]')
BILD="$REGISTRY/$BESITZER/${{ vars.IMAGE_NAME || 'flaro' }}"
MARKEN="$BILD:latest,$BILD:${GITHUB_SHA::7}"
# Auf einem Git-Tag zusätzlich dessen Namen — damit sich eine
# Fassung festnageln lässt, statt immer „latest" zu ziehen.
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
MARKEN="$MARKEN,$BILD:${GITHUB_REF_NAME#v}"
fi
echo "marken=$MARKEN" >> "$GITHUB_OUTPUT"
echo "Gebaut wird: $MARKEN"
- name: Bauen und hochladen
uses: docker/build-push-action@v5
with:
context: .
file: server/Dockerfile
# **Beide Architekturen.** amd64 für die Intel-NAS, arm64 für
# ARM-Geräte und für den Apple-Rechner.
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.marken.outputs.marken }}
# Zwischenergebnisse behalten: ohne das lädt jeder Lauf alle
# Pakete neu, und ein Bau dauert statt zwei Minuten zwölf.
cache-from: type=gha
cache-to: type=gha,mode=max
+2
View File
@@ -6,6 +6,7 @@ import UIKit
import FlaroDesign
import FlaroDownloads
import FlaroPlayback
import FlaroServerKit
import JellyfinKit
import MediaKit
@@ -45,6 +46,7 @@ struct FlaroApp: App {
client: Branding.appName,
version: Branding.version
),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
+374 -51
View File
@@ -108,6 +108,23 @@
}
}
},
"action.back": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurück"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Back"
}
}
}
},
"action.cancel": {
"extractionState": "stale",
"localizations": {
@@ -3854,23 +3871,6 @@
}
}
},
"player.jellyfinPending": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Playback from Jellyfin isn't finished yet — it's the next step."
}
}
}
},
"player.loadFailed": {
"extractionState": "stale",
"localizations": {
@@ -6254,6 +6254,312 @@
}
}
},
"signIn.flaro.addressHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "You can leave out the scheme and port — Flaro tries the usual ones."
}
}
}
},
"signIn.flaro.addressMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Trag eine Adresse ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Enter an address."
}
}
}
},
"signIn.flaro.addressPlaceholder": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "z. B. 192.168.1.50 oder medien.example.com"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "e.g. 192.168.1.50 or media.example.com"
}
}
}
},
"signIn.flaro.addressPrompt": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wo steht dein Server?"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Where is your server?"
}
}
}
},
"signIn.flaro.changeAddress": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Andere Adresse"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Change address"
}
}
}
},
"signIn.flaro.codeExpired": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Code ist abgelaufen. Fordere einen neuen an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The code has expired. Request a new one."
}
}
}
},
"signIn.flaro.codeHint": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Flaro on your phone and enter this code."
}
}
}
},
"signIn.flaro.credentialsMissing": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername und Passwort fehlen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username and password are missing."
}
}
}
},
"signIn.flaro.findServer": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server suchen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Find server"
}
}
}
},
"signIn.flaro.needsSetup": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This server isn't set up yet. Open it in a browser and create an account."
}
}
}
},
"signIn.flaro.notFound": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Unter dieser Adresse antwortet kein Flaro-Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No Flaro server answers at this address."
}
}
}
},
"signIn.flaro.searching": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Server wird gesucht …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Looking for the server…"
}
}
}
},
"signIn.flaro.signIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Anmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sign in"
}
}
}
},
"signIn.flaro.signingIn": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wird angemeldet …"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Signing in…"
}
}
}
},
"signIn.flaro.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Flaro-Server"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Flaro Server"
}
}
}
},
"signIn.flaro.useCode": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Code"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a code instead"
}
}
}
},
"signIn.flaro.usePassword": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stattdessen Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Use a password instead"
}
}
}
},
"signIn.flaro.waitingForApproval": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Warte auf Bestätigung"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Waiting for approval"
}
}
}
},
"signIn.jellyfin.addressHint": {
"localizations": {
"de": {
@@ -6403,23 +6709,6 @@
}
}
},
"signIn.jellyfin.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.jellyfin.passwordNote": {
"localizations": {
"de": {
@@ -6568,23 +6857,6 @@
}
}
},
"signIn.jellyfin.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.jellyfin.waitingForApproval": {
"localizations": {
"de": {
@@ -6601,6 +6873,23 @@
}
}
},
"signIn.password": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Passwort"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Password"
}
}
}
},
"signIn.privacy": {
"localizations": {
"de": {
@@ -6634,6 +6923,40 @@
}
}
},
"signIn.userName": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Username"
}
}
}
},
"signIn.withFlaro": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mit Flaro verbinden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Connect to Flaro"
}
}
}
},
"signIn.withJellyfin": {
"localizations": {
"de": {
+19 -1
View File
@@ -122,7 +122,8 @@ struct DownloadService {
// hinterher niemanden mehr, der das nachholen könnte — und seit es
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
// schwarz. Also wird schon beim Herunterladen umgepackt.
let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part)
let umpacken = quality == .original && !kannAlleSpuren
&& !systemPlayerOpens(media: media, part: part)
let url: URL?
switch quality {
@@ -200,6 +201,17 @@ struct DownloadService {
)
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
/// verbessern, nur etwas zu verlieren.
private var kannAlleSpuren: Bool {
backend?.capabilities.contains(.allTracksDownload) == true
}
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
/// werden muss.
///
@@ -211,6 +223,12 @@ struct DownloadService {
/// falsche Weg.
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
guard quality == .original else { return false }
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
// zum selben Ergebnis führen.
guard !kannAlleSpuren else { return false }
guard let media = mediaIndex < item.versions.count
? item.versions[mediaIndex] : item.preferredVersion,
let part = media.files.first
+180 -17
View File
@@ -1,4 +1,5 @@
import Foundation
import FlaroServerKit
import JellyfinKit
import MediaKit
import Observation
@@ -31,12 +32,41 @@ final class FlaroSession {
// stillschweigend als „ungleich" behandelt.
}
/// Mit welcher Art Server gesprochen wird.
///
/// **Diese Unterscheidung gab es schon einmal** — damals für Plex und
/// Jellyfin — und sie wurde beim Entfernen von Plex gestrichen, weil ein
/// Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt
/// es wieder zwei, und es ist wieder eine Unterscheidung.
///
/// **Einer zur Zeit, nicht beide.** Zwei gleichzeitig hiessen: zwei
/// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem
/// Titel die Frage, woher er stammt.
enum ServerKind: String, CaseIterable, Sendable, Identifiable {
case jellyfin, flaro
var id: String { rawValue }
}
private enum Key {
/// Welche Art zuletzt angemeldet war.
///
/// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten,
/// und der steht in der Aufräumliste weiter unten. Denselben Namen
/// mit anderer Bedeutung zu benutzen wäre eine Falle für den
/// Nächsten, der die Liste liest.
static let activeKind = "flaro.backend.active"
static let jellyfinAddress = "flaro.jellyfin.address"
static let jellyfinUserID = "flaro.jellyfin.userID"
static let jellyfinUserName = "flaro.jellyfin.userName"
static let jellyfinServerName = "flaro.jellyfin.serverName"
static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator"
static let flaroAddress = "flaro.flaroServer.address"
static let flaroUserID = "flaro.flaroServer.userID"
static let flaroUserName = "flaro.flaroServer.userName"
static let flaroServerName = "flaro.flaroServer.serverName"
static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator"
}
/// Unter welchem Namen das Server-Token im Schlüsselbund liegt.
@@ -45,6 +75,7 @@ final class FlaroSession {
/// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server
/// später daneben passt, ohne diesen zu verdrängen.
private static let jellyfinTokenAccount = "jellyfin.server"
private static let flaroTokenAccount = "flaro.server"
/// Was die Plex-Zeit auf dem Gerät hinterlassen hat.
///
@@ -90,8 +121,14 @@ final class FlaroSession {
/// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist.
private(set) var jellyfinAccount: JellyfinAccount?
/// Das angemeldete Flaro-Konto, solange Flaro aktiv ist.
private(set) var flaroAccount: FlaroAccount?
/// Mit welcher Art Server gerade gesprochen wird.
private(set) var activeKind: ServerKind?
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName }
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
@@ -101,7 +138,12 @@ final class FlaroSession {
var serverName: String { backend?.serverName ?? "" }
/// Unter welcher Adresse er erreicht wird.
var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) }
var serverAddress: String? {
switch activeKind {
case .flaro: defaults.string(forKey: Key.flaroAddress)
case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress)
}
}
/// Wie die Verbindung zum Server zu beschreiben ist.
///
@@ -136,30 +178,28 @@ final class FlaroSession {
/// Warum gerade nicht abgespielt werden kann.
///
/// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist
/// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch
/// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist
/// niemand angemeldet, und „Wiedergabe steht aus" wäre es.
///
/// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall
/// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie
/// hingehört: an der Frage, ob überhaupt ein Server da ist.
/// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine
/// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es
/// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst
/// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt
/// diese Meldung ausschliesslich dann, wenn gar kein Server da ist.
var playbackUnavailableMessage: String {
backend != nil
? String(localized: "player.jellyfinPending")
: String(localized: "error.serverUnreachable")
String(localized: "error.serverUnreachable")
}
private let jellyfinIdentity: JellyfinIdentity
private let flaroIdentity: FlaroIdentity
private let tokenStore: any TokenStoring
private let defaults: UserDefaults
init(
jellyfinIdentity: JellyfinIdentity,
flaroIdentity: FlaroIdentity,
tokenStore: any TokenStoring,
defaults: UserDefaults = .standard
) {
self.jellyfinIdentity = jellyfinIdentity
self.flaroIdentity = flaroIdentity
self.tokenStore = tokenStore
self.defaults = defaults
}
@@ -174,7 +214,13 @@ final class FlaroSession {
if await adoptStubServerIfRequested() { return }
#endif
if await restoreJellyfin() { return }
// **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor
// dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau
// das ist dann gemeint.
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
@@ -212,7 +258,16 @@ final class FlaroSession {
/// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach
/// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen.
func signOut() {
// **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin
// erfährt es über das Verfallen des Tokens; Flaro zieht es auf
// Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst
// bis zum Ablauf in der Geräteliste.
if let client = flaroClient {
Task { await FlaroSignIn.signOut(client: client) }
}
forgetJellyfin()
forgetFlaro()
defaults.removeObject(forKey: Key.activeKind)
clearActiveServer()
// Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
@@ -276,14 +331,14 @@ final class FlaroSession {
let backend = JellyfinBackend(client: client, account: account, serverName: serverName)
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
isLocalConnection = await backend.isInLocalNetwork()
do {
libraries = try await loadLibraries(from: backend)
} catch {
self.backend = nil
jellyfinAccount = nil
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
@@ -320,6 +375,7 @@ final class FlaroSession {
clearActiveServer()
jellyfinAccount = account
activeKind = .jellyfin
self.backend = backend
// Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im
// Zug wiederhergestellt werden, und die Antwort gilt je Sitzung.
@@ -341,7 +397,99 @@ final class FlaroSession {
}
}
// MARK: - Verbinden mit Flaro
/// Übernimmt eine fertige Flaro-Anmeldung.
///
/// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht:
/// was danach kommt — Mediatheken laden, Phase setzen, merken — steht
/// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt.
func adoptFlaro(client: FlaroClient, account: FlaroAccount) async {
phase = .connecting(serverName: account.serverName)
let backend = FlaroBackend(client: client, account: account)
clearActiveServer()
flaroAccount = account
flaroClient = client
activeKind = .flaro
self.backend = backend
// **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung
// im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet
// auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu
// stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft.
isLocalConnection = false
do {
libraries = try await loadLibraries(from: backend)
} catch {
clearActiveServer()
phase = .failed(message: ErrorText.describe(error))
return
}
rememberFlaro(account: account, token: nil)
phase = .ready
}
/// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort.
func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async {
await adoptFlaro(client: client, account: account)
guard phase == .ready else { return }
rememberFlaro(account: account, token: token)
}
/// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf.
private func restoreFlaro() async -> Bool {
guard let address = defaults.string(forKey: Key.flaroAddress),
let baseURL = URL(string: address),
let token = tokenStore.token(for: Self.flaroTokenAccount)
else { return false }
phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "")
do {
let (account, client) = try await FlaroSignIn.restore(
baseURL: baseURL, token: token, identity: flaroIdentity)
await adoptFlaro(client: client, account: account)
return true
} catch MediaBackendError.unauthorized {
// Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem
// Start denselben Fehler.
forgetFlaro()
clearActiveServer()
return false
} catch {
// Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder
// Server das, und es ist kein Grund, jemanden abzumelden.
//
// **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen
// kein Konto da, und das Heruntergeladene wäre unerreichbar.
phase = .failed(message: ErrorText.describe(error))
activeKind = .flaro
return true
}
}
private func rememberFlaro(account: FlaroAccount, token: String?) {
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
defaults.set(account.userID, forKey: Key.flaroUserID)
defaults.set(account.userName, forKey: Key.flaroUserName)
defaults.set(account.serverName, forKey: Key.flaroServerName)
defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator)
if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) }
}
private func forgetFlaro() {
for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName,
Key.flaroServerName, Key.flaroIsAdministrator] {
defaults.removeObject(forKey: key)
}
tokenStore.remove(for: Self.flaroTokenAccount)
}
private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) {
defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind)
defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress)
defaults.set(account.userID, forKey: Key.jellyfinUserID)
defaults.set(account.userName, forKey: Key.jellyfinUserName)
@@ -385,12 +533,18 @@ final class FlaroSession {
private func clearActiveServer() {
backend = nil
jellyfinAccount = nil
flaroAccount = nil
flaroClient = nil
activeKind = nil
libraries = []
}
/// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren.
func retry() async {
if await restoreJellyfin() { return }
switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) {
case .flaro: if await restoreFlaro() { return }
case .jellyfin, nil: if await restoreJellyfin() { return }
}
phase = .signedOut
}
@@ -400,4 +554,13 @@ final class FlaroSession {
/// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App
/// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers.
var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity }
/// Dasselbe für Flaro.
var flaroDeviceIdentity: FlaroIdentity { flaroIdentity }
/// Der Client des aktiven Flaro-Servers — zum Abmelden.
///
/// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine
/// Änderung daran soll kein Neuzeichnen auslösen.
@ObservationIgnored private var flaroClient: FlaroClient?
}
+211
View File
@@ -0,0 +1,211 @@
import Foundation
import FlaroServerKit
import MediaKit
import Observation
/// Führt durch die Anmeldung an einem Flaro-Server.
///
/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du.
///
/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn:
/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon
/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits
/// angemeldetes Gerät, und das erste ist gerade dieses hier.
///
/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im
/// Initialisierer gesagt.
@MainActor
@Observable
final class FlaroSignInModel {
enum Step: Equatable {
case address
case checking
/// Der Code steht auf dem Bildschirm, jetzt wird gewartet.
case code(String)
case credentials
case signingIn
}
enum PreferredMethod { case credentials, code }
var address = ""
var userName = ""
var password = ""
private(set) var step: Step = .address
private(set) var errorMessage: String?
/// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse
/// stimmt, noch bevor jemand Anmeldedaten eintippt.
private(set) var serverName: String?
/// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung.
private(set) var attributions: [FlaroSignIn.MetadataAttribution] = []
var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)?
private let identity: FlaroIdentity
private let preferred: PreferredMethod
private var baseURL: URL?
private var pollTask: Task<Void, Never>?
/// Der Vorgabewert hält iOS unverändert.
init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) {
self.identity = identity
self.preferred = preferring
}
// Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und
// ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim
// Verschwinden der Ansicht — wie beim Jellyfin-Modell auch.
/// Hält die Abfrageschleife an.
///
/// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang
/// weiter — unsichtbar und für nichts.
func cancel() {
pollTask?.cancel()
pollTask = nil
}
// MARK: - Schritt 1: den Server finden
func findServer() async {
errorMessage = nil
let kandidaten = FlaroAddress.candidates(for: address)
guard !kandidaten.isEmpty else {
errorMessage = String(localized: "signIn.flaro.addressMissing")
return
}
step = .checking
for kandidat in kandidaten {
guard let gruss = try? await FlaroSignIn.greet(
baseURL: kandidat, identity: identity) else { continue }
// **Ein frischer Server ist kein falsches Passwort.** Wer hier
// weitermachte, bekäme „Benutzername oder Passwort stimmt nicht"
// für ein Konto, das es noch gar nicht gibt.
guard !gruss.needsSetup else {
baseURL = kandidat
serverName = gruss.serverName
step = .address
errorMessage = String(localized: "signIn.flaro.needsSetup")
return
}
baseURL = kandidat
address = kandidat.absoluteString
serverName = gruss.serverName
attributions = gruss.attributions
if preferred == .code {
await beginCode()
} else {
step = .credentials
}
return
}
step = .address
errorMessage = String(localized: "signIn.flaro.notFound")
}
/// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist.
func changeAddress() {
cancel()
baseURL = nil
serverName = nil
errorMessage = nil
step = .address
}
// MARK: - Schritt 2a: der Code
/// Fordert einen Code an und wartet auf die Bestätigung.
func beginCode() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let offen: FlaroSignIn.PendingCode
do {
offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity)
} catch {
step = .credentials
errorMessage = ErrorText.describe(error)
return
}
step = .code(offen.code)
// **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten
// auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins
// Zeitlimit.
pollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(2))
if Task.isCancelled { return }
guard let self else { return }
guard await self.checkCode(baseURL: baseURL, pending: offen) else { return }
}
}
}
/// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten.
private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool {
guard let antwort = try? await FlaroSignIn.pollCode(
baseURL: baseURL, pending: pending, identity: identity)
else { return true } // Ein Aussetzer ist kein Abbruch.
switch antwort.state {
case .pending:
return true
case .expired:
errorMessage = String(localized: "signIn.flaro.codeExpired")
step = .credentials
return false
case .approved(let token, let konto):
guard let client = antwort.client else { return false }
onAuthenticated?(client, konto, token)
return false
}
}
/// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann.
func useCredentials() {
cancel()
errorMessage = nil
step = .credentials
}
// MARK: - Schritt 2b: anmelden
func signIn() async {
guard let baseURL else { return }
cancel()
errorMessage = nil
let name = userName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !password.isEmpty else {
errorMessage = String(localized: "signIn.flaro.credentialsMissing")
return
}
step = .signingIn
do {
let (token, konto, client) = try await FlaroSignIn.signIn(
baseURL: baseURL, userName: name, password: password, identity: identity)
// **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer
// Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu
// lassen hiesse, es in einem Speicherabbild wiederzufinden.
password = ""
onAuthenticated?(client, konto, token)
} catch {
password = ""
step = .credentials
errorMessage = ErrorText.describe(error)
}
}
}
+176
View File
@@ -0,0 +1,176 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server.
///
/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht —
/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt.
struct FlaroSignInView: View {
@Environment(FlaroSession.self) private var session
@Environment(\.dismiss) private var dismiss
@Environment(\.flaroAccent) private var accent
@State private var model: FlaroSignInModel?
@FocusState private var focusedField: Field?
private enum Field { case address, userName, password }
var body: some View {
NavigationStack {
ScrollView {
if let model {
content(for: model)
.padding(.horizontal, FlaroSpacing.section)
.padding(.vertical, FlaroSpacing.section)
.frame(maxWidth: 420)
.frame(maxWidth: .infinity)
}
}
.scrollDismissesKeyboard(.interactively)
.background(Color.flaroCanvas)
.navigationTitle(Text("signIn.flaro.title"))
.flaroInlineTitle()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("action.cancel") { dismiss() }
}
}
}
.tint(accent)
.task {
guard model == nil else { return }
let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity)
fresh.onAuthenticated = { client, konto, token in
dismiss()
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = fresh
}
}
@ViewBuilder
private func content(for model: FlaroSignInModel) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
switch model.step {
case .address, .checking:
addressStep(model: model)
case .credentials, .signingIn:
credentialsStep(model: model)
case .code:
// **Am Telefon führt der Code nirgendwohin.** Er braucht ein
// zweites, bereits angemeldetes Gerät — und das erste ist
// gerade dieses hier. Das Modell bietet ihn deshalb nur an,
// wenn es mit `preferring: .code` gebaut wurde; am Fernseher
// ist das so, hier nicht. Der Fall steht trotzdem da, weil
// ein `default` die nächste neue Lage verschlucken würde.
EmptyView()
}
if let message = model.errorMessage {
Text(message)
.font(.flaroCaption)
.foregroundStyle(Color.flaroDestructive)
}
}
}
@ViewBuilder
private func addressStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.textFieldStyle(.roundedBorder)
.textContentType(.URL)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .address)
.onSubmit { Task { await model.findServer() } }
Text("signIn.flaro.addressHint")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
Button {
Task { await model.findServer() }
} label: {
if model.step == .checking {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.findServer").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .checking)
}
.onAppear { focusedField = .address }
}
@ViewBuilder
private func credentialsStep(model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
// Der Name des Servers steht hier, damit sichtbar ist,
// **wo** man sich gleich anmeldet — eine Adresse allein sagt
// das nicht.
HStack {
Text(name)
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
Spacer()
Button("signIn.flaro.changeAddress") { model.changeAddress() }
.font(.flaroCaption)
}
}
TextField("signIn.userName", text: $model.userName)
.textFieldStyle(.roundedBorder)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.focused($focusedField, equals: .userName)
.onSubmit { focusedField = .password }
SecureField("signIn.password", text: $model.password)
.textFieldStyle(.roundedBorder)
.textContentType(.password)
.focused($focusedField, equals: .password)
.onSubmit { Task { await model.signIn() } }
Button {
Task { await model.signIn() }
} label: {
if model.step == .signingIn {
ProgressView().frame(maxWidth: .infinity)
} else {
Text("signIn.flaro.signIn").frame(maxWidth: .infinity)
}
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(model.step == .signingIn)
// Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der
// Server liefert ihn ohne Anmeldung genau dafür.
ForEach(model.attributions, id: \.text) { hinweis in
Text(hinweis.text)
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
}
}
.onAppear { focusedField = .userName }
}
}
+2 -2
View File
@@ -184,7 +184,7 @@ struct JellyfinSignInView: View {
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
foundServerHeader(model: model)
TextField("signIn.jellyfin.userName", text: $model.userName)
TextField("signIn.userName", text: $model.userName)
.textContentType(.username)
.flaroPlainEntry()
.submitLabel(.next)
@@ -203,7 +203,7 @@ struct JellyfinSignInView: View {
.padding(FlaroSpacing.regular)
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
SecureField("signIn.jellyfin.password", text: $model.password)
SecureField("signIn.password", text: $model.password)
.textContentType(.password)
.submitLabel(.go)
.focused($focusedField, equals: .password)
+29 -9
View File
@@ -3,16 +3,23 @@ import FlaroDesign
/// Der Anmeldebildschirm.
///
/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe
/// nebeneinander, und der obere war der auffälligere — er führte zu Plex.
/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie
/// die zweite Wahl, lässt den Benutzer nach der ersten suchen.
/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon
/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von
/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich
/// neu: welcher steht oben?
///
/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb
/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein
/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`.
/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der
/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten
/// Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu
/// anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf
/// auffälliger war.
///
/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen.
/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden
/// Anmeldeansichten.
struct SignInView: View {
@Environment(\.flaroAccent) private var accent
@State private var showsFlaroSignIn = false
@State private var showsJellyfinSignIn = false
var body: some View {
@@ -37,6 +44,17 @@ struct SignInView: View {
Spacer()
VStack(spacing: FlaroSpacing.regular) {
Button {
showsFlaroSignIn = true
} label: {
Text("signIn.withFlaro")
.font(.flaroRowHeader)
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
Button {
showsJellyfinSignIn = true
} label: {
@@ -45,8 +63,7 @@ struct SignInView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, FlaroSpacing.regular)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.buttonStyle(.bordered)
Text("signIn.privacy")
.font(.flaroCaption)
@@ -60,6 +77,9 @@ struct SignInView: View {
.frame(maxWidth: 420)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.flaroCanvas)
.sheet(isPresented: $showsFlaroSignIn) {
FlaroSignInView()
}
.sheet(isPresented: $showsJellyfinSignIn) {
JellyfinSignInView()
}
+2
View File
@@ -2,6 +2,7 @@ import SwiftUI
import FlaroDesign
import FlaroPlayback
import MediaKit
import FlaroServerKit
import JellyfinKit
/// Die App auf dem Fernseher.
@@ -45,6 +46,7 @@ struct FlaroTVApp: App {
client: Branding.appName,
version: Branding.version
),
flaroIdentity: FlaroIdentity.current(),
tokenStore: KeychainTokenStore(service: Branding.keychainService)
))
}
@@ -0,0 +1,130 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
/// Die Anmeldung an einem Flaro-Server, am Fernseher.
///
/// Dasselbe Modell wie am Telefon, nur mit `preferring: .code`: sechs Ziffern
/// auf dem Bildschirm, bestätigt am Telefon. Das Passwort bleibt der Rückfall
/// — für den Fall, dass gerade kein angemeldetes Gerät zur Hand ist.
struct TVFlaroSignInView: View {
@Environment(FlaroSession.self) private var session
let zurueck: () -> Void
@State private var model: FlaroSignInModel?
var body: some View {
Group {
if let model {
inhalt(for: model)
} else {
ProgressView()
}
}
.task {
guard model == nil else { return }
let frisch = FlaroSignInModel(
identity: session.flaroDeviceIdentity, preferring: .code)
frisch.onAuthenticated = { client, konto, token in
Task { await session.adoptFlaro(client: client, account: konto, token: token) }
}
model = frisch
}
.onDisappear { model?.cancel() }
}
@ViewBuilder
private func inhalt(for model: FlaroSignInModel) -> some View {
@Bindable var model = model
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
Text(Branding.appName).font(.flaroHero)
Text("signIn.flaro.title").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
switch model.step {
case .address:
adresse(model: model)
case .checking:
warten("signIn.flaro.searching")
case .code(let code):
codeAnzeigen(model: model, code: code)
case .credentials:
zugangsdaten(model: model)
case .signingIn:
warten("signIn.flaro.signingIn")
}
if let fehler = model.errorMessage {
Text(fehler).font(.flaroBody).foregroundStyle(Color.flaroDestructive)
}
}
.padding(FlaroSpacing.section)
}
private func adresse(model: FlaroSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
Text("signIn.flaro.addressPrompt").font(.flaroRowHeader)
TextField("signIn.flaro.addressPlaceholder", text: $model.address)
.flaroPlainEntry(isAddress: true)
Text("signIn.flaro.addressHint")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.findServer") { Task { await model.findServer() } }
Button("action.back", action: zurueck)
}
}
}
private func codeAnzeigen(model: FlaroSignInModel, code: String) -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
// **Der Code ist hier der Inhalt, nicht eine Beschriftung.**
// Er wird aus drei Metern abgelesen und in ein Telefon getippt;
// in Fliesstextgrösse ist das eine Zumutung.
Text(code)
.font(.system(size: 96, weight: .bold, design: .monospaced))
.kerning(12)
.foregroundStyle(.white)
Text("signIn.flaro.codeHint").font(.flaroBody)
Label("signIn.flaro.waitingForApproval", systemImage: "hourglass")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.usePassword") { model.useCredentials() }
Button("signIn.flaro.changeAddress") { model.changeAddress() }
}
}
}
private func zugangsdaten(model: FlaroSignInModel) -> some View {
@Bindable var model = model
return VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
TextField("signIn.userName", text: $model.userName)
.flaroPlainEntry()
SecureField("signIn.password", text: $model.password)
HStack(spacing: FlaroSpacing.regular) {
Button("signIn.flaro.signIn") { Task { await model.signIn() } }
Button("signIn.flaro.useCode") { Task { await model.beginCode() } }
Button("signIn.flaro.changeAddress") { model.changeAddress() }
}
}
}
private func warten(_ text: LocalizedStringKey) -> some View {
HStack(spacing: FlaroSpacing.regular) {
ProgressView()
Text(text).font(.flaroBody)
}
}
}
+38 -14
View File
@@ -1,40 +1,51 @@
import SwiftUI
import FlaroDesign
import FlaroServerKit
import JellyfinKit
/// Die Anmeldung am Fernseher.
///
/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell
/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier
/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere
/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und
/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa.
/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn —
/// und Flaro oben, weil die App dort mehr kann.
///
/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf
/// denen Quick Connect abgeschaltet ist.
/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide
/// Modelle bekommen hier den Code-Weg voran. Vor einem Fernseher ist er der
/// bequemere: es gibt keinen Passwortspeicher, getippt wird mit dem
/// Steuerkreuz, und das Gerät, auf dem man bestätigt, liegt sowieso auf dem
/// Sofa. Benutzername und Passwort bleiben erreichbar.
struct TVSignInView: View {
@Environment(FlaroSession.self) private var session
@State private var art: FlaroSession.ServerKind?
@State private var model: JellyfinSignInModel?
var body: some View {
ZStack {
Color.flaroCanvas.ignoresSafeArea()
Group {
switch art {
case nil:
auswahl()
case .flaro:
TVFlaroSignInView(zurueck: { art = nil })
case .jellyfin:
if let model {
inhalt(for: model)
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
} else {
ProgressView()
}
}
.task {
}
// Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile
// liest sich aus drei Metern nicht mehr als Zeile.
.frame(maxWidth: 900)
}
.onChange(of: art) { _, neu in
guard neu == .jellyfin, model == nil else { return }
// Erst hier, nicht im Eigenschaftswert: das Modell braucht die
// Gerätekennung der Sitzung. Baut es sich eine eigene, steht die
// App nach dem Anmelden ein zweites Mal in der Geräteliste.
guard model == nil else { return }
let frisch = JellyfinSignInModel(
identity: session.jellyfinDeviceIdentity,
preferring: .quickConnect
@@ -51,6 +62,19 @@ struct TVSignInView: View {
.onDisappear { model?.cancel() }
}
private func auswahl() -> some View {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
Text(Branding.appName).font(.flaroHero)
Text("signIn.tagline").font(.flaroBody).foregroundStyle(Color.flaroTextMuted)
VStack(spacing: FlaroSpacing.regular) {
Button("signIn.withFlaro") { art = .flaro }
Button("signIn.withJellyfin") { art = .jellyfin }
}
}
.padding(FlaroSpacing.section)
}
@ViewBuilder
private func inhalt(for model: JellyfinSignInModel) -> some View {
@Bindable var model = model
@@ -129,9 +153,9 @@ struct TVSignInView: View {
if let name = model.serverName {
Text(name).font(.flaroRowHeader)
}
TextField("signIn.jellyfin.userName", text: $model.userName)
TextField("signIn.userName", text: $model.userName)
.flaroPlainEntry()
SecureField("signIn.jellyfin.password", text: $model.password)
SecureField("signIn.password", text: $model.password)
Text("signIn.jellyfin.passwordNote")
.font(.flaroCaption).foregroundStyle(Color.flaroTextMuted)
+7
View File
@@ -14,6 +14,7 @@ let package = Package(
products: [
.library(name: "MediaKit", targets: ["MediaKit"]),
.library(name: "JellyfinKit", targets: ["JellyfinKit"]),
.library(name: "FlaroServerKit", targets: ["FlaroServerKit"]),
.library(name: "FlaroDesign", targets: ["FlaroDesign"]),
.library(name: "FlaroPlayback", targets: ["FlaroPlayback"]),
.library(name: "FlaroDownloads", targets: ["FlaroDownloads"]),
@@ -39,6 +40,12 @@ let package = Package(
resources: [.copy("Fixtures")]
),
// Flaros eigener Server. Daneben, nicht darin — genau der Steckplatz,
// den Plex geräumt hat. Dass die Naht zwei Server zugleich trägt, war
// schon einmal bewiesen; hier wird es wieder gebraucht.
.target(name: "FlaroServerKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "FlaroServerKitTests", dependencies: ["FlaroServerKit"]),
// Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche,
// ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt
// eines halben Tages, und das Upload-Kontingent bleibt für Builds.
@@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine {
pendingStartTime = source.startTime
// **Cookies muss man AVFoundation ausdrücklich mitgeben.**
//
// `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset`
// nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in
// der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde
// sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link.
// Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz.
//
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
// ändert sich nichts.
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
let asset = AVURLAsset(url: source.url, options: [
// Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei
// manchen Formaten die ganze Datei, bevor es überhaupt anfängt —
// und „ganze Datei" heißt bei einem Remux vierzig Gigabyte.
AVURLAssetPreferPreciseDurationAndTimingKey: false,
AVURLAssetHTTPCookiesKey: kekse,
])
let item = AVPlayerItem(asset: asset)
@@ -0,0 +1,19 @@
import Foundation
import MediaKit
/// Die Adressraterei für Flaro.
///
/// Der Inhalt steht in `ServerAddress`. Hier steht nur, was Flaro von
/// Jellyfin unterscheidet: der Port.
public enum FlaroAddress {
/// Der Standardport eines Flaro-Servers.
///
/// 8080, nicht 8096. Die Beispiel-Compose-Datei bildet ihn so ab, und
/// einen fremden Port zu erben hiesse, dass jede Anmeldung einmal ins
/// Leere läuft, bevor sie den richtigen findet.
public static let defaultPort = 8080
public static func candidates(for input: String) -> [URL] {
ServerAddress.candidates(for: input, defaultPort: defaultPort)
}
}
@@ -0,0 +1,417 @@
import Foundation
import MediaKit
/// Ein Flaro-Server, gesehen durch `MediaBackend`.
///
/// Der zweite Server an derselben Naht, an der vorher Plex und Jellyfin
/// nebeneinander hingen. Was hier steht, ist die Übersetzung von Flaros
/// Schnittstelle in den Vertrag — nichts weiter: keine Oberfläche, keine
/// Entscheidung über Wiedergabe, keine Zwischenspeicher.
public actor FlaroBackend: MediaBackend {
private let client: FlaroClient
private let account: FlaroAccount
/// Die Mediatheken, einmal geholt.
///
/// Sie ändern sich nur, wenn jemand in der Verwaltung eine anlegt — und
/// jede Seite der Mediathek bräuchte sie sonst erneut, nur um zu wissen,
/// ob sie nach Filmen oder nach Serien fragt.
private var libraryMemo: [MediaLibrary]?
public init(client: FlaroClient, account: FlaroAccount) {
self.client = client
self.account = account
}
// MARK: - Wer dieser Server ist
public nonisolated var serverName: String { account.serverName }
/// **Die Adresse, nicht der Name.** Zwei Server dürfen „Medien" heissen,
/// und derselbe darf umbenannt werden. Was hier steht, überlebt beides.
public nonisolated var identity: String { "flaro:" + account.baseURL.absoluteString }
public nonisolated var capabilities: Set<BackendCapability> {
[
.favourites,
.ratings,
.libraryScan,
.transcoding,
// **Der Grund, aus dem es diesen Server gibt.** Flaros Download
// bringt jede Ton- und Textspur mit; die App muss vorher nichts
// wählen und kann offline umstellen.
.allTracksDownload,
]
}
// MARK: - Blättern
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
if let libraryMemo { return libraryMemo }
let roh = try await client.send(FlaroRequest(path: "/api/v1/libraries"), as: [Wire.Library].self)
let liste = roh.map { $0.neutral() }
libraryMemo = liste
return liste
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine
// App zeigt, entscheidet die App. Der Server liefert die Zutaten.
async let weiter = reihe(pfad: "/api/v1/home/continue")
async let naechste = reihe(pfad: "/api/v1/home/next")
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self)
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
var reihen: [MediaRowGroup] = []
if let eintraege = try? await weiter, !eintraege.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.continue",
title: String(localized: "flaro.row.continue", bundle: .module),
items: eintraege))
}
if let eintraege = try? await naechste, !eintraege.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.nextup",
title: String(localized: "flaro.row.nextUp", bundle: .module),
items: eintraege))
}
if let gefunden = try? await filme, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.movies",
title: String(localized: "flaro.row.movies", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
if let gefunden = try? await serien, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.series",
title: String(localized: "flaro.row.series", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
return reihen
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage {
var abfrage = [
URLQueryItem(name: "library", value: library.id),
URLQueryItem(name: "skip", value: String(offset)),
URLQueryItem(name: "take", value: String(limit)),
]
if let text = query.searchText, !text.isEmpty {
abfrage.append(URLQueryItem(name: "search", value: text))
}
if library.kind == .show {
let seite = try await client.send(
FlaroRequest(path: "/api/v1/series", query: abfrage),
as: Wire.Page<Wire.Series>.self)
return MediaPage(
items: gesiebt(seite.items.map { $0.neutral() }, query),
offset: offset, totalCount: seite.total)
}
let seite = try await client.send(
FlaroRequest(path: "/api/v1/movies", query: abfrage),
as: Wire.Page<Wire.Movie>.self)
return MediaPage(
items: gesiebt(seite.items.map { $0.neutral() }, query),
offset: offset, totalCount: seite.total)
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
// **Erst Film, dann Serie, dann Folge.** Die App reicht eine Kennung
// durch und weiss nicht, was dahintersteckt — sie hat sie aus einer
// Liste. Drei Anfragen im schlechtesten Fall; der Normalfall ist eine.
if let film = try? await client.send(
FlaroRequest(path: "/api/v1/movies/\(id)"), as: Wire.Movie.self) {
return film.neutral()
}
if let serie = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
return serie.neutral()
}
throw .notFound
}
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
try await episodes(inSeries: id)
}
/// Alle Folgen einer Serie — in einer Anfrage.
///
/// Der allgemeine Weg über Staffeln kostete bei zwölf Staffeln dreizehn
/// Anfragen. Flaro kann es in einer, also tut es das.
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
let serie = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self)
let folgen = try await client.send(
FlaroRequest(path: "/api/v1/series/\(id)/episodes"),
as: Wire.Page<Wire.Episode>.self)
return folgen.items.map { $0.neutral(series: serie) }
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
guard item.kind == .episode else { return nil }
// 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger.
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next"))
guard !daten.isEmpty else { return nil }
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
return folge.neutral()
}
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
let abfrage = [
URLQueryItem(name: "search", value: text),
URLQueryItem(name: "take", value: String(limit)),
]
async let filme = client.send(
FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page<Wire.Movie>.self)
async let serien = client.send(
FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page<Wire.Series>.self)
var reihen: [MediaRowGroup] = []
if let gefunden = try? await filme, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.search.movies",
title: String(localized: "flaro.row.movies", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
if let gefunden = try? await serien, !gefunden.items.isEmpty {
reihen.append(MediaRowGroup(
id: "flaro.search.series",
title: String(localized: "flaro.row.series", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
}
return reihen
}
/// Flaro kennt noch keine Schlagwörter.
///
/// Eine leere Liste, kein Fehler: die Oberfläche blendet den Filter dann
/// aus, statt einen Knopf zu zeigen, hinter dem nichts ist.
public func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption] { [] }
public func libraryID(for item: MediaItem) async -> String? { nil }
// MARK: - Bilder
/// **Ohne Grössenangabe.** Flaro legt die Bilder so ab, wie TMDB sie
/// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein
/// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet
/// Rechenzeit, die auf einem NAS knapper ist.
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
rawImageURL(path: path)
}
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path, !path.isEmpty else { return nil }
return client.url(path: "/api/v1/images/\(path)")
}
// MARK: - Herunterladen
/// **Eine Adresse für alles.** Flaro entscheidet selbst, ob die Datei
/// unverändert hinausgeht oder erst umgepackt wird — und packt dabei
/// *alle* Ton- und Textspuren mit ein.
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
client.url(path: "/api/v1/files/\(file.id)/download")
}
/// **Dieselbe Adresse, und das ist der Punkt.**
///
/// Der Vertrag nimmt hier je eine Spurnummer entgegen, weil Jellyfins
/// Transcoder genau eine Tonspur je Sitzung abbildet. Flaro braucht die
/// Wahl nicht: was herauskommt, hat alles. Die Nummern werden deshalb
/// nicht ignoriert, sondern sind gegenstandslos — und die Oberfläche
/// fragt bei `.allTracksDownload` gar nicht erst danach.
public nonisolated func remuxDownloadURL(
itemID: String,
versionIndex: Int,
audioStreamIndex: Int?,
subtitleStreamIndex: Int?
) -> URL? { nil }
/// Noch nicht da: das Herunterrechnen auf eine Bitrate kommt mit Stufe 3.
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? { nil }
// MARK: - Abspielen
/// Wie lange auf ein Umpacken gewartet wird, bevor aufgegeben wird.
///
/// Grosszügig: ein Dreistünder in 4K braucht auf einem NAS Minuten, und
/// abzubrechen, während die Arbeit läuft, wäre die eine Antwort, die
/// niemandem hilft. Irgendwann muss es trotzdem eine geben — ein Spieler,
/// der unbegrenzt auf einen hängengebliebenen Prozess wartet, zeigt
/// Schwarz ohne Begründung.
private static let packWartezeit = Duration.seconds(600)
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else {
throw .transport(description: "Adresse nicht baubar")
}
var auskunft = try await client.send(
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
as: Wire.Delivery.self)
if let fehler = auskunft.error { throw .refused(reason: fehler) }
// **Erst warten, dann die Adresse herausgeben.**
//
// Muss der Server umpacken, antwortet er auf den Download mit 202 und
// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner.
// Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird
// wiederhergestellt", 0:00, für immer.
//
// Das Anstossen besorgt der Server beim ersten Abruf selbst; hier
// wird nur nachgefragt, bis es so weit ist.
if auskunft.method != "directFile", auskunft.packageState != "ready" {
let bis = ContinuousClock.now + Self.packWartezeit
while ContinuousClock.now < bis {
// Ein HEAD stösst an und fragt zugleich nach. Die Datei dafür
// herunterzuladen wäre absurd.
_ = try? await client.send(FlaroRequest(
method: .head, path: "/api/v1/files/\(intent.file.id)/download"))
auskunft = try await client.send(
FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"),
as: Wire.Delivery.self)
if let fehler = auskunft.error { throw .refused(reason: fehler) }
if auskunft.packageState == "ready" { break }
try? await Task.sleep(for: .seconds(1))
}
guard auskunft.packageState == "ready" else {
throw .refused(reason: String(
localized: "flaro.remux.tooLong", bundle: .module))
}
}
// **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das
// hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder
// liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was
// weiterrechnet, wenn niemand mehr zusieht.
return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id)
}
public func endPlayback(sessionID: String) async {}
public func reportProgress(_ report: PlaybackReport) async {
// Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden
// Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen
// wäre die schlechtere Hälfte des Tauschs.
//
// Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen
// Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden
// hiesse, den ersten Bericht wegzuwerfen.
_ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress",
Wire.ProgressRequest(
positionSeconds: report.time.seconds,
runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil)))
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: true)))
}
public func markUnwatched(id: String) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: false)))
}
/// Aus „Weiterschauen" nehmen, ohne als gesehen zu gelten.
///
/// **Flaro hat dafür einen eigenen Weg**, und nur deshalb bleibt die
/// Stelle erhalten. Bei Jellyfin wird hier das Benutzerdatenblatt
/// geschrieben, und darin steht auch „gesehen" — wer es nicht mitschickt,
/// löscht es.
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
try await client.send(FlaroRequest(method: .post, path: "/api/v1/items/\(item.id)/dismiss"))
}
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/favourite",
Wire.FavouriteRequest(favourite: isFavourite)))
}
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
try await client.send(.json(.put, "/api/v1/items/\(id)/rating",
Wire.RatingRequest(rating: rating > 0 ? rating : nil)))
}
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
try await client.send(FlaroRequest(method: .post, path: "/api/v1/libraries/\(library.id)/scan"))
}
// MARK: - Gemeinsames
private func seite<T: Decodable & Sendable>(
pfad: String, take: Int, as type: T.Type
) async throws(MediaBackendError) -> Wire.Page<T> {
try await client.send(
FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]),
as: Wire.Page<T>.self)
}
private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] {
let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page<Wire.Started>.self)
return seite.items.map { $0.neutral() }
}
/// Was der Server noch nicht kann, siebt die App — aber nur das.
///
/// **Nicht alles.** Gefiltert wird hier über eine *Seite*, nicht über die
/// Mediathek: wer bei fünftausend Titeln „nur ungesehene" wählt, bekommt
/// die ungesehenen der ersten fünfzig. Das ist ein Zwischenstand, kein
/// Entwurf — sobald Flaro die Einschränkung selbst kann, fällt das hier
/// weg. Es jetzt wegzulassen hiesse, einen Schalter anzubieten, der
/// nichts tut.
private func gesiebt(_ titel: [MediaItem], _ query: MediaQuery) -> [MediaItem] {
var liste = titel
if query.unwatchedOnly { liste = liste.filter { !$0.isWatched } }
if query.favouritesOnly { liste = liste.filter(\.isFavourite) }
return liste
}
}
/// Mit wem die App spricht und als wer.
public struct FlaroAccount: Sendable, Equatable {
public let baseURL: URL
public let serverName: String
public let userID: String
public let userName: String
public let isAdministrator: Bool
public init(
baseURL: URL,
serverName: String,
userID: String,
userName: String,
isAdministrator: Bool
) {
self.baseURL = baseURL
self.serverName = serverName
self.userID = userID
self.userName = userName
self.isAdministrator = isAdministrator
}
}
@@ -0,0 +1,294 @@
import Foundation
import MediaKit
/// Wer wir für einen Flaro-Server sind.
///
/// Anders als bei Jellyfin steht davon nichts in einer Kopfzeile: Flaro nimmt
/// den Gerätenamen einmal bei der Anmeldung entgegen und führt das Gerät
/// danach über das Token. Die Kennung muss trotzdem über Starts hinweg
/// gleich bleiben — wechselt sie, meldet sich dasselbe Gerät als neues an,
/// und in der Geräteliste sammelt sich Müll.
public struct FlaroIdentity: Sendable, Equatable {
public let deviceName: String
public let deviceID: String
public init(deviceName: String, deviceID: String) {
self.deviceName = deviceName
self.deviceID = deviceID
}
}
/// Spricht mit einem Flaro-Server.
public actor FlaroClient {
public nonisolated let baseURL: URL
private let identity: FlaroIdentity
private let transport: any FlaroTransport
private let cookieJar: (any FlaroCookieJar)?
private var token: String?
public init(
baseURL: URL,
identity: FlaroIdentity,
token: String? = nil,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) {
self.baseURL = baseURL
self.identity = identity
self.token = token
self.transport = transport
self.cookieJar = cookieJar
if let token { plant(token) }
}
public func setToken(_ token: String?) {
self.token = token
if let token { plant(token) } else { clearCookie() }
}
public nonisolated var deviceID: String { identity.deviceID }
public nonisolated var deviceName: String { identity.deviceName }
// MARK: - Das Cookie
/// Legt den Zugang als Cookie ab, damit auch fremde Lader ihn mitschicken.
///
/// **Warum überhaupt.** Flaro nimmt kein Token in der Adresse — es stünde
/// sonst in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem
/// weitergegebenen Link. Jellyfin macht das anders, und `artworkURL`
/// heisst deshalb im Vertrag ausdrücklich „der Token steckt in der
/// Adresse selbst".
///
/// Bleibt der Weg, den der Browser längst geht: ein Cookie. Der
/// Bilderlader und die Hintergrundübertragung benutzen `URLSession` mit
/// der Standardkonfiguration und schicken es von sich aus mit; dem
/// Systemplayer wird es ausdrücklich mitgegeben (siehe
/// `playbackCookies`), weil AVFoundation den gemeinsamen Speicher nicht
/// von allein liest.
///
/// **Gesetzt, nicht abgewartet.** Der Server schickt es bei der Anmeldung
/// auch selbst — aber nach einem Neustart hat die App nur das Token aus
/// dem Schlüsselbund und meldet sich gar nicht erst neu an. Dann wäre
/// kein Cookie da, und jedes Plakat bliebe grau.
private nonisolated func plant(_ token: String) {
guard let cookie = Self.cookie(for: baseURL, token: token) else { return }
cookieJar?.store(cookie)
}
private nonisolated func clearCookie() {
guard let host = baseURL.host() else { return }
cookieJar?.removeCookie(named: Self.cookieName, host: host)
}
public static let cookieName = "flaro_session"
nonisolated static func cookie(for baseURL: URL, token: String) -> HTTPCookie? {
guard let host = baseURL.host() else { return nil }
return HTTPCookie(properties: [
.name: cookieName,
.value: token,
.domain: host,
.path: "/",
// Ein Jahr, wie der Server es auch setzt. Kürzer hiesse: das
// Plakat lädt nicht mehr, obwohl das Token noch gilt.
.expires: Date(timeIntervalSinceNow: 365 * 24 * 3600),
])
}
/// Die Cookies, die der Systemplayer für diese Adresse mitschicken soll.
public nonisolated func cookies(for url: URL, token: String?) -> [HTTPCookie] {
guard let token, let cookie = Self.cookie(for: url, token: token) else { return [] }
return [cookie]
}
// MARK: - Adressen
/// Die Adresse eines Endpunkts.
///
/// **Ohne Token.** Das ist der Unterschied zu Jellyfin und kein Versehen —
/// wer die Adresse abruft, bringt das Cookie mit.
public nonisolated func url(path: String, query: [URLQueryItem] = []) -> URL? {
guard var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
) else { return nil }
components.queryItems = query.isEmpty ? nil : query
return components.url
}
// MARK: - Anfragen
public func send<Value: Decodable & Sendable>(
_ request: FlaroRequest,
as type: Value.Type
) async throws(MediaBackendError) -> Value {
let data = try await send(request)
do {
return try Self.decoder.decode(Value.self, from: data)
} catch {
throw .decoding(description: String(describing: error))
}
}
@discardableResult
public func send(_ request: FlaroRequest) async throws(MediaBackendError) -> Data {
guard var components = URLComponents(
url: baseURL.appending(path: request.path),
resolvingAgainstBaseURL: false
) else {
throw .transport(description: "Adresse nicht baubar")
}
components.queryItems = request.query.isEmpty ? nil : request.query
guard let url = components.url else {
throw .transport(description: "Adresse nicht baubar")
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.timeoutInterval = request.timeout.seconds
// Die App schickt die Kopfzeile. Das Cookie ist der zweite Weg zum
// selben Token, für Lader, die keine Kopfzeilen können.
if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
if let body = request.body {
urlRequest.httpBody = body
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await transport.data(for: urlRequest)
} catch {
throw .transport(description: error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw .transport(description: "Keine HTTP-Antwort")
}
switch http.statusCode {
case 200..<300: return data
case 401, 403: throw .unauthorized
case 404: throw .notFound
default:
// **Flaro begründet seine Absagen.** Ein Problem-Dokument nach
// RFC 9457 trägt einen Satz, den der Server selbst formuliert
// hat — und der ist mehr wert als die Zahl davor. Genau daran
// hing beim Live-TV tagelang alles, weil die Antwort weggeworfen
// wurde.
if let grund = Self.begruendung(in: data) { throw .refused(reason: grund) }
throw .server(status: http.statusCode)
}
}
/// Der Satz aus einem Problem-Dokument, falls einer dasteht.
private static func begruendung(in data: Data) -> String? {
struct Problem: Decodable { let detail: String?; let title: String? }
guard let problem = try? JSONDecoder().decode(Problem.self, from: data) else { return nil }
let text = problem.detail ?? problem.title
return text?.isEmpty == false ? text : nil
}
/// Flaro schreibt klein und rechnet in Sekunden.
///
/// Kein `convertFromSnakeCase`: die Felder heissen schon so, wie sie
/// heissen sollen. ASP.NET Core schreibt sie in `camelCase` hinaus, und
/// Swift liest sie genauso.
///
/// Nicht `private`, damit die Prüfungen denselben Dekoder benutzen. Mit
/// einem frisch gebauten `JSONDecoder()` scheitern sie — und zwar an
/// genau dem Datumsformat, für das die Strategie unten da ist.
static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601WithFraction
return decoder
}()
}
/// Eine Anfrage an Flaro.
public struct FlaroRequest: Sendable {
public enum Method: String, Sendable { case get = "GET", head = "HEAD", post = "POST", put = "PUT", delete = "DELETE" }
public let method: Method
public let path: String
public let query: [URLQueryItem]
public let body: Data?
public let timeout: Duration
public init(
method: Method = .get,
path: String,
query: [URLQueryItem] = [],
body: Data? = nil,
timeout: Duration = .seconds(30)
) {
self.method = method
self.path = path
self.query = query
self.body = body
self.timeout = timeout
}
/// Eine Anfrage mit einem Rumpf aus einem beliebigen Wert.
public static func json(
_ method: Method, _ path: String, _ value: some Encodable
) -> FlaroRequest {
FlaroRequest(method: method, path: path, body: try? JSONEncoder().encode(value))
}
}
/// Wie Anfragen tatsächlich verschickt werden.
///
/// Dieselbe Trennung wie bei Jellyfin: Tests brauchen keinen Server, sondern
/// vorbereitete Antworten.
public protocol FlaroTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: FlaroTransport {}
/// Wo Cookies landen.
///
/// Eigenes Protokoll, damit ein Test nachsehen kann, ob eines abgelegt wurde,
/// ohne den Cookie-Speicher des ganzen Systems anzufassen.
public protocol FlaroCookieJar: Sendable {
func store(_ cookie: HTTPCookie)
func removeCookie(named name: String, host: String)
}
extension HTTPCookieStorage: FlaroCookieJar {
public func store(_ cookie: HTTPCookie) { setCookie(cookie) }
public func removeCookie(named name: String, host: String) {
cookies?
.filter { $0.name == name && $0.domain.hasSuffix(host) }
.forEach(deleteCookie)
}
}
extension JSONDecoder.DateDecodingStrategy {
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
///
/// `.iso8601` kommt damit nicht zurecht und wirft — ein Datum, das
/// „2026-10-03T19:25:04.5209555+00:00" lautet, ist für den
/// Standarddekoder kein Datum. Der Fehler fällt erst dort auf, wo ein
/// Feld gelesen wird, das die meisten Antworten leer lassen.
static var iso8601WithFraction: Self {
.custom { decoder in
let text = try decoder.singleValueContainer().decode(String.self)
let mitBruch = ISO8601DateFormatter()
mitBruch.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let datum = mitBruch.date(from: text) { return datum }
let ohne = ISO8601DateFormatter()
ohne.formatOptions = [.withInternetDateTime]
if let datum = ohne.date(from: text) { return datum }
throw DecodingError.dataCorrupted(.init(
codingPath: decoder.codingPath,
debugDescription: "Kein ISO-8601-Datum: \(text)"))
}
}
}
@@ -0,0 +1,175 @@
import Foundation
/// Was Flaro über die Leitung schickt.
///
/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird
/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen
/// Typen steht in `FlaroBackend+Mapping`.
///
/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung:
/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede
/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand
/// nachziehen müsste.
enum Wire {
struct Page<T: Decodable & Sendable>: Decodable, Sendable {
let items: [T]
let total: Int
}
struct SystemInfo: Decodable, Sendable {
let version: String?
let serverName: String?
let needsSetup: Bool
let attributions: [Attribution]?
}
struct Attribution: Decodable, Sendable {
let text: String
let logoUrl: String
let url: String
}
struct LoginResponse: Decodable, Sendable {
let token: String
let user: User
}
struct User: Decodable, Sendable {
let id: String
let userName: String
let isAdministrator: Bool
}
struct Library: Decodable, Sendable {
let id: String
let name: String
let kind: String
let path: String
}
struct Stream: Decodable, Sendable {
let index: Int
let kind: String
let codec: String?
let language: String?
let title: String?
let isDefault: Bool
let isForced: Bool
let channels: Int?
let width: Int?
let height: Int?
}
struct File: Decodable, Sendable {
let id: String
let fileName: String
let sizeBytes: Int64
let container: String?
let durationSeconds: Double?
let streams: [Stream]
}
struct ItemState: Decodable, Sendable {
let positionSeconds: Double
let runtimeSeconds: Double?
let watched: Bool
let playCount: Int
let favourite: Bool
let rating: Int?
let dismissed: Bool
let lastPlayedAt: Date?
}
struct Movie: Decodable, Sendable {
let id: String
let title: String
let year: Int?
let overview: String?
let posterImage: String?
let backdropImage: String?
let files: [File]
let state: ItemState?
}
struct Series: Decodable, Sendable {
let id: String
let title: String
let year: Int?
let overview: String?
let posterImage: String?
let backdropImage: String?
let episodeCount: Int
let seasons: [Int]
let unwatchedCount: Int
}
struct Episode: Decodable, Sendable {
let id: String
let season: Int
let episode: Int
let title: String?
let overview: String?
let stillImage: String?
let files: [File]
let state: ItemState?
}
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
struct Started: Decodable, Sendable {
let id: String
let kind: String
let title: String
let subTitle: String?
let season: Int?
let episode: Int?
let year: Int?
let posterImage: String?
let backdropImage: String?
let state: ItemState
}
struct CodeResponse: Decodable, Sendable {
let code: String
let handle: String
let expiresAt: Date
}
/// `state` ist „pending", „approved" oder „expired".
struct CodeState: Decodable, Sendable {
let state: String
let login: LoginResponse?
}
struct CodeRequest: Encodable, Sendable {
let deviceName: String
let deviceId: String
}
struct Delivery: Decodable, Sendable {
let method: String
let reasons: [String]
let audioToEncode: [Int]
let subtitlesDropped: [Int]
let packageState: String
let progress: Double
let error: String?
}
// --- Was hinausgeht --------------------------------------------------
struct LoginRequest: Encodable, Sendable {
let userName: String
let password: String
let deviceName: String
let deviceId: String
}
struct ProgressRequest: Encodable, Sendable {
let positionSeconds: Double
let runtimeSeconds: Double?
}
struct WatchedRequest: Encodable, Sendable { let watched: Bool }
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
struct RatingRequest: Encodable, Sendable { let rating: Int? }
}
@@ -0,0 +1,39 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
extension FlaroIdentity {
private static let storageKey = "flaro.flaroServer.deviceID"
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
///
/// **Eine eigene, nicht die von Jellyfin.** Beide Server führen eigene
/// Geräteverzeichnisse; dieselbe Kennung doppelt zu vergeben hiesse, dass
/// ein Widerruf auf dem einen Server im anderen verwirrt — und wer von
/// Jellyfin zu Flaro wechselt, hinterliesse auf dem alten Server ein
/// Gerät, das es dort gar nicht mehr gibt.
///
/// Gleichbleiben muss sie trotzdem: wechselt sie, gilt die App als neues
/// Gerät, und in der Liste steht bei jedem Start ein Eintrag mehr.
///
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
@MainActor
public static func current(defaults: UserDefaults = .standard) -> FlaroIdentity {
let deviceID: String
if let stored = defaults.string(forKey: storageKey) {
deviceID = stored
} else {
deviceID = UUID().uuidString
defaults.set(deviceID, forKey: storageKey)
}
#if canImport(UIKit)
let name = UIDevice.current.name
#else
let name = ProcessInfo.processInfo.hostName
#endif
return FlaroIdentity(deviceName: name, deviceID: deviceID)
}
}
@@ -0,0 +1,179 @@
import Foundation
import MediaKit
/// Aus Flaros Antworten werden neutrale Typen.
///
/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt
/// wird. Alles darüber kennt nur noch `MediaItem`.
extension Wire.Movie {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .movie,
title: title,
summary: overview,
year: year,
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: posterImage,
art: backdropImage,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
versions: [Wire.fassung(id: id, files: files)]
)
}
}
extension Wire.Series {
func neutral() -> MediaItem {
MediaItem(
id: id,
kind: .show,
title: title,
summary: overview,
year: year,
thumb: posterImage,
art: backdropImage,
// **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was
// offen ist — die App zeigt daraus die Plakette, rechnet aber
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
// zu derselben Aussage zu haben.
episodeCount: episodeCount,
playedEpisodeCount: episodeCount - unwatchedCount,
childCount: seasons.count
)
}
}
extension Wire.Episode {
func neutral(series: Wire.Series? = nil) -> MediaItem {
MediaItem(
id: id,
kind: .episode,
title: title ?? "Folge \(episode)",
summary: overview,
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: stillImage,
seriesThumb: series?.posterImage,
seriesArt: series?.backdropImage,
index: episode,
parentIndex: season,
seriesTitle: series?.title,
seriesID: series?.id,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
versions: [Wire.fassung(id: id, files: files)]
)
}
}
extension Wire.Started {
/// Die Kachel aus „Weiterschauen" als Titel.
///
/// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist
/// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie
/// antippt, holt den Titel einzeln — und bekommt dann alles.
func neutral() -> MediaItem {
let folge = kind == "episode"
return MediaItem(
id: id,
kind: folge ? .episode : .movie,
title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title,
year: year,
duration: state.runtimeSeconds.map { Int($0 * 1000) },
thumb: folge ? backdropImage : posterImage,
art: backdropImage,
seriesThumb: folge ? posterImage : nil,
index: episode,
parentIndex: season,
seriesTitle: folge ? title : nil,
playCount: state.playCount,
resumeMilliseconds: Int(state.positionSeconds * 1000),
lastPlayedAt: state.lastPlayedAt,
isFavourite: state.favourite
)
}
}
extension Wire.Library {
func neutral() -> MediaLibrary {
MediaLibrary(
id: id,
title: name,
// Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other`
// durchzulassen wäre eine Vorsorge für einen Fall, den der
// Server gar nicht erzeugen kann.
kind: kind.lowercased() == "shows" ? .show : .movie)
}
}
extension Wire.File {
func neutral(itemID: String) -> MediaFile {
MediaFile(
id: id,
key: "/api/v1/files/\(id)/download",
itemID: itemID,
container: container,
size: sizeBytes,
duration: durationSeconds.map { Int($0 * 1000) },
file: fileName,
streams: streams.map { $0.neutral() })
}
}
extension Wire.Stream {
func neutral() -> MediaStream {
let art: MediaStream.Kind = switch kind.lowercased() {
case "video": .video
case "subtitle": .subtitle
default: .audio
}
return MediaStream(
// **Die Spurnummer ist die Kennung.** `MediaStream.index` liest
// sie aus `id` zurück, und daran hängt die Spurwahl beim
// Umpacken. Eine eigene Kennung wäre hier eine, die niemand
// zurückrechnen kann.
id: String(index),
kind: art,
codec: codec,
language: language,
languageCode: language,
displayTitle: title ?? language,
channels: channels,
isSelected: isDefault,
isForced: isForced,
title: title,
isDefault: isDefault)
}
}
extension Wire {
/// Eine Fassung aus den Dateien eines Titels.
///
/// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro
/// führt mehrere Dateien zu einem Titel als Fassungen desselben Films —
/// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür
/// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird,
/// entscheidet die App.
static func fassung(id: String, files: [Wire.File]) -> MediaVersion {
let erste = files.first
let bild = erste?.streams.first { $0.kind.lowercased() == "video" }
let ton = erste?.streams.first { $0.kind.lowercased() == "audio" }
return MediaVersion(
id: erste?.id ?? id,
container: erste?.container,
videoCodec: bild?.codec,
audioCodec: ton?.codec,
videoResolution: bild?.height.map(String.init),
width: bild?.width,
height: bild?.height,
audioChannels: ton?.channels,
duration: erste?.durationSeconds.map { Int($0 * 1000) },
files: files.map { $0.neutral(itemID: id) })
}
}
@@ -0,0 +1,195 @@
import Foundation
import MediaKit
/// Die Anmeldung an einem Flaro-Server.
///
/// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch
/// keinen Zugang und nichts, was `MediaBackend` versprechen könnte.
public enum FlaroSignIn {
/// Was der Server über sich sagt, bevor sich jemand anmeldet.
public struct Greeting: Sendable, Equatable {
public let serverName: String
public let version: String?
/// Ob der Server überhaupt schon eingerichtet ist.
///
/// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen
/// Server anspricht, hat kein Konto — und „Benutzername oder
/// Passwort stimmt nicht" wäre dann die unbrauchbarste aller
/// Antworten.
public let needsSetup: Bool
/// Der Pflichthinweis auf die Metadatenquelle.
public let attributions: [MetadataAttribution]
}
public struct MetadataAttribution: Sendable, Equatable {
public let text: String
public let logoURL: URL?
public let url: URL?
}
/// Fragt einen Server, wer er ist — ohne Anmeldung.
///
/// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse
/// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein
/// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf.
public static func greet(
baseURL: URL,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared
) async throws(MediaBackendError) -> Greeting {
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
let auskunft = try await client.send(
FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self)
return Greeting(
serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro",
version: auskunft.version,
needsSetup: auskunft.needsSetup,
attributions: (auskunft.attributions ?? []).map {
MetadataAttribution(
text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url))
})
}
/// Meldet an und liefert den Zugang samt fertigem Backend.
public static func signIn(
baseURL: URL,
userName: String,
password: String,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) {
let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport)
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
let antwort = try await client.send(
.json(.post, "/api/v1/auth/login", Wire.LoginRequest(
userName: userName,
password: password,
deviceName: identity.deviceName,
deviceId: identity.deviceID)),
as: Wire.LoginResponse.self)
// **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre
// eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre
// dann schon gesetzt, bevor feststeht, dass es gilt.
await client.setToken(antwort.token)
return (antwort.token, FlaroAccount(
baseURL: baseURL,
serverName: gruss.serverName,
userID: antwort.user.id,
userName: antwort.user.userName,
isAdministrator: antwort.user.isAdministrator), client)
}
/// Nimmt ein gespeichertes Token wieder auf.
///
/// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät
/// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an,
/// und in der Liste stünde dann bei jedem Start eines mehr.
public static func restore(
baseURL: URL,
token: String,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) {
let client = FlaroClient(
baseURL: baseURL, identity: identity, token: token,
transport: transport, cookieJar: cookieJar)
let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self)
let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport)
return (FlaroAccount(
baseURL: baseURL,
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: wer.id,
userName: wer.userName,
isAdministrator: wer.isAdministrator), client)
}
// MARK: - Der Weg über einen Code
/// Ein angeforderter Code samt Abholschein.
public struct PendingCode: Sendable, Equatable {
/// Die sechs Ziffern, die auf dem Fernseher stehen.
public let code: String
/// **Der Abholschein, und er ist das eigentliche Geheimnis.**
///
/// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der
/// Code allein, liessen sich eine Million Möglichkeiten durchprobieren
/// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm
/// im Wohnzimmer.
public let handle: String
public let expiresAt: Date
}
public enum CodeState: Sendable, Equatable {
case pending
case approved(token: String, account: FlaroAccount)
case expired
}
/// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt.
public static func requestCode(
baseURL: URL,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared
) async throws(MediaBackendError) -> PendingCode {
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil)
let antwort = try await client.send(
.json(.post, "/api/v1/auth/code", Wire.CodeRequest(
deviceName: identity.deviceName, deviceId: identity.deviceID)),
as: Wire.CodeResponse.self)
return PendingCode(
code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt)
}
/// Fragt nach, ob jemand den Code inzwischen bestätigt hat.
public static func pollCode(
baseURL: URL,
pending: PendingCode,
identity: FlaroIdentity,
transport: any FlaroTransport = URLSession.shared,
cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared
) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) {
let client = FlaroClient(
baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar)
// **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`.
let zustand = try await client.send(
FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self)
switch zustand.state {
case "approved":
guard let anmeldung = zustand.login else { return (.expired, nil) }
await client.setToken(anmeldung.token)
let gruss = try? await greet(
baseURL: baseURL, identity: identity, transport: transport)
return (.approved(token: anmeldung.token, account: FlaroAccount(
baseURL: baseURL,
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: anmeldung.user.id,
userName: anmeldung.user.userName,
isAdministrator: anmeldung.user.isAdministrator)), client)
case "expired":
return (.expired, nil)
default:
return (.pending, nil)
}
}
/// Meldet dieses Gerät ab und widerruft sein Token.
public static func signOut(client: FlaroClient) async {
_ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout"))
await client.setToken(nil)
}
}
@@ -0,0 +1,91 @@
{
"sourceLanguage": "en",
"strings": {
"flaro.remux.tooLong": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Das Umpacken dauert ungewöhnlich lange. Sieh im Serverprotokoll nach."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Remuxing is taking unusually long. Check the server log."
}
}
}
},
"flaro.row.continue": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Weiterschauen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Continue Watching"
}
}
}
},
"flaro.row.movies": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Filme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Movies"
}
}
}
},
"flaro.row.nextUp": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nächste Folge"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Next Up"
}
}
}
},
"flaro.row.series": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Serien"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Shows"
}
}
}
}
},
"version": "1.0"
}
@@ -1,65 +1,17 @@
import Foundation
import MediaKit
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
/// Die Adressraterei für Jellyfin.
///
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
/// die sich als Jellyfin-Server ausweist.
///
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
/// Domänenname fast immer.
/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem,
/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen,
/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port
/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen.
public enum JellyfinAddress {
/// Der Standardport eines Jellyfin-Servers.
public static let defaultPort = 8096
public static func candidates(for input: String) -> [URL] {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
while text.hasSuffix("/") { text.removeLast() }
guard !text.isEmpty else { return [] }
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
// ausdrücklich „https" getippt hat.
if text.contains("://") {
return URL(string: text).map { [$0] } ?? []
}
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
// Doppelpunkte sonst als Port gelesen würden.
if text.hasPrefix("[") {
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
let host = String(authority.split(separator: ":").first ?? "")
let hasExplicitPort = authority.contains(":")
&& Int(authority.split(separator: ":").last ?? "") != nil
if hasExplicitPort {
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
// eigener Port fast immer ein Server ohne Zertifikat ist.
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
// — alles Dinge, die es nur im eigenen Netz gibt.
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|| host.hasSuffix(".local")
|| !host.contains(".")
let forms = isLocalLooking
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
return forms.compactMap(URL.init(string:))
}
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
private static func withDefaultPort(_ text: String) -> String {
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
ServerAddress.candidates(for: input, defaultPort: defaultPort)
}
}
@@ -173,6 +173,18 @@ public enum BackendCapability: String, Sendable, Hashable {
/// Die Verwaltung des Servers aus der App heraus.
case administration
case transcoding
/// Ein Download bringt **alle** Ton- und Textspuren mit.
///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Jellyfins
/// Transcode-Endpunkt bildet eine Tonspur je Sitzung ab: was
/// heruntergeladen wird, ist einspurig, und die Sprachwahl ist offline
/// weg. Einmal hat die App dabei still die Audiodeskription genommen —
/// drei Gigabyte Mobilfunk für eine unbrauchbare Datei.
///
/// Kann der Server es besser, soll die App nicht so tun, als müsste
/// vorher gewählt werden: `remuxDownloadURL` entfällt, die Spurauswahl
/// im Sheet entfällt, und der gewöhnliche Download liefert alles.
case allTracksDownload
}
/// Ein Ausschnitt aus einer Mediathek.
@@ -0,0 +1,68 @@
import Foundation
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
///
/// Weder Jellyfin noch Flaro haben ein Verzeichnis wie plex.tv — die Adresse
/// kommt vom Nutzer. Und die tippt niemand vollständig: „192.168.1.50" ohne
/// Schema, ohne Port, oder „medien.example.com" ohne „https://". Dieser Typ
/// rät nicht **eine** Ergänzung, sondern nennt die plausiblen in der
/// Reihenfolge, in der sie es wahrscheinlich sind; der Aufrufer probiert sie
/// durch und nimmt die erste, die sich ausweist.
///
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
/// Domänenname fast immer.
///
/// **In der neutralen Schicht, weil beide Server dasselbe Problem haben.**
/// Nur der Standardport unterscheidet sich, und der ist ein Wert, kein
/// Gedanke. Diese Überlegungen zweimal hinzuschreiben hiesse, dass die eine
/// Fassung beim nächsten Sonderfall nachgezogen wird und die andere nicht.
public enum ServerAddress {
public static func candidates(for input: String, defaultPort: Int) -> [URL] {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
while text.hasSuffix("/") { text.removeLast() }
guard !text.isEmpty else { return [] }
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
// ausdrücklich „https" getippt hat.
if text.contains("://") {
return URL(string: text).map { [$0] } ?? []
}
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
// Doppelpunkte sonst als Port gelesen würden.
if text.hasPrefix("[") {
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
let host = String(authority.split(separator: ":").first ?? "")
let hasExplicitPort = authority.contains(":")
&& Int(authority.split(separator: ":").last ?? "") != nil
if hasExplicitPort {
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
// eigener Port fast immer ein Server ohne Zertifikat ist.
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
// — alles Dinge, die es nur im eigenen Netz gibt.
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|| host.hasSuffix(".local")
|| !host.contains(".")
let mitPort = withDefaultPort(text, port: defaultPort)
let forms = isLocalLooking
? ["http://\(mitPort)", "http://\(text)", "https://\(text)"]
: ["https://\(text)", "http://\(text)", "http://\(mitPort)"]
return forms.compactMap(URL.init(string:))
}
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
private static func withDefaultPort(_ text: String, port: Int) -> String {
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(port)" }
return "\(text[text.startIndex..<slash]):\(port)\(text[slash...])"
}
}
@@ -0,0 +1,150 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Adressen und Zugang — alles, was ohne Netz entsteht.
@Suite("Flaro: Adressen und Zugang")
struct AdressenTests {
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: transport, cookieJar: keksglas),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: true))
}
/// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.**
///
/// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine
/// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem
/// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link.
/// Flaro geht über ein Cookie.
@Test("Bildadressen tragen keinen Zugang")
func bildAdresseOhneToken() {
let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123")
#expect(url?.path == "/api/v1/images/abc123")
#expect(url?.query == nil)
#expect(url?.absoluteString.contains("geheim") == false)
}
@Test("Ein Zugang wird als Cookie abgelegt")
func tokenWirdZuKeks() async {
let glas = Keksglas()
_ = FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
let keks = glas.kekse.first
#expect(keks?.name == "flaro_session")
#expect(keks?.value == "geheim-123")
#expect(keks?.path == "/")
#expect(keks?.domain.hasSuffix("nas") == true)
}
/// **Nach einem Neustart meldet sich niemand neu an.**
///
/// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
/// bliebe grau, obwohl der Zugang gilt.
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
func keksAuchOhneAnmeldung() async throws {
let glas = Keksglas()
let transport = Attrappe([
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
("/api/v1/system/info", 200, Proben.info),
])
_ = try await FlaroSignIn.restore(
baseURL: Proben.adresse, token: "geheim-123",
identity: Proben.kennung, transport: transport, cookieJar: glas)
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
}
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
func downloadAdresse() {
let url = backend(Attrappe(rumpf: "{}")).downloadURL(
for: MediaFile(id: "datei-1", itemID: "film-1"))
#expect(url?.path == "/api/v1/files/datei-1/download")
}
/// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.**
///
/// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine
/// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer
/// eine Lüge über das, was herauskommt — also gibt es keine, und die
/// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss.
@Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit")
func alleSpuren() {
let b = backend(Attrappe(rumpf: "{}"))
#expect(b.capabilities.contains(.allTracksDownload))
#expect(b.remuxDownloadURL(
itemID: "film-1", versionIndex: 0,
audioStreamIndex: 1, subtitleStreamIndex: nil) == nil)
}
/// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden.
@Test("Die Kennung hängt an der Adresse, nicht am Namen")
func kennung() {
#expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080")
}
@Test("Anmelden schickt Gerätename und Kennung mit")
func anmeldung() async throws {
let transport = Attrappe([
("/api/v1/system/info", 200, Proben.info),
("/api/v1/auth/login", 200, Proben.anmeldung),
])
let (token, konto, _) = try await FlaroSignIn.signIn(
baseURL: Proben.adresse, userName: "guido", password: "geheim",
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
#expect(token == "geheim-123")
#expect(konto.userName == "guido")
#expect(konto.isAdministrator)
#expect(konto.serverName == "Wohnzimmer")
#expect(rumpf["deviceName"] as? String == "Prüfgerät")
#expect(rumpf["deviceId"] as? String == "geraet-1")
}
/// **Ein frischer Server ist kein falsches Passwort.**
@Test("Ein nicht eingerichteter Server sagt das")
func nochNichtEingerichtet() async throws {
let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#)
let gruss = try await FlaroSignIn.greet(
baseURL: Proben.adresse, identity: Proben.kennung, transport: transport)
#expect(gruss.needsSetup)
}
/// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.**
@Test("Eine Absage mit Begründung kommt als Begründung an")
func begruendeteAbsage() async {
let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409)
let b = backend(transport)
await #expect(throws: MediaBackendError.refused(
reason: "Für diese Bibliothek läuft bereits ein Scan.")) {
try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie))
}
}
@Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung")
func abgelaufen() async {
let b = backend(Attrappe(rumpf: "", status: 401))
await #expect(throws: MediaBackendError.unauthorized) {
_ = try await b.libraries()
}
}
}
@@ -0,0 +1,104 @@
import Foundation
@testable import FlaroServerKit
/// Ein Transport, der vorbereitete Antworten gibt und mitschreibt.
///
/// Dieselbe Trennung wie bei Jellyfin: Prüfungen brauchen keinen Server,
/// sondern eine Antwort und die Frage, die gestellt wurde.
final class Attrappe: FlaroTransport, @unchecked Sendable {
/// Antworten je Pfad. Der erste Treffer gewinnt — so kann eine Prüfung
/// `/api/v1/movies` und `/api/v1/movies/{id}` getrennt bedienen.
private let antworten: [(pfad: String, status: Int, rumpf: String)]
private let sperre = NSLock()
private var protokoll: [URLRequest] = []
init(_ antworten: [(pfad: String, status: Int, rumpf: String)]) {
self.antworten = antworten
}
convenience init(rumpf: String, status: Int = 200) {
self.init([("", status, rumpf)])
}
var anfragen: [URLRequest] { sperre.withLock { protokoll } }
var letzte: URLRequest? { anfragen.last }
func anfrage(auf pfad: String) -> URLRequest? {
anfragen.last { $0.url?.path == pfad }
}
func rumpf(von pfad: String) -> [String: Any]? {
guard let daten = anfrage(auf: pfad)?.httpBody else { return nil }
return try? JSONSerialization.jsonObject(with: daten) as? [String: Any]
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
sperre.withLock { protokoll.append(request) }
let pfad = request.url?.path ?? ""
let treffer = antworten.first { $0.pfad.isEmpty || pfad.hasSuffix($0.pfad) || pfad == $0.pfad }
?? ("", 404, "")
return (
Data(treffer.rumpf.utf8),
HTTPURLResponse(url: request.url!, statusCode: treffer.status,
httpVersion: nil, headerFields: nil)!
)
}
}
/// Ein Keksglas, das nur mitschreibt.
///
/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste
/// beeinflusst — und auf einem Entwicklerrechner auch den Browser.
final class Keksglas: FlaroCookieJar, @unchecked Sendable {
private let sperre = NSLock()
private var inhalt: [HTTPCookie] = []
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
func removeCookie(named name: String, host: String) {
sperre.withLock { inhalt.removeAll { $0.name == name } }
}
}
enum Proben {
static let adresse = URL(string: "http://nas:8080")!
static let kennung = FlaroIdentity(deviceName: "Prüfgerät", deviceID: "geraet-1")
/// Ein Film mit zwei Tonspuren — der Fall, um den es geht.
static let film = """
{
"id": "film-1", "title": "Der Untergang", "year": 2004,
"overview": "Berlin, April 1945.",
"posterImage": "aaa", "backdropImage": "bbb",
"files": [{
"id": "datei-1", "fileName": "Der Untergang (2004).mkv",
"sizeBytes": 9000000000, "container": "matroska", "durationSeconds": 9360.0,
"streams": [
{"index":0,"kind":"Video","codec":"h264","language":null,"title":null,
"isDefault":true,"isForced":false,"channels":null,"width":1920,"height":1080},
{"index":1,"kind":"Audio","codec":"ac3","language":"deu","title":"Deutsch",
"isDefault":true,"isForced":false,"channels":6,"width":null,"height":null},
{"index":2,"kind":"Audio","codec":"ac3","language":"deu","title":"Audiodeskription",
"isDefault":false,"isForced":false,"channels":2,"width":null,"height":null},
{"index":3,"kind":"Subtitle","codec":"subrip","language":"deu","title":"Deutsch",
"isDefault":false,"isForced":false,"channels":null,"width":null,"height":null}
]
}],
"state": {"positionSeconds":300.0,"runtimeSeconds":9360.0,"watched":false,
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
"lastPlayedAt":"2026-10-03T19:25:04.5209555+00:00"}
}
"""
static let info = """
{"version":"0.1.0","serverName":"Wohnzimmer","needsSetup":false,
"attributions":[{"text":"This product uses TMDB","logoUrl":"https://x/t.svg","url":"https://x"}]}
"""
static let anmeldung = """
{"token":"geheim-123","user":{"id":"u1","userName":"guido","isAdministrator":true}}
"""
}
@@ -0,0 +1,262 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Aus Flaros Antworten werden neutrale Typen — ohne Verlust.
@Suite("Flaro: Übersetzung")
struct UebersetzungTests {
private func backend(_ transport: any FlaroTransport) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "t",
transport: transport, cookieJar: nil),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: false))
}
/// **Beide Tonspuren kommen an, mit Namen und Nummer.**
///
/// Das ist der ganze Anlass: eine Datei mit „Deutsch" und
/// „Audiodeskription", und die App muss beide sehen, um nicht blind die
/// falsche zu nehmen.
@Test("Ein Film behält alle Spuren")
func filmMitZweiTonspuren() async throws {
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
let titel = film.neutral()
let datei = try #require(titel.versions.first?.files.first)
#expect(titel.title == "Der Untergang")
#expect(titel.year == 2004)
#expect(datei.audioStreams.count == 2)
#expect(datei.audioStreams.map(\.title) == ["Deutsch", "Audiodeskription"])
// Die Nummer muss zurückrechenbar sein — daran hängt die Spurwahl.
#expect(datei.audioStreams.map(\.index) == [1, 2])
#expect(datei.subtitleStreams.count == 1)
#expect(datei.videoStream?.codec == "h264")
}
/// Fünf Minuten von zwei Stunden sechsunddreissig — die Stelle, die
/// Jellyfin wegwirft.
@Test("Der Stand kommt als Fortschritt an")
func standWirdFortschritt() throws {
let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8)))
let titel = film.neutral()
#expect(titel.resumeMilliseconds == 300_000)
#expect(titel.duration == 9_360_000)
#expect(titel.isWatched == false)
let anteil = try #require(titel.watchProgress)
#expect(abs(anteil - 300.0 / 9360.0) < 0.0001)
}
/// ASP.NET Core schreibt Bruchteile von Sekunden mit.
///
/// **Der Standarddekoder scheitert daran**, und zwar stumm bis zur
/// Stelle, an der jemand das Datum liest. Hier stehen beide nebeneinander,
/// damit sichtbar bleibt, dass die eigene Strategie keine Verzierung ist.
@Test("Ein Datum mit Sekundenbruchteilen wird gelesen")
func datumMitBruch() async throws {
let roh = Data(Proben.film.utf8)
#expect((try? JSONDecoder().decode(Wire.Movie.self, from: roh)) == nil,
"2026-10-03T19:25:04.5209555+00:00 ist für .iso8601 kein Datum")
#expect((try? FlaroClient.decoder.decode(Wire.Movie.self, from: roh)) != nil)
let transport = Attrappe(rumpf: #"{"items":[\#(Proben.film)],"total":1}"#)
let seite = try await backend(transport).items(
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie),
query: MediaQuery(), offset: 0, limit: 50)
#expect(seite.items.first?.lastPlayedAt != nil)
}
/// Der Server zählt, was offen ist; die App rechnet mit der Gegenzahl.
@Test("Offene Folgen werden zu gesehenen")
func serieZaehlt() throws {
let roh = """
{"id":"s1","title":"Dark","year":2017,"overview":null,
"posterImage":"p","backdropImage":"b",
"episodeCount":10,"seasons":[1,2],"unwatchedCount":3}
"""
let serie = try FlaroClient.decoder.decode(Wire.Series.self, from: Data(roh.utf8))
let titel = serie.neutral()
#expect(titel.kind == .show)
#expect(titel.episodeCount == 10)
#expect(titel.playedEpisodeCount == 7)
#expect(titel.childCount == 2)
}
/// **Oben die Serie, darunter die Folge.** Eine Kachel, die nur
/// „Lügen" sagt, hilft beim Wiedererkennen nicht.
@Test("Eine Folge aus Weiterschauen trägt ihre Serie mit")
func weiterschauenFolge() throws {
let roh = """
{"id":"f2","kind":"episode","title":"Dark","subTitle":"Lügen",
"season":1,"episode":2,"year":null,"posterImage":"p","backdropImage":"still",
"state":{"positionSeconds":400.0,"runtimeSeconds":2700.0,"watched":false,
"playCount":0,"favourite":false,"rating":null,"dismissed":false,
"lastPlayedAt":null}}
"""
let eintrag = try FlaroClient.decoder.decode(Wire.Started.self, from: Data(roh.utf8))
let titel = eintrag.neutral()
#expect(titel.kind == .episode)
#expect(titel.title == "Lügen")
#expect(titel.seriesTitle == "Dark")
#expect(titel.parentIndex == 1)
#expect(titel.index == 2)
#expect(titel.resumeMilliseconds == 400_000)
}
@Test("Mediatheken: Shows werden Serien, alles andere Filme")
func mediatheken() async throws {
let transport = Attrappe(rumpf: """
[{"id":"b1","name":"Filme","kind":"Movies","path":"/media/Filme"},
{"id":"b2","name":"Serien","kind":"Shows","path":"/media/Serien"}]
""")
let liste = try await backend(transport).libraries()
#expect(liste.map(\.kind) == [.movie, .show])
#expect(liste.map(\.title) == ["Filme", "Serien"])
}
/// Nach der letzten Folge kommt keine — 204, kein Fehler.
@Test("Hinter der letzten Folge steht nichts")
func keineNaechsteFolge() async throws {
let transport = Attrappe(rumpf: "", status: 204)
let folge = MediaItem(id: "f9", kind: .episode, title: "Letzte")
let naechste = try await backend(transport).nextEpisode(after: folge)
#expect(naechste == nil)
}
@Test("Eine Fortschrittsmeldung geht in Sekunden hinaus")
func fortschrittMelden() async {
let transport = Attrappe(rumpf: "{}")
await backend(transport).reportProgress(PlaybackReport(
itemID: "film-1", state: .playing,
time: .seconds(300), duration: .seconds(9360)))
let rumpf = transport.rumpf(von: "/api/v1/items/film-1/progress")
#expect(transport.letzte?.httpMethod == "POST")
#expect(rumpf?["positionSeconds"] as? Double == 300)
#expect(rumpf?["runtimeSeconds"] as? Double == 9360)
}
/// **Flaro hat einen eigenen Weg dafür — nur deshalb bleibt die Stelle.**
/// Bei Jellyfin wird hier das Benutzerdatenblatt überschrieben.
@Test("Weglegen ist nicht dasselbe wie gesehen")
func weglegen() async throws {
let transport = Attrappe(rumpf: "{}")
try await backend(transport).removeFromContinueWatching(
MediaItem(id: "film-1", title: "Der Untergang"))
#expect(transport.letzte?.url?.path == "/api/v1/items/film-1/dismiss")
#expect(transport.letzte?.httpMethod == "POST")
}
@Test("Gesehen und ungesehen gehen an dieselbe Stelle, mit anderem Wert")
func gesehenMelden() async throws {
let transport = Attrappe(rumpf: "{}")
let b = backend(transport)
try await b.markWatched(id: "film-1")
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == true)
try await b.markUnwatched(id: "film-1")
#expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == false)
#expect(transport.letzte?.httpMethod == "PUT")
}
// --- Abspielen -----------------------------------------------------
/// **Direkt spielbar: sofort los.**
@Test("Eine MP4 geht ohne Umweg hinaus")
func direkteWiedergabe() async throws {
let transport = Attrappe(rumpf: """
{"method":"directFile","reasons":[],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"none","progress":0.0,"error":null}
""")
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
// Flaro legt für eine Wiedergabe nichts an, das aufzuräumen wäre.
#expect(ziel.sessionID == nil)
}
/// **Der Fehler, den erst der Simulator zeigte.**
///
/// Muss der Server umpacken, antwortet er auf den Download mit 202 und
/// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere
/// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner:
/// „Stelle wird wiederhergestellt", 0:00, für immer.
@Test("Ein Umpacken wird abgewartet, nicht überholt")
func wartetAufsUmpacken() async throws {
let transport = Fortschrittsattrappe()
let ziel = try await backend(transport).preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
#expect(ziel.url.path == "/api/v1/files/datei-1/download")
#expect(transport.abfragen >= 2, "es wurde nachgefragt, nicht einmal geschaut")
}
/// Eine Absage des Servers ist eine Absage, kein Weiterwarten.
@Test("Ein gescheitertes Umpacken kommt als Begründung an")
func gescheitertesUmpacken() async {
let transport = Attrappe(rumpf: """
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"failed","progress":0.0,"error":"ffmpeg ist abgestürzt."}
""")
let b = backend(transport)
await #expect(throws: MediaBackendError.refused(reason: "ffmpeg ist abgestürzt.")) {
_ = try await b.preparePlayback(PlaybackIntent(
itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1")))
}
}
/// Flaro kennt noch keine Schlagwörter — eine leere Liste, kein Fehler.
@Test("Ohne Schlagwörter bleibt der Filter leer statt kaputt")
func keineSchlagwoerter() async throws {
let optionen = try await backend(Attrappe(rumpf: "{}")).filterOptions(
inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), field: .genre)
#expect(optionen.isEmpty)
}
}
/// Antwortet beim ersten Mal „läuft noch", danach „fertig".
///
/// Damit prüfbar ist, dass gewartet wird — eine Attrappe mit immer derselben
/// Antwort könnte das nicht von „einmal geschaut und losgelaufen" trennen.
final class Fortschrittsattrappe: FlaroTransport, @unchecked Sendable {
private let sperre = NSLock()
private var runde = 0
var abfragen: Int { sperre.withLock { runde } }
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
let pfad = request.url?.path ?? ""
let fertig: Bool = sperre.withLock {
if pfad.hasSuffix("/delivery") { runde += 1 }
return runde > 1
}
let rumpf = """
{"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[],
"packageState":"\(fertig ? "ready" : "running")","progress":\(fertig ? 1.0 : 0.4),"error":null}
"""
return (Data(rumpf.utf8),
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
}
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Erzeugt einen kleinen Bestand zum Ausprobieren: echte Dateinamen, winzige
# Dateien. Jede ist eine Sekunde Testbild mit Ton — damit der Scan, die
# Metadaten und das Umpacken etwas zu tun haben, ohne Gigabytes zu brauchen.
set -euo pipefail
ZIEL="${1:-$HOME/.flaro-medien}"
command -v ffmpeg >/dev/null || { echo "ffmpeg fehlt."; exit 1; }
mach() { # mach <pfad> <schachtel> [zweite-tonspur]
mkdir -p "$(dirname "$1")"
if [ "${3:-}" = "zwei" ]; then
ffmpeg -y -loglevel error \
-f lavfi -i "testsrc=duration=2:size=320x240:rate=12" \
-f lavfi -i "sine=frequency=440:duration=2" \
-f lavfi -i "sine=frequency=880:duration=2" \
-map 0:v -map 1:a -map 2:a \
-metadata:s:a:0 language=deu -metadata:s:a:0 title=Deutsch \
-metadata:s:a:1 language=deu -metadata:s:a:1 title=Audiodeskription \
-c:v libx264 -preset ultrafast -c:a aac "$1"
else
ffmpeg -y -loglevel error \
-f lavfi -i "testsrc=duration=2:size=320x240:rate=12" \
-f lavfi -i "sine=frequency=440:duration=2" \
-c:v libx264 -preset ultrafast -c:a aac "$1"
fi
echo " $(basename "$1")"
}
echo "Filme:"
mach "$ZIEL/Filme/Der Untergang (2004)/Der Untergang (2004).mkv" mkv zwei
mach "$ZIEL/Filme/The Matrix (1999)/The Matrix (1999).mkv" mkv
mach "$ZIEL/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 (2017).mp4" mp4
mach "$ZIEL/Filme/Inception (2010)/Inception (2010).mkv" mkv
echo "Serien:"
mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E01.mkv" mkv
mach "$ZIEL/Serien/Dark/Staffel 1/Dark - S01E02.mkv" mkv
mach "$ZIEL/Serien/Dark/Staffel 2/Dark - S02E01.mkv" mkv
mach "$ZIEL/Serien/Notruf Hafenkante/Staffel 1/Notruf Hafenkante - S01E01.mkv" mkv zwei
# **Eine zweite Filmbibliothek, und das ist Absicht.** Mit nur „Filme" und
# „Serien" lässt sich nicht prüfen, ob mehrere Mediatheken wirklich getrennt
# bleiben — zwei Bestände derselben Art sind der Fall, in dem es auffällt.
echo "Dokumentationen:"
mach "$ZIEL/Dokus/Nomaden der Lüfte (2001)/Nomaden der Lüfte (2001).mkv" mkv
mach "$ZIEL/Dokus/Unsere Erde (2007)/Unsere Erde (2007).mp4" mp4
echo "Konzerte:"
mach "$ZIEL/Konzerte/Stop Making Sense (1984)/Stop Making Sense (1984).mkv" mkv zwei
chmod -R a+rwX "$ZIEL"
echo
echo "Fertig: $ZIEL"
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# Baut das Flaro-Image für beide Architekturen und legt es in die Registry.
#
# **Der Weg von Hand** — für den Fall, dass kein Gitea-Runner läuft. Sonst
# macht das `.gitea/workflows/image.yml` bei jedem Push von selbst.
#
# Tools/image-veroeffentlichen.sh → :latest
# Tools/image-veroeffentlichen.sh 0.2.0 → :latest und :0.2.0
#
# Vorher einmalig anmelden — das Passwort tippst du selbst ein:
#
# docker login git.scarriffle.com
set -euo pipefail
REGISTRY="${FLARO_REGISTRY:-git.scarriffle.com}"
PFAD="${FLARO_IMAGE_PATH:-scarriffle/flaro}"
BILD="$REGISTRY/$PFAD"
FASSUNG="${1:-}"
cd "$(dirname "$0")/.."
command -v docker >/dev/null || { echo "docker fehlt."; exit 1; }
# **Ohne Anmeldung bricht der Push erst nach dem ganzen Bau ab.** Zwei
# Minuten Arbeit für eine Fehlermeldung, die am Anfang gestanden hätte.
# Kein `docker info | grep -q`: `grep -q` beendet die Pipe beim ersten
# Treffer, Docker bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
# Fehlschlag. Die Prüfung meldete dann „Docker läuft nicht", während es lief.
if ! docker info >/dev/null 2>&1; then
echo "Docker läuft nicht."; exit 1
fi
if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
echo "Nicht an $REGISTRY angemeldet. Erst:"
echo
echo " docker login $REGISTRY"
exit 1
fi
MARKEN=(-t "$BILD:latest")
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|| docker buildx create --name flaro-bauer --use >/dev/null
echo "Baue $BILD für amd64 und arm64 — das dauert, arm64 läuft unter Emulation."
docker buildx build \
--builder flaro-bauer \
--platform linux/amd64,linux/arm64 \
-f server/Dockerfile \
"${MARKEN[@]}" \
--push .
echo
echo "Fertig. Auf dem NAS:"
echo " image: $BILD:${FASSUNG:-latest}"
+286
View File
@@ -0,0 +1,286 @@
# Flaro auf dem NAS einrichten
Für ein UGREEN-NAS mit der mitgelieferten Docker-App, und für jedes andere
System, auf dem Docker Compose läuft.
## Die kurze Antwort auf „ein Image oder mehrere?"
**Ein einziges.** Die Weboberfläche wird beim Bauen mit hineingelegt und vom
Server selbst ausgeliefert; es gibt keinen zweiten Container, keine Datenbank
daneben, keinen Proxy davor. Ein Container, drei Volumes, fertig.
```
flaro:<fassung> 231 MB
├── .NET-Laufzeit + Flaro
├── wwwroot/ die Weboberfläche
└── ffmpeg + VAAPI-Treiber
```
---
## Schritt 1: Die Architektur bestimmen
**Das ist der Schritt, den man am leichtesten überspringt und am teuersten
bezahlt.** Ein Image für `arm64` läuft auf einem Intel-NAS nicht, und die
Fehlermeldung (`exec format error`) sagt das nicht deutlich.
Auf dem NAS, über SSH oder die Konsole der Docker-App:
```bash
uname -m
```
| Ausgabe | Architektur | Was du bauen musst |
|---|---|---|
| `x86_64` | amd64 | `--platform linux/amd64` |
| `aarch64` | arm64 | `--platform linux/arm64` |
Die meisten UGREEN-Geräte mit Intel-Prozessor (N100, i5) melden `x86_64`.
Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu.
---
## Schritt 2: Das Image auf das NAS bringen
Drei Wege. Der erste ist der bequemste, der zweite braucht keine Registry.
### Weg A — über die Registry deines Gitea (empfohlen)
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
#### A1 · Gitea baut selbst
`.gitea/workflows/image.yml` liegt im Projekt. Bei jedem Push auf `main`
baut Gitea das Image für **beide** Architekturen und legt es ab — ohne dass
jemand daran denken muss, und ohne Anmeldung am Arbeitsrechner.
**Voraussetzung ist ein registrierter Act-Runner.** Hat dein Gitea keinen,
passiert nichts; dann gilt A2. Nachsehen unter
*Einstellungen → Actions → Runner*. Ein Runner lässt sich als Container
neben Gitea betreiben:
```bash
docker run -d --restart unless-stopped --name gitea-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
-e GITEA_INSTANCE_URL=https://git.scarriffle.com \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<Token aus den Einstellungen> \
gitea/act_runner:latest
```
#### A2 · Von Hand vom Arbeitsrechner
Einmalig anmelden — das Passwort tippst du selbst ein:
```bash
docker login git.scarriffle.com
```
Danach genügt ein Befehl; er baut amd64 **und** arm64 und lädt hoch:
```bash
Tools/image-veroeffentlichen.sh
```
Mit einer Fassungsnummer, wenn du sie festnageln willst:
```bash
Tools/image-veroeffentlichen.sh 0.2.0
```
#### A3 · Das Paket öffentlich stellen
Sonst muss sich auch das NAS anmelden. In Gitea unter
*dein Profil → Pakete → flaro → Einstellungen* auf öffentlich stellen. Der
Quelltext ist ohnehin offen; das Image enthält nichts, was er nicht auch
enthält.
#### A4 · Die Registry im NAS eintragen
In der Docker-App des UGREEN: **Repository → Zahnrad → Quellname des
Images → Hinzufügen**. Dort stehen schon DockerHub und `ghcr.io`; daneben
kommt:
| Feld | Wert |
|---|---|
| Alias | `gitea` |
| Link | `https://git.scarriffle.com/` |
**Bestätigen** — danach findet die Suche in der Docker-App auch
`scarriffle/flaro`. Ist das Paket privat, trägst du unter
*Registierungseinstellungen* Benutzername und ein Gitea-Zugriffstoken ein
(nicht dein Kontopasswort: ein Token lässt sich einzeln widerrufen, unter
*Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung `read:package`).
In der Compose-Datei steht dann:
```yaml
image: git.scarriffle.com/scarriffle/flaro:latest
```
### Weg B — als Datei hochladen
Ohne Registry, dafür bei jedem Update von Hand.
```bash
docker buildx build --platform linux/amd64 \
-f server/Dockerfile -t flaro:latest --load .
```
```bash
docker save flaro:latest | gzip > flaro.tar.gz
```
Das ergibt rund **230 MB** — gemessen, nicht geschätzt. Das `gzip` bringt
dabei fast nichts: die Ebenen eines Docker-Images sind bereits gepackt.
Die Datei über die Dateiverwaltung auf das NAS legen, dann dort:
```bash
docker load < /volume1/docker/flaro.tar.gz
```
In der Compose-Datei steht dann schlicht `image: flaro:latest`.
### Weg C — auf dem NAS bauen
Geht, lohnt sich aber selten: der Bau braucht den Quelltext, Node, das
.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App
von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer.
---
## Schritt 3: Die Compose-Datei
```yaml
services:
flaro:
image: git.scarriffle.com/scarriffle/flaro:latest
container_name: flaro
restart: unless-stopped
ports:
- "8096:8080"
environment:
- TZ=Europe/Zurich
# Schreibt Plakate und NFO neben die Mediendateien. Ohne das bleibt
# der Medienordner unangetastet.
- FLARO_WRITE_SIDECARS=true
volumes:
# Datenbank und Konfiguration. **Das ist das Volume, das du sichern
# musst** — darin stehen Benutzer, Zuordnungen und Sehfortschritt.
- /volume1/docker/flaro/config:/config
# Heruntergeladene Plakate. Verlierbar: wird neu geholt.
- /volume1/docker/flaro/cache:/cache
# Umgepackte Dateien. Ebenfalls verlierbar, kann aber gross werden.
- /volume1/docker/flaro/transcode:/transcode
# Die Medien. Links der Pfad auf dem NAS, rechts der im Container —
# und **rechts** ist der, den du in Flaro einträgst.
- /volume1/Medien/Filme:/media/Filme
- /volume1/Medien/Serien:/media/Serien
# Hardware-Beschleunigung. Weglassen, wenn das NAS keine hat.
devices:
- /dev/dri:/dev/dri
```
### Rechte
Der Container läuft als Benutzer `1000:1000` — der erste Benutzer auf fast
jedem Linux und auf den üblichen NAS-Geräten. Gehören deine Ordner jemand
anderem, setz es ausdrücklich:
```yaml
user: "1001:100"
```
Welche Kennung gilt, sagt auf dem NAS:
```bash
id
```
### Der Port
`8096` ist frei gewählt. Läuft dort schon Jellyfin, nimm etwas anderes —
links die Zahl, unter der du Flaro erreichst, rechts bleibt `8080`.
---
## Schritt 4: Starten und einrichten
```bash
docker compose up -d
```
Dann im Browser `http://<nas>:8096` öffnen. Es erscheint der
Einrichtungsassistent:
1. **Administratorkonto** anlegen — Benutzername und Passwort, mindestens
acht Zeichen.
2. Unter **Verwaltung → Mediatheken** eine Mediathek hinzufügen. Beim Ordner
gehört der Pfad **im Container** hinein, also `/media/Filme`, nicht
`/volume1/Medien/Filme`.
3. **Scannen.** Der Scan liest die Dateien, danach holt Flaro die Metadaten.
4. Unter **Verwaltung → Server → Metadaten** die Sprache einstellen, etwa
`de-DE`. Danach einmal **Metadaten auffrischen** — vorhandene Titel
ändern sich sonst nicht.
---
## Sichern
Was unersetzlich ist, liegt in **einem** Verzeichnis:
```
/volume1/docker/flaro/config/
```
Darin: die SQLite-Datenbank mit Benutzern, Zuordnungen, Sehfortschritt und
Einstellungen. Eine Kopie dieser Datei ist die ganze Sicherung — kein
Datenbank-Dump, kein Export.
`cache` und `transcode` dürfen weg; beides baut sich neu auf.
**Die Mediendateien fasst Flaro nicht an.** Es liest sie, und es legt —
sofern `FLARO_WRITE_SIDECARS=true` gesetzt ist — Plakate und NFO-Dateien
daneben. Umbenannt oder gelöscht wird nie etwas; es gibt im ganzen Programm
keine Stelle, die das könnte.
---
## Aktualisieren
Mit Weg A:
```bash
docker compose pull && docker compose up -d
```
Migrationen der Datenbank laufen beim Start von selbst. Ein Rückschritt auf
eine ältere Fassung ist **nicht** vorgesehen — vor einem Sprung lohnt eine
Kopie von `config/`.
---
## Wenn etwas nicht geht
```bash
docker compose logs -f flaro
```
| Zeichen | Ursache |
|---|---|
| `exec format error` | falsche Architektur — siehe Schritt 1 |
| Mediathek bleibt leer | der Pfad in Flaro ist der des NAS statt der im Container |
| „Zugriff verweigert" im Protokoll | Rechte, siehe `user:` oben |
| Keine Plakate, Titel wie Dateinamen | kein TMDB-Zugang oder kein Netz |
| Hardware-Beschleunigung greift nicht | `/dev/dri` nicht durchgereicht |
Ein Gesundheitsendpunkt ohne Anmeldung:
```bash
curl http://<nas>:8096/health
```
+4
View File
@@ -57,6 +57,8 @@ targets:
# zweiter dazu, steht er daneben.
- package: FlaroKit
product: JellyfinKit
- package: FlaroKit
product: FlaroServerKit
- package: FlaroKit
product: FlaroDesign
- package: FlaroKit
@@ -188,6 +190,8 @@ targets:
product: MediaKit
- package: FlaroKit
product: JellyfinKit
- package: FlaroKit
product: FlaroServerKit
- package: FlaroKit
product: FlaroDesign
- package: FlaroKit
-6
View File
@@ -1,6 +0,0 @@
**/bin/
**/obj/
**/*.user
data/
cache/
transcode/
+15 -4
View File
@@ -1,5 +1,15 @@
# syntax=docker/dockerfile:1
# Die Oberfläche zuerst, in einem eigenen Abschnitt: sie braucht Node, der
# Server nicht. So landet Node nie im fertigen Image.
FROM --platform=$BUILDPLATFORM node:22-alpine AS web
WORKDIR /web
COPY web/app/package.json web/app/package-lock.json ./
RUN npm ci
COPY web/app/ ./
RUN npm run build
# Gebaut wird auf der Architektur des Bauwirts, übersetzt wird für die des
# Ziels — `dotnet publish -a` kann das. Ohne diese Trennung liefe der Bau für
# arm64 unter Emulation und dauerte ein Vielfaches.
@@ -13,11 +23,11 @@ WORKDIR /src
# `--parents` behält die Verzeichnisse bei. **Ein Muster statt einer Liste**,
# weil eine Liste bei jedem neuen Projekt vergessen wird — und dann scheitert
# erst der Bau im Container, nicht der auf dem Arbeitsrechner.
COPY --parents src/*/*.csproj ./
RUN dotnet restore src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
COPY --parents server/src/*/*.csproj ./
RUN dotnet restore server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH
COPY . .
RUN dotnet publish src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
COPY server/ ./server/
RUN dotnet publish server/src/Flaro.Api/Flaro.Api.csproj -a $TARGETARCH -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
@@ -65,6 +75,7 @@ ENV FLARO_DATA=/config \
WORKDIR /app
COPY --from=build /app .
COPY --from=web /web/dist ./wwwroot
EXPOSE 8080
VOLUME ["/config", "/cache", "/transcode"]
+33 -3
View File
@@ -65,10 +65,34 @@ in Software; das ist langsamer, aber es läuft. **Zum blossen Umpacken wird gar
keine Hardware gebraucht**, und das ist der Weg, der den meisten Beständen
hilft.
## Weboberfläche
Der Server liefert sie selbst aus, unter derselben Adresse wie die
Schnittstelle. Kein Proxy davor, kein CORS, kein Mischmasch aus http und
https. Gebaut wird sie im Image mit; wer nur an ihr arbeitet, nimmt Vite:
```bash
cd ../web/app
npm install
FLARO_SERVER=http://localhost:18080 npm run dev # auf http://localhost:5173
```
Vite reicht `/api` an den Server weiter, damit die Herkunft dieselbe bleibt
wie im Betrieb.
**Der Zugang liegt in einem Cookie, nicht nur in der Kopfzeile.** Ein
`<img>`, ein `<video>` und ein Download-Link senden keine eigenen
Kopfzeilen; das Token stattdessen in die Adresse zu schreiben hiesse, es in
jedes Zugriffsprotokoll und jeden weitergegebenen Link zu setzen. Das Cookie
ist `HttpOnly` und `SameSite=Strict`, `Secure` nur über https — sonst liesse
sich die Oberfläche im Heimnetz gar nicht benutzen. Die Apps benutzen
weiterhin die Kopfzeile: ein zweiter Weg zum selben Token, kein zweites
Verfahren.
## Entwickeln
```bash
dotnet test # 293 Tests
dotnet test # 313 Tests
dotnet run --project src/Flaro.Api # auf http://localhost:5000
```
@@ -76,8 +100,14 @@ Ohne gesetzte Variablen legt der Server `data/`, `cache/` und `transcode/`
neben dem Programm an.
```bash
# Image bauen, beide Architekturen
docker buildx build --platform linux/amd64,linux/arm64 -t flaro:dev .
# Image bauen, beide Architekturen.
# **Aus der Wurzel des Projekts**, nicht aus server/: der Bau braucht
# web/app für die Oberfläche.
cd .. && docker buildx build --platform linux/amd64,linux/arm64 \
-f server/Dockerfile -t flaro:dev .
# Ein lokaler Server mit Beispielmedien zum Ausprobieren
Tools/demo-medien.sh && docker compose -f server/docker-compose.local.yml up -d --build
# Eine Migration nach einer Modelländerung
dotnet ef migrations add <Name> --project src/Flaro.Data --startup-project src/Flaro.Api --output-dir Migrations
+3 -1
View File
@@ -5,7 +5,9 @@
services:
flaro:
image: ghcr.io/scarriffle/flaro:latest
# Die Registry des eigenen Gitea. Wer woanders veröffentlicht, trägt
# hier etwas anderes ein — siehe docs/NAS-EINRICHTEN.md.
image: git.scarriffle.com/scarriffle/flaro:latest
container_name: flaro
restart: unless-stopped
+45
View File
@@ -0,0 +1,45 @@
# Zum Ausprobieren auf dem eigenen Rechner — nicht für den Betrieb.
#
# Unterschiede zur Beispieldatei:
# * benannte Volumes statt Pfaden auf dem NAS
# * Medien aus ~/.flaro-medien, erzeugt von Tools/demo-medien.sh
# * Port 8080 bleibt frei, es läuft auf 18080
#
# docker compose -f docker-compose.local.yml up -d
# docker compose -f docker-compose.local.yml logs -f
# docker compose -f docker-compose.local.yml down -v (räumt auch die Daten weg)
services:
flaro:
image: flaro:local
# Der Bauzusammenhang ist das Repo, nicht server/ — das Image braucht
# auch web/ für die Oberfläche.
build:
context: ..
dockerfile: server/Dockerfile
container_name: flaro-local
restart: unless-stopped
ports:
- "18080:8080"
# **Nur auf dem Mac nötig.** Colimas Dateifreigabe reicht die
# Besitzverhältnisse des Wirts nicht in die VM durch — eingehängte Ordner
# erscheinen im Container als root. Auf einem Linux-NAS gehören sie dem
# Benutzer 1000, und dann gehört diese Zeile weg.
user: "0:0"
environment:
- TZ=Europe/Zurich
- FLARO_WRITE_SIDECARS=true
volumes:
- config:/config
- cache:/cache
- transcode:/transcode
- ${HOME}/.flaro-medien:/media
volumes:
config:
cache:
transcode:
@@ -31,17 +31,43 @@ public sealed class TokenAuthenticationHandler(
{
public const string SchemeName = "FlaroToken";
public const string DeviceTokenIdClaim = "flaro:device";
public const string DeviceNameClaim = "flaro:deviceName";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
/// <summary>Derselbe Zugang, nur für den Browser.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil <c>&lt;img&gt;</c>, <c>&lt;video&gt;</c> und ein Download-Link
/// keine Kopfzeile setzen können.</b> Ohne Cookie müsste die Oberfläche
/// jedes Plakat und jeden Film über <c>fetch</c> holen und im
/// Arbeitsspeicher halten — bei einem Film ist das keine Lösung, und ein
/// Token in der Adresse stünde in jedem Zugriffsprotokoll.
/// </para>
/// <para>
/// <c>HttpOnly</c>, damit kein Skript herankommt, und
/// <c>SameSite=Strict</c> gegen Anfragen von fremden Seiten. Apps benutzen
/// weiterhin die Kopfzeile; das Cookie ist ein zweiter Weg zum selben
/// Token, kein zweites Verfahren.
/// </para>
/// </remarks>
public const string CookieName = "flaro_session";
private string? AusKopfzeile()
{
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return AuthenticateResult.NoResult();
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return null;
var roh = kopfzeile.ToString();
const string vorsatz = "Bearer ";
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return AuthenticateResult.NoResult();
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return null;
var token = roh[vorsatz.Length..].Trim();
if (token.Length == 0) return AuthenticateResult.NoResult();
return token.Length == 0 ? null : token;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var token = AusKopfzeile() ?? Request.Cookies[CookieName];
if (string.IsNullOrEmpty(token)) return AuthenticateResult.NoResult();
var fingerabdruck = Secrets.Fingerprint(token);
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
@@ -66,6 +92,10 @@ public sealed class TokenAuthenticationHandler(
new Claim(ClaimTypes.NameIdentifier, benutzer.Id.ToString()),
new Claim(ClaimTypes.Name, benutzer.UserName),
new Claim(DeviceTokenIdClaim, eintrag.Id.ToString()),
// Für das Dashboard: woher die Wiedergabe kommt. Der Name steht
// schon in der Geräteliste; ihn hier mitzugeben spart bei jeder
// Fortschrittsmeldung eine zweite Abfrage.
new Claim(DeviceNameClaim, eintrag.DeviceName),
.. benutzer.IsAdministrator ? new[] { new Claim(ClaimTypes.Role, FlaroRoles.Administrator) } : [],
], SchemeName);
@@ -23,7 +23,7 @@ public static class AuthEndpoints
public static void MapAuth(this IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/auth/login", async (
LoginRequest anfrage, FlaroContext db, TimeProvider clock) =>
LoginRequest anfrage, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
{
var name = anfrage.UserName.Trim();
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
@@ -45,13 +45,28 @@ public static class AuthEndpoints
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
await db.SaveChangesAsync();
CookieSetzen(zusammenhang, antwort.Token, anfrage.StaySignedIn);
return Results.Ok(antwort);
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapPost("/api/v1/auth/logout", async (ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
// **Abmelden scheitert nie.**
//
// Vorher verlangte dieser Weg eine gültige Anmeldung — und genau dann,
// wenn man ihn am dringendsten braucht, gibt es keine: nach einem
// Serverumzug, nach einem geleerten Datenbestand, nach einem
// zurückgezogenen Token. Das Cookie blieb dann im Browser stehen und
// ging bei **jedem** Bild erneut hinaus, und der Server lehnte es
// jedes Mal ab. Achtzig Ablehnungen in einer Stunde, und nichts davon
// liess sich von aussen beenden.
//
// Jetzt wird das Cookie immer gelöscht; widerrufen wird, was sich
// widerrufen lässt.
app.MapPost("/api/v1/auth/logout", async (
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, HttpContext zusammenhang) =>
{
zusammenhang.Response.Cookies.Delete(FlaroTokenCookie);
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
return Results.Unauthorized();
return Results.NoContent();
var eintrag = await db.DeviceTokens.FindAsync(id);
if (eintrag is not null)
@@ -60,7 +75,7 @@ public static class AuthEndpoints
await db.SaveChangesAsync();
}
return Results.NoContent();
}).RequireAuthorization();
}).AllowAnonymous();
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
@@ -178,6 +193,36 @@ public static class AuthEndpoints
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
}
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
/// <summary>Legt dasselbe Token als Cookie ab — für den Browser.</summary>
///
/// <remarks>
/// <para>
/// <c>HttpOnly</c>: kein Skript kommt heran, also auch keines, das sich
/// über eine Lücke in der Oberfläche eingeschlichen hat. <c>Strict</c>:
/// eine fremde Seite kann damit nichts auslösen. <c>Secure</c> nur bei
/// HTTPS, sonst liesse sich die Oberfläche im Heimnetz über http gar
/// nicht benutzen.
/// </para>
/// <para>
/// <b>Mit oder ohne Verfallsdatum, und das entscheidet der Benutzer.</b>
/// Ohne <c>MaxAge</c> ist es ein Sitzungscookie: es verschwindet, wenn
/// der Browser zugeht. Das ist die richtige Vorgabe an einem fremden
/// Rechner — und die falsche am eigenen, wo niemand sich täglich neu
/// anmelden will. Deshalb wird gefragt statt geraten.
/// </para>
/// </remarks>
private static void CookieSetzen(HttpContext zusammenhang, string token, bool bleiben) =>
zusammenhang.Response.Cookies.Append(FlaroTokenCookie, token, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = zusammenhang.Request.IsHttps,
Path = "/",
MaxAge = bleiben ? TimeSpan.FromDays(365) : null,
});
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
private static IResult Abgelehnt() =>
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
+124 -6
View File
@@ -5,11 +5,26 @@ namespace Flaro.Api.Endpoints;
// und bricht Clients, die niemand mehr aktualisiert.
public sealed record SystemInfo(
string Version, bool NeedsSetup, IReadOnlyList<AttributionResponse> Attributions);
string Version, string ServerName, bool NeedsSetup,
IReadOnlyList<AttributionResponse> Attributions);
public sealed record ServerNameRequest(string Name);
public sealed record SetupRequest(string UserName, string Password);
public sealed record LoginRequest(string UserName, string Password, string DeviceName, string DeviceId);
/// <param name="StaySignedIn">
/// <b>Ob die Anmeldung einen geschlossenen Browser überlebt.</b> Vorgabe ist
/// ja — am eigenen Rechner will niemand sich täglich neu anmelden. Wer an
/// einem fremden sitzt, schaltet es ab, und das Cookie verfällt mit dem
/// Fenster.
///
/// Das Token selbst läuft in keinem Fall ab; es wird widerrufen, nicht
/// vergessen. Was hier entschieden wird, ist allein die Lebensdauer des
/// Cookies im Browser.
/// </param>
public sealed record LoginRequest(
string UserName, string Password, string DeviceName, string DeviceId,
bool StaySignedIn = true);
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
@@ -29,7 +44,17 @@ public sealed record CodeState(string State, LoginResponse? Login);
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path);
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
///
/// <remarks>
/// <c>ItemCount</c> ist <c>null</c>, wenn er nicht mitgezählt wurde — etwa
/// direkt nach dem Anlegen oder Umbenennen. Nicht <c>0</c>: „keine Titel" und
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
/// Bibliothek als leer auszugeben wäre die falsche.
/// </remarks>
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount);
public sealed record RenameLibraryRequest(string Name);
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
@@ -75,16 +100,51 @@ public sealed record MediaFileResponse(
public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage,
IReadOnlyList<MediaFileResponse> Files);
IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State,
/// <summary>Der Schriftzug des Titels — fehlt oft, und das ist in Ordnung.</summary>
string? LogoImage = null,
int? RuntimeMinutes = null,
double? Rating = null,
string? Tagline = null,
string? ContentRating = null,
/// <summary>Leer in Listen, gefüllt beim Einzelabruf.</summary>
///
/// <remarks>
/// <b>Eine Seite mit fünfzig Titeln braucht keine Besetzungslisten.</b>
/// Sie mitzuschicken hiesse, tausend Namen über die Leitung zu schicken,
/// von denen keiner angezeigt wird.
/// </remarks>
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null);
public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image);
public sealed record SeriesResponse(
Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage,
int EpisodeCount, IReadOnlyList<int> Seasons);
int EpisodeCount, IReadOnlyList<int> Seasons,
/// <summary>Wie viele Folgen dieser Benutzer noch nicht gesehen hat.</summary>
///
/// <remarks>
/// Statt eines Zustands je Serie: eine Serie wird nicht angeschaut,
/// ihre Folgen werden es. Was man an der Kachel sehen will, ist die Zahl
/// — bei Plex steht sie als Plakette in der Ecke.
/// </remarks>
int UnwatchedCount,
string? LogoImage = null,
int? RuntimeMinutes = null,
double? Rating = null,
string? Tagline = null,
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files);
IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
@@ -132,3 +192,61 @@ public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
public sealed record MatchState(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
/// <summary>Eine Fortschrittsmeldung.</summary>
///
/// <remarks>
/// <b>In Sekunden, nicht in Ticks.</b> Jellyfin rechnet in Hundertnanosekunden,
/// und jeder Client muss das wissen. Sekunden als Gleitkommazahl sind in jeder
/// Sprache dasselbe und in der Entwicklerkonsole lesbar.
///
/// <c>RuntimeSeconds</c> ist freiwillig: kennt der Client die Laufzeit der
/// Fassung, die er gerade abspielt, ist seine Angabe genauer als die des
/// Servers. Fehlt sie, nimmt der Server die aus der Datei.
/// </remarks>
public sealed record ProgressRequest(
double PositionSeconds, double? RuntimeSeconds,
/// <summary>Die letzte Meldung einer Wiedergabe.</summary>
///
/// <remarks>
/// Nimmt den Eintrag sofort aus „läuft gerade". Ohne sie stünde ein
/// beendeter Film dort noch eine Minute, bis das Lebenszeichen ausbleibt
/// — und wer gerade den Spieler geschlossen hat, sieht sich selbst noch
/// beim Schauen zu.
/// </remarks>
bool? Stopped = null);
public sealed record WatchedRequest(bool Watched);
public sealed record FavouriteRequest(bool Favourite);
public sealed record RatingRequest(int? Rating);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
public sealed record ItemStateResponse(
double PositionSeconds, double? RuntimeSeconds,
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
DateTimeOffset? LastPlayedAt);
/// <summary>Eine Zeile in „Weiterschauen".</summary>
///
/// <remarks>
/// Film und Folge in einer Form, weil die Reihe beide mischt. Bei einer Folge
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
/// </remarks>
public sealed record ContinueEntry(
Guid Id, string Kind, string Title, string? SubTitle,
int? Season, int? Episode, int? Year,
string? PosterImage, string? BackdropImage,
ItemStateResponse State);
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
public sealed record SessionResponse(
string UserName, string DeviceName,
Guid ItemId, string Title, string? SubTitle, string? PosterImage,
double PositionSeconds, double? RuntimeSeconds);
/// <summary>Was der Server gerade tut.</summary>
public sealed record ServerStatusResponse(
int Playing, int Packaging, int Scanning, double Uptime);
@@ -18,7 +18,13 @@ public static class DeliveryEndpoints
return Results.Ok(Auskunft(plan, packer, datei.Id, datei.ModifiedAt));
}).RequireAuthorization();
app.MapGet("/api/v1/files/{id:guid}/download", async (
// **GET und HEAD.** Der Client fragt mit HEAD nach, ob das Umpacken
// fertig ist — eine Antwort ohne Rumpf genügt dafür, und einen
// halbfertigen Film herunterzuladen, nur um das zu erfahren, wäre
// absurd. `MapGet` allein nimmt HEAD nicht an; die Anfrage fiele dann
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
// Genau das ist passiert: der Spieler bekam HTML statt Video.
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
Guid id, FlaroContext db, PackageCoordinator packer) =>
{
var gefunden = await Laden(db, id);
@@ -1,3 +1,5 @@
using System.Security.Claims;
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
@@ -14,6 +16,24 @@ public static class LibraryContentEndpoints
/// </remarks>
private const int HöchstensJeSeite = 200;
/// <summary>Wonach eine Liste geordnet wird.</summary>
///
/// <remarks>
/// <b>Als Wort, nicht als Spaltenname.</b> „added" sagt, was gemeint ist;
/// `AddedAt desc` verriete den Aufbau der Datenbank und wäre beim
/// nächsten Umbau falsch. Unbekanntes fällt auf den Titel zurück, statt
/// die Anfrage abzulehnen — eine Liste in falscher Reihenfolge ist
/// besser als keine.
/// </remarks>
private enum Reihenfolge { Title, Added, Year }
private static Reihenfolge Ordnung(string? wunsch) => wunsch?.ToLowerInvariant() switch
{
"added" => Reihenfolge.Added,
"year" => Reihenfolge.Year,
_ => Reihenfolge.Title,
};
public static void MapLibraryContent(this IEndpointRouteBuilder app)
{
MapScan(app);
@@ -45,6 +65,66 @@ public static class LibraryContentEndpoints
ILookup<Guid, MediaStreamResponse> spuren) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
await db.GenreLinks.AsNoTracking()
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
.ToListAsync();
/// <summary>Besetzung und Stab — Darsteller zuerst, in ihrer Reihenfolge.</summary>
private static async Task<IReadOnlyList<CreditResponse>> MitwirkendeZu(
FlaroContext db, Guid? film, Guid? serie) =>
await db.Credits.AsNoTracking()
.Where(c => film != null ? c.MovieId == film : c.SeriesId == serie)
.Join(db.People.AsNoTracking(), c => c.PersonId, p => p.Id, (c, p) => new { c, p })
.OrderBy(x => x.c.Role).ThenBy(x => x.c.Order)
.Select(x => new CreditResponse(
x.p.Id, x.p.Name, x.c.Role.ToString(), x.c.Character, x.p.Image))
.ToListAsync();
/// <summary>Die Stände dieses Benutzers zu den genannten Titeln.</summary>
///
/// <remarks>
/// In einer Abfrage für die ganze Seite. Ein Titel ohne Zeile hat keinen
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
/// </remarks>
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
var gesucht = (filme ?? folgen)!;
if (gesucht.Count == 0) return [];
var zeilen = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer)
.Where(z => filme != null
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
: z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value))
.ToListAsync();
return zeilen.ToDictionary(
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value,
PlaybackEndpoints.Abbild);
}
/// <summary>Welche der genannten Folgen dieser Benutzer gesehen hat.</summary>
private static async Task<HashSet<Guid>> GeseheneFolgen(
FlaroContext db, ClaimsPrincipal wer, List<Guid> folgen)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer || folgen.Count == 0) return [];
var ids = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && z.Watched
&& z.EpisodeId != null && folgen.Contains(z.EpisodeId!.Value))
.Select(z => z.EpisodeId!.Value)
.ToListAsync();
return [.. ids];
}
private static void MapScan(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
@@ -59,6 +139,26 @@ public static class LibraryContentEndpoints
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Metadaten neu holen — für alles, nicht nur für Neues.**
//
// Ein gewöhnlicher Scan fragt nichts erneut: das ist schneller und
// hält Handarbeit. Wer aber die Sprache von Englisch auf Deutsch
// stellt, bekäme sonst nie deutsche Titel — die Einstellung griffe
// erst beim nächsten neuen Film. Geholt wird über die bekannte
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
// bleibt damit stehen.
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
return koordinator.TryStart(id, erneut: true)
? Results.AcceptedAtRoute("Scanstand", new { id })
: Results.Problem(
"Für diese Bibliothek läuft bereits ein Lauf.",
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
{
var zustand = koordinator.State(id);
@@ -82,18 +182,38 @@ public static class LibraryContentEndpoints
private static void MapBrowse(IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/movies", async (
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{
var abfrage = db.Movies.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
if (!string.IsNullOrWhiteSpace(genre))
{
var gesucht = genre.Trim();
abfrage = abfrage.Where(m => db.GenreLinks
.Any(l => l.MovieId == m.Id && db.Genres.Any(g => g.Id == l.GenreId && g.Name == gesucht)));
}
// Der Titel immer als letzter Vergleich: zwei Filme aus demselben
// Jahr hätten sonst keine feste Reihenfolge, und eine Seite 2
// zeigte dann denselben Film noch einmal oder gar nicht.
abfrage = Ordnung(sort) switch
{
Reihenfolge.Added => abfrage.OrderByDescending(m => m.AddedAt).ThenBy(m => m.SortTitleValue),
Reihenfolge.Year => abfrage.OrderByDescending(m => m.Year).ThenBy(m => m.SortTitleValue),
_ => abfrage.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year),
};
var gesamt = await abfrage.CountAsync();
var kennungen = await abfrage
.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year)
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage })
.Select(m => new
{
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
})
.ToListAsync();
// Dateien in **einer** Abfrage nachholen statt je Film einer.
@@ -105,48 +225,188 @@ public static class LibraryContentEndpoints
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: ids);
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id)
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).ToList();
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
stände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
app.MapGet("/api/v1/series", async (
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{
var abfrage = db.Series.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
if (!string.IsNullOrWhiteSpace(genre))
{
var gesucht = genre.Trim();
abfrage = abfrage.Where(x => db.GenreLinks
.Any(l => l.SeriesId == x.Id && db.Genres.Any(g => g.Id == l.GenreId && g.Name == gesucht)));
}
abfrage = Ordnung(sort) switch
{
Reihenfolge.Added => abfrage.OrderByDescending(s => s.AddedAt).ThenBy(s => s.SortTitleValue),
Reihenfolge.Year => abfrage.OrderByDescending(s => s.Year).ThenBy(s => s.SortTitleValue),
_ => abfrage.OrderBy(s => s.SortTitleValue).ThenBy(s => s.Year),
};
var gesamt = await abfrage.CountAsync();
var serien = await abfrage
.OrderBy(s => s.SortTitleValue)
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(s => new { s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage })
.Select(s => new
{
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage,
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
})
.ToListAsync();
var ids = serien.Select(s => s.Id).ToList();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => ids.Contains(f.SeriesId))
.Select(f => new { f.SeriesId, f.SeasonNumber })
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber })
.ToListAsync();
// Gesehene Folgen in **einer** Abfrage, für alle Serien der Seite
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
// einundfünfzig Abfragen.
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
var items = serien.Select(s =>
{
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
return new SeriesResponse(
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList());
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
meine.Count(f => !gesehen.Contains(f.Id)),
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating);
}).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async (Guid id, FlaroContext db, int? season) =>
// **Nur die Genres, die in dieser Mediathek auch vorkommen.**
//
// Alle 19 Genres von TMDB anzubieten hiesse: ein Filter, hinter dem
// zu zwei Dritteln nichts steht. Ein Knopf, der zu einer leeren Liste
// führt, ist schlimmer als kein Knopf.
app.MapGet("/api/v1/libraries/{id:guid}/genres", async (Guid id, FlaroContext db) =>
{
var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id);
if (bibliothek is null) return Results.NotFound();
var namen = bibliothek.Kind == Core.Libraries.LibraryKind.Shows
? await db.GenreLinks.AsNoTracking()
.Where(l => l.SeriesId != null
&& db.Series.Any(x => x.Id == l.SeriesId && x.LibraryId == id))
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
.Distinct().OrderBy(n => n).ToListAsync()
: await db.GenreLinks.AsNoTracking()
.Where(l => l.MovieId != null
&& db.Movies.Any(m => m.Id == l.MovieId && m.LibraryId == id))
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id, (_, g) => g.Name)
.Distinct().OrderBy(n => n).ToListAsync();
return Results.Ok(namen);
}).RequireAuthorization();
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
app.MapGet("/api/v1/movies/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id)
.FirstOrDefaultAsync();
if (film is null) return Results.NotFound();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId == id)
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]);
return Results.Ok(new MovieResponse(
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating,
await GenresZu(db, film: id, serie: null),
await MitwirkendeZu(db, film: id, serie: null)));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var serie = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
.FirstOrDefaultAsync();
if (serie is null) return Results.NotFound();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == id)
.Select(f => new { f.Id, f.SeasonNumber })
.ToListAsync();
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
return Results.Ok(new SeriesResponse(
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage,
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
folgen.Count(f => !gesehen.Contains(f.Id)),
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating,
await GenresZu(db, film: null, serie: id),
await MitwirkendeZu(db, film: null, serie: id)));
}).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
// Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann
// der Scan sie fand, nicht wann sie läuft.
app.MapGet("/api/v1/episodes/{id:guid}/next", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var diese = await db.Episodes.AsNoTracking()
.Where(f => f.Id == id)
.Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber })
.FirstOrDefaultAsync();
if (diese is null) return Results.NotFound();
var naechste = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == diese.SeriesId)
.Where(f => f.SeasonNumber > diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.FirstOrDefaultAsync();
// Keine weitere Folge ist kein Fehler — es war die letzte.
if (naechste is null) return Results.NoContent();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == naechste.Id)
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
return Results.Ok(new EpisodeResponse(
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
naechste.Title, naechste.Overview, naechste.StillImage,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
stände.GetValueOrDefault(naechste.Id)));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, int? season) =>
{
if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound();
@@ -164,11 +424,13 @@ public static class LibraryContentEndpoints
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids);
var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList())).ToList();
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
@@ -45,12 +45,32 @@ public static class ManagementEndpoints
var bibliotheken = app.MapGroup("/api/v1/libraries");
// Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an.
//
// **Mit Zählwert.** Die Seitenleiste führt jede Bibliothek einzeln
// auf, und „Dokumentationen · 0" sagt mehr als ein Eintrag, hinter
// dem sich nach dem Klick nichts findet — da ist der Unterschied
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
bibliotheken.MapGet("/", async (FlaroContext db) =>
Results.Ok(await db.Libraries
.OrderBy(l => l.Name)
.Select(l => new LibraryResponse(l.Id, l.Name, l.Kind.ToString(), l.Path))
.ToListAsync()))
.RequireAuthorization();
{
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync();
var filme = await db.Movies.AsNoTracking()
.GroupBy(m => m.LibraryId)
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
var serien = await db.Series.AsNoTracking()
.GroupBy(x => x.LibraryId)
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
return Results.Ok(liste.Select(l => new LibraryResponse(
l.Id, l.Name, l.Kind.ToString(), l.Path,
l.Kind == LibraryKind.Shows
? serien.GetValueOrDefault(l.Id)
: filme.GetValueOrDefault(l.Id))).ToList());
}).RequireAuthorization();
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{
@@ -74,7 +94,45 @@ public static class ManagementEndpoints
db.Libraries.Add(neu);
await db.SaveChangesAsync();
return Results.Created($"/api/v1/libraries/{neu.Id}",
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path));
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0));
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
// Serien zu verwandeln; den Pfad zu ändern hiesse, einen ganzen
// Bestand gegen einen anderen zu tauschen, während die Einträge
// stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue
// Bibliothek — und die legt man an.
bibliotheken.MapPut("/{id:guid}", async (
Guid id, RenameLibraryRequest anfrage, FlaroContext db) =>
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Ein Name darf nicht leer sein."],
});
var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound();
vorhanden.Name = name;
await db.SaveChangesAsync();
return Results.Ok(new LibraryResponse(
vorhanden.Id, vorhanden.Name, vorhanden.Kind.ToString(), vorhanden.Path, null));
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Die Mediendateien bleiben liegen.** Entfernt wird, was Flaro über
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
// den man nicht noch einmal benutzt.
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) =>
{
var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound();
db.Libraries.Remove(vorhanden);
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
}
}
@@ -0,0 +1,500 @@
using System.Security.Claims;
using Flaro.Api.Auth;
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Fortschritt, Gesehen-Status, Favoriten — alles je Benutzer.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein Titel wird über seine Kennung allein angesprochen</b>, ohne dass
/// der Aufrufer sagen muss, ob es ein Film oder eine Folge ist. Die App hat
/// diese Auskunft an der Stelle nicht — sie reicht durch, was eine Liste ihr
/// gegeben hat. Der Preis ist eine zusätzliche Abfrage, wenn es eine Folge
/// ist; der Gegenwert ist eine Schnittstelle, die man nicht falsch bedienen
/// kann.
/// </para>
/// </remarks>
public static class PlaybackEndpoints
{
private const int HöchstensJeSeite = 200;
public static void MapPlayback(this IEndpointRouteBuilder app)
{
MapZustand(app);
MapStartseite(app);
}
// ------------------------------------------------------------------
// Melden
// ------------------------------------------------------------------
private static void MapZustand(IEndpointRouteBuilder app)
{
var titel = app.MapGroup("/api/v1/items/{id:guid}").RequireAuthorization();
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
titel.MapPost("/progress", async (
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
TimeProvider clock, PlaybackSessions laufende) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
// Die Laufzeit vom Client hat Vorrang: er misst an der Fassung,
// die er gerade abspielt. Fehlt sie, hilft die Datei weiter —
// ohne Laufzeit gäbe es keinen Anteil und damit kein „gesehen".
var laenge = anfrage.RuntimeSeconds is { } sekunden and > 0
? TimeSpan.FromSeconds(sekunden)
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge);
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
// soll beides haben: das Häkchen und die neue Stelle.
if (ergebnis.Watched && !zustand.Watched)
{
zustand.Watched = true;
zustand.PlayCount++;
}
zustand.Position = ergebnis.Position;
zustand.Runtime = laenge;
zustand.LastPlayedAt = clock.GetUtcNow();
zustand.UpdatedAt = clock.GetUtcNow();
// Wer weitersieht, hat es sich offenbar anders überlegt.
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
Nachfuehren(zustand);
await db.SaveChangesAsync();
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
// meldet beim Abspielen ohnehin alle paar Sekunden; daraus das
// Dashboard zu speisen braucht kein zweites Verfahren, das ein
// Client vergessen könnte. Bleibt die Meldung aus, läuft der
// Eintrag von selbst ab.
//
// `stopped` nimmt ihn sofort heraus — sonst stünde ein beendeter
// Film noch eine Minute als „läuft".
if (anfrage.Stopped == true) laufende.Beenden(Geraet(wer));
else await Mitschreiben(db, laufende, wer, benutzer, zustand, clock);
return Results.Ok(Abbild(zustand));
});
titel.MapPut("/watched", async (
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
if (anfrage.Watched)
{
if (!zustand.Watched) zustand.PlayCount++;
zustand.Watched = true;
zustand.LastPlayedAt = clock.GetUtcNow();
// **Von Hand als gesehen markiert heisst: fertig damit.** Hier
// wäre eine stehengebliebene Stelle keine Hilfe, sondern eine
// Zeile in „Weiterschauen", die niemand bestellt hat.
zustand.Position = TimeSpan.Zero;
}
else
{
zustand.Watched = false;
zustand.Position = TimeSpan.Zero;
zustand.Dismissed = false;
}
zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand);
await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand));
});
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
titel.MapPost("/dismiss", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
zustand.Dismissed = true;
zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand);
await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand));
});
titel.MapPut("/favourite", async (
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
zustand.Favourite = anfrage.Favourite;
zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand);
await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand));
});
titel.MapPut("/rating", async (
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
statusCode: StatusCodes.Status400BadRequest);
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
zustand.Rating = anfrage.Rating;
zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand);
await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand));
});
}
// ------------------------------------------------------------------
// Startseite
// ------------------------------------------------------------------
private static void MapStartseite(IEndpointRouteBuilder app)
{
var start = app.MapGroup("/api/v1/home").RequireAuthorization();
start.MapGet("/continue", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
// Die Regel steht in `ProgressRule`; was hier abgefragt wird, ist
// ihr festgehaltenes Ergebnis. Siehe `UserItemState.InProgress`.
var zustände = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && z.InProgress)
.OrderByDescending(z => z.UpdatedAt)
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
.ToListAsync();
return Results.Ok(new Page<ContinueEntry>(
await Eintraege(db, zustände), zustände.Count));
});
// **Die erste ungesehene Folge je angefangener Serie.**
//
// Ohne Überschneidung mit „Weiterschauen": was dort schon steht, weil
// es mitten drin liegt, taucht hier nicht noch einmal auf. Jellyfin
// zeigt beides doppelt; auf einer Startseite mit zwei Reihen
// nebeneinander sieht das nach einem Fehler aus.
start.MapGet("/next", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
// Welche Serien der Benutzer überhaupt angefasst hat. Ohne diese
// Einschränkung stünde die erste Folge jeder Serie der Mediathek
// hier — eine Reihe, die nichts über den Benutzer aussagt.
var angefasst = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && z.EpisodeId != null)
.Join(db.Episodes.AsNoTracking(), z => z.EpisodeId, f => f.Id,
(z, f) => new { f.SeriesId, z.UpdatedAt })
.GroupBy(x => x.SeriesId)
.Select(g => new { SeriesId = g.Key, Zuletzt = g.Max(x => x.UpdatedAt) })
.OrderByDescending(x => x.Zuletzt)
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
.ToListAsync();
if (angefasst.Count == 0) return Results.Ok(new Page<ContinueEntry>([], 0));
var serienIds = angefasst.Select(x => x.SeriesId).ToList();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => serienIds.Contains(f.SeriesId))
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage })
.ToListAsync();
var folgenIds = folgen.Select(f => f.Id).ToList();
var zustände = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && z.EpisodeId != null && folgenIds.Contains(z.EpisodeId!.Value))
.ToDictionaryAsync(z => z.EpisodeId!.Value);
var serien = await db.Series.AsNoTracking()
.Where(x => serienIds.Contains(x.Id))
.Select(x => new { x.Id, x.Title, x.PosterImage })
.ToDictionaryAsync(x => x.Id);
var liste = new List<ContinueEntry>();
foreach (var x in angefasst)
{
var naechste = folgen
.Where(f => f.SeriesId == x.SeriesId)
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true });
// Nichts offen — die Serie ist durch.
if (naechste is null) continue;
// Mittendrin: steht bereits in „Weiterschauen".
if (zustände.GetValueOrDefault(naechste.Id) is { InProgress: true }) continue;
if (!serien.TryGetValue(x.SeriesId, out var serie)) continue;
var zustand = zustände.GetValueOrDefault(naechste.Id);
liste.Add(new ContinueEntry(
naechste.Id, "episode", serie.Title, naechste.Title,
naechste.SeasonNumber, naechste.EpisodeNumber, null,
serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
zustand is null ? Leer() : Abbild(zustand)));
}
return Results.Ok(new Page<ContinueEntry>(liste, liste.Count));
});
// **Ähnliches — „weil du X gesehen hast".**
//
// Über die Genres der zuletzt gesehenen Titel, nicht über eine
// Empfehlungsmaschine. Ein Haushaltsserver hat keine Millionen
// Sehverläufe, aus denen sich etwas lernen liesse; was er hat, sind
// die Genres dessen, was dieser eine Mensch angefangen hat. Das ist
// eine schlichte Regel — und sie ist nachvollziehbar, was eine
// undurchschaubare Empfehlung nie ist.
start.MapGet("/suggestions", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
// Woran dieser Mensch zuletzt dran war — Filme wie Folgen.
var zuletzt = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && (z.Watched || z.Position > TimeSpan.Zero))
.OrderByDescending(z => z.UpdatedAt)
.Take(30)
.ToListAsync();
if (zuletzt.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
var filmIds = zuletzt.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
var folgenIds = zuletzt.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
var serienIds = await db.Episodes.AsNoTracking()
.Where(f => folgenIds.Contains(f.Id)).Select(f => f.SeriesId).Distinct().ToListAsync();
// Die Genres daraus, nach Häufigkeit: wer dreimal Science Fiction
// angefangen hat, meint Science Fiction.
var genres = await db.GenreLinks.AsNoTracking()
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
.GroupBy(l => l.GenreId)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.Take(5)
.ToListAsync();
if (genres.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
// **Nichts vorschlagen, was schon angefasst wurde.** Ein
// Vorschlag, den man gestern gesehen hat, ist kein Vorschlag.
var beruehrt = filmIds.ToHashSet();
var treffer = await db.Movies.AsNoTracking()
.Where(m => !beruehrt.Contains(m.Id))
.Where(m => db.GenreLinks.Any(l => l.MovieId == m.Id && genres.Contains(l.GenreId)))
// Je mehr gemeinsame Genres, desto besser der Vorschlag.
.Select(m => new
{
Film = m,
Treffer = db.GenreLinks.Count(l => l.MovieId == m.Id && genres.Contains(l.GenreId)),
})
.OrderByDescending(x => x.Treffer)
.ThenByDescending(x => x.Film.Rating ?? 0)
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(x => x.Film)
.ToListAsync();
var items = treffer.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
[], null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
return Results.Ok(new Page<MovieResponse>(items, items.Count));
});
}
/// <summary>Der Zustand eines Titels, den noch niemand angefasst hat.</summary>
///
/// <remarks>
/// Lieber eine Antwort mit Nullen als <c>null</c>: die Reihe „Nächste
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
/// </remarks>
private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null);
/// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary>
///
/// <remarks>
/// In zwei Abfragen statt in einer je Zeile: zwanzig Einträge wären sonst
/// zwanzig Abfragen, und SQLite führt die nacheinander aus.
/// </remarks>
private static async Task<List<ContinueEntry>> Eintraege(FlaroContext db, List<UserItemState> zustände)
{
var filme = zustände.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id))
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage })
.ToDictionaryAsync(m => m.Id);
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
{
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage,
})
.ToDictionaryAsync(f => f.Id);
var liste = new List<ContinueEntry>(zustände.Count);
foreach (var z in zustände)
{
if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m))
liste.Add(new ContinueEntry(
m.Id, "movie", m.Title, null, null, null, m.Year,
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z)));
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
liste.Add(new ContinueEntry(
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z)));
}
return liste;
}
// ------------------------------------------------------------------
// Gemeinsames
// ------------------------------------------------------------------
/// <summary>Hält fest, was die Regel zu dieser Zeile sagt.</summary>
///
/// <remarks>
/// Nach jeder Änderung aufzurufen. Die einzige Stelle, die
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
/// </remarks>
private static void Nachfuehren(UserItemState zustand) =>
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand);
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
///
/// <remarks>
/// Nicht der Benutzer: derselbe Mensch kann auf dem Fernseher und auf
/// dem Telefon gleichzeitig etwas laufen lassen, und beides gehört ins
/// Dashboard. Nicht der Gerätename: zwei iPhones heissen beide „iPhone".
/// </remarks>
private static string Geraet(ClaimsPrincipal wer) =>
wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim) ?? "unbekannt";
/// <summary>Trägt die laufende Wiedergabe ins Dashboard ein.</summary>
private static async Task Mitschreiben(
FlaroContext db, PlaybackSessions laufende, ClaimsPrincipal wer,
Guid benutzer, UserItemState zustand, TimeProvider clock)
{
string titel;
string? untertitel = null;
string? plakat;
if (zustand.MovieId is { } film)
{
var eintrag = await db.Movies.AsNoTracking()
.Where(m => m.Id == film)
.Select(m => new { m.Title, m.PosterImage })
.FirstOrDefaultAsync();
if (eintrag is null) return;
titel = eintrag.Title;
plakat = eintrag.PosterImage;
}
else if (zustand.EpisodeId is { } folge)
{
var eintrag = await db.Episodes.AsNoTracking()
.Where(f => f.Id == folge)
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
{
SerienTitel = s.Title, s.PosterImage,
f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title,
})
.FirstOrDefaultAsync();
if (eintrag is null) return;
titel = eintrag.SerienTitel;
untertitel = $"S{eintrag.SeasonNumber} · F{eintrag.EpisodeNumber}"
+ (eintrag.FolgenTitel is { Length: > 0 } n ? $" · {n}" : "");
plakat = eintrag.PosterImage;
}
else return;
laufende.Melden(new PlaybackSessions.Wiedergabe(
DeviceKey: Geraet(wer),
UserId: benutzer,
UserName: wer.FindFirstValue(ClaimTypes.Name) ?? "",
ItemId: zustand.MovieId ?? zustand.EpisodeId ?? Guid.Empty,
Title: titel,
SubTitle: untertitel,
PosterImage: plakat,
Position: zustand.Position,
Runtime: zustand.Runtime,
DeviceName: wer.FindFirstValue(TokenAuthenticationHandler.DeviceNameClaim) ?? "Unbekannt",
LastSeenAt: clock.GetUtcNow()));
}
internal static Guid? Benutzer(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
///
/// <remarks>
/// <b>Erst Film, dann Folge.</b> Zwei Abfragen im schlechteren Fall. Eine
/// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder
/// anderen Stelle im Modell kosten.
/// </remarks>
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
{
var vorhanden = await db.UserItemStates
.FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel));
if (vorhanden is not null) return vorhanden;
if (await db.Movies.AnyAsync(m => m.Id == titel))
{
var neu = new UserItemState { UserId = benutzer, MovieId = titel };
db.UserItemStates.Add(neu);
return neu;
}
if (await db.Episodes.AnyAsync(f => f.Id == titel))
{
var neu = new UserItemState { UserId = benutzer, EpisodeId = titel };
db.UserItemStates.Add(neu);
return neu;
}
return null;
}
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
///
/// <remarks>
/// Die längste, weil mehrere Fassungen vorliegen können und eine davon
/// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen
/// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat.
/// </remarks>
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand) =>
await db.MediaFiles.AsNoTracking()
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
.MaxAsync(d => (TimeSpan?)d.Duration);
internal static ItemStateResponse Abbild(UserItemState z) => new(
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt);
}
@@ -1,4 +1,5 @@
using Flaro.Core.Users;
using Flaro.Core;
using Flaro.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
@@ -16,11 +17,26 @@ public static class SystemEndpoints
/// </remarks>
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
/// <summary>Unter welchem Schlüssel der Servername liegt.</summary>
public const string NameSchlüssel = "server.name";
/// <summary>Wann dieser Prozess angefangen hat.</summary>
private static readonly DateTimeOffset Gestartet = DateTimeOffset.UtcNow;
public static void MapSystem(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
Results.Ok(new SystemInfo(
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
// Server hat, unterscheidet sie an einer Zahl.
//
// Vorgabe ist der Rechnername des Containers: besser als
// nichts und besser als „Flaro", weil davon jeder einen hat.
ServerName: (await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(e => e.Key == NameSchlüssel))?.Value
?? Environment.MachineName,
NeedsSetup: !await db.Users.AnyAsync(),
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
// auf dem Anmeldebildschirm zeigen können.
@@ -30,6 +46,48 @@ public static class SystemEndpoints
])))
.AllowAnonymous();
// **Wer schaut gerade was.**
//
// Nur für Administratoren: wer wann welchen Film anschaut, ist eine
// Auskunft über Menschen, nicht über den Server. In einem Haushalt
// mag das harmlos sein; die Vorgabe eines offenen Projekts darf es
// trotzdem nicht sein.
app.MapGet("/api/v1/system/sessions", (PlaybackSessions laufende) =>
Results.Ok(laufende.Laufende().Select(w => new SessionResponse(
w.UserName, w.DeviceName, w.ItemId, w.Title, w.SubTitle, w.PosterImage,
w.Position.TotalSeconds, w.Runtime?.TotalSeconds)).ToList()))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Was der Server gerade tut.** Keine Kurven über zwei Minuten wie
// bei Plex — die bräuchten eine Zeitreihe im Speicher, und für die
// Frage „läuft gerade eine Umwandlung?" genügt eine Zahl.
app.MapGet("/api/v1/system/status", (
PlaybackSessions laufende, PackageCoordinator packer, ScanCoordinator scans,
FlaroContext db) =>
Results.Ok(new ServerStatusResponse(
Playing: laufende.Laufende().Count,
Packaging: packer.LaufendeAnzahl,
Scanning: scans.LaufendeAnzahl,
Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds)))
.RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Ein Name darf nicht leer sein."],
});
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
else vorhanden.Value = name;
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
{
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
@@ -49,6 +49,9 @@ public sealed class PackageCoordinator(
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
/// <summary>Wie viele Umwandlungen gerade laufen — für das Dashboard.</summary>
public int LaufendeAnzahl => _zustand.Values.Count(z => z.Status == "running");
/// <summary>Wohin die umgepackte Datei kommt.</summary>
///
/// <remarks>
+69
View File
@@ -0,0 +1,69 @@
using System.Collections.Concurrent;
namespace Flaro.Api;
/// <summary>Was gerade läuft, je Gerät.</summary>
///
/// <remarks>
/// <para>
/// <b>Im Arbeitsspeicher, nicht in der Datenbank.</b> „Wer schaut gerade
/// was" ist eine Frage über den Augenblick; sie zu speichern hiesse, nach
/// einem Neustart Wiedergaben anzuzeigen, die es nicht mehr gibt. Fällt der
/// Container aus, ist die Antwort ohnehin „niemand".
/// </para>
/// <para>
/// <b>Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen
/// Anmeldung am Spieler.</b> Jeder Client meldet beim Abspielen ohnehin alle
/// paar Sekunden, wo er steht — wer das als Lebenszeichen liest, braucht
/// kein zweites Verfahren, das ein Client vergessen könnte. Bleibt die
/// Meldung aus, läuft der Eintrag von selbst ab.
/// </para>
/// </remarks>
public sealed class PlaybackSessions(TimeProvider clock)
{
/// <summary>Wie lange eine Wiedergabe ohne Meldung als laufend gilt.</summary>
///
/// <remarks>
/// Die Clients melden alle zehn Sekunden. Eine Minute lässt Raum für
/// einen Aussetzer, ohne dass eine längst beendete Wiedergabe noch
/// minutenlang im Dashboard steht.
/// </remarks>
public static readonly TimeSpan Lebensdauer = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, Wiedergabe> _laufend = new();
public sealed record Wiedergabe(
string DeviceKey,
Guid UserId,
string UserName,
Guid ItemId,
string Title,
string? SubTitle,
string? PosterImage,
TimeSpan Position,
TimeSpan? Runtime,
string DeviceName,
DateTimeOffset LastSeenAt);
/// <summary>Hält fest, dass dieses Gerät gerade etwas abspielt.</summary>
public void Melden(Wiedergabe wiedergabe) =>
_laufend[wiedergabe.DeviceKey] = wiedergabe;
/// <summary>Nimmt eine Wiedergabe heraus — beim Beenden.</summary>
public void Beenden(string deviceKey) => _laufend.TryRemove(deviceKey, out _);
/// <summary>Was gerade läuft. Abgelaufenes fällt dabei heraus.</summary>
public IReadOnlyList<Wiedergabe> Laufende()
{
var grenze = clock.GetUtcNow() - Lebensdauer;
// Beiläufig aufräumen. Ein eigener Hintergrunddienst für eine
// Handvoll Einträge wäre Aufwand ohne Gegenwert.
foreach (var (schluessel, eintrag) in _laufend)
{
if (eintrag.LastSeenAt < grenze) _laufend.TryRemove(schluessel, out _);
}
return _laufend.Values.OrderBy(w => w.UserName).ThenBy(w => w.Title).ToList();
}
}
+90
View File
@@ -10,6 +10,7 @@ using Flaro.Metadata;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
@@ -92,6 +93,9 @@ builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
builder.Services.AddSingleton<PlaybackSessions>();
builder.Services.AddProblemDetails();
var app = builder.Build();
@@ -106,6 +110,66 @@ using (var bereich = app.Services.CreateScope())
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRateLimiter();
// --- Die Oberfläche -------------------------------------------------------
//
// **Vom Server selbst ausgeliefert, nicht von einem Proxy davor.** Damit
// liegen Seite und Schnittstelle unter derselben Adresse: kein CORS, kein
// Mischmasch aus http und https, und der Zugang bleibt beim Server statt im
// Browser. Der Node-Proxy aus der Jellyfin-Zeit wird dadurch überflüssig.
//
// **Vor der Anmeldung, und das ist kein Versehen.** Die Rückfallregel weiter
// oben verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten
// Benutzer — und `/assets/index.js` hat keinen. Stünde das hier unter der
// Anmeldung, bekäme ein neuer Besucher 401 auf genau das Skript, das ihm die
// Anmeldemaske zeichnen soll. Die Seite selbst ist kein Geheimnis; sie zeigt
// ohne gültigen Zugang nichts als ein Anmeldefeld.
var oberfläche = Path.Combine(app.Environment.ContentRootPath, "wwwroot");
if (Directory.Exists(oberfläche))
{
app.UseDefaultFiles();
// **Zwei Lebensdauern, und die Unterscheidung ist nötig.**
//
// Die Dateien unter `assets/` tragen eine Prüfsumme im Namen: ändert
// sich ihr Inhalt, ändert sich der Name. Sie dürfen deshalb für immer im
// Zwischenspeicher liegen — und sollen es auch, sonst lädt jeder Besuch
// dasselbe Skript erneut.
//
// `index.html` trägt keine. Sie nennt die Namen der Skripte, und wer sie
// aus dem Zwischenspeicher nimmt, bekommt nach einem Server-Update
// weiter die alte App — nicht kaputt, sondern **veraltet**, und das
// fällt niemandem auf. Genau das ist hier passiert: der Browser lud
// `index-C0K3V8eg.js`, während der Server längst `index-DomHN4_b.js`
// auslieferte.
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = zusammenhang =>
{
var name = zusammenhang.File.Name;
var kopf = zusammenhang.Context.Response.GetTypedHeaders();
if (name.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
{
kopf.CacheControl = new CacheControlHeaderValue
{
NoCache = true,
MustRevalidate = true,
};
}
else
{
kopf.CacheControl = new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromDays(365),
Extensions = { new NameValueHeaderValue("immutable") },
};
}
},
});
}
app.UseAuthentication();
app.UseAuthorization();
@@ -116,8 +180,34 @@ app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
app.MapPlayback();
app.MapMatching();
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
// Webseite mit Status 200 — ein vertippter Aufruf sieht dann aus wie ein
// Erfolg, und der Fehler fällt erst dort auf, wo HTML verarbeitet wird.
// Buchstaben schlagen Platzhalter, also gewinnt jeder echte Endpunkt hier.
app.Map("/api/{*rest}", () => Results.NotFound()).AllowAnonymous();
// Alles Übrige bekommt die Seite — der Router läuft im Browser. Ohne das
// endet ein Neuladen auf /verwaltung in einem 404.
if (Directory.Exists(oberfläche))
{
// Auch hier `no-cache`: der Rückfall liefert dieselbe `index.html`, und
// sie über diesen Weg zwischenspeichern zu lassen hätte genau denselben
// Effekt wie oben.
app.MapFallback((HttpContext zusammenhang) =>
{
zusammenhang.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
{
NoCache = true,
MustRevalidate = true,
};
return Results.File(Path.Combine(oberfläche, "index.html"), "text/html");
}).AllowAnonymous();
}
app.Run();
/// <summary>Damit die Tests den Server hochfahren können.</summary>
+8 -4
View File
@@ -40,10 +40,14 @@ public sealed class ScanCoordinator(
public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId);
/// <summary>Wie viele Läufe gerade unterwegs sind — für das Dashboard.</summary>
public int LaufendeAnzahl =>
_zustand.Values.Count(z => z.Status is "running" or "enriching");
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
public bool TryStart(Guid libraryId)
public bool TryStart(Guid libraryId, bool erneut = false)
{
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
@@ -55,11 +59,11 @@ public sealed class ScanCoordinator(
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
if (!ReferenceEquals(vorher, start)) return false;
_ = Task.Run(() => Laufen(libraryId));
_ = Task.Run(() => Laufen(libraryId, erneut));
return true;
}
private async Task Laufen(Guid libraryId)
private async Task Laufen(Guid libraryId, bool erneut)
{
try
{
@@ -73,7 +77,7 @@ public sealed class ScanCoordinator(
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
var metadaten = await bereich.ServiceProvider
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId);
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId, erneut);
_zustand[libraryId] = _zustand[libraryId] with
{
@@ -0,0 +1,66 @@
namespace Flaro.Core.Library;
/// <summary>Ein Genre, einmal je Name.</summary>
///
/// <remarks>
/// <b>Eine eigene Tabelle und keine Liste im Film.</b> „Science Fiction" als
/// Text in jedem Film stehen zu haben hiesse: filtern über <c>LIKE</c>,
/// Tippfehler als eigenes Genre, und bei einer Umbenennung vierhundert
/// Zeilen anfassen. So ist es eine Zeile und ein Verweis.
/// </remarks>
public sealed class Genre
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Name { get; set; }
}
/// <summary>Verbindet ein Genre mit einem Film oder einer Serie.</summary>
public sealed class GenreLink
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid GenreId { get; init; }
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
}
/// <summary>Jemand, der mitgewirkt hat.</summary>
///
/// <remarks>
/// Über die Kennung der Quelle geführt, nicht über den Namen: es gibt zwei
/// Schauspieler namens „Chris Evans", und sie über einen Kamm zu scheren
/// hiesse, dem einen die Filmografie des anderen anzuhängen.
/// </remarks>
public sealed class Person
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Name { get; set; }
/// <summary>Die Kennung bei TMDB — der eindeutige Schlüssel.</summary>
public required int TmdbId { get; init; }
/// <summary>Das Porträt in der Bildablage.</summary>
public string? Image { get; set; }
}
public enum CreditRole { Actor, Director, Writer }
/// <summary>Wer in welcher Rolle woran beteiligt war.</summary>
public sealed class Credit
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid PersonId { get; init; }
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
public required CreditRole Role { get; init; }
/// <summary>Die gespielte Rolle — nur bei Darstellern.</summary>
public string? Character { get; set; }
/// <summary>Die Reihenfolge, wie die Quelle sie nennt.</summary>
///
/// <remarks>
/// Die Besetzungsliste ist nicht alphabetisch: oben stehen die
/// Hauptrollen. Nach Namen zu sortieren hiesse, diese Aussage wegzuwerfen.
/// </remarks>
public int Order { get; set; }
}
@@ -121,6 +121,33 @@ public sealed class Movie
public string? BackdropImage { get; set; }
/// <summary>Die Laufzeit laut Quelle, in Minuten.</summary>
///
/// <remarks>
/// Nicht die der Datei — die steht in <see cref="MediaFile.Duration"/>
/// und misst, was wirklich vorliegt. Beide sind nützlich und beide
/// verschieden: eine Fassung mit Werbepausen ist länger als der Film.
/// </remarks>
public int? RuntimeMinutes { get; set; }
/// <summary>Die Bewertung der Quelle, 0–10.</summary>
public double? Rating { get; set; }
/// <summary>Die Werbezeile, etwa „Die Wirklichkeit ist eine Lüge".</summary>
public string? Tagline { get; set; }
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
public string? ContentRating { get; set; }
/// <summary>Der Schriftzug des Titels in der Bildablage.</summary>
///
/// <remarks>
/// Freigestellt auf durchsichtigem Grund. Über dem Hintergrundbild sagt
/// er mehr als derselbe Titel in der Systemschrift — und nicht jeder
/// Titel hat einen.
/// </remarks>
public string? LogoImage { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
@@ -184,6 +211,33 @@ public sealed class Series
public string? BackdropImage { get; set; }
/// <summary>Die Laufzeit laut Quelle, in Minuten.</summary>
///
/// <remarks>
/// Nicht die der Datei — die steht in <see cref="MediaFile.Duration"/>
/// und misst, was wirklich vorliegt. Beide sind nützlich und beide
/// verschieden: eine Fassung mit Werbepausen ist länger als der Film.
/// </remarks>
public int? RuntimeMinutes { get; set; }
/// <summary>Die Bewertung der Quelle, 0–10.</summary>
public double? Rating { get; set; }
/// <summary>Die Werbezeile, etwa „Die Wirklichkeit ist eine Lüge".</summary>
public string? Tagline { get; set; }
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
public string? ContentRating { get; set; }
/// <summary>Der Schriftzug des Titels in der Bildablage.</summary>
///
/// <remarks>
/// Freigestellt auf durchsichtigem Grund. Über dem Hintergrundbild sagt
/// er mehr als derselbe Titel in der Systemschrift — und nicht jeder
/// Titel hat einen.
/// </remarks>
public string? LogoImage { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
@@ -3,13 +3,78 @@ namespace Flaro.Core.Metadata;
/// <summary>Ein Bild bei der Quelle — noch nicht heruntergeladen.</summary>
public sealed record RemoteImage(string Url);
/// <summary>Jemand, der mitgewirkt hat.</summary>
///
/// <remarks>
/// <c>Character</c> ist nur bei Darstellern gefüllt. Regie und Drehbuch
/// haben keine Rolle, und ein leeres Feld dafür wäre ehrlicher als „—".
/// </remarks>
public sealed record CreditMetadata(
int ProviderId, string Name, CreditKind Kind, string? Character, RemoteImage? Portrait, int Order);
public enum CreditKind { Actor, Director, Writer }
/// <summary>Was eine Quelle über einen Film weiss.</summary>
///
/// <remarks>
/// <b>Gewachsen, und das hat einen Grund.</b> Mit Titel, Jahr, Beschreibung
/// und zwei Bildern lässt sich eine Kachel zeichnen und sonst nichts: keine
/// Detailseite, die mehr sagt als der Dateiname, keine Vorschläge, kein
/// Filter nach Genre. Alles drei hängt daran, dass hier mehr ankommt.
///
/// Jedes Feld darf fehlen. Ein Mitschnitt, den keine Quelle kennt, soll
/// trotzdem in der Mediathek stehen.
/// </remarks>
public sealed record MovieMetadata(
int ProviderId, string Title, int? Year, string? Overview,
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null,
/// <summary>Laufzeit in Minuten, wie die Quelle sie nennt.</summary>
///
/// <remarks>
/// Nicht die der Datei: die steht in <c>MediaFile.Duration</c> und misst,
/// was wirklich vorliegt. Beide sind nützlich und beide verschieden —
/// eine Fassung mit Werbepausen ist länger als der Film.
/// </remarks>
int? RuntimeMinutes = null,
/// <summary>Die Bewertung der Quelle, 0–10.</summary>
double? Rating = null,
string? Tagline = null,
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditMetadata>? Credits = null,
/// <summary>Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund.</summary>
///
/// <remarks>
/// <b>Das, was eine Startseite teuer aussehen lässt.</b> „Blade Runner
/// 2049" in seiner eigenen Schrift über dem Hintergrundbild sagt mehr als
/// derselbe Titel in der Systemschrift — und es ist der Unterschied
/// zwischen einer Kachelwand und etwas, das wie ein Filmdienst aussieht.
///
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
/// </remarks>
RemoteImage? Logo = null);
public sealed record SeriesMetadata(
int ProviderId, string Title, int? Year, string? Overview,
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null,
int? RuntimeMinutes = null,
double? Rating = null,
string? Tagline = null,
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditMetadata>? Credits = null,
/// <summary>Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund.</summary>
///
/// <remarks>
/// <b>Das, was eine Startseite teuer aussehen lässt.</b> „Blade Runner
/// 2049" in seiner eigenen Schrift über dem Hintergrundbild sagt mehr als
/// derselbe Titel in der Systemschrift — und es ist der Unterschied
/// zwischen einer Kachelwand und etwas, das wie ein Filmdienst aussieht.
///
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
/// </remarks>
RemoteImage? Logo = null);
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
@@ -0,0 +1,88 @@
namespace Flaro.Core.Playback;
/// <summary>Was eine Fortschrittsmeldung am Zustand ändert.</summary>
///
/// <remarks>
/// <para>
/// <b>Keine untere Schwelle. Keine Mindestlaufzeit. Keine verworfene
/// Position.</b> Das ist der Anlass für diesen Server, und es lohnt, den
/// Vergleich festzuhalten, damit später niemand „das macht Jellyfin doch
/// genauso" denkt.
/// </para>
/// <para>
/// Jellyfin rechnet in <c>UserDataManager.UpdatePlayState</c> mit drei Werten
/// (Vorgaben aus <c>ServerConfiguration</c>): <c>MinResumePct</c> 5,
/// <c>MaxResumePct</c> 90, <c>MinResumeDurationSeconds</c> 300. Unterhalb von
/// fünf Prozent setzt es die Position auf null — der Kommentar im Quelltext
/// lautet wörtlich „ignore progress during the beginning". Bei einem
/// Zweistünder sind das die ersten sechs Minuten. Oberhalb von neunzig
/// Prozent gilt der Titel als gesehen, und auch dann fällt die Position weg.
/// Und alles, was kürzer als fünf Minuten ist, gilt nach dem ersten
/// Lebenszeichen als vollständig gesehen, egal an welcher Stelle.
/// </para>
/// <para>
/// Flaro behält in allen drei Fällen die Position. Ein gesehener Titel und
/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus —
/// wer einen Film ein zweites Mal anfängt, soll beides haben.
/// </para>
/// </remarks>
public static class ProgressRule
{
/// <summary>Ab welchem Anteil ein Titel als gesehen gilt.</summary>
///
/// <remarks>
/// <b>Eine Schwelle ist hier unvermeidlich</b>, und das ist kein
/// Widerspruch zum Obigen: Niemand sieht einen Film bis zur letzten
/// Sekunde des Abspanns. Ohne diese Regel stünde jeder zu Ende geschaute
/// Film für immer bei 98 % in „Weiterschauen".
///
/// Der Unterschied zu Jellyfin ist nicht, <i>dass</i> es diese Grenze
/// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort
/// zusätzlich die Position weggeworfen.
/// </remarks>
public const double WatchedFraction = 0.90;
/// <summary>Was aus einer Meldung folgt.</summary>
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
public readonly record struct Outcome(TimeSpan Position, bool Watched);
/// <summary>Rechnet eine gemeldete Stelle in einen Zustand um.</summary>
///
/// <param name="position">Wo der Client steht.</param>
/// <param name="runtime">
/// Wie lang der Titel ist, falls bekannt. Ohne Laufzeit gibt es keinen
/// Anteil zu rechnen — dann wird die Stelle abgelegt und sonst nichts.
/// Zu raten wäre hier schlimmer als nichts zu tun: „gesehen", weil die
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
/// es zu merken.
/// </param>
public static Outcome Apply(TimeSpan position, TimeSpan? runtime)
{
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
// kurz Unsinn, der andere beim Beenden die Laufzeit plus Rundung.
var stelle = position < TimeSpan.Zero ? TimeSpan.Zero : position;
if (runtime is not { } laenge || laenge <= TimeSpan.Zero)
return new Outcome(stelle, Watched: false);
if (stelle > laenge) stelle = laenge;
return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction);
}
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
///
/// <remarks>
/// <b>Dieselbe Regel wie in der Abfrage, an einer Stelle.</b> Die
/// Serverabfrage kann sie nicht aufrufen — sie wird zu SQL übersetzt —,
/// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander,
/// fällt es hier auf und nicht beim Benutzer.
/// </remarks>
public static bool BelongsInContinueWatching(UserItemState zustand) =>
zustand is { Dismissed: false, Position.Ticks: > 0 }
&& (zustand.Runtime is not { } laenge
|| laenge <= TimeSpan.Zero
|| zustand.Position < laenge * WatchedFraction);
}
@@ -0,0 +1,84 @@
namespace Flaro.Core.Playback;
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
///
/// <remarks>
/// <para>
/// <b>Je Benutzer, nicht je Server.</b> Zwei Leute im selben Haushalt stehen
/// an verschiedenen Stellen desselben Films, und wessen Stand gilt, darf
/// nicht davon abhängen, wer zuletzt geschaut hat.
/// </para>
/// <para>
/// <b>Der Verweis zeigt auf Film <i>oder</i> Folge</b>, wie bei
/// <c>MediaFile</c>. Eine gemeinsame Kennung mit einem Typfeld daneben wäre
/// kürzer, aber die Datenbank könnte dann nicht mehr dafür einstehen, dass
/// der Verweis irgendwohin zeigt.
/// </para>
/// </remarks>
public sealed class UserItemState
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid UserId { get; init; }
public Guid? MovieId { get; init; }
public Guid? EpisodeId { get; init; }
/// <summary>Wo der Benutzer steht.</summary>
///
/// <remarks>
/// <b>Ab der ersten Sekunde, ohne untere Schwelle.</b> Das ist der
/// Unterschied, um dessentwillen dieser Server existiert — siehe
/// <see cref="ProgressRule"/>.
/// </remarks>
public TimeSpan Position { get; set; }
/// <summary>Wie lang der Titel ist, so wie der Client es gemeldet hat.</summary>
///
/// <remarks>
/// Hier abgelegt, damit „wie weit ist er" ohne einen Blick in die Dateien
/// zu beantworten ist. Bei zwei Fassungen eines Films — 4K und 1080p —
/// können sich die Laufzeiten um Sekunden unterscheiden; was zählt, ist
/// die, gegen die gemessen wurde.
/// </remarks>
public TimeSpan? Runtime { get; set; }
public bool Watched { get; set; }
public int PlayCount { get; set; }
public DateTimeOffset? LastPlayedAt { get; set; }
/// <summary>Aus „Weiterschauen" genommen, ohne als gesehen zu gelten.</summary>
///
/// <remarks>
/// <b>Der Fall, für den es sonst nichts gibt.</b> Nach einer halben Stunde
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
/// nur nicht weiter oben stehen. „Als gesehen" wäre eine Lüge, „als
/// ungesehen" täte nichts: die Position bliebe.
///
/// Die Position bleibt auch hier erhalten. Wer doch weitermacht, fängt
/// nicht von vorn an.
/// </remarks>
public bool Dismissed { get; set; }
public bool Favourite { get; set; }
/// <summary>Eine eigene Bewertung von 1 bis 10, oder keine.</summary>
public int? Rating { get; set; }
/// <summary>Ob dieser Titel in „Weiterschauen" gehört.</summary>
///
/// <remarks>
/// <b>Kein zweites Urteil, sondern das festgehaltene Ergebnis des
/// ersten.</b> Geschrieben wird es ausschliesslich aus
/// <see cref="ProgressRule.BelongsInContinueWatching"/>, bei jeder
/// Änderung an dieser Zeile.
///
/// Warum überhaupt eine Spalte: die Regel vergleicht die Stelle mit einem
/// Anteil der Laufzeit, und diese Rechnung lässt sich nicht nach SQL
/// übersetzen. Die Regel daneben ein zweites Mal als Abfrage zu schreiben
/// wäre die Variante, bei der beide irgendwann auseinanderlaufen — und
/// zwar unbemerkt, weil keine von beiden dabei falsch aussieht.
/// </remarks>
public bool InProgress { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+84
View File
@@ -2,6 +2,7 @@ using Flaro.Core;
using Flaro.Core.Auth;
using Flaro.Core.Libraries;
using Flaro.Core.Library;
using Flaro.Core.Playback;
using Flaro.Core.Users;
using Microsoft.EntityFrameworkCore;
@@ -32,6 +33,11 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<Series> Series => Set<Series>();
public DbSet<Episode> Episodes => Set<Episode>();
public DbSet<MediaStream> MediaStreams => Set<MediaStream>();
public DbSet<UserItemState> UserItemStates => Set<UserItemState>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Person> People => Set<Person>();
public DbSet<Credit> Credits => Set<Credit>();
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
///
@@ -122,6 +128,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512);
e.Property(x => x.ContentRating).HasMaxLength(16);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
@@ -133,6 +141,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512);
e.Property(x => x.ContentRating).HasMaxLength(16);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
@@ -150,6 +160,80 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Genre>(e =>
{
e.HasKey(x => x.Id);
// Ein Name genau einmal. „Science Fiction" zweimal in der Tabelle
// hiesse: ein Filter, der die Hälfte übersieht.
e.HasIndex(x => x.Name).IsUnique();
e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE");
});
b.Entity<GenreLink>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.GenreId, x.MovieId }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.GenreId, x.SeriesId }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL");
e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.SeriesId);
e.HasOne<Genre>().WithMany().HasForeignKey(x => x.GenreId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Person>(e =>
{
e.HasKey(x => x.Id);
// Über die Kennung der Quelle, nicht über den Namen: es gibt zwei
// Schauspieler namens „Chris Evans".
e.HasIndex(x => x.TmdbId).IsUnique();
e.Property(x => x.Name).HasMaxLength(256);
});
b.Entity<Credit>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.SeriesId);
e.HasIndex(x => x.PersonId);
e.Property(x => x.Character).HasMaxLength(256);
e.Property(x => x.Role).HasConversion<string>().HasMaxLength(16);
e.HasOne<Person>().WithMany().HasForeignKey(x => x.PersonId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<UserItemState>(e =>
{
e.HasKey(x => x.Id);
// **Je Benutzer und Titel genau eine Zeile.** Ohne diese Regel
// legen zwei Fortschrittsmeldungen, die sich überholen, zwei
// Zeilen an — und welche davon später gelesen wird, entscheidet
// der Zufall.
//
// Gefiltert, weil die jeweils andere Spalte leer ist: eine
// ungefilterte Regel über (UserId, MovieId) liesse zwar beliebig
// viele Folgen-Zeilen zu (NULL gilt als verschieden), aber das
// wäre Verlass auf eine Eigenheit statt auf eine Aussage.
e.HasIndex(x => new { x.UserId, x.MovieId }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
// Der Weg von „Weiterschauen": die offenen Zeilen eines
// Benutzers, neueste zuerst. Ohne Index ein vollständiger
// Durchlauf je Aufruf der Startseite.
e.HasIndex(x => new { x.UserId, x.InProgress, x.UpdatedAt });
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
// Verschwindet ein Film aus der Mediathek, verschwindet auch der
// Stand dazu. Ein Fortschritt zu einem Titel, den es nicht mehr
// gibt, ist keine Erinnerung, sondern Datenmüll.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Episode>(e =>
{
e.HasKey(x => x.Id);
+178 -10
View File
@@ -32,7 +32,18 @@ public sealed class MetadataEnricher(
ISidecarStore beiwerk,
ILogger<MetadataEnricher> log)
{
public async Task<EnrichReport> EnrichAsync(Guid libraryId, CancellationToken ct = default)
/// <param name="erneut">
/// <b>Auch holen, was schon zugeordnet ist.</b> Im Normalfall fragt ein
/// zweiter Lauf nichts erneut — das ist schneller und hält Handarbeit.
/// Aber wer die Sprache von Englisch auf Deutsch stellt, bekommt sonst
/// nie deutsche Titel: die Einstellung griffe erst beim nächsten neuen
/// Film. Dasselbe gilt, wenn eine Quelle Beschreibungen nachträgt.
///
/// <b>Nachgeholt wird über die bekannte Kennung, nicht über eine neue
/// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen.
/// </param>
public async Task<EnrichReport> EnrichAsync(
Guid libraryId, bool erneut = false, CancellationToken ct = default)
{
if (!await anbieter.IsConfiguredAsync(ct))
{
@@ -43,9 +54,9 @@ public sealed class MetadataEnricher(
int filme = 0, serien = 0, folgen = 0;
try
{
filme = await FilmeZuordnen(libraryId, ct);
serien = await SerienZuordnen(libraryId, ct);
folgen = await FolgenZuordnen(libraryId, ct);
filme = await FilmeZuordnen(libraryId, erneut, ct);
serien = await SerienZuordnen(libraryId, erneut, ct);
folgen = await FolgenZuordnen(libraryId, erneut, ct);
}
catch (MetadataCredentialsException fehler)
{
@@ -60,9 +71,11 @@ public sealed class MetadataEnricher(
return new EnrichReport(filme, serien, folgen, Skipped: false, null);
}
private async Task<int> FilmeZuordnen(Guid libraryId, CancellationToken ct)
private async Task<int> FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
{
var offen = await db.Movies.Where(m => m.LibraryId == libraryId && m.TmdbId == null).ToListAsync(ct);
var offen = await db.Movies
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null))
.ToListAsync(ct);
var zugeordnet = 0;
foreach (var film in offen)
@@ -91,7 +104,15 @@ public sealed class MetadataEnricher(
film.Overview = gefunden.Overview;
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
film.LogoImage = await BildAblegen(gefunden.Logo, ct);
film.RuntimeMinutes = gefunden.RuntimeMinutes;
film.Rating = gefunden.Rating;
film.Tagline = gefunden.Tagline;
film.ContentRating = gefunden.ContentRating;
film.MatchLocked = festgelegt;
await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct);
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
zugeordnet++;
if (datei is not null) await FilmBeiwerk(film, datei, ct);
@@ -121,9 +142,11 @@ public sealed class MetadataEnricher(
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
}
private async Task<int> SerienZuordnen(Guid libraryId, CancellationToken ct)
private async Task<int> SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
{
var offen = await db.Series.Where(s => s.LibraryId == libraryId && s.TmdbId == null).ToListAsync(ct);
var offen = await db.Series
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null))
.ToListAsync(ct);
var zugeordnet = 0;
foreach (var serie in offen)
@@ -153,6 +176,14 @@ public sealed class MetadataEnricher(
serie.Overview = gefunden.Overview;
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
serie.RuntimeMinutes = gefunden.RuntimeMinutes;
serie.Rating = gefunden.Rating;
serie.Tagline = gefunden.Tagline;
serie.ContentRating = gefunden.ContentRating;
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
serie.MatchLocked = festgelegt;
zugeordnet++;
@@ -199,7 +230,7 @@ public sealed class MetadataEnricher(
}
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
private async Task<int> FolgenZuordnen(Guid libraryId, CancellationToken ct)
private async Task<int> FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct)
{
var serien = await db.Series
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
@@ -209,7 +240,7 @@ public sealed class MetadataEnricher(
var kennungen = serien.Select(s => s.Id).ToList();
var offen = await db.Episodes
.Where(f => kennungen.Contains(f.SeriesId) && f.Overview == null)
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
.ToListAsync(ct);
var zugeordnet = 0;
@@ -236,6 +267,143 @@ public sealed class MetadataEnricher(
return zugeordnet;
}
/// <summary>Schreibt die Genres eines Titels — alte weg, neue hin.</summary>
///
/// <remarks>
/// <b>Ersetzen statt ergänzen.</b> Nach einer Korrektur von Hand ist es
/// ein anderer Film; die Genres des vorigen stehen zu lassen hiesse, aus
/// zwei Zuordnungen eine falsche dritte zu machen.
/// </remarks>
private async Task GenresSetzen(
IReadOnlyList<string>? namen, Guid? film, Guid? serie, CancellationToken ct)
{
await db.GenreLinks
.Where(l => film != null ? l.MovieId == film : l.SeriesId == serie)
.ExecuteDeleteAsync(ct);
if (namen is not { Count: > 0 }) return;
foreach (var name in namen)
{
db.GenreLinks.Add(new GenreLink
{
GenreId = await GenreKennung(name, ct), MovieId = film, SeriesId = serie,
});
}
}
/// <summary>Die Kennung eines Genres — angelegt, falls es sie nicht gibt.</summary>
///
/// <remarks>
/// <b>Sofort gespeichert, und mit einem zweiten Blick nach dem Fehlschlag.</b>
///
/// Der Scan läuft je Bibliothek, und vier Bibliotheken scannen gleichzeitig
/// — jede mit eigenem Datenbankkontext. Zwei davon stossen auf „Drama",
/// keine sieht die ungespeicherte Zeile der anderen, und beide legen sie
/// an. Am Ende scheitert der ganze Lauf an
/// <c>UNIQUE constraint failed: Genres.Name</c>, und zwar mitsamt allem,
/// was sonst noch im Stapel stand: in der Mediathek erschien kein
/// einziger Titel.
///
/// Statt die Läufe zu serialisieren — das kostete bei zehn Bibliotheken
/// das Zehnfache an Zeit — wird der Fehlschlag hingenommen und die Zeile
/// des anderen benutzt. Sie steht ja genau dafür da.
/// </remarks>
private async Task<Guid> GenreKennung(string name, CancellationToken ct)
{
var vorhanden = db.Genres.Local
.FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase))
?? await db.Genres.FirstOrDefaultAsync(g => g.Name == name, ct);
if (vorhanden is not null) return vorhanden.Id;
var neu = new Genre { Name = name };
db.Genres.Add(neu);
try
{
await db.SaveChangesAsync(ct);
return neu.Id;
}
catch (DbUpdateException)
{
// Ein anderer Lauf war schneller. Die eigene Zeile wieder
// vergessen, sonst versucht der nächste Speichervorgang sie
// erneut — und scheitert wieder.
db.Entry(neu).State = EntityState.Detached;
var fremd = await db.Genres.FirstOrDefaultAsync(g => g.Name == name, ct);
if (fremd is not null) return fremd.Id;
throw;
}
}
/// <summary>Schreibt Besetzung und Stab — ebenfalls ersetzend.</summary>
private async Task MitwirkendeSetzen(
IReadOnlyList<CreditMetadata>? mitwirkende, Guid? film, Guid? serie, CancellationToken ct)
{
await db.Credits
.Where(c => film != null ? c.MovieId == film : c.SeriesId == serie)
.ExecuteDeleteAsync(ct);
if (mitwirkende is not { Count: > 0 }) return;
foreach (var eintrag in mitwirkende)
{
db.Credits.Add(new Credit
{
PersonId = await PersonKennung(eintrag, ct),
MovieId = film,
SeriesId = serie,
Role = eintrag.Kind switch
{
CreditKind.Director => CreditRole.Director,
CreditKind.Writer => CreditRole.Writer,
_ => CreditRole.Actor,
},
Character = eintrag.Character,
Order = eintrag.Order,
});
}
}
/// <summary>Die Kennung einer Person — dieselbe Vorsicht wie beim Genre.</summary>
///
/// <remarks>
/// Dieselbe Lage: zwei Bibliotheken, zwei Kontexte, derselbe Schauspieler.
/// </remarks>
private async Task<Guid> PersonKennung(CreditMetadata eintrag, CancellationToken ct)
{
var vorhanden = db.People.Local.FirstOrDefault(p => p.TmdbId == eintrag.ProviderId)
?? await db.People.FirstOrDefaultAsync(p => p.TmdbId == eintrag.ProviderId, ct);
if (vorhanden is not null)
{
// Der Name kann sich ändern, und ein Porträt kann nachkommen. Ein
// vorhandenes gegen nichts zu tauschen wäre dagegen ein Rückschritt.
vorhanden.Name = eintrag.Name;
vorhanden.Image ??= await BildAblegen(eintrag.Portrait, ct);
return vorhanden.Id;
}
var neu = new Person
{
Name = eintrag.Name,
TmdbId = eintrag.ProviderId,
Image = await BildAblegen(eintrag.Portrait, ct),
};
db.People.Add(neu);
try
{
await db.SaveChangesAsync(ct);
return neu.Id;
}
catch (DbUpdateException)
{
db.Entry(neu).State = EntityState.Detached;
var fremd = await db.People.FirstOrDefaultAsync(p => p.TmdbId == eintrag.ProviderId, ct);
if (fremd is not null) return fremd.Id;
throw;
}
}
private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
@@ -0,0 +1,587 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261003192048_Sehfortschritt")]
partial class Sehfortschritt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Sehfortschritt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserItemStates",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
Position = table.Column<long>(type: "INTEGER", nullable: false),
Runtime = table.Column<long>(type: "INTEGER", nullable: true),
Watched = table.Column<bool>(type: "INTEGER", nullable: false),
PlayCount = table.Column<int>(type: "INTEGER", nullable: false),
LastPlayedAt = table.Column<long>(type: "INTEGER", nullable: true),
Dismissed = table.Column<bool>(type: "INTEGER", nullable: false),
Favourite = table.Column<bool>(type: "INTEGER", nullable: false),
Rating = table.Column<int>(type: "INTEGER", nullable: true),
InProgress = table.Column<bool>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserItemStates", x => x.Id);
table.ForeignKey(
name: "FK_UserItemStates_Episodes_EpisodeId",
column: x => x.EpisodeId,
principalTable: "Episodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserItemStates_Movies_MovieId",
column: x => x.MovieId,
principalTable: "Movies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserItemStates_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UserItemStates_EpisodeId",
table: "UserItemStates",
column: "EpisodeId");
migrationBuilder.CreateIndex(
name: "IX_UserItemStates_MovieId",
table: "UserItemStates",
column: "MovieId");
migrationBuilder.CreateIndex(
name: "IX_UserItemStates_UserId_EpisodeId",
table: "UserItemStates",
columns: new[] { "UserId", "EpisodeId" },
unique: true,
filter: "\"EpisodeId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UserItemStates_UserId_InProgress_UpdatedAt",
table: "UserItemStates",
columns: new[] { "UserId", "InProgress", "UpdatedAt" });
migrationBuilder.CreateIndex(
name: "IX_UserItemStates_UserId_MovieId",
table: "UserItemStates",
columns: new[] { "UserId", "MovieId" },
unique: true,
filter: "\"MovieId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserItemStates");
}
}
}
@@ -0,0 +1,774 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261003210324_Katalog")]
partial class Katalog
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,279 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Katalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ContentRating",
table: "Series",
type: "TEXT",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LogoImage",
table: "Series",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Rating",
table: "Series",
type: "REAL",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RuntimeMinutes",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Tagline",
table: "Series",
type: "TEXT",
maxLength: 512,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ContentRating",
table: "Movies",
type: "TEXT",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LogoImage",
table: "Movies",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Rating",
table: "Movies",
type: "REAL",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "RuntimeMinutes",
table: "Movies",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Tagline",
table: "Movies",
type: "TEXT",
maxLength: 512,
nullable: true);
migrationBuilder.CreateTable(
name: "Genres",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false, collation: "NOCASE")
},
constraints: table =>
{
table.PrimaryKey("PK_Genres", x => x.Id);
});
migrationBuilder.CreateTable(
name: "People",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
TmdbId = table.Column<int>(type: "INTEGER", nullable: false),
Image = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_People", x => x.Id);
});
migrationBuilder.CreateTable(
name: "GenreLinks",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
GenreId = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_GenreLinks", x => x.Id);
table.ForeignKey(
name: "FK_GenreLinks_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GenreLinks_Movies_MovieId",
column: x => x.MovieId,
principalTable: "Movies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GenreLinks_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Credits",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
PersonId = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
Role = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
Character = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Order = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Credits", x => x.Id);
table.ForeignKey(
name: "FK_Credits_Movies_MovieId",
column: x => x.MovieId,
principalTable: "Movies",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Credits_People_PersonId",
column: x => x.PersonId,
principalTable: "People",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Credits_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Credits_MovieId",
table: "Credits",
column: "MovieId");
migrationBuilder.CreateIndex(
name: "IX_Credits_PersonId",
table: "Credits",
column: "PersonId");
migrationBuilder.CreateIndex(
name: "IX_Credits_SeriesId",
table: "Credits",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_GenreLinks_GenreId_MovieId",
table: "GenreLinks",
columns: new[] { "GenreId", "MovieId" },
unique: true,
filter: "\"MovieId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_GenreLinks_GenreId_SeriesId",
table: "GenreLinks",
columns: new[] { "GenreId", "SeriesId" },
unique: true,
filter: "\"SeriesId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_GenreLinks_MovieId",
table: "GenreLinks",
column: "MovieId");
migrationBuilder.CreateIndex(
name: "IX_GenreLinks_SeriesId",
table: "GenreLinks",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_Genres_Name",
table: "Genres",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_People_TmdbId",
table: "People",
column: "TmdbId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Credits");
migrationBuilder.DropTable(
name: "GenreLinks");
migrationBuilder.DropTable(
name: "People");
migrationBuilder.DropTable(
name: "Genres");
migrationBuilder.DropColumn(
name: "ContentRating",
table: "Series");
migrationBuilder.DropColumn(
name: "LogoImage",
table: "Series");
migrationBuilder.DropColumn(
name: "Rating",
table: "Series");
migrationBuilder.DropColumn(
name: "RuntimeMinutes",
table: "Series");
migrationBuilder.DropColumn(
name: "Tagline",
table: "Series");
migrationBuilder.DropColumn(
name: "ContentRating",
table: "Movies");
migrationBuilder.DropColumn(
name: "LogoImage",
table: "Movies");
migrationBuilder.DropColumn(
name: "Rating",
table: "Movies");
migrationBuilder.DropColumn(
name: "RuntimeMinutes",
table: "Movies");
migrationBuilder.DropColumn(
name: "Tagline",
table: "Movies");
}
}
}
@@ -137,6 +137,44 @@ namespace Flaro.Data.Migrations
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
@@ -173,6 +211,58 @@ namespace Flaro.Data.Migrations
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
@@ -291,6 +381,10 @@ namespace Flaro.Data.Migrations
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
@@ -298,6 +392,9 @@ namespace Flaro.Data.Migrations
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
@@ -314,11 +411,21 @@ namespace Flaro.Data.Migrations
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
@@ -337,6 +444,31 @@ namespace Flaro.Data.Migrations
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
@@ -349,6 +481,10 @@ namespace Flaro.Data.Migrations
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
@@ -356,6 +492,9 @@ namespace Flaro.Data.Migrations
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
@@ -372,11 +511,21 @@ namespace Flaro.Data.Migrations
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
@@ -395,6 +544,70 @@ namespace Flaro.Data.Migrations
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
@@ -451,6 +664,25 @@ namespace Flaro.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
@@ -460,6 +692,25 @@ namespace Flaro.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -495,6 +746,25 @@ namespace Flaro.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
+245 -33
View File
@@ -38,6 +38,19 @@ public sealed class TmdbClient(
private const string PosterSize = "w500";
private const string BackdropSize = "w1280";
private const string StillSize = "w300";
/// <summary>Porträts sind klein und viele — ein Kopf je Kachel.</summary>
private const string ProfileSize = "w185";
/// <summary>Schriftzüge sind breit und flach — w500 reicht auch gross.</summary>
private const string LogoSize = "w500";
/// <summary>Wie viele Darsteller je Titel übernommen werden.</summary>
///
/// <remarks>
/// TMDB nennt bei grossen Produktionen über hundert. Eine Besetzungsliste,
/// die man scrollen muss, bis die Statisten kommen, hilft niemandem — und
/// jede Zeile ist ein Porträt, das heruntergeladen und abgelegt wird.
/// </remarks>
private const int HöchstensDarsteller = 20;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
@@ -58,7 +71,13 @@ public sealed class TmdbClient(
{
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct);
if (treffer is null) return null;
// **Der Treffer der Suche taugt nur als Kennung.** `search/movie`
// liefert weder Laufzeit noch Genres noch Besetzung; dafür braucht es
// den Einzelabruf. Der kostet eine Anfrage — dieselbe, die vorher
// ohnehin für die IMDb-Kennung fällig war.
return await GetMovieAsync(treffer.Id, ct) ?? Als(treffer, title);
}
public async Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
@@ -74,9 +93,36 @@ public sealed class TmdbClient(
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
// **Alles in einer Anfrage.** `append_to_response` hängt Besetzung,
// Fremdkennungen und Freigaben an den Einzelabruf. Drei getrennte
// Anfragen je Titel wären bei vierhundert Filmen zwölfhundert — und
// TMDB begrenzt, wie oft man fragen darf.
var film = await HolenAsync<TmdbMovie>(
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
"&append_to_response=credits,external_ids,release_dates,images" +
// **Sprachlos ist erwünscht.** Viele Schriftzüge tragen gar keine
// Sprache (`null`), weil sie keinen Text enthalten, der übersetzt
// werden müsste. Ohne diese Angabe liefert TMDB sie nicht mit.
$"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null",
einstellung, ct);
// **Kennung null gibt es bei TMDB nicht.** Antwortet etwas anderes
// als der erwartete Einzelabruf — ein Anmeldeportal, ein Proxy, eine
// Trefferliste —, liest der Dekoder daraus eine Null. Das als Treffer
// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen.
if (film is null or { Id: 0 }) return null;
return Als(film, "") with
{
ImdbId = Leer(film.ExternalIds?.ImdbId),
RuntimeMinutes = film.Runtime > 0 ? film.Runtime : null,
Rating = film.VoteAverage > 0 ? film.VoteAverage : null,
Tagline = Leer(film.Tagline),
ContentRating = Freigabe(film.ReleaseDates, Land(einstellung.Language)),
Genres = Namen(film.Genres),
Credits = Mitwirkende(film.Credits),
Logo = Schriftzug(film.Images, Sprachkürzel(einstellung.Language)),
};
}
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
@@ -87,7 +133,8 @@ public sealed class TmdbClient(
{
var treffer = await SuchenAsync<TmdbSeries>("search/tv", title,
year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct);
return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
if (treffer is null) return null;
return await GetSeriesAsync(treffer.Id, ct) ?? Als(treffer, title);
}
public async Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
@@ -104,39 +151,34 @@ public sealed class TmdbClient(
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var serie = await HolenAsync<TmdbSeries>(
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct);
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
"&append_to_response=credits,external_ids,content_ratings,images" +
$"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null",
einstellung, ct);
if (serie is null or { Id: 0 }) return null;
return Als(serie, "") with
{
ImdbId = Leer(serie.ExternalIds?.ImdbId),
// Die übliche Länge einer Folge, nicht die der ganzen Serie.
RuntimeMinutes = serie.EpisodeRunTime?.FirstOrDefault(l => l > 0),
Rating = serie.VoteAverage > 0 ? serie.VoteAverage : null,
Tagline = Leer(serie.Tagline),
ContentRating = Freigabe(serie.ContentRatings, Land(einstellung.Language)),
Genres = Namen(serie.Genres),
Credits = Mitwirkende(serie.Credits),
Logo = Schriftzug(serie.Images, Sprachkürzel(einstellung.Language)),
};
}
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
/// <summary>Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.</summary>
///
/// <remarks>
/// <b>Die Suche bei TMDB liefert sie nicht mit.</b> Gebraucht wird sie für
/// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet
/// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht
/// kennt.
///
/// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den
/// ganzen Treffer wegzuwerfen.
/// </remarks>
private async Task<MovieMetadata> MitImdb(MovieMetadata film, string art, CancellationToken ct) =>
film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) };
private async Task<SeriesMetadata> MitImdb(SeriesMetadata serie, string art, CancellationToken ct) =>
serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) };
private async Task<string?> ImdbAsync(string art, int kennung, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var fremde = await HolenAsync<TmdbExternalIds>($"{art}/{kennung}/external_ids", einstellung, ct);
return Leer(fremde?.ImdbId);
}
// Der eigene Abruf von `external_ids` ist entfallen: die Kennung hängt
// jetzt über `append_to_response` am Einzelabruf. Das spart eine Anfrage
// je Titel — bei vierhundert Filmen vierhundert.
public async Task<EpisodeMetadata?> FindEpisodeAsync(
int seriesProviderId, int season, int episode, CancellationToken ct = default)
@@ -261,6 +303,115 @@ public sealed class TmdbClient(
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
}
/// <summary>Das Land aus einer Sprachangabe wie „de-DE".</summary>
///
/// <remarks>
/// Freigaben sind national: die FSK gilt in Deutschland, die MPAA in den
/// USA, und TMDB führt beide nebeneinander. Steht kein Land dabei, ist
/// „US" die Angabe, die am häufigsten gefüllt ist — besser als keine.
/// </remarks>
private static string Land(string sprache)
{
var teile = sprache.Split('-');
return teile.Length > 1 && teile[1].Length == 2 ? teile[1].ToUpperInvariant() : "US";
}
/// <summary>„de" aus „de-DE".</summary>
private static string Sprachkürzel(string sprache) =>
sprache.Split('-')[0].ToLowerInvariant();
/// <summary>Der Schriftzug, möglichst in der eingestellten Sprache.</summary>
///
/// <remarks>
/// <b>Die Reihenfolge ist die Aussage.</b> Erst die eigene Sprache — ein
/// deutscher Filmtitel in deutscher Schrift —, dann der ohne Sprache
/// (meist ein reines Bildzeichen, überall richtig), dann Englisch. Erst
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
/// um Bildqualität geht und nicht mehr um die Sprache.
/// </remarks>
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
{
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
if (alle is not { Count: > 0 }) return null;
var beste = alle
.OrderBy(l => string.Equals(l.Language, sprache, StringComparison.OrdinalIgnoreCase) ? 0
: string.IsNullOrEmpty(l.Language) ? 1
: string.Equals(l.Language, "en", StringComparison.OrdinalIgnoreCase) ? 2
: 3)
.ThenByDescending(l => l.VoteAverage ?? 0)
.First();
return Bild(beste.FilePath, LogoSize);
}
private static string? Freigabe(TmdbReleaseDates? quelle, string land) =>
Leer(quelle?.Results?
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
.Releases?
.Select(r => r.Certification)
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)));
private static string? Freigabe(TmdbContentRatings? quelle, string land) =>
Leer(quelle?.Results?
.FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))?
.Rating);
private static IReadOnlyList<string>? Namen(List<TmdbGenre>? genres)
{
var liste = genres?
.Select(g => g.Name?.Trim())
.Where(n => !string.IsNullOrEmpty(n))
.Select(n => n!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return liste is { Count: > 0 } ? liste : null;
}
/// <summary>Darsteller, Regie und Drehbuch — in dieser Reihenfolge.</summary>
///
/// <remarks>
/// <b>Aus dem Stab nur zwei Rollen.</b> TMDB führt dort auch Beleuchter
/// und Caterer; eine Detailseite, die alle nennt, nennt nichts. Regie und
/// Drehbuch sind das, wonach jemand sucht.
/// </remarks>
private static IReadOnlyList<CreditMetadata>? Mitwirkende(TmdbCredits? quelle)
{
if (quelle is null) return null;
var liste = new List<CreditMetadata>();
foreach (var d in (quelle.Cast ?? []).OrderBy(c => c.Order ?? int.MaxValue).Take(HöchstensDarsteller))
{
if (string.IsNullOrWhiteSpace(d.Name)) continue;
liste.Add(new CreditMetadata(
d.Id, d.Name.Trim(), CreditKind.Actor, Leer(d.Character),
Bild(d.ProfilePath, ProfileSize), d.Order ?? liste.Count));
}
var stab = 0;
foreach (var m in quelle.Crew ?? [])
{
var rolle = m.Job?.Trim() switch
{
"Director" => CreditKind.Director,
"Writer" or "Screenplay" or "Story" => CreditKind.Writer,
_ => (CreditKind?)null,
};
if (rolle is not { } art || string.IsNullOrWhiteSpace(m.Name)) continue;
// Dieselbe Person steht im Stab mehrfach, wenn sie mehrere
// Aufgaben hatte — „Drehbuch" und „Story" sind bei TMDB zwei
// Zeilen und auf einer Detailseite ein Name.
if (liste.Any(e => e.ProviderId == m.Id && e.Kind == art)) continue;
liste.Add(new CreditMetadata(
m.Id, m.Name.Trim(), art, null, Bild(m.ProfilePath, ProfileSize), stab++));
}
return liste.Count > 0 ? liste : null;
}
private static RemoteImage? Bild(string? pfad, string grösse) =>
string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}");
@@ -280,7 +431,16 @@ public sealed class TmdbClient(
[property: JsonPropertyName("release_date")] string? ReleaseDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
[property: JsonPropertyName("backdrop_path")] string? BackdropPath,
// Die folgenden liefert nur der Einzelabruf, nicht die Suche.
[property: JsonPropertyName("runtime")] int? Runtime = null,
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
[property: JsonPropertyName("tagline")] string? Tagline = null,
[property: JsonPropertyName("genres")] List<TmdbGenre>? Genres = null,
[property: JsonPropertyName("credits")] TmdbCredits? Credits = null,
[property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null,
[property: JsonPropertyName("release_dates")] TmdbReleaseDates? ReleaseDates = null,
[property: JsonPropertyName("images")] TmdbImages? Images = null);
private sealed record TmdbSeries(
[property: JsonPropertyName("id")] int Id,
@@ -288,7 +448,59 @@ public sealed class TmdbClient(
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
[property: JsonPropertyName("backdrop_path")] string? BackdropPath,
[property: JsonPropertyName("episode_run_time")] List<int>? EpisodeRunTime = null,
[property: JsonPropertyName("vote_average")] double? VoteAverage = null,
[property: JsonPropertyName("tagline")] string? Tagline = null,
[property: JsonPropertyName("genres")] List<TmdbGenre>? Genres = null,
[property: JsonPropertyName("credits")] TmdbCredits? Credits = null,
[property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null,
[property: JsonPropertyName("content_ratings")] TmdbContentRatings? ContentRatings = null,
[property: JsonPropertyName("images")] TmdbImages? Images = null);
private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name);
private sealed record TmdbImages(
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
private sealed record TmdbImage(
[property: JsonPropertyName("file_path")] string? FilePath,
[property: JsonPropertyName("iso_639_1")] string? Language,
[property: JsonPropertyName("vote_average")] double? VoteAverage);
private sealed record TmdbCredits(
[property: JsonPropertyName("cast")] List<TmdbCast>? Cast,
[property: JsonPropertyName("crew")] List<TmdbCrew>? Crew);
private sealed record TmdbCast(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("character")] string? Character,
[property: JsonPropertyName("profile_path")] string? ProfilePath,
[property: JsonPropertyName("order")] int? Order);
private sealed record TmdbCrew(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("job")] string? Job,
[property: JsonPropertyName("profile_path")] string? ProfilePath);
private sealed record TmdbReleaseDates(
[property: JsonPropertyName("results")] List<TmdbReleaseCountry>? Results);
private sealed record TmdbReleaseCountry(
[property: JsonPropertyName("iso_3166_1")] string? Country,
[property: JsonPropertyName("release_dates")] List<TmdbRelease>? Releases);
private sealed record TmdbRelease(
[property: JsonPropertyName("certification")] string? Certification);
private sealed record TmdbContentRatings(
[property: JsonPropertyName("results")] List<TmdbContentRating>? Results);
private sealed record TmdbContentRating(
[property: JsonPropertyName("iso_3166_1")] string? Country,
[property: JsonPropertyName("rating")] string? Rating);
private sealed record TmdbEpisode(
[property: JsonPropertyName("name")] string? Name,
@@ -104,7 +104,10 @@ public class CodeSignInTests
await server.SetUpAndSignIn();
var angefordert = await (await CodeAnfordern(server)).Content.ReadFromJsonAsync<CodeResponse>();
var versuch = await server.Client.PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
// den Angemeldeten — und bestünde nie wieder.
var versuch = await server.OhneGedaechtnis().PostAsync($"/api/v1/auth/code/{angefordert!.Code}", null);
Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
var danach = await server.Client.GetFromJsonAsync<CodeState>($"/api/v1/auth/code/{angefordert.Handle}");
@@ -0,0 +1,204 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Wer gerade was schaut.</summary>
public class DashboardTests
{
private string _bestand = "";
[SetUp]
public void Anlegen() => _bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-dash-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
}
private void Datei(string relativ)
{
var voll = Path.Combine(_bestand, relativ);
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
File.WriteAllBytes(voll, new byte[2048]);
}
private async Task<HttpClient> Eingelesen(FlaroServer server, string art = "Movies")
{
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", art, _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State is "finished") return client;
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
await Task.Delay(50);
}
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
return null!;
}
private static async Task<Guid> ErsterFilm(HttpClient client) =>
(await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Items[0].Id;
/// <summary>Die Fortschrittsmeldung ist zugleich das Lebenszeichen.</summary>
///
/// <remarks>
/// Kein zweites Verfahren, das ein Client vergessen könnte: er meldet
/// beim Abspielen ohnehin alle paar Sekunden, wo er steht.
/// </remarks>
[Test]
public async Task Eine_Fortschrittsmeldung_erscheint_im_Dashboard()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
new ProgressRequest(600, 7200));
var laufend = await client.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
Assert.Multiple(() =>
{
Assert.That(laufend, Has.Count.EqualTo(1));
Assert.That(laufend![0].Title, Is.EqualTo("Matrix"));
Assert.That(laufend[0].UserName, Is.EqualTo("guido"));
Assert.That(laufend[0].DeviceName, Is.EqualTo("iPhone"));
Assert.That(laufend[0].PositionSeconds, Is.EqualTo(600));
});
}
/// <summary>Beendet heisst sofort weg, nicht erst nach einer Minute.</summary>
///
/// <remarks>
/// Ohne das stünde ein beendeter Film noch eine Minute als „läuft" — und
/// wer gerade den Spieler geschlossen hat, sähe sich selbst beim Schauen
/// zu.
/// </remarks>
[Test]
public async Task Eine_beendete_Wiedergabe_verschwindet_sofort()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(600, 7200));
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
new ProgressRequest(900, 7200, Stopped: true));
var laufend = await client.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
Assert.That(laufend, Is.Empty);
// Der Fortschritt bleibt trotzdem erhalten — nur das „läuft gerade"
// ist vorbei.
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(900));
}
/// <summary>Eine Folge nennt Serie und Nummer, nicht nur den Folgentitel.</summary>
[Test]
public async Task Eine_Folge_erscheint_mit_Serie_und_Nummer()
{
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
await client.PostAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/progress",
new ProgressRequest(400, 2700));
var laufend = await client.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
Assert.Multiple(() =>
{
Assert.That(laufend![0].Title, Is.EqualTo("Dark"));
Assert.That(laufend[0].SubTitle, Does.Contain("S1").And.Contain("F2"));
});
}
/// <summary>Wer wann was schaut, geht nicht jeden an.</summary>
[Test]
public async Task Nur_Administratoren_sehen_das_Dashboard()
{
using var server = new FlaroServer();
var admin = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
(await admin.PostAsJsonAsync("/api/v1/users",
new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
var gast = server.AlsAngemeldeter(
(await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token);
Assert.Multiple(async () =>
{
Assert.That((await gast.GetAsync("/api/v1/system/sessions")).StatusCode,
Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That((await gast.GetAsync("/api/v1/system/status")).StatusCode,
Is.EqualTo(HttpStatusCode.Forbidden));
});
}
/// <summary>Zwei Geräte desselben Menschen sind zwei Wiedergaben.</summary>
///
/// <remarks>
/// Derselbe Mensch kann auf dem Fernseher und auf dem Telefon
/// gleichzeitig etwas laufen lassen; beides gehört ins Dashboard.
/// </remarks>
[Test]
public async Task Zwei_Geraete_erscheinen_getrennt()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var telefon = await Eingelesen(server);
var film = await ErsterFilm(telefon);
var fernseher = server.AlsAngemeldeter(
(await server.SignIn("guido", "ein-langes-passwort", "Apple TV", "geraet-tv")).Token);
await telefon.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(100, 7200));
await fernseher.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(3000, 7200));
var laufend = await telefon.GetFromJsonAsync<List<SessionResponse>>("/api/v1/system/sessions");
Assert.Multiple(() =>
{
Assert.That(laufend, Has.Count.EqualTo(2));
Assert.That(laufend!.Select(w => w.DeviceName),
Is.EquivalentTo(new[] { "iPhone", "Apple TV" }));
});
}
[Test]
public async Task Der_Serverzustand_zaehlt_was_gerade_laeuft()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(60, 7200));
var stand = await client.GetFromJsonAsync<ServerStatusResponse>("/api/v1/system/status");
Assert.Multiple(() =>
{
Assert.That(stand!.Playing, Is.EqualTo(1));
Assert.That(stand.Scanning, Is.Zero, "der Scan ist längst durch");
Assert.That(stand.Uptime, Is.GreaterThan(0));
});
}
}
+28 -1
View File
@@ -180,13 +180,40 @@ public class DeliveryTests
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
/// <summary>Der Client fragt mit HEAD nach, ob das Umpacken fertig ist.</summary>
///
/// <remarks>
/// <b>Der Fehler, den erst der Browser zeigte.</b> <c>MapGet</c> nimmt
/// HEAD nicht an. Die Anfrage fiel durch bis zum SPA-Rückfall, bekam
/// <c>200 text/html</c>, und der Spieler hielt die Webseite für den Film:
/// „MEDIA_ELEMENT_ERROR: Format error", 531 Byte lang.
/// </remarks>
[Test]
public async Task HEAD_auf_den_Download_antwortet_mit_dem_Download()
{
Film("Kopfpruefung (2021)", "mp4");
var (client, datei) = await Eingelesen();
var antwort = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Head, $"/api/v1/files/{datei.Id}/download"));
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(antwort.Content.Headers.ContentType?.MediaType, Is.EqualTo("video/mp4"));
});
}
[Test]
public async Task Ohne_Anmeldung_gibt_es_nichts()
{
Film("Geschuetzt (2020)", "mp4");
var (_, datei) = await Eingelesen();
var antwort = await _server!.Client.GetAsync($"/api/v1/files/{datei.Id}/download");
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
// den Angemeldeten — und bestünde nie wieder.
var antwort = await _server!.OhneGedaechtnis().GetAsync($"/api/v1/files/{datei.Id}/download");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
}
+24 -3
View File
@@ -1,6 +1,7 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -24,7 +25,12 @@ public sealed class FlaroServer : IDisposable
public HttpClient Client { get; }
public FlaroServer(Action<IServiceCollection>? dienste = null)
/// <param name="inhaltswurzel">
/// Ein anderes Stammverzeichnis für den Server — nur nötig, wenn ein Test
/// eine ausgelieferte Oberfläche braucht, denn die sucht der Server unter
/// <c>wwwroot</c> neben sich.
/// </param>
public FlaroServer(Action<IServiceCollection>? dienste = null, string? inhaltswurzel = null)
{
_verzeichnis = Path.Combine(Path.GetTempPath(), "flaro-test-" + Guid.CreateVersion7().ToString("N"));
Environment.SetEnvironmentVariable("FLARO_DATA", Path.Combine(_verzeichnis, "config"));
@@ -36,7 +42,11 @@ public sealed class FlaroServer : IDisposable
Environment.SetEnvironmentVariable("FLARO_RATELIMIT_SIGNIN", "10000");
_grundlage = new WebApplicationFactory<Program>();
_fabrik = _grundlage.WithWebHostBuilder(b => b.ConfigureServices(sammlung =>
_fabrik = _grundlage.WithWebHostBuilder(b =>
{
if (inhaltswurzel is not null) b.UseContentRoot(inhaltswurzel);
b.ConfigureServices(sammlung =>
{
// **Ohne das dauert der ganze Lauf eine Viertelstunde.** Ein Host
// wartet beim Herunterfahren fünf Sekunden auf seine Dienste, und
@@ -56,7 +66,8 @@ public sealed class FlaroServer : IDisposable
sammlung.AddScoped<IMetadataProvider, AbgeschalteterAnbieter>();
dienste?.Invoke(sammlung);
}));
});
});
Client = _fabrik.CreateClient();
}
@@ -84,6 +95,16 @@ public sealed class FlaroServer : IDisposable
return client;
}
/// <summary>Ein Client, der Cookies weder behält noch mitschickt.</summary>
///
/// <remarks>
/// Der gewöhnliche Testclient führt im Hintergrund einen Cookie-Behälter
/// mit. Wer prüfen will, <i>ob</i> ein Cookie wirkt, darf sich nicht
/// darauf verlassen — sonst prüft er den Behälter, nicht den Server.
/// </remarks>
public HttpClient OhneGedaechtnis() =>
_fabrik.CreateClient(new WebApplicationFactoryClientOptions { HandleCookies = false });
public void Dispose()
{
_fabrik.Dispose();
@@ -383,7 +383,10 @@ public class ImageEndpointTests
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.Client.GetAsync($"/api/v1/images/{new string('a', 64)}");
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
// den Angemeldeten — und bestünde nie wieder.
var antwort = await server.OhneGedaechtnis().GetAsync($"/api/v1/images/{new string('a', 64)}");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -0,0 +1,247 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Mehrere Mediatheken nebeneinander — wie bei Plex und Jellyfin.</summary>
///
/// <remarks>
/// <b>Zwei Filmbibliotheken sind keine doppelte Filmbibliothek.</b> „Filme"
/// und „Dokumentationen" liegen in verschiedenen Ordnern, gehören in der
/// Seitenleiste getrennt und dürfen sich in keiner Liste vermischen. Dass
/// das Modell es kann, hiess bisher nicht, dass es geprüft war.
/// </remarks>
public class MultipleLibraryTests
{
private readonly List<string> _ordner = [];
[TearDown]
public void Aufraeumen()
{
foreach (var o in _ordner)
{
try { Directory.Delete(o, recursive: true); } catch (IOException) { /* Kür */ }
}
_ordner.Clear();
}
/// <summary>Ein Ordner mit den genannten Dateien darin.</summary>
private string Bestand(params string[] dateien)
{
var wurzel = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-mehrere-" + Guid.CreateVersion7().ToString("N"))).FullName;
_ordner.Add(wurzel);
foreach (var relativ in dateien)
{
var voll = Path.Combine(wurzel, relativ);
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
File.WriteAllBytes(voll, new byte[2048]);
}
return wurzel;
}
private static async Task<LibraryResponse> Anlegen(
HttpClient client, string name, string art, string pfad)
{
var antwort = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest(name, art, pfad));
antwort.EnsureSuccessStatusCode();
return (await antwort.Content.ReadFromJsonAsync<LibraryResponse>())!;
}
private static async Task Scannen(HttpClient client, Guid bibliothek)
{
(await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek}/scan");
if (stand!.State is "finished") return;
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
await Task.Delay(50);
}
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
}
[Test]
public async Task Zwei_Filmbibliotheken_bleiben_getrennt()
{
var spielfilme = Bestand("Matrix (1999)/Matrix (1999).mkv", "Inception (2010)/Inception (2010).mkv");
var dokus = Bestand("Winged Migration (2001)/Winged Migration (2001).mkv");
using var server = new FlaroServer();
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var a = await Anlegen(client, "Filme", "Movies", spielfilme);
var b = await Anlegen(client, "Dokumentationen", "Movies", dokus);
await Scannen(client, a.Id);
await Scannen(client, b.Id);
var inA = await client.GetFromJsonAsync<Page<MovieResponse>>($"/api/v1/movies?library={a.Id}");
var inB = await client.GetFromJsonAsync<Page<MovieResponse>>($"/api/v1/movies?library={b.Id}");
var alle = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.Multiple(() =>
{
Assert.That(inA!.Total, Is.EqualTo(2));
Assert.That(inB!.Total, Is.EqualTo(1));
Assert.That(inB.Items[0].Title, Is.EqualTo("Winged Migration"));
// Ohne Einschränkung alle zusammen — für die Suche quer durch alles.
Assert.That(alle!.Total, Is.EqualTo(3));
});
}
/// <summary>Die Seitenleiste braucht zu jeder Zeile eine Zahl.</summary>
[Test]
public async Task Die_Liste_nennt_je_Bibliothek_die_Anzahl()
{
var filme = Bestand("Matrix (1999)/Matrix (1999).mkv");
var serien = Bestand("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv",
"Dark/Season 01/Dark - S01E02 - Luegen.mkv");
var leer = Bestand();
using var server = new FlaroServer();
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var a = await Anlegen(client, "Filme", "Movies", filme);
var b = await Anlegen(client, "Serien", "Shows", serien);
await Anlegen(client, "Noch nichts drin", "Movies", leer);
await Scannen(client, a.Id);
await Scannen(client, b.Id);
var liste = await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries");
var nachNamen = liste!.ToDictionary(l => l.Name);
Assert.Multiple(() =>
{
Assert.That(nachNamen["Filme"].ItemCount, Is.EqualTo(1));
// **Serien, nicht Folgen.** Zwei Folgen einer Serie sind eine Zeile.
Assert.That(nachNamen["Serien"].ItemCount, Is.EqualTo(1));
Assert.That(nachNamen["Noch nichts drin"].ItemCount, Is.Zero);
});
}
[Test]
public async Task Eine_Bibliothek_laesst_sich_umbenennen()
{
using var server = new FlaroServer();
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var angelegt = await Anlegen(client, "Flime", "Movies", Bestand());
var antwort = await client.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}",
new RenameLibraryRequest("Filme"));
var geaendert = await antwort.Content.ReadFromJsonAsync<LibraryResponse>();
Assert.Multiple(() =>
{
Assert.That(geaendert!.Name, Is.EqualTo("Filme"));
Assert.That(geaendert.Path, Is.EqualTo(angelegt.Path), "der Pfad bleibt, wo er war");
});
}
[Test]
public async Task Ein_leerer_Name_wird_abgelehnt()
{
using var server = new FlaroServer();
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var angelegt = await Anlegen(client, "Filme", "Movies", Bestand());
var antwort = await client.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}",
new RenameLibraryRequest(" "));
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
/// <summary>Entfernt wird der Eintrag — nicht der Film auf der Platte.</summary>
///
/// <remarks>
/// <b>Das ist die wichtigste Zusage dieses Servers.</b> Ein Server, der
/// beim Aufräumen in der Oberfläche fremde Mediendateien löscht, ist
/// einer, den man nicht noch einmal benutzt.
/// </remarks>
[Test]
public async Task Loeschen_nimmt_die_Eintraege_und_laesst_die_Dateien_liegen()
{
var ordner = Bestand("Matrix (1999)/Matrix (1999).mkv");
var datei = Path.Combine(ordner, "Matrix (1999)", "Matrix (1999).mkv");
using var server = new FlaroServer();
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var angelegt = await Anlegen(client, "Filme", "Movies", ordner);
await Scannen(client, angelegt.Id);
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total,
Is.EqualTo(1), "vorher ist er da");
var geloescht = await client.DeleteAsync($"/api/v1/libraries/{angelegt.Id}");
Assert.Multiple(() =>
{
Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(File.Exists(datei), Is.True, "die Datei liegt noch da");
});
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var liste = await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries");
Assert.Multiple(() =>
{
Assert.That(danach!.Total, Is.Zero);
Assert.That(liste, Is.Empty);
});
}
[Test]
public async Task Nur_Administratoren_duerfen_loeschen_und_umbenennen()
{
using var server = new FlaroServer();
var admin = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var angelegt = await Anlegen(admin, "Filme", "Movies", Bestand());
(await admin.PostAsJsonAsync("/api/v1/users",
new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
var gast = server.AlsAngemeldeter(
(await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token);
var umbenannt = await gast.PutAsJsonAsync($"/api/v1/libraries/{angelegt.Id}",
new RenameLibraryRequest("Meins jetzt"));
var geloescht = await gast.DeleteAsync($"/api/v1/libraries/{angelegt.Id}");
Assert.Multiple(() =>
{
Assert.That(umbenannt.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
Assert.That(geloescht.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
});
}
/// <summary>„Zuletzt hinzugefügt" braucht eine andere Reihenfolge als A–Z.</summary>
[Test]
public async Task Nach_Aufnahme_sortiert_steht_das_Neueste_oben()
{
var ordner = Bestand("Zzz Zuletzt (2024)/Zzz Zuletzt (2024).mkv");
using var server = new FlaroServer();
var client = server.AlsAngemeldeter((await server.SetUpAndSignIn()).Token);
var angelegt = await Anlegen(client, "Filme", "Movies", ordner);
await Scannen(client, angelegt.Id);
// Ein zweiter Film, nachweislich später aufgenommen.
var voll = Path.Combine(ordner, "Aaa Zuerst (1999)", "Aaa Zuerst (1999).mkv");
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
await File.WriteAllBytesAsync(voll, new byte[2048]);
await Task.Delay(1100);
await Scannen(client, angelegt.Id);
var alphabetisch = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var nachAufnahme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies?sort=added");
Assert.Multiple(() =>
{
Assert.That(alphabetisch!.Items.Select(m => m.Title),
Is.EqualTo(new[] { "Aaa Zuerst", "Zzz Zuletzt" }));
Assert.That(nachAufnahme!.Items[0].Title, Is.EqualTo("Aaa Zuerst"),
"zuletzt gefunden steht oben");
});
}
}
@@ -0,0 +1,464 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
using Flaro.Core.Playback;
namespace Flaro.Api.Tests;
/// <summary>Fortschritt, Gesehen-Status und die Startseite — je Benutzer.</summary>
public class PlaybackTests
{
private string _bestand = "";
[SetUp]
public void Anlegen() => _bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
}
private void Datei(string relativ)
{
var voll = Path.Combine(_bestand, relativ);
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
File.WriteAllBytes(voll, new byte[2048]);
}
/// <summary>Legt eine Bibliothek an, scannt sie und meldet den Client.</summary>
private async Task<HttpClient> Eingelesen(FlaroServer server, string art = "Movies")
{
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", art, _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State is "finished") return client;
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
await Task.Delay(50);
}
Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig.");
return null!;
}
private static async Task<Guid> ErsterFilm(HttpClient client)
{
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
return seite!.Items[0].Id;
}
private static Task<HttpResponseMessage> Melden(HttpClient c, Guid id, double stelle, double? laenge) =>
c.PostAsJsonAsync($"/api/v1/items/{id}/progress", new ProgressRequest(stelle, laenge));
// ------------------------------------------------------------------
/// <summary>Der Fall, um dessentwillen der Server existiert.</summary>
///
/// <remarks>
/// Jellyfin verwirft bei zwei Stunden alles unterhalb von sechs Minuten —
/// <c>MinResumePct</c> steht auf 5. Hier kommt die Stelle zurück.
/// </remarks>
[Test]
public async Task Fuenf_Minuten_von_zwei_Stunden_bleiben_erhalten()
{
Datei("Der Untergang (2004)/Der Untergang (2004).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
var antwort = await Melden(client, film, 300, 7200);
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
Assert.Multiple(() =>
{
Assert.That(stand.PositionSeconds, Is.EqualTo(300));
Assert.That(stand.Watched, Is.False);
});
}
[Test]
public async Task Eine_Sekunde_wird_schon_gemerkt()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 1, 7200);
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(1));
}
[Test]
public async Task Kurz_vor_Schluss_gilt_er_als_gesehen_und_behaelt_die_Stelle()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
var antwort = await Melden(client, film, 7000, 7200);
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
Assert.Multiple(() =>
{
Assert.That(stand.Watched, Is.True);
Assert.That(stand.PositionSeconds, Is.EqualTo(7000));
Assert.That(stand.PlayCount, Is.EqualTo(1));
});
}
[Test]
public async Task Angefangenes_steht_in_Weiterschauen()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 600, 7200);
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.Multiple(() =>
{
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
Assert.That(reihe.Items[0].Title, Is.EqualTo("Matrix"));
Assert.That(reihe.Items[0].Kind, Is.EqualTo("movie"));
Assert.That(reihe.Items[0].State.PositionSeconds, Is.EqualTo(600));
});
}
[Test]
public async Task Durchgeschautes_verlaesst_Weiterschauen()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 600, 7200);
await Melden(client, film, 7000, 7200);
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(reihe!.Items, Is.Empty);
}
/// <summary>Ein zweites Mal angefangen — mit Häkchen und Stelle zugleich.</summary>
[Test]
public async Task Ein_gesehener_Film_kommt_beim_zweiten_Anlauf_zurueck()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 7000, 7200);
await Melden(client, film, 120, 7200);
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.Multiple(() =>
{
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
Assert.That(reihe.Items[0].State.Watched, Is.True, "gesehen hat er ihn ja");
Assert.That(reihe.Items[0].State.PositionSeconds, Is.EqualTo(120));
});
}
[Test]
public async Task Weglegen_nimmt_aus_der_Reihe_ohne_gesehen_zu_behaupten()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 1800, 7200);
(await client.PostAsync($"/api/v1/items/{film}/dismiss", null)).EnsureSuccessStatusCode();
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.Multiple(() =>
{
Assert.That(reihe!.Items, Is.Empty);
Assert.That(seite!.Items[0].State!.Watched, Is.False, "weggelegt heisst nicht gesehen");
Assert.That(seite.Items[0].State!.PositionSeconds, Is.EqualTo(1800), "die Stelle bleibt");
});
}
[Test]
public async Task Wer_doch_weiterschaut_holt_es_zurueck()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 1800, 7200);
await client.PostAsync($"/api/v1/items/{film}/dismiss", null);
await Melden(client, film, 1900, 7200);
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
}
[Test]
public async Task Von_Hand_als_gesehen_raeumt_die_Stelle_weg()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 1800, 7200);
var antwort = await client.PutAsJsonAsync($"/api/v1/items/{film}/watched", new WatchedRequest(true));
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.Multiple(() =>
{
Assert.That(stand.Watched, Is.True);
Assert.That(stand.PositionSeconds, Is.Zero);
Assert.That(reihe!.Items, Is.Empty);
});
}
[Test]
public async Task Favorit_und_Bewertung_bleiben_stehen()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await client.PutAsJsonAsync($"/api/v1/items/{film}/favourite", new FavouriteRequest(true));
await client.PutAsJsonAsync($"/api/v1/items/{film}/rating", new RatingRequest(9));
var einzeln = await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film}");
Assert.Multiple(() =>
{
Assert.That(einzeln!.State!.Favourite, Is.True);
Assert.That(einzeln.State!.Rating, Is.EqualTo(9));
});
}
[Test]
public async Task Eine_Bewertung_ausserhalb_von_eins_bis_zehn_wird_abgelehnt()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
var antwort = await client.PutAsJsonAsync($"/api/v1/items/{film}/rating", new RatingRequest(11));
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
/// <summary>Zwei Leute im selben Haushalt stehen an verschiedenen Stellen.</summary>
[Test]
public async Task Der_Stand_gehoert_dem_Benutzer_nicht_dem_Server()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var admin = await Eingelesen(server);
var film = await ErsterFilm(admin);
(await admin.PostAsJsonAsync("/api/v1/users",
new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
var zweiter = server.AlsAngemeldeter(
(await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token);
await Melden(admin, film, 600, 7200);
await Melden(zweiter, film, 3000, 7200);
var seiteA = await admin.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var seiteB = await zweiter.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
Assert.Multiple(() =>
{
Assert.That(seiteA!.Items[0].State!.PositionSeconds, Is.EqualTo(600));
Assert.That(seiteB!.Items[0].State!.PositionSeconds, Is.EqualTo(3000));
});
}
[Test]
public async Task Einen_Titel_den_es_nicht_gibt_kann_man_nicht_melden()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var antwort = await Melden(client, Guid.CreateVersion7(), 60, 7200);
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
}
/// <summary>Ohne Angabe nimmt der Server die Laufzeit aus der Datei.</summary>
///
/// <remarks>
/// Die Prüfdateien haben keine lesbare Laufzeit — ffprobe findet nichts in
/// zweitausend Nullbytes. Ohne Laufzeit darf nichts vermutet werden, und
/// genau das wird hier festgehalten.
/// </remarks>
[Test]
public async Task Ohne_Laufzeit_wird_nichts_als_gesehen_vermutet()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
var antwort = await Melden(client, film, 99999, null);
var stand = (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
Assert.That(stand.Watched, Is.False);
}
// --- Serien --------------------------------------------------------
[Test]
public async Task Ungesehene_Folgen_werden_gezaehlt()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
Datei("Dark/Season 01/Dark - S01E03 - Gestern und heute.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var serie = serien!.Items[0];
Assert.That(serie.UnwatchedCount, Is.EqualTo(3));
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>($"/api/v1/series/{serie.Id}/episodes");
await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true));
var danach = await client.GetFromJsonAsync<SeriesResponse>($"/api/v1/series/{serie.Id}");
Assert.That(danach!.UnwatchedCount, Is.EqualTo(2));
}
[Test]
public async Task Die_naechste_Folge_richtet_sich_nach_Staffel_und_Nummer()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
var naechste = await client.GetFromJsonAsync<EpisodeResponse>(
$"/api/v1/episodes/{folgen!.Items[0].Id}/next");
var hinterDerLetzten = await client.GetAsync($"/api/v1/episodes/{folgen.Items[1].Id}/next");
Assert.Multiple(() =>
{
Assert.That(naechste!.Episode, Is.EqualTo(2));
Assert.That(hinterDerLetzten.StatusCode, Is.EqualTo(HttpStatusCode.NoContent),
"nach der letzten Folge kommt keine — das ist kein Fehler");
});
}
[Test]
public async Task Die_erste_ungesehene_Folge_steht_unter_Naechste_Folge()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true));
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
Assert.Multiple(() =>
{
Assert.That(reihe!.Items, Has.Count.EqualTo(1));
Assert.That(reihe.Items[0].Episode, Is.EqualTo(2));
Assert.That(reihe.Items[0].Title, Is.EqualTo("Dark"), "oben die Serie");
});
}
/// <summary>Was in „Weiterschauen" steht, steht nicht nochmal daneben.</summary>
[Test]
public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
await Melden(client, folgen!.Items[0].Id, 400, 2700);
var weiter = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
var naechste = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
Assert.Multiple(() =>
{
Assert.That(weiter!.Items, Has.Count.EqualTo(1));
Assert.That(weiter.Items[0].Episode, Is.EqualTo(1));
Assert.That(naechste!.Items, Is.Empty);
});
}
/// <summary>Die Spalte und die Regel dürfen nicht auseinanderlaufen.</summary>
///
/// <remarks>
/// <c>InProgress</c> ist das festgehaltene Ergebnis von
/// <see cref="ProgressRule.BelongsInContinueWatching"/>. Wer die Regel
/// ändert und das Nachführen vergisst, bekommt eine Startseite, die lügt —
/// und zwar lautlos. Deshalb hier gegeneinandergehalten.
/// </remarks>
[Test]
public async Task Die_Reihe_zeigt_genau_das_was_die_Regel_sagt()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
foreach (var (stelle, laenge) in new (double, double?)[]
{ (1, 7200), (300, 7200), (7000, 7200), (120, 7200) })
{
await Melden(client, film, stelle, laenge);
var erwartet = ProgressRule.BelongsInContinueWatching(new UserItemState
{
UserId = Guid.Empty,
MovieId = film,
Position = TimeSpan.FromSeconds(stelle),
Runtime = laenge is { } l ? TimeSpan.FromSeconds(l) : null,
});
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(reihe!.Items.Count == 1, Is.EqualTo(erwartet),
$"bei {stelle}s von {laenge}s");
}
}
}
+4 -1
View File
@@ -71,7 +71,10 @@ public class SignInTests
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.Client.GetAsync("/api/v1/auth/me");
// **Ohne Gedächtnis, weil der Client nach der Anmeldung ein Cookie
// hält.** Sonst prüfte dieser Test nicht den Unangemeldeten, sondern
// den Angemeldeten — und bestünde nie wieder.
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/auth/me");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
@@ -0,0 +1,309 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Auth;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Was der Browser braucht und die Apps nicht.</summary>
///
/// <remarks>
/// Eine App legt ihr Token in jede Anfrage. Ein Browser kann das nicht:
/// <c>&lt;img&gt;</c>, <c>&lt;video&gt;</c> und ein Download-Link senden keine
/// eigenen Kopfzeilen. Das Token in die Adresse zu schreiben wäre der
/// naheliegende Ausweg — und stünde danach in jedem Zugriffsprotokoll, in
/// jedem Verlauf und in jedem weitergegebenen Link. Also ein Cookie.
/// </remarks>
[TestFixture]
public sealed class WebInterfaceTests
{
[Test]
public async Task Anmeldung_legt_ein_Cookie_ab()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
var gesetzt = antwort.Headers.GetValues("Set-Cookie").Single();
Assert.Multiple(() =>
{
Assert.That(gesetzt, Does.StartWith($"{TokenAuthenticationHandler.CookieName}="));
// Kein Skript kommt heran, auch keines, das sich eingeschlichen hat.
Assert.That(gesetzt, Does.Contain("httponly").IgnoreCase);
// Eine fremde Seite kann damit nichts auslösen.
Assert.That(gesetzt, Does.Contain("samesite=strict").IgnoreCase);
// Über http wäre „secure" ein Cookie, das der Browser nie schickt —
// und damit eine Oberfläche, die sich im Heimnetz nicht bedienen lässt.
Assert.That(gesetzt, Does.Not.Contain("secure").IgnoreCase);
});
}
[Test]
public async Task Cookie_allein_genuegt_fuer_eine_Anfrage()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
var keks = Keks(angemeldet);
// Ohne Kopfzeile — genau so, wie ein <img> fragt.
var anfrage = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
anfrage.Headers.Add("Cookie", keks);
var antwort = await client.SendAsync(anfrage);
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK));
}
[Test]
public async Task Ohne_Cookie_und_ohne_Kopfzeile_bleibt_es_bei_401()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/movies");
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
}
[Test]
public async Task Abmelden_entwertet_das_Cookie()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var angemeldet = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "browser-1"));
var keks = Keks(angemeldet);
var abmelden = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
abmelden.Headers.Add("Cookie", keks);
(await client.SendAsync(abmelden)).EnsureSuccessStatusCode();
// **Das Token muss serverseitig weg sein, nicht nur das Cookie im
// Browser.** Ein gelöschtes Cookie schützt niemanden, der den Wert
// vorher mitgeschrieben hat.
var danach = new HttpRequestMessage(HttpMethod.Get, "/api/v1/movies");
danach.Headers.Add("Cookie", keks);
Assert.That((await client.SendAsync(danach)).StatusCode,
Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Die Oberfläche kommt heraus, bevor jemand angemeldet ist.</summary>
///
/// <remarks>
/// <b>Der Fehler, den dieser Test festhält.</b> Die Rückfallregel verlangt
/// für jede Anfrage ohne eigenen Endpunkt einen angemeldeten Benutzer, und
/// <c>/assets/index.js</c> hat keinen. Standen die statischen Dateien unter
/// <c>UseAuthorization</c>, bekam ein neuer Besucher 401 auf genau das
/// Skript, das ihm die Anmeldemaske zeichnen sollte: eine weisse Seite,
/// ohne Hinweis worauf.
/// </remarks>
[Test]
public async Task Die_Oberflaeche_braucht_keine_Anmeldung()
{
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "app.js"), "export const da = true;");
try
{
using var server = new FlaroServer(inhaltswurzel: wurzel);
var client = server.OhneGedaechtnis();
var seite = await client.GetAsync("/");
var skript = await client.GetAsync("/assets/app.js");
// Der Router läuft im Browser: ein Neuladen auf einer Unterseite
// darf nicht in einem 404 enden.
var unterseite = await client.GetAsync("/verwaltung");
Assert.Multiple(() =>
{
Assert.That(seite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(skript.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(unterseite.StatusCode, Is.EqualTo(HttpStatusCode.OK));
});
Assert.That(await skript.Content.ReadAsStringAsync(), Is.EqualTo("export const da = true;"));
}
finally
{
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
}
}
/// <summary>Die Seite darf nicht im Zwischenspeicher hängenbleiben.</summary>
///
/// <remarks>
/// <b>Der Fehler, den der Browser zeigte.</b> Die Dateien unter
/// <c>assets/</c> tragen eine Prüfsumme im Namen; <c>index.html</c> nicht
/// — sie nennt nur deren Namen. Lag sie im Zwischenspeicher, bekam der
/// Browser nach einem Server-Update weiter die alte App: nicht kaputt,
/// sondern veraltet, und das fällt niemandem auf. Nachgemessen hat es
/// sich so: geladen war <c>index-C0K3V8eg.js</c>, ausgeliefert wurde
/// längst <c>index-DomHN4_b.js</c>.
/// </remarks>
[Test]
public async Task Die_Seite_wird_nicht_zwischengespeichert_die_Bausteine_schon()
{
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot", "assets"));
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "assets", "index-abc123.js"), "export const da = true;");
try
{
using var server = new FlaroServer(inhaltswurzel: wurzel);
var client = server.OhneGedaechtnis();
var seite = await client.GetAsync("/");
var unterseite = await client.GetAsync("/verwaltung");
var skript = await client.GetAsync("/assets/index-abc123.js");
Assert.Multiple(() =>
{
Assert.That(seite.Headers.CacheControl?.NoCache, Is.True);
Assert.That(unterseite.Headers.CacheControl?.NoCache, Is.True,
"der SPA-Rückfall liefert dieselbe Datei");
Assert.That(skript.Headers.CacheControl?.MaxAge, Is.EqualTo(TimeSpan.FromDays(365)));
Assert.That(skript.Headers.CacheControl?.ToString(), Does.Contain("immutable"));
});
}
finally
{
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
}
}
/// <summary>Ein unbekannter Pfad unter /api ist ein Fehler, keine Seite.</summary>
///
/// <remarks>
/// <b>Der Fund, der dahintersteckt.</b> Der Rückfall für den Router im
/// Browser nimmt jeden Pfad ohne Dateiendung — auch
/// <c>/api/v1/vertippt</c>. Ein vertippter Aufruf bekam die Webseite mit
/// Status 200 zurück und sah damit wie ein Erfolg aus.
/// </remarks>
[Test]
public async Task Ein_unbekannter_Schnittstellenpfad_gibt_404()
{
var wurzel = Path.Combine(Path.GetTempPath(), "flaro-www-" + Guid.CreateVersion7().ToString("N"));
Directory.CreateDirectory(Path.Combine(wurzel, "wwwroot"));
await File.WriteAllTextAsync(Path.Combine(wurzel, "wwwroot", "index.html"), "<!doctype html><title>Flaro</title>");
try
{
using var server = new FlaroServer(inhaltswurzel: wurzel);
var antwort = await server.OhneGedaechtnis().GetAsync("/api/v1/gibtsnicht");
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
Assert.That(antwort.Content.Headers.ContentType?.MediaType, Is.Not.EqualTo("text/html"));
});
}
finally
{
try { Directory.Delete(wurzel, recursive: true); } catch (IOException) { /* Aufräumen ist Kür */ }
}
}
/// <summary>„Angemeldet bleiben" entscheidet über die Lebensdauer.</summary>
///
/// <remarks>
/// Ohne <c>max-age</c> ist es ein Sitzungscookie und verschwindet mit dem
/// Browserfenster — richtig an einem fremden Rechner, falsch am eigenen.
/// Deshalb wird gefragt statt geraten.
/// </remarks>
[Test]
public async Task Angemeldet_bleiben_setzt_ein_dauerhaftes_Cookie()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var dauerhaft = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: true));
var nurJetzt = await client.PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1", StaySignedIn: false));
Assert.Multiple(() =>
{
Assert.That(GanzerKeks(dauerhaft), Does.Contain("max-age=31536000").IgnoreCase);
Assert.That(GanzerKeks(nurJetzt), Does.Not.Contain("max-age").IgnoreCase);
});
}
/// <summary>Die Vorgabe ist „bleiben" — ein Client darf es weglassen.</summary>
[Test]
public async Task Ohne_Angabe_bleibt_man_angemeldet()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var antwort = await server.OhneGedaechtnis().PostAsJsonAsync("/api/v1/auth/login",
new LoginRequest("guido", "ein-langes-passwort", "Browser", "b1"));
Assert.That(GanzerKeks(antwort), Does.Contain("max-age=31536000").IgnoreCase);
}
/// <summary>Abmelden scheitert nie — auch nicht ohne gültiges Token.</summary>
///
/// <remarks>
/// <b>Der Fehler dahinter.</b> Vorher verlangte dieser Weg eine gültige
/// Anmeldung, und genau dann, wenn man ihn am dringendsten braucht, gibt
/// es keine: nach einem geleerten Datenbestand zeigt das Cookie im
/// Browser auf ein Token, das es nicht mehr gibt. Es ging bei jedem Bild
/// erneut hinaus, der Server lehnte es jedes Mal ab — achtzig
/// Ablehnungen in einer Stunde —, und nichts davon liess sich von aussen
/// beenden.
/// </remarks>
[Test]
public async Task Abmelden_loescht_das_Cookie_auch_ohne_gueltiges_Token()
{
using var server = new FlaroServer();
await server.SetUpAndSignIn();
var client = server.OhneGedaechtnis();
var anfrage = new HttpRequestMessage(HttpMethod.Post, "/api/v1/auth/logout");
anfrage.Headers.Add("Cookie", $"{TokenAuthenticationHandler.CookieName}=laengst-ungueltig");
var antwort = await client.SendAsync(anfrage);
Assert.Multiple(() =>
{
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
// Ein gelöschtes Cookie ist eines mit abgelaufenem Datum.
Assert.That(antwort.Headers.GetValues("Set-Cookie").Single(),
Does.Contain(TokenAuthenticationHandler.CookieName));
});
}
/// <summary>Ein gültiges Token wird dabei trotzdem widerrufen.</summary>
[Test]
public async Task Abmelden_zieht_ein_gueltiges_Token_weiterhin_zurueck()
{
using var server = new FlaroServer();
var anmeldung = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(anmeldung.Token);
(await client.PostAsync("/api/v1/auth/logout", null)).EnsureSuccessStatusCode();
Assert.That((await client.GetAsync("/api/v1/auth/me")).StatusCode,
Is.EqualTo(HttpStatusCode.Unauthorized));
}
/// <summary>Nimmt den Namen und den Wert aus der Set-Cookie-Kopfzeile.</summary>
private static string Keks(HttpResponseMessage antwort) =>
GanzerKeks(antwort).Split(';')[0];
/// <summary>Die ganze Kopfzeile — samt Lebensdauer und Schutzangaben.</summary>
private static string GanzerKeks(HttpResponseMessage antwort) =>
antwort.Headers.GetValues("Set-Cookie").Single();
}
@@ -0,0 +1,196 @@
using Flaro.Core.Playback;
namespace Flaro.Core.Tests;
/// <summary>Die Regel, um derentwillen dieser Server existiert.</summary>
[TestFixture]
public sealed class ProgressRuleTests
{
private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2);
/// <summary>Der Fall, der den Ausschlag gab.</summary>
///
/// <remarks>
/// Jellyfins <c>MinResumePct</c> steht auf 5. Bei zwei Stunden sind das
/// sechs Minuten, und wer nach fünf abbricht, findet beim nächsten Mal
/// nichts vor. Hier ist genau diese Stelle abgelegt.
/// </remarks>
[Test]
public void Die_ersten_Minuten_zaehlen_auch()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(5)));
Assert.That(ergebnis.Watched, Is.False);
});
}
[Test]
public void Eine_einzige_Sekunde_zaehlt_auch()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(1)));
}
/// <summary>Jellyfins <c>MinResumeDurationSeconds</c> steht auf 300.</summary>
///
/// <remarks>
/// Dort gilt eine vierminütige Folge nach dreissig Sekunden als
/// vollständig gesehen. Hier ist sie das, was sie ist: angefangen.
/// </remarks>
[Test]
public void Ein_kurzer_Titel_gilt_nicht_sofort_als_gesehen()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4));
Assert.Multiple(() =>
{
Assert.That(ergebnis.Watched, Is.False);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(30)));
});
}
[Test]
public void Kurz_vor_Schluss_gilt_er_als_gesehen()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder);
Assert.That(ergebnis.Watched, Is.True);
}
/// <summary>Und die Stelle bleibt trotzdem stehen.</summary>
///
/// <remarks>
/// Jellyfin setzt sie an dieser Stelle auf null. Wer den Abspann sehen
/// oder die letzte Szene nochmal will, fängt dort von vorn an.
/// </remarks>
[Test]
public void Gesehen_wirft_die_Stelle_nicht_weg()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Watched, Is.True);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(118)));
});
}
[Test]
public void Genau_an_der_Grenze_gilt_er_als_gesehen()
{
var ergebnis = ProgressRule.Apply(Zweistünder * ProgressRule.WatchedFraction, Zweistünder);
Assert.That(ergebnis.Watched, Is.True);
}
[Test]
public void Eine_Sekunde_davor_noch_nicht()
{
var ergebnis = ProgressRule.Apply(
Zweistünder * ProgressRule.WatchedFraction - TimeSpan.FromSeconds(1), Zweistünder);
Assert.That(ergebnis.Watched, Is.False);
}
/// <summary>Ohne Laufzeit gibt es keinen Anteil — also kein Urteil.</summary>
///
/// <remarks>
/// Jellyfin nimmt in diesem Fall an, der Titel sei vollständig gesehen
/// („we'll just have to assume it was fully played"). Das ist die
/// Annahme, die man nicht zurücknehmen kann, ohne sie zu bemerken.
/// </remarks>
[Test]
public void Ohne_Laufzeit_wird_nichts_vermutet()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Watched, Is.False);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(90)));
});
}
[Test]
public void Eine_Laufzeit_von_null_ist_keine()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero);
Assert.That(ergebnis.Watched, Is.False);
}
/// <summary>Spieler melden beim Spulen und beim Beenden Unsinn.</summary>
[Test]
public void Eine_negative_Stelle_wird_zu_null()
{
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder);
Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.Zero));
}
[Test]
public void Jenseits_des_Endes_wird_auf_das_Ende_gekuerzt()
{
var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder);
Assert.Multiple(() =>
{
Assert.That(ergebnis.Position, Is.EqualTo(Zweistünder));
Assert.That(ergebnis.Watched, Is.True);
});
}
// --- Weiterschauen ---------------------------------------------------
[Test]
public void Angefangen_gehoert_in_Weiterschauen()
{
Assert.That(ProgressRule.BelongsInContinueWatching(
Zustand(TimeSpan.FromMinutes(20), Zweistünder)), Is.True);
}
[Test]
public void Nie_angefangen_gehoert_nicht_hinein()
{
Assert.That(ProgressRule.BelongsInContinueWatching(
Zustand(TimeSpan.Zero, Zweistünder)), Is.False);
}
[Test]
public void Durchgeschaut_gehoert_nicht_hinein()
{
Assert.That(ProgressRule.BelongsInContinueWatching(
Zustand(TimeSpan.FromMinutes(118), Zweistünder)), Is.False);
}
[Test]
public void Weggelegtes_gehoert_nicht_hinein()
{
var zustand = Zustand(TimeSpan.FromMinutes(20), Zweistünder);
zustand.Dismissed = true;
Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.False);
}
/// <summary>Ein zweites Mal angefangen — dann steht er wieder da.</summary>
///
/// <remarks>
/// Das Häkchen bleibt: gesehen hat man ihn ja. Beides nebeneinander ist
/// genau das, was ohne weggeworfene Position möglich wird.
/// </remarks>
[Test]
public void Ein_gesehener_Titel_kommt_beim_zweiten_Mal_zurueck()
{
var zustand = Zustand(TimeSpan.FromMinutes(10), Zweistünder);
zustand.Watched = true;
Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.True);
}
private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) =>
new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge };
}
@@ -0,0 +1,219 @@
using Flaro.Core.Metadata;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Metadata.Tests;
/// <summary>Was ausser Titel und Plakat noch ankommt.</summary>
///
/// <remarks>
/// <b>Daran hängen Detailseite, Vorschläge und der Filter nach Genre.</b> Mit
/// Titel, Jahr, Beschreibung und zwei Bildern lässt sich eine Kachel zeichnen
/// und sonst nichts.
/// </remarks>
public class AnreicherungTests
{
private static TmdbClient Client(Attrappe attrappe, string sprache = "de-DE") =>
new(new HttpClient(attrappe) { BaseAddress = new Uri(TmdbClient.BaseAddress) },
FesteEinstellungen.MitSchluessel(sprache), NullLogger<TmdbClient>.Instance);
private const string Suche = """{"results":[{"id":603,"title":"Matrix"}]}""";
private const string Einzeln = """
{"id":603,"title":"Matrix","release_date":"1999-03-30",
"overview":"Neo zweifelt an der Wirklichkeit.",
"poster_path":"/abc.jpg","backdrop_path":"/def.jpg",
"runtime":136,"vote_average":8.2,"tagline":"Die Wirklichkeit ist eine Lüge.",
"genres":[{"name":"Science Fiction"},{"name":"Action"}],
"external_ids":{"imdb_id":"tt0133093"},
"release_dates":{"results":[
{"iso_3166_1":"US","release_dates":[{"certification":"R"}]},
{"iso_3166_1":"DE","release_dates":[{"certification":""},{"certification":"16"}]}
]},
"images":{"logos":[
{"file_path":"/englisch.png","iso_639_1":"en","vote_average":9.0},
{"file_path":"/deutsch.png","iso_639_1":"de","vote_average":2.0},
{"file_path":"/ohne.png","iso_639_1":null,"vote_average":5.0}
]},
"credits":{
"cast":[
{"id":6384,"name":"Keanu Reeves","character":"Neo","profile_path":"/k.jpg","order":0},
{"id":2975,"name":"Laurence Fishburne","character":"Morpheus","profile_path":null,"order":1}
],
"crew":[
{"id":9339,"name":"Lana Wachowski","job":"Director","profile_path":"/l.jpg"},
{"id":9339,"name":"Lana Wachowski","job":"Writer","profile_path":"/l.jpg"},
{"id":9339,"name":"Lana Wachowski","job":"Story","profile_path":"/l.jpg"},
{"id":111,"name":"Jemand vom Licht","job":"Gaffer","profile_path":null}
]
}}
""";
private static Attrappe Vollstaendig() =>
Attrappe.NachPfad(("search/movie", Suche), ("movie/603", Einzeln));
[Test]
public async Task Laufzeit_Bewertung_und_Werbezeile_kommen_an()
{
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
Assert.Multiple(() =>
{
Assert.That(film!.ProviderId, Is.EqualTo(603));
Assert.That(film.RuntimeMinutes, Is.EqualTo(136));
Assert.That(film.Rating, Is.EqualTo(8.2));
Assert.That(film.Tagline, Is.EqualTo("Die Wirklichkeit ist eine Lüge."));
Assert.That(film.ImdbId, Is.EqualTo("tt0133093"));
});
}
[Test]
public async Task Genres_kommen_als_Namen_an()
{
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
Assert.That(film!.Genres, Is.EqualTo(new[] { "Science Fiction", "Action" }));
}
/// <summary>Freigaben sind national — die FSK gilt nicht in Kansas.</summary>
[Test]
public async Task Die_Freigabe_richtet_sich_nach_dem_eingestellten_Land()
{
var deutsch = await Client(Vollstaendig(), "de-DE").FindMovieAsync("Matrix", 1999);
var amerikanisch = await Client(Vollstaendig(), "en-US").FindMovieAsync("Matrix", 1999);
Assert.Multiple(() =>
{
// Der erste DE-Eintrag ist leer; genommen wird der erste gefüllte.
Assert.That(deutsch!.ContentRating, Is.EqualTo("16"));
Assert.That(amerikanisch!.ContentRating, Is.EqualTo("R"));
});
}
/// <summary>Der Schriftzug in der eigenen Sprache schlägt den besser bewerteten.</summary>
[Test]
public async Task Das_Logo_kommt_in_der_eingestellten_Sprache()
{
var film = await Client(Vollstaendig(), "de-DE").FindMovieAsync("Matrix", 1999);
Assert.That(film!.Logo!.Url, Does.EndWith("/deutsch.png"),
"auch wenn das englische bei TMDB besser bewertet ist");
}
/// <summary>Ohne eigene Sprache gewinnt das sprachlose vor dem englischen.</summary>
///
/// <remarks>
/// Ein Schriftzug ohne Sprachangabe enthält meist gar keinen Text —
/// er ist überall richtig.
/// </remarks>
[Test]
public async Task Ohne_passende_Sprache_gewinnt_das_sprachlose()
{
var film = await Client(Vollstaendig(), "fr-FR").FindMovieAsync("Matrix", 1999);
Assert.That(film!.Logo!.Url, Does.EndWith("/ohne.png"));
}
[Test]
public async Task Darsteller_kommen_in_der_Reihenfolge_der_Quelle()
{
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
var darsteller = film!.Credits!.Where(c => c.Kind == CreditKind.Actor).ToList();
Assert.Multiple(() =>
{
Assert.That(darsteller.Select(d => d.Name),
Is.EqualTo(new[] { "Keanu Reeves", "Laurence Fishburne" }));
Assert.That(darsteller[0].Character, Is.EqualTo("Neo"));
Assert.That(darsteller[0].Portrait!.Url, Does.EndWith("/k.jpg"));
Assert.That(darsteller[1].Portrait, Is.Null, "kein Bild ist null, keine kaputte Adresse");
});
}
/// <summary>Aus dem Stab nur Regie und Drehbuch.</summary>
///
/// <remarks>
/// TMDB führt dort auch Beleuchter und Caterer. Eine Detailseite, die
/// alle nennt, nennt nichts.
/// </remarks>
[Test]
public async Task Vom_Stab_kommen_nur_Regie_und_Drehbuch()
{
var film = await Client(Vollstaendig()).FindMovieAsync("Matrix", 1999);
var stab = film!.Credits!.Where(c => c.Kind != CreditKind.Actor).ToList();
Assert.Multiple(() =>
{
Assert.That(stab.Count(c => c.Kind == CreditKind.Director), Is.EqualTo(1));
// „Writer" und „Story" sind bei TMDB zwei Zeilen und hier ein Name.
Assert.That(stab.Count(c => c.Kind == CreditKind.Writer), Is.EqualTo(1));
Assert.That(stab.Any(c => c.Name.Contains("Licht")), Is.False);
});
}
/// <summary>Was ein Einzelabruf nicht hergibt, darf den Treffer nicht kippen.</summary>
///
/// <remarks>
/// Eine Antwort, die keine Kennung enthält — ein Anmeldeportal, ein Proxy,
/// eine Trefferliste —, liest der Dekoder als Null. Das als Treffer
/// durchzulassen hiesse, einen Film mit der Kennung 0 anzulegen; statt
/// dessen bleibt, was die Suche schon wusste.
/// </remarks>
[Test]
public async Task Ein_leerer_Einzelabruf_faellt_auf_den_Suchtreffer_zurueck()
{
var attrappe = Attrappe.NachPfad(
("search/movie", """{"results":[{"id":603,"title":"Matrix","release_date":"1999-03-30"}]}"""),
("movie/603", "{}"));
var film = await Client(attrappe).FindMovieAsync("Matrix", 1999);
Assert.Multiple(() =>
{
Assert.That(film!.ProviderId, Is.EqualTo(603));
Assert.That(film.Title, Is.EqualTo("Matrix"));
Assert.That(film.Genres, Is.Null);
});
}
/// <summary>Alles in einer Anfrage statt in vieren.</summary>
[Test]
public async Task Der_Einzelabruf_holt_alles_auf_einmal()
{
var attrappe = Vollstaendig();
await Client(attrappe).FindMovieAsync("Matrix", 1999);
var einzeln = attrappe.Aufrufe.Single(a => a.Contains("movie/603"));
Assert.Multiple(() =>
{
Assert.That(einzeln, Does.Contain("credits"));
Assert.That(einzeln, Does.Contain("external_ids"));
Assert.That(einzeln, Does.Contain("release_dates"));
Assert.That(einzeln, Does.Contain("images"));
// Sprachlose Schriftzüge liefert TMDB nur, wenn man sie nennt.
Assert.That(einzeln, Does.Contain("null"));
Assert.That(attrappe.Aufrufe, Has.Count.EqualTo(2), "Suche und Einzelabruf, mehr nicht");
});
}
[Test]
public async Task Eine_Serie_bekommt_die_Folgenlaenge_als_Laufzeit()
{
var attrappe = Attrappe.NachPfad(
("search/tv", """{"results":[{"id":70523,"name":"Dark"}]}"""),
("tv/70523", """
{"id":70523,"name":"Dark","first_air_date":"2017-12-01",
"episode_run_time":[60],"vote_average":8.4,
"genres":[{"name":"Mystery"}],
"content_ratings":{"results":[{"iso_3166_1":"DE","rating":"16"}]}}
"""));
var serie = await Client(attrappe).FindSeriesAsync("Dark", null);
Assert.Multiple(() =>
{
Assert.That(serie!.RuntimeMinutes, Is.EqualTo(60));
Assert.That(serie.Rating, Is.EqualTo(8.4));
Assert.That(serie.Genres, Is.EqualTo(new[] { "Mystery" }));
Assert.That(serie.ContentRating, Is.EqualTo("16"));
});
}
}
@@ -34,6 +34,28 @@ public sealed class Attrappe(Func<HttpRequestMessage, HttpResponseMessage> antwo
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
/// <summary>Antwortet je nach Pfad verschieden.</summary>
///
/// <remarks>
/// <b>Nötig, seit ein Treffer zwei Anfragen kostet.</b> Die Suche liefert
/// nur eine Kennung; Laufzeit, Genres, Besetzung und Schriftzug stehen
/// erst im Einzelabruf. Eine Attrappe mit einer Antwort für alles gäbe
/// dem Einzelabruf die Trefferliste zurück — und der läse daraus eine
/// Kennung von null.
///
/// Der erste Eintrag, dessen Schlüssel im Pfad vorkommt, gewinnt.
/// </remarks>
public static Attrappe NachPfad(params (string Teil, string Json)[] antworten) =>
new(anfrage =>
{
var pfad = anfrage.RequestUri!.AbsoluteUri;
var treffer = antworten.FirstOrDefault(a => pfad.Contains(a.Teil, StringComparison.Ordinal));
return new HttpResponseMessage(treffer.Json is null ? HttpStatusCode.NotFound : HttpStatusCode.OK)
{
Content = new StringContent(treffer.Json ?? "{}", Encoding.UTF8, "application/json"),
};
});
public static Attrappe MitStatus(HttpStatusCode code) => new(_ => new HttpResponseMessage(code));
public static Attrappe DieWirft(Exception fehler) => new(_ => throw fehler);
+3 -1
View File
@@ -3,12 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0B0B0D">
<title>Flaro</title>
</head>
<body>
<section id="login"></section>
<header id="topbar" class="hidden"></header>
<div id="app" class="hidden">
<nav id="chrome"><div class="island" id="island"></div></nav>
<nav id="chrome"></nav>
<main id="content"></main>
</div>
<script type="module" src="/src/main.ts"></script>
+419 -136
View File
@@ -1,151 +1,434 @@
import type { Wiedergabeauskunft } from './types';
/* Die Schnittstelle von Flaro, wie der Browser sie sieht.
*
* Eine Datei, weil es eine Schnittstelle ist: wer wissen will, was der Server
* kann, liest hier und nicht in fünf Ansichten nach. */
/* Der Draht zum Server.
----------------------------------------------------------------------------
**Alles geht über `/api`, nicht über die Adresse des Medienservers.** Das
ist der Grund, warum es diesen Server überhaupt gibt: Er reicht die
Anfragen weiter, und damit haben Seite und Daten dieselbe Herkunft. Kein
CORS, kein Ärger mit gemischtem HTTP und HTTPS, und der Zugangsschlüssel
bleibt beim Server statt im Browser zu liegen.
Die Endpunkte selbst sind dieselben, die auch die App benutzt — übernommen
aus JellyfinKit, nicht geraten. */
const BASIS = '/api';
export const sitzung = {
token: localStorage.getItem('flaro.token') ?? '',
userId: localStorage.getItem('flaro.user') ?? '',
geraet:
localStorage.getItem('flaro.device') ??
(() => {
const id = 'flaro-web-' + Math.random().toString(36).slice(2, 12);
localStorage.setItem('flaro.device', id);
return id;
})(),
export type Rolle = { id: string; userName: string; isAdministrator: boolean };
export type Spur = {
index: number; kind: 'Video' | 'Audio' | 'Subtitle';
codec: string | null; language: string | null; title: string | null;
isDefault: boolean; isForced: boolean;
channels: number | null; width: number | null; height: number | null;
};
export type Datei = {
id: string; fileName: string; sizeBytes: number;
container: string | null; durationSeconds: number | null; streams: Spur[];
};
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
export type Stand = {
positionSeconds: number; runtimeSeconds: number | null;
watched: boolean; playCount: number;
favourite: boolean; rating: number | null; dismissed: boolean;
lastPlayedAt: string | null;
};
export type Mitwirkender = {
id: string; name: string;
role: 'Actor' | 'Director' | 'Writer';
character: string | null; image: string | null;
};
/** Die Kopfzeile, mit oder ohne Schlüssel.
export type Film = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null; files: Datei[];
state: Stand | null;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
/** Leer in Listen, gefüllt beim Einzelabruf. */
genres: string[] | null;
credits: Mitwirkender[] | null;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
posterImage: string | null; backdropImage: string | null;
episodeCount: number; seasons: number[]; unwatchedCount: number;
logoImage: string | null;
runtimeMinutes: number | null;
rating: number | null;
tagline: string | null;
contentRating: string | null;
genres: string[] | null;
credits: Mitwirkender[] | null;
};
export type Folge = {
id: string; season: number; episode: number;
title: string | null; overview: string | null; stillImage: string | null; files: Datei[];
state: Stand | null;
};
/** Eine Kachel in „Weiterschauen" oder „Nächste Folge" — Film wie Folge. */
export type Angefangen = {
id: string; kind: 'movie' | 'episode';
title: string; subTitle: string | null;
season: number | null; episode: number | null; year: number | null;
posterImage: string | null; backdropImage: string | null;
state: Stand;
};
export type Bibliothek = {
id: string; name: string;
/** „Movies" oder „Shows" — so nennt der Server es. */
kind: 'Movies' | 'Shows';
path: string;
/** `null` heisst „nicht gezählt", nicht „leer". */
itemCount: number | null;
};
export type Seite<T> = { items: T[]; total: number };
export type Lieferung = {
method: 'directFile' | 'remux'; reasons: string[];
audioToEncode: number[]; subtitlesDropped: number[];
packageState: string; progress: number; error: string | null;
};
export type Scanstand = {
state: string; filesFound: number | null; added: number | null;
removed: number | null; unrecognised: number | null; probed: number | null;
problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null;
};
export type Vorschlag = {
tmdbId: number; title: string; year: number | null;
overview: string | null; posterUrl: string | null;
};
export type Hinweis = { text: string; logoUrl: string; url: string };
export type Systemauskunft = {
version: string; serverName: string | null;
needsSetup: boolean; attributions: Hinweis[];
};
/* ---------- Sitzung ---------- */
const SCHLUESSEL = 'flaro.session';
export const sitzung: { token: string | null; wer: Rolle | null } = {
token: null, wer: null,
};
/** Ob die Anmeldung einen geschlossenen Browser überleben soll. */
let bleibenAngemeldet = true;
/** Wo die Sitzung liegt — und das ist die ganze Umsetzung von „bleiben".
*
* Die Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen —
* Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht
* angemeldet, obwohl der Schlüssel stimmt. */
function kopfzeile(): string {
const teile = [
'Client="Flaro Web"',
'Device="Browser"',
`DeviceId="${sitzung.geraet}"`,
'Version="0.1"',
];
if (sitzung.token) teile.push(`Token="${sitzung.token}"`);
return 'MediaBrowser ' + teile.join(', ');
* `localStorage` überlebt das Schliessen des Browsers, `sessionStorage`
* nicht. Ein Verfallsdatum selbst zu verwalten wäre die schlechtere
* Variante: eine Uhr, die man stellen kann, gegen zwei Speicher, die der
* Browser ohnehin richtig aufräumt. */
function ablage(dauerhaft: boolean): Storage | null {
try { return dauerhaft ? localStorage : sessionStorage; } catch { return null; }
}
export class ServerFehler extends Error {
constructor(public status: number, text: string) {
super(text);
try {
// Erst der dauerhafte, dann der fürs Fenster. Liegt in beiden etwas, gilt
// der dauerhafte — er ist der zuletzt ausdrücklich gewählte.
const roh = ablage(true)?.getItem(SCHLUESSEL) ?? ablage(false)?.getItem(SCHLUESSEL);
if (roh) {
Object.assign(sitzung, JSON.parse(roh));
bleibenAngemeldet = ablage(true)?.getItem(SCHLUESSEL) != null;
}
} catch { /* Privates Fenster oder gesperrter Speicher — dann eben anmelden. */ }
function merken(): void {
try {
// Immer aus beiden entfernen, bevor geschrieben wird: sonst bliebe nach
// einem Wechsel von „bleiben" auf „nicht bleiben" die alte Kopie im
// dauerhaften Speicher liegen — und überlebte genau das, was sie nicht
// überleben sollte.
ablage(true)?.removeItem(SCHLUESSEL);
ablage(false)?.removeItem(SCHLUESSEL);
if (sitzung.token) ablage(bleibenAngemeldet)?.setItem(SCHLUESSEL, JSON.stringify(sitzung));
} catch { /* Nicht schlimm: die Sitzung gilt dann nur bis zum Neuladen. */ }
}
export async function api<T = unknown>(pfad: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(BASIS + pfad, {
...opts,
headers: {
Authorization: kopfzeile(),
Accept: 'application/json',
...(opts.body ? { 'Content-Type': 'application/json' } : {}),
...opts.headers,
},
export const angemeldet = (): boolean => Boolean(sitzung.token);
export const istAdmin = (): boolean => Boolean(sitzung.wer?.isAdministrator);
/* ---------- Anfragen ---------- */
export class ApiFehler extends Error {
constructor(public status: number, nachricht: string) { super(nachricht); }
}
async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
const kopf = new Headers(opts.headers);
if (sitzung.token) kopf.set('Authorization', `Bearer ${sitzung.token}`);
if (opts.body && !kopf.has('Content-Type')) kopf.set('Content-Type', 'application/json');
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
// **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.**
//
// Hier stand vorher ein sofortiges `abmelden()` — und das rief
// `/auth/logout`, der Server zog das Token also *aktiv zurück*. War die
// Ablehnung nur ein Aussetzer, war eine intakte Anmeldung damit
// absichtlich zerstört, und zwar unwiderruflich.
//
// Jetzt wird einmal nachgefragt: gilt das Token wirklich nicht mehr, ist
// die Sitzung vorbei; antwortet der Server, war es diese eine Anfrage.
if (antwort.status === 401 && sitzung.token && !pfad.startsWith('/auth/')) {
if (await sitzungTot()) { verwerfen(); location.reload(); }
}
if (!antwort.ok) {
let text = `${antwort.status}`;
try {
const fehler = await antwort.json();
text = fehler.detail ?? fehler.title
?? Object.values(fehler.errors ?? {}).flat().join(' ') ?? text;
} catch { /* Keine JSON-Antwort — dann bleibt der Status. */ }
throw new ApiFehler(antwort.status, text);
}
return antwort.status === 204 ? (undefined as T) : antwort.json();
}
/* ---------- Bilder ---------- */
/** Die Adresse eines Bildes.
*
* Schlicht ein Pfad, denn der Browser weist sich über das Sitzungs-Cookie
* aus — `<img>`, `<video>` und ein Download-Link können das von sich aus.
* Ohne das Cookie müsste jedes Plakat über fetch kommen und im
* Arbeitsspeicher liegen. */
export const bildAdresse = (schluessel: string | null): string | null =>
schluessel ? `/api/v1/images/${schluessel}` : null;
/* ---------- System und Anmeldung ---------- */
export const systemauskunft = () =>
anfrage<Systemauskunft>('/system/info');
export const einrichten = (benutzer: string, passwort: string) =>
anfrage<Rolle>('/system/setup', {
method: 'POST', body: JSON.stringify({ userName: benutzer, password: passwort }),
});
if (!res.ok) {
throw new ServerFehler(
res.status,
res.status === 401 ? 'Nicht angemeldet' : `HTTP ${res.status}`,
);
}
return res.status === 204 ? (null as T) : ((await res.json()) as T);
}
/** Eine Adresse für das <video>-Element oder ein <img>.
*
* Beide schicken keine Kopfzeilen mit, deshalb hängt der Schlüssel in der
* Adresse. Denselben Umweg geht die App. */
export function medienURL(pfad: string, query: Record<string, string | number> = {}): string {
const u = new URL(BASIS + pfad, location.origin);
for (const [k, v] of Object.entries(query)) {
if (v !== '' && v != null) u.searchParams.set(k, String(v));
}
if (sitzung.token) u.searchParams.set('api_key', sitzung.token);
return u.href;
}
/* ---------- Anmeldung ---------- */
export async function anmelden(benutzer: string, passwort: string): Promise<void> {
sitzung.token = '';
const r = await api<{ AccessToken: string; User: { Id: string } }>(
'/Users/AuthenticateByName',
{ method: 'POST', body: JSON.stringify({ Username: benutzer, Pw: passwort }) },
);
sitzung.token = r.AccessToken;
sitzung.userId = r.User.Id;
localStorage.setItem('flaro.token', sitzung.token);
localStorage.setItem('flaro.user', sitzung.userId);
}
export function abmelden(): void {
localStorage.removeItem('flaro.token');
localStorage.removeItem('flaro.user');
location.reload();
}
export const angemeldet = (): boolean => Boolean(sitzung.token && sitzung.userId);
/* ---------- Wiedergabe ---------- */
/** Handelt mit dem Server aus, wie abgespielt wird.
*
* Das Geräteprofil sagt, was dieses Ende kann; die Antwort sagt, was daraus
* folgt. Leere `DirectPlayProfiles` heißt: nichts geht unverändert, rechne
* um. Im Browser ist das die ehrliche Auskunft — ein <video>-Element kann
* kein MKV und kein DTS. */
export function wiedergabeauskunft(
itemID: string,
wahl: { quality?: number; audio?: number | null; subtitle?: number | null } = {},
): Promise<Wiedergabeauskunft> {
const grenze = (wahl.quality ?? 0) > 0 ? wahl.quality! : 20000;
const profil = {
MaxStreamingBitrate: grenze * 1000,
DirectPlayProfiles: [],
TranscodingProfiles: [
{
Type: 'Video', Container: 'ts', Protocol: 'hls', VideoCodec: 'h264',
AudioCodec: 'aac,mp3', Context: 'Streaming', MaxAudioChannels: '2',
MinSegments: 1, BreakOnNonKeyFrames: true,
},
],
SubtitleProfiles: [
{ Format: 'vtt', Method: 'Hls' },
{ Format: 'srt', Method: 'External' },
],
};
return api<Wiedergabeauskunft>(
`/Items/${itemID}/PlaybackInfo?` + new URLSearchParams({ userId: sitzung.userId }),
{
export async function anmelden(
benutzer: string, passwort: string, bleiben = true,
): Promise<void> {
const antwort = await anfrage<{ token: string; user: Rolle }>('/auth/login', {
method: 'POST',
body: JSON.stringify({
DeviceProfile: profil,
UserId: sitzung.userId,
MaxStreamingBitrate: profil.MaxStreamingBitrate,
EnableDirectPlay: false,
EnableDirectStream: false,
EnableTranscoding: true,
AutoOpenLiveStream: true,
...(wahl.audio != null ? { AudioStreamIndex: wahl.audio } : {}),
...((wahl.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: wahl.subtitle } : {}),
userName: benutzer, password: passwort,
deviceName: geraetename(), deviceId: geraetekennung(),
staySignedIn: bleiben,
}),
},
);
});
bleibenAngemeldet = bleiben;
sitzung.token = antwort.token;
sitzung.wer = antwort.user;
merken();
}
/** Prüft einmal, ob das Token wirklich nicht mehr gilt.
*
* Mehrere gleichzeitige Anfragen laufen in dieselbe Prüfung — sonst stellte
* eine Startseite mit acht Abfragen acht Mal dieselbe Frage. */
let laufendePruefung: Promise<boolean> | null = null;
function sitzungTot(): Promise<boolean> {
laufendePruefung ??= fetch('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${sitzung.token}` },
})
.then(a => a.status === 401)
// Antwortet der Server gar nicht, ist das kein Beleg gegen das Token.
// Jemanden abzumelden, weil das WLAN hakt, ist der falsche Schluss.
.catch(() => false)
.finally(() => { window.setTimeout(() => { laufendePruefung = null; }, 2000); });
return laufendePruefung;
}
/** Wirft die Sitzung hier weg — ohne den Server zu fragen. */
function verwerfen(): void {
sitzung.token = null; sitzung.wer = null;
merken();
}
/** Meldet ab und zieht das Token am Server zurück.
*
* **Nur auf ausdrücklichen Wunsch.** Der Weg hierher führt über den Knopf,
* nicht über eine abgelehnte Anfrage.
*/
export function abmelden(): void {
// Auch ohne gültiges Token aufrufen: der Server löscht dabei das Cookie,
// und genau das bleibt sonst im Browser stehen und geht bei jedem Bild
// erneut hinaus.
void fetch('/api/v1/auth/logout', {
method: 'POST',
headers: sitzung.token ? { Authorization: `Bearer ${sitzung.token}` } : {},
}).catch(() => {});
verwerfen();
}
function geraetename(): string {
const u = navigator.userAgent;
const was = /iPhone/.test(u) ? 'iPhone' : /iPad/.test(u) ? 'iPad'
: /Macintosh/.test(u) ? 'Mac' : /Windows/.test(u) ? 'Windows' : 'Browser';
return `${was} · ${/Firefox/.test(u) ? 'Firefox' : /Chrome/.test(u) ? 'Chrome' : 'Safari'}`;
}
/** Bleibt gleich, damit derselbe Browser nicht als zehn Geräte dasteht. */
function geraetekennung(): string {
const name = 'flaro.device';
try {
let vorhanden = localStorage.getItem(name);
if (!vorhanden) { vorhanden = crypto.randomUUID(); localStorage.setItem(name, vorhanden); }
return vorhanden;
} catch { return crypto.randomUUID(); }
}
/* ---------- Mediathek ---------- */
/** Wonach eine Liste geordnet wird — die Wörter des Servers. */
export type Ordnung = 'title' | 'added' | 'year';
type Suchfrage = {
library?: string; search?: string; sort?: Ordnung; genre?: string;
skip?: number; take?: number;
};
export const filme = (q: Suchfrage = {}) => anfrage<Seite<Film>>(`/movies${abfrage(q)}`);
export const serien = (q: Suchfrage = {}) => anfrage<Seite<Serie>>(`/series${abfrage(q)}`);
/** Holt aus einer Mediathek, was auch immer darin liegt.
*
* Die Unterscheidung Film/Serie steckt in der Mediathek, nicht im Aufrufer —
* wer eine Reihe zeichnet, will Kacheln und nicht zwei Codepfade. */
export const inhalt = (b: Bibliothek, q: Omit<Suchfrage, 'library'> = {}) =>
b.kind === 'Shows'
? serien({ ...q, library: b.id })
: filme({ ...q, library: b.id });
export const folgen = (serie: string, staffel?: number) =>
anfrage<Seite<Folge>>(`/series/${serie}/episodes${abfrage({ season: staffel })}`);
export const film = (id: string) => anfrage<Film>(`/movies/${id}`);
export const serie = (id: string) => anfrage<Serie>(`/series/${id}`);
export const lieferung = (datei: string) => anfrage<Lieferung>(`/files/${datei}/delivery`);
/* ---------- Stand je Benutzer ---------- */
export const weiterschauen = (take = 20) =>
anfrage<Seite<Angefangen>>(`/home/continue${abfrage({ take })}`);
export const naechsteFolgen = (take = 20) =>
anfrage<Seite<Angefangen>>(`/home/next${abfrage({ take })}`);
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine. */
/** Meldet, wo man steht. Jede Meldung zählt — es gibt keine zu kleine.
*
* `beendet` nimmt die Wiedergabe sofort aus „läuft gerade". Ohne das stünde
* ein geschlossener Spieler dort noch eine Minute. */
export const fortschritt = (
id: string, stelle: number, laenge?: number | null, beendet = false,
) =>
anfrage<Stand>(`/items/${id}/progress`, {
method: 'POST',
body: JSON.stringify({
positionSeconds: stelle, runtimeSeconds: laenge ?? null,
stopped: beendet || null,
}),
});
export const gesehen = (id: string, wert: boolean) =>
anfrage<Stand>(`/items/${id}/watched`, {
method: 'PUT', body: JSON.stringify({ watched: wert }),
});
/** Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. */
export const weglegen = (id: string) =>
anfrage<Stand>(`/items/${id}/dismiss`, { method: 'POST' });
export const favorit = (id: string, wert: boolean) =>
anfrage<Stand>(`/items/${id}/favourite`, {
method: 'PUT', body: JSON.stringify({ favourite: wert }),
});
export const downloadAdresse = (datei: string) => `/api/v1/files/${datei}/download`;
/* ---------- Verwaltung ---------- */
export const bibliotheken = () => anfrage<Bibliothek[]>('/libraries');
/** Nur die Genres, die in dieser Mediathek auch vorkommen. */
export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/genres`);
/** „Weil du Ähnliches gesehen hast" — über die Genres des Gesehenen. */
export const empfehlungen = (take = 20) =>
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
export const metadatenAuffrischen = (id: string) =>
anfrage<void>(`/libraries/${id}/refresh`, { method: 'POST' });
/** Eine laufende Wiedergabe — nur für Administratoren sichtbar. */
export type Wiedergabe = {
userName: string; deviceName: string;
itemId: string; title: string; subTitle: string | null; posterImage: string | null;
positionSeconds: number; runtimeSeconds: number | null;
};
export type Serverzustand = {
playing: number; packaging: number; scanning: number; uptime: number;
};
export const laufendeWiedergaben = () =>
anfrage<Wiedergabe[]>('/system/sessions');
export const serverzustand = () => anfrage<Serverzustand>('/system/status');
export const servernameSetzen = (name: string) =>
anfrage<void>('/system/name', { method: 'PUT', body: JSON.stringify({ name }) });
export const bibliothekUmbenennen = (id: string, name: string) =>
anfrage<Bibliothek>(`/libraries/${id}`, {
method: 'PUT', body: JSON.stringify({ name }),
});
/** Entfernt den Eintrag — die Mediendateien bleiben liegen. */
export const bibliothekLoeschen = (id: string) =>
anfrage<void>(`/libraries/${id}`, { method: 'DELETE' });
export const bibliothekAnlegen = (name: string, art: string, pfad: string) =>
anfrage<Bibliothek>('/libraries', {
method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }),
});
export const scanStarten = (id: string) =>
anfrage<unknown>(`/libraries/${id}/scan`, { method: 'POST' });
export const scanstand = (id: string) => anfrage<Scanstand>(`/libraries/${id}/scan`);
export const benutzer = () => anfrage<Rolle[]>('/users');
export const benutzerAnlegen = (name: string, passwort: string, admin: boolean) =>
anfrage<Rolle>('/users', {
method: 'POST',
body: JSON.stringify({ userName: name, password: passwort, isAdministrator: admin }),
});
export const metadatenEinstellung = () =>
anfrage<{ hasApiKey: boolean; language: string; usingProjectKey: boolean }>('/settings/metadata');
export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) =>
anfrage<void>('/settings/metadata', { method: 'PUT', body: JSON.stringify(werte) });
/* ---------- Zuordnung ---------- */
export const vorschlaege = (art: 'movies' | 'series', id: string, suche?: string, jahr?: number) =>
anfrage<Vorschlag[]>(`/${art}/${id}/matches${abfrage({ query: suche, year: jahr })}`);
export const zuordnen = (art: 'movies' | 'series', id: string, tmdbId: number) =>
anfrage<{ tmdbId: number; imdbId: string | null; locked: boolean; title: string; year: number | null }>(
`/${art}/${id}/match`, { method: 'PUT', body: JSON.stringify({ tmdbId }) });
/* ---------- Hilfsmittel ---------- */
function abfrage(werte: Record<string, string | number | undefined>): string {
const teile = Object.entries(werte)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`);
return teile.length ? `?${teile.join('&')}` : '';
}
+174
View File
@@ -0,0 +1,174 @@
import { bildAdresse, type Film, type Serie, type Angefangen, type Stand } from './api';
import { esc, anteil } from './util';
import { icons } from './icons';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = {
id: string;
art: 'filme' | 'serien';
/** Die obere, fette Zeile. Bei einer Folge die Serie. */
titel: string;
/** Die zweite Zeile. Bei einer Folge der Folgentitel, sonst das Jahr. */
zweite?: string | null;
/** Die dritte Zeile — „S14 · F22". Nur bei Folgen. */
dritte?: string | null;
bild: string | null;
stand?: Stand | null;
/** Die Zahl offener Folgen, als Plakette oben rechts. */
offen?: number | null;
};
/* ---------- Umwandlungen ---------- */
export const alsKachel = (e: Film | Serie): Kachel => ({
id: e.id,
art: 'episodeCount' in e ? 'serien' : 'filme',
titel: e.title,
zweite: e.year ? String(e.year) : null,
bild: e.posterImage,
stand: 'state' in e ? e.state : null,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
});
/** Eine Kachel aus „Weiterschauen" — bei Folgen dreizeilig, wie bei Plex. */
export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode'
? {
id: e.id, art: 'serien',
titel: e.title,
zweite: e.subTitle,
dritte: `S${e.season} · F${e.episode}`,
bild: e.posterImage, stand: e.state,
}
: {
id: e.id, art: 'filme',
titel: e.title,
zweite: e.year ? String(e.year) : null,
bild: e.posterImage, stand: e.state,
};
/* ---------- Die Kachel selbst ---------- */
/** Baut eine Plakatkachel.
*
* **Alles auf einer Kachel steht an einer Stelle**, weil sie an fünf Stellen
* vorkommt — Startseite, Mediathek, Vorschläge, Suche, Staffeln. Jede davon
* eigen zu zeichnen hiesse: der Haken für Gesehenes erscheint an vier
* Stellen und an der fünften nicht, und niemand weiss, warum. */
export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement {
const knopf = document.createElement('button');
knopf.className = 'kachel';
knopf.onclick = () => oeffnen(k.art, k.id);
const rahmen = document.createElement('div');
rahmen.className = 'plakat';
rahmen.setAttribute('role', 'img');
rahmen.setAttribute('aria-label', k.titel);
const adresse = bildAdresse(k.bild);
if (adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.loading = 'lazy';
// Erst einblenden, wenn es da ist — sonst blitzt der Platzhalter durch.
bild.onload = () => rahmen.classList.add('geladen');
rahmen.appendChild(bild);
}
// **Der Haken sitzt in der Ecke, nicht in der Beschriftung.** So wie bei
// Plex: ein Blick über das Raster zeigt, was noch offen ist, ohne eine
// einzige Zeile zu lesen.
if (k.stand?.watched) {
const haken = document.createElement('span');
haken.className = 'ecke haken';
haken.innerHTML = icons.haken;
haken.title = 'Gesehen';
rahmen.appendChild(haken);
} else if (k.offen != null && k.offen > 0) {
const plakette = document.createElement('span');
plakette.className = 'ecke zahl';
plakette.textContent = String(k.offen);
plakette.title = `${k.offen} Folgen offen`;
rahmen.appendChild(plakette);
}
// Der Balken unten — ab der ersten Sekunde, mit Mindestbreite, sonst wären
// zwei Minuten eines Zweistünders anderthalb Bildpunkte.
const wie = anteil(k.stand);
if (wie != null && wie > 0 && !k.stand?.watched) {
const balken = document.createElement('div');
balken.className = 'fortschritt';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, wie * 100)}%`;
balken.appendChild(gefuellt);
rahmen.appendChild(balken);
}
// Beim Überfahren ein Abspielknopf in der Mitte — auch das von Plex.
const schweben = document.createElement('span');
schweben.className = 'schweber';
schweben.innerHTML = icons.abspielen;
rahmen.appendChild(schweben);
knopf.appendChild(rahmen);
const text = document.createElement('div');
text.className = 'kacheltext';
text.innerHTML =
`<strong>${esc(k.titel)}</strong>`
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '')
+ (k.dritte ? `<span class="dritte">${esc(k.dritte)}</span>` : '');
knopf.appendChild(text);
return knopf;
}
/** Eine waagerechte Reihe mit Überschrift und Pfeilen. */
export function reihe(
titel: string,
kacheln: HTMLElement[],
mehr?: { text: string; tun: () => void },
): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'reihe';
const kopf = document.createElement('div');
kopf.className = 'reihe-kopf';
const h = document.createElement('h2');
h.textContent = titel;
kopf.appendChild(h);
const streifen = document.createElement('div');
streifen.className = 'strip';
kacheln.forEach(k => streifen.appendChild(k));
// **Pfeile, weil eine Reihe mit zwanzig Kacheln nicht in den Bildschirm
// passt.** Scrollen mit dem Rad geht quer nur mit Umschalttaste, und ein
// Trackpad hat nicht jeder.
const steuerung = document.createElement('div');
steuerung.className = 'reihe-pfeile';
(['links', 'rechts'] as const).forEach(richtung => {
const p = document.createElement('button');
p.className = 'pfeil';
p.innerHTML = icons[richtung];
p.setAttribute('aria-label', richtung === 'links' ? 'Zurück' : 'Weiter');
p.onclick = () => streifen.scrollBy({
left: (richtung === 'links' ? -1 : 1) * Math.max(240, streifen.clientWidth * 0.8),
behavior: 'smooth',
});
steuerung.appendChild(p);
});
if (mehr) {
const m = document.createElement('button');
m.className = 'mehr';
m.textContent = mehr.text;
m.onclick = mehr.tun;
kopf.appendChild(m);
}
kopf.appendChild(steuerung);
bereich.append(kopf, streifen);
return bereich;
}
-97
View File
@@ -1,97 +0,0 @@
/* Kontrastrechnung nach WCAG 2.1 — dieselbe wie in der App.
----------------------------------------------------------------------------
Flaro lässt jede Farbe zu, aber ein dunkles Marineblau auf fast schwarzem
Grund ist als Schrift unlesbar. Solche Farben werden angehoben, bis sie den
geforderten Kontrast erreichen — und es wird gesagt, statt die Farbe
stillschweigend zu verändern.
Zuerst die Helligkeit, weil das den Farbton erhält: eine gewählte
Akzentfarbe soll erkennbar dieselbe bleiben. Reicht selbst volle Helligkeit
nicht, wird die Sättigung zurückgenommen, und die Farbe wandert Richtung
Weiß. */
export type RGB = [number, number, number];
const MINDESTENS = 4.5;
export function leuchtdichte([r, g, b]: RGB): number {
// Der Knick bei 0,03928 ist kein Rundungsfehler, sondern Teil der
// sRGB-Definition: unterhalb davon ist die Kurve linear.
const linear = (c: number): number =>
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
}
export function verhaeltnis(a: RGB, b: RGB): number {
const la = leuchtdichte(a);
const lb = leuchtdichte(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
export function lesbarMachen(farbe: RGB, grund: RGB): { farbe: RGB; geaendert: boolean } {
if (verhaeltnis(farbe, grund) >= MINDESTENS) return { farbe, geaendert: false };
const [h, s, v] = zuHSV(farbe);
// Beide Schritte sind monoton — mehr Helligkeit und weniger Sättigung
// erhöhen die Leuchtdichte immer. Deshalb genügt eine binäre Suche, und
// sie endet garantiert.
const suche = (tief: number, hoch: number, bauen: (x: number) => RGB): RGB | null => {
if (verhaeltnis(bauen(hoch), grund) < MINDESTENS) return null;
let u = tief;
let o = hoch;
for (let i = 0; i < 24; i++) {
const mitte = (u + o) / 2;
if (verhaeltnis(bauen(mitte), grund) >= MINDESTENS) o = mitte;
else u = mitte;
}
return bauen(o);
};
const heller = suche(v, 1, (x) => ausHSV(h, s, x));
if (heller) return { farbe: heller, geaendert: true };
const blasser = suche(s, 0, (x) => ausHSV(h, x, 1));
return { farbe: blasser ?? [1, 1, 1], geaendert: true };
}
function zuHSV([r, g, b]: RGB): RGB {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d) {
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h /= 6;
if (h < 0) h += 1;
}
return [h, max ? d / max : 0, max];
}
function ausHSV(h: number, s: number, v: number): RGB {
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
const saetze: RGB[] = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]];
return saetze[i % 6]!;
}
export function ausHex(hex: string): RGB | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return null;
const n = parseInt(m[1]!, 16);
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
}
export function alsHex([r, g, b]: RGB): string {
return (
'#' +
[r, g, b]
.map((c) => Math.round(Math.min(Math.max(c, 0), 1) * 255).toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
);
}
+196
View File
@@ -0,0 +1,196 @@
import { icons } from './icons';
import { knopf } from './util';
/* Dialoge über dem Inhalt statt Blöcken darin.
*
* **Mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten.** Der
* kann, was ein `<div>` erst lernen müsste: Escape schliesst, der
* Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, und
* Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst
* baut, ist eine Zeile, die es schlechter kann. */
export type Feldart = 'text' | 'password' | 'number';
export type Dialogfeld = {
name: string;
beschriftung: string;
art?: Feldart;
wert?: string;
platzhalter?: string;
hinweis?: string;
/** Eine Auswahl statt eines Feldes. */
auswahl?: { wert: string; text: string }[];
/** Ein Schalter statt eines Feldes. */
schalter?: boolean;
};
/** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „".
*
* Als Funktion statt als Objekt: ein Tippfehler im Feldnamen gäbe sonst
* stillschweigend `undefined` zurück und landete so im Server. */
export type Dialogwerte = (name: string) => string;
/** Zeigt einen Dialog mit Feldern und gibt zurück, was eingetippt wurde.
*
* `null` heisst abgebrochen — nicht „leer". Die beiden zu verwechseln hiesse,
* ein Abbrechen wie ein Speichern zu behandeln. */
export function dialog(optionen: {
titel: string;
text?: string;
felder?: Dialogfeld[];
bestaetigen: string;
/** Färbt den Knopf rot — für alles, was etwas wegnimmt. */
gefaehrlich?: boolean;
/** Wird mit den Werten gerufen; wirft sie, bleibt der Dialog offen. */
tun: (wert: Dialogwerte) => Promise<void>;
}): void {
const d = document.createElement('dialog');
d.className = 'dlg';
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = optionen.titel;
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
if (optionen.text) {
const p = document.createElement('p');
p.className = 'hinweis';
p.textContent = optionen.text;
koerper.appendChild(p);
}
const eingaben = new Map<string, HTMLInputElement | HTMLSelectElement>();
(optionen.felder ?? []).forEach(f => {
const block = document.createElement('label');
block.className = 'dlg-feld';
const titel = document.createElement('span');
titel.className = 'dlg-beschriftung';
titel.textContent = f.beschriftung;
block.appendChild(titel);
if (f.auswahl) {
const wahl = document.createElement('select');
wahl.className = 'feld';
f.auswahl.forEach(o => {
const e = document.createElement('option');
e.value = o.wert; e.textContent = o.text;
wahl.appendChild(e);
});
if (f.wert) wahl.value = f.wert;
eingaben.set(f.name, wahl);
block.appendChild(wahl);
} else if (f.schalter) {
const e = document.createElement('input');
e.type = 'checkbox';
e.checked = f.wert === 'true';
eingaben.set(f.name, e);
// Der Schalter steht vor seiner Beschriftung, nicht darunter.
block.classList.add('waagerecht');
block.prepend(e);
titel.remove();
block.append(titel);
} else {
const e = document.createElement('input');
e.className = 'feld';
e.type = f.art ?? 'text';
e.value = f.wert ?? '';
e.placeholder = f.platzhalter ?? '';
eingaben.set(f.name, e);
block.appendChild(e);
}
if (f.hinweis) {
const kleingedrucktes = document.createElement('span');
kleingedrucktes.className = 'winzig';
kleingedrucktes.textContent = f.hinweis;
block.appendChild(kleingedrucktes);
}
koerper.appendChild(block);
});
const fehlerplatz = document.createElement('div');
koerper.appendChild(fehlerplatz);
const fuss = document.createElement('div');
fuss.className = 'dlg-fuss';
const abbrechen = knopf('Abbrechen', 'btn');
abbrechen.onclick = () => d.close();
const los = knopf(optionen.bestaetigen,
optionen.gefaehrlich ? 'btn btn-gefahr' : 'btn btn-primary');
fuss.append(abbrechen, los);
los.onclick = async () => {
fehlerplatz.innerHTML = '';
los.disabled = true;
const gesammelt = new Map<string, string>();
eingaben.forEach((e, name) => {
gesammelt.set(name, e instanceof HTMLInputElement && e.type === 'checkbox'
? String(e.checked)
: e.value.trim());
});
try {
await optionen.tun(name => gesammelt.get(name) ?? '');
d.close();
} catch (fehler) {
// **Offen bleiben.** Einen Dialog bei einem Fehler zu schliessen
// hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu
// schreiben, wo gerade niemand hinsieht.
const m = document.createElement('div');
m.className = 'fehler';
m.textContent = (fehler as Error).message;
fehlerplatz.appendChild(m);
los.disabled = false;
}
};
d.append(kopf, koerper, fuss);
// Der Dialog räumt sich selbst weg — sonst sammeln sich im Dokument
// beliebig viele geschlossene an.
d.addEventListener('close', () => d.remove());
// Ein Klick auf den Hintergrund schliesst, ein Klick in den Dialog nicht.
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
const erstes = koerper.querySelector<HTMLElement>('input, select');
erstes?.focus();
// Enter im Feld bestätigt — ausser im mehrzeiligen Fall, den es hier
// nicht gibt.
koerper.addEventListener('keydown', e => {
if (e.key === 'Enter' && !(e.target as HTMLElement).matches('select')) {
e.preventDefault();
los.click();
}
});
}
/** Fragt einmal nach — für alles, was etwas wegnimmt. */
export function nachfragen(optionen: {
titel: string;
text: string;
bestaetigen: string;
tun: () => Promise<void>;
}): void {
dialog({
titel: optionen.titel,
text: optionen.text,
bestaetigen: optionen.bestaetigen,
gefaehrlich: true,
tun: () => optionen.tun(),
});
}
+45 -47
View File
@@ -1,51 +1,49 @@
/* Die Symbole der Oberfläche.
----------------------------------------------------------------------------
Als Vektor, nicht als Schriftzeichen. Ein Wiedergabedreieck oder ein
Lautsprecher-Emoji wird auf jeder Plattform anders gezeichnet, trägt fremde
Farben, sitzt schief auf der Grundlinie und lässt sich weder in der
Strichstärke noch in der Farbe anpassen — es sieht nach Platzhalter aus,
weil es einer ist.
/* Icons als SVG, nicht als Zeichen aus einer Schriftart.
*
* **Keine Emojis.** Ein ▶ oder ✓ aus Unicode sieht auf jedem System anders
* aus, lässt sich nicht einfärben, nicht in der Strichstärke anpassen und
* springt in der Grundlinie. Diese hier sind auf 24×24 gezeichnet, erben die
* Textfarbe und skalieren mit der Schriftgrösse.
*
* Der Strichstil ist durchgehend derselbe: 1.8 Einheiten, runde Enden. */
`currentColor` erbt Farbe und Zustand vom Knopf darum. */
const rahmen = (inhalt: string, gefuellt = false): string =>
`<svg viewBox="0 0 24 24" aria-hidden="true" fill="${gefuellt ? 'currentColor' : 'none'}"`
+ ` stroke="${gefuellt ? 'none' : 'currentColor'}" stroke-width="1.8"`
+ ` stroke-linecap="round" stroke-linejoin="round">${inhalt}</svg>`;
type Zeichner = string | ((n: number) => string);
export const icons = {
start: rahmen('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/>'),
/** Filmstreifen — für eine Filmmediathek. */
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
/** Bildschirm — für eine Serienmediathek. */
serien: rahmen('<rect x="2.5" y="4.5" width="19" height="12" rx="2"/><path d="M8 20h8"/>'),
verwaltung: rahmen('<circle cx="12" cy="12" r="3.2"/>'
+ '<path d="M19.4 14.5a1.6 1.6 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.6 1.6 0 0 0 1.8.3H9a1.6 1.6 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.6 1.6 0 0 0-.3 1.8V9a1.6 1.6 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.6 1.6 0 0 0-1.5 1z"/>'),
suche: rahmen('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
abspielen: rahmen('<path d="M8 5.5v13l11-6.5z"/>', true),
haken: rahmen('<path d="m5 12.5 4.5 4.5L19 7"/>'),
hakenKreis: rahmen('<circle cx="12" cy="12" r="9"/><path d="m8 12.3 2.8 2.7L16 9.6"/>'),
stift: rahmen('<path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>'),
download: rahmen('<path d="M12 3v12"/><path d="m7.5 11 4.5 4.5L16.5 11"/><path d="M4 20h16"/>'),
links: rahmen('<path d="M15 5 8 12l7 7"/>'),
rechts: rahmen('<path d="m9 5 7 7-7 7"/>'),
mehr: rahmen('<circle cx="12" cy="5.5" r="1.3" fill="currentColor" stroke="none"/>'
+ '<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none"/>'
+ '<circle cx="12" cy="18.5" r="1.3" fill="currentColor" stroke="none"/>'),
abmelden: rahmen('<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3"/>'
+ '<path d="M10 16.5 14.5 12 10 7.5"/><path d="M14.5 12H4"/>'),
schliessen: rahmen('<path d="M6 6l12 12M18 6 6 18"/>'),
stern: rahmen('<path d="m12 3.6 2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.8l5.9-.9z"/>'),
} as const;
const ICONS: Record<string, Zeichner> = {
play: '<path d="M8 5.5v13l11-6.5z" fill="currentColor" stroke="none"/>',
waveform: '<path d="M3 12h2M7 8v8M11 4.5v15M15 8v8M19 11h2"/>',
speed: '<path d="M12 20a8 8 0 100-16 8 8 0 000 16z"/><path d="M12 12l4-3"/>',
// Kreispfeile mit der Sekundenzahl darin — dasselbe Bild wie
// `gobackward.10` und `goforward.30` auf dem iPad.
goBack: (n) => `<path d="M11.5 4.5L7 8l4.5 3.5" /><path d="M7 8h4.5a7.5 7.5 0 11-7.2 9.6"/>`
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
goFwd: (n) => `<path d="M12.5 4.5L17 8l-4.5 3.5"/><path d="M17 8h-4.5a7.5 7.5 0 107.2 9.6"/>`
+ `<text x="12" y="17.6" text-anchor="middle" font-size="7.5" font-weight="700"`
+ ` fill="currentColor" stroke="none" font-family="inherit">${n}</text>`,
captions: '<rect x="3" y="5.5" width="18" height="13" rx="3"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3"/>',
captionsOn: '<rect x="3" y="5.5" width="18" height="13" rx="3" fill="currentColor" stroke="none"/><path d="M9.5 10.5a2.2 2.2 0 100 3M16 10.5a2.2 2.2 0 100 3" stroke="#0B0B0D"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
// Rechteck mit Balken oben bzw. Spalte links — die Leiste zieht dorthin.
barTop: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M2 9h20"/>'
+ '<rect x="2" y="3.5" width="20" height="5.5" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
barSide: '<rect x="2" y="3.5" width="20" height="17" rx="3"/><path d="M8.5 3.5v17"/>'
+ '<rect x="2" y="3.5" width="6.5" height="17" rx="3" fill="currentColor" stroke="none" opacity=".9"/>',
pause: '<path d="M9 5.5v13M15 5.5v13"/>',
close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
sound: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9a4 4 0 010 6"/><path d="M18.5 6.5a7.5 7.5 0 010 11"/>',
muted: '<path d="M4 9.5v5h3.5L12 18.5v-13L7.5 9.5H4z" fill="currentColor"/><path d="M16 9.5l5 5M21 9.5l-5 5"/>',
expand: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
left: '<path d="M14.5 5.5L8 12l6.5 6.5"/>',
right: '<path d="M9.5 5.5L16 12l-6.5 6.5"/>',
up: '<path d="M12 18.5v-13M6.5 11L12 5.5 17.5 11"/>',
down: '<path d="M12 5.5v13M6.5 13L12 18.5 17.5 13"/>',
}
/** Ein Symbol als Vektor. `size` nur, wo es von der Vorgabe abweicht. */
export function icon(name: keyof typeof ICONS, size?: number, arg?: number): string {
const eintrag = ICONS[name];
const inhalt = typeof eintrag === 'function' ? eintrag(arg ?? 0) : eintrag;
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
${size ? `style="width:${size}px;height:${size}px"` : ''}>${inhalt}</svg>`;
export type IconName = keyof typeof icons;
/** Ein Icon als Element — für alles, was nicht aus einer Zeichenkette gebaut wird. */
export function icon(name: IconName, klasse = 'icon'): HTMLElement {
const h = document.createElement('span');
h.className = klasse;
h.innerHTML = icons[name];
return h;
}
+191 -173
View File
@@ -1,193 +1,211 @@
import './style.css';
import type { Item, Seite } from './types';
import { api, sitzung, angemeldet, abmelden } from './api';
import { P, anwenden, setzen } from './prefs';
import { icon } from './icons';
import { esc, FELDER } from './util';
import {
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken,
type Hinweis, type Bibliothek,
} from './api';
import { icons, icon } from './icons';
import { einrichtung } from './views/setup';
import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
import { einstellungen } from './views/settings';
import { blatt } from './views/cards';
import { playerOeffnen } from './views/player';
import { detailseite } from './views/detail';
import { verwaltung } from './views/admin';
import { suchseite } from './views/search';
import { esc } from './util';
const anmeldeflaeche = document.getElementById('login')!;
const kopfleiste = document.getElementById('topbar')!;
const app = document.getElementById('app')!;
const leiste = document.getElementById('chrome')!;
const inhalt = document.getElementById('content')!;
const insel = document.getElementById('island')!;
const chrome = document.getElementById('chrome')!;
let ansichten: Item[] = [];
let wo = 'home';
let hinweise: Hinweis[] = [];
let mediatheken: Bibliothek[] = [];
let serverName = 'Flaro';
/* ---------- Navigation ---------- */
/* ---------- Start ---------- */
function inselBauen(): void {
insel.innerHTML = '';
void hochfahren();
// Der Umschalter zuerst, wie auf dem iPad: an der Seite oben in der Ecke,
// oben ganz links vor den Einträgen.
const nav = document.createElement('button');
nav.className = 'toggle-nav';
nav.title = P.nav === 'side' ? 'Leiste nach oben' : 'Leiste an die Seite';
nav.setAttribute('aria-label', nav.title);
nav.innerHTML = icon(P.nav === 'side' ? 'barTop' : 'barSide', 18);
nav.onclick = () => leisteUmziehen(P.nav === 'side' ? 'top' : 'side');
insel.appendChild(nav);
const stueck = (klasse: string, text?: string): void => {
const d = document.createElement('div');
d.className = klasse;
if (text) d.textContent = text;
insel.appendChild(d);
};
const eintrag = (text: string, ziel: string): void => {
const b = document.createElement('button');
b.textContent = text;
b.dataset.key = ziel;
b.onclick = () => gehe(ziel);
insel.appendChild(b);
};
stueck('brand', 'Flaro');
eintrag('Start', 'home');
eintrag('Einstellungen', 'settings');
if (ansichten.length) {
stueck('sep');
stueck('group-label', 'Mediatheken');
for (const v of ansichten) eintrag(v.Name ?? '—', 'lib:' + v.Id);
}
stueck('sep');
eintrag('Abmelden', 'out');
[...insel.querySelectorAll('button[data-key]')].forEach((b) =>
b.classList.toggle('active', (b as HTMLElement).dataset.key === wo));
}
/* Lässt die Leiste von ihrer alten Lage in die neue fahren.
----------------------------------------------------------------------------
**Zwei Züge, nicht einer.** Ein reiner Weg von A nach B sieht aus, als würde
ein Kasten verschoben. Die Leiste zieht sich erst *in sich* zusammen und
zieht dann erst um — wie ein Gummiband, das sich sammelt, bevor es schnellt.
Gemacht mit FLIP: alte Lage messen, umbauen, neue Lage messen, und vom alten
Zustand in den neuen fahren. Über CSS allein ginge das nicht — `right: auto`
und `bottom: auto` lassen sich nicht animieren, und genau die wechseln. */
function leisteUmziehen(nach: 'top' | 'side'): void {
const vorher = insel.getBoundingClientRect();
const sparsam = matchMedia('(prefers-reduced-motion: reduce)').matches;
setzen('nav', nach);
inselBauen();
if (sparsam) return;
const nachher = insel.getBoundingClientRect();
if (!vorher.width || !nachher.width) return;
const dx = vorher.left - nachher.left;
const dy = vorher.top - nachher.top;
const sx = vorher.width / nachher.width;
const sy = vorher.height / nachher.height;
// Welche Richtung sich zuerst sammelt, hängt davon ab, wohin es geht: Wer
// nach oben zieht, wird flach — also klappt zuerst die Höhe zusammen.
const gesammelt = nach === 'top'
? `translate(${dx}px, ${dy}px) scale(${sx}, 1)`
: `translate(${dx}px, ${dy}px) scale(1, ${sy})`;
insel.animate(
[
{ transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
easing: 'cubic-bezier(.5,0,.75,0)', offset: 0 },
{ transformOrigin: 'top left', transform: gesammelt,
easing: 'cubic-bezier(.22,1,.36,1)', offset: 0.42 },
{ transformOrigin: 'top left', transform: 'none', offset: 1 },
],
{ duration: 520 },
);
insel.animate([{ opacity: 0.35 }, { opacity: 0.5, offset: 0.42 }, { opacity: 1 }],
{ duration: 420, easing: 'ease-out' });
}
function gehe(ziel: string): void {
if (ziel === 'out') return abmelden();
wo = ziel;
[...insel.querySelectorAll('button[data-key]')].forEach((b) =>
b.classList.toggle('active', (b as HTMLElement).dataset.key === ziel));
scrollTo({ top: 0 });
inhalt.classList.remove('pad-top');
if (ziel === 'home') void startseite(inhalt, ansichten, tippen);
else if (ziel === 'settings') einstellungen(inhalt, () => gehe('home'));
else void mediathek(inhalt, ziel.slice(4), tippen);
}
/* ---------- Was beim Antippen geschieht ---------- */
function tippen(item: Item): void {
blatt(item, starten);
}
/** Startet — und bei einer Serie die Folge, die als Nächstes dran ist.
*
* Eine Serie gar nicht abzuspielen war die falsche Antwort: die
* offensichtliche Erwartung ist, dass es losgeht. */
async function starten(item: Item): Promise<void> {
const ordner = ['Series', 'Season'].includes(item.Type ?? '');
const ziel = ordner ? await naechsteFolge(item) : item;
if (!ziel) return;
playerOeffnen(ziel, () => { if (wo === 'home') void startseite(inhalt, ansichten, tippen); });
}
async function naechsteFolge(item: Item): Promise<Item | null> {
const serie = item.Type === 'Season' ? (item.SeriesId ?? item.Id) : item.Id;
async function hochfahren(): Promise<void> {
try {
// Erst „Als Nächstes": wer eine Serie anfängt und zurückkommt, meint die
// Folge, bei der er stehengeblieben ist — nicht die erste.
const weiter = await api<Seite>('/Shows/NextUp?' + new URLSearchParams({
userId: sitzung.userId, seriesId: serie, Limit: '1', Fields: FELDER,
}));
const folge = weiter.Items?.[0];
if (folge && (item.Type !== 'Season' || folge.SeasonId === item.Id)) return folge;
} catch { /* dann eben die erste */ }
const auskunft = await systemauskunft();
hinweise = auskunft.attributions;
serverName = auskunft.serverName ?? 'Flaro';
try {
const q: Record<string, string> = { userId: sitzung.userId, Limit: '1', Fields: FELDER };
if (item.Type === 'Season') q.seasonId = item.Id;
const alle = await api<Seite>(`/Shows/${serie}/Episodes?` + new URLSearchParams(q));
return alle.Items?.[0] ?? null;
} catch {
return null;
}
}
if (auskunft.needsSetup) { zeigeAnmeldung(einrichtung(() => location.reload())); return; }
if (!angemeldet()) { zeigeAnmeldung(anmeldung(() => location.reload())); return; }
/* ---------- Los ---------- */
async function starte(): Promise<void> {
document.getElementById('login')!.classList.add('hidden');
anmeldeflaeche.classList.add('hidden');
kopfleiste.classList.remove('hidden');
app.classList.remove('hidden');
anwenden();
inhalt.innerHTML = '<div class="spinner"></div>';
try {
const r = await api<Seite>(`/Users/${sitzung.userId}/Views`);
ansichten = (r.Items ?? []).filter((v) => ['movies', 'tvshows'].includes(v.CollectionType ?? ''));
inselBauen();
gehe('home');
} catch (e) {
inhalt.innerHTML = `<div class="empty pad-top">${esc((e as Error).message)}<br><br>
<button class="btn" id="raus">Abmelden</button></div>`;
document.getElementById('raus')!.onclick = abmelden;
// **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek
// einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht
// auflösen.
mediatheken = await bibliotheken();
kopfleisteBauen();
leisteBauen();
window.addEventListener('hashchange', zeichnen);
zeichnen();
} catch (fehler) {
// Der Server ist nicht erreichbar. Die Anmeldemaske zu zeigen wäre eine
// Lüge — sie würde bei jedem Versuch scheitern.
anmeldeflaeche.innerHTML =
`<div class="login-card"><h1>Flaro</h1>`
+ `<div class="fehler">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
}
// Die Insel oben fährt beim Scrollen weg und kommt zurück, sobald man wieder
// oben steht. An der Seite bleibt sie stehen — dort nimmt sie niemandem etwas.
addEventListener('scroll', () => {
if (P.nav === 'side') return;
chrome.classList.toggle('away', scrollY > 8);
}, { passive: true });
function zeigeAnmeldung(karte: HTMLElement): void {
anmeldeflaeche.innerHTML = '';
anmeldeflaeche.appendChild(karte);
anmeldeflaeche.classList.remove('hidden');
kopfleiste.classList.add('hidden');
app.classList.add('hidden');
}
anwenden();
if (angemeldet()) void starte();
else anmeldung(document.getElementById('login')!, () => void starte());
export async function mediathekenNeuLaden(): Promise<void> {
mediatheken = await bibliotheken();
leisteBauen();
zeichnen();
}
/* ---------- Kopfleiste ---------- */
function kopfleisteBauen(): void {
kopfleiste.innerHTML = '';
const marke = document.createElement('div');
marke.className = 'marke';
marke.textContent = 'Flaro';
// **Die Suche gehört nach oben, nicht in jede Mediathek.** Bei Plex steht
// sie in der Kopfleiste und geht quer durch alles; wer einen Titel sucht,
// weiss oft gar nicht, in welcher Mediathek er liegt.
const suche = document.createElement('form');
suche.className = 'suchfeld';
suche.innerHTML = icons.suche;
const eingabe = document.createElement('input');
eingabe.type = 'search';
eingabe.placeholder = 'Suchen';
eingabe.setAttribute('aria-label', 'Mediathek durchsuchen');
suche.appendChild(eingabe);
suche.onsubmit = e => {
e.preventDefault();
const text = eingabe.value.trim();
if (text) location.hash = `#/suche/${encodeURIComponent(text)}`;
};
const rechts = document.createElement('div');
rechts.className = 'rechts';
const konto = document.createElement('button');
konto.className = 'konto';
const name = sitzung.wer?.userName ?? '';
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
konto.title = `${name} — abmelden`;
konto.appendChild(icon('abmelden'));
konto.onclick = () => { abmelden(); location.reload(); };
rechts.appendChild(konto);
kopfleiste.append(marke, suche, rechts);
}
/* ---------- Seitenleiste ---------- */
function leisteBauen(): void {
leiste.innerHTML = '';
eintrag('Start', '#/', 'start');
// **Jede Mediathek ihre eigene Zeile**, mit eigenem Icon und Zahl — eine
// Zeile „Filme", hinter der zwei getrennt angelegte Bestände liegen, nähme
// die Entscheidung zurück, sie zu trennen.
if (mediatheken.length) {
gruppe('Mediatheken');
mediatheken.forEach(b => eintrag(
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount));
}
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
function gruppe(text: string): void {
const d = document.createElement('div');
d.className = 'nav-gruppe';
d.textContent = text;
leiste.appendChild(d);
}
function eintrag(
text: string, ziel: string, bild: keyof typeof icons, anzahl?: number | null,
): void {
const b = document.createElement('button');
b.dataset.ziel = ziel;
b.innerHTML = `<span class="icon">${icons[bild]}</span>`
+ `<span class="name">${esc(text)}</span>`
+ (anzahl != null ? `<span class="zahl">${anzahl}</span>` : '');
b.onclick = () => { location.hash = ziel; };
leiste.appendChild(b);
}
}
/* ---------- Router ---------- */
function zeichnen(): void {
const weg = location.hash.replace(/^#\/?/, '');
const teile = weg.split('/').filter(Boolean);
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
const ziel = (b as HTMLElement).dataset.ziel!;
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
});
inhalt.innerHTML = '';
window.scrollTo(0, 0);
const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; };
const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; };
const art = teile[0] === 'filme' || teile[0] === 'serien' ? teile[0] : null;
const kennung = teile[1];
if (art && kennung) {
inhalt.appendChild(detailseite(art, kennung, () => history.back()));
} else if (teile[0] === 'm' && kennung) {
const b = mediatheken.find(x => x.id === kennung);
// Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem
// anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger
// als eine Fehlermeldung über eine Kennung, die niemand gelesen hat.
if (b) inhalt.appendChild(mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen));
else location.hash = '#/';
} else if (teile[0] === 'suche' && kennung) {
inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen));
} else if (teile[0] === 'verwaltung' && istAdmin()) {
inhalt.appendChild(verwaltung(mediathekenNeuLaden));
} else {
inhalt.appendChild(startseite(mediatheken, oeffnen, alleZeigen));
}
inhalt.appendChild(hinweisleiste());
}
/** Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, nicht Höflichkeit. */
function hinweisleiste(): HTMLElement {
const d = document.createElement('div');
d.className = 'attribution';
hinweise.forEach(h => {
const a = document.createElement('a');
a.href = h.url;
a.target = '_blank';
a.rel = 'noreferrer';
a.innerHTML = `<img src="${esc(h.logoUrl)}" alt="TMDB">`;
d.append(a, document.createTextNode(h.text));
});
return d;
}
-144
View File
@@ -1,144 +0,0 @@
/* Die Einstellungen.
----------------------------------------------------------------------------
**Pro Gerät, nicht über den Server.** Am Fernseher will man größere Kacheln
als am Laptop, und das eine soll das andere nicht umstellen. Dieselbe
Entscheidung wie in der App, dort nachzulesen in der Spec zum Abgleich.
Namen und Wertebereiche decken sich mit denen der App — ein Regler von
70 bis 180 Prozent heißt auf beiden Seiten dasselbe. Nur der Speicherort
unterscheidet sich, hier localStorage statt UserDefaults. */
export type Navigation = 'top' | 'side';
export type Aufmacherquelle = 'continueFirst' | 'latestOnly';
interface Einstellungen {
accent: string;
nav: Navigation;
cardW: number;
heroSource: Aufmacherquelle;
heroCount: number;
heroHeight: number;
combineContinue: boolean;
hideWatched: boolean;
hiddenRows: string[];
rowOrder: string[];
rate: number;
skipForward: number;
skipBackward: number;
quality: number;
audioLanguage: string;
subtitleLanguage: string;
}
const vorgabe: Einstellungen = {
accent: '#C04AC9',
nav: 'top',
cardW: 150,
heroSource: 'continueFirst',
heroCount: 5,
heroHeight: 82,
combineContinue: true,
hideWatched: false,
hiddenRows: [],
rowOrder: [],
rate: 1,
skipForward: 30,
skipBackward: 10,
quality: 0,
audioLanguage: '',
subtitleLanguage: '',
};
function lesen<K extends keyof Einstellungen>(schluessel: K): Einstellungen[K] {
const roh = localStorage.getItem('flaro.' + schluessel);
if (roh == null) return vorgabe[schluessel];
const art = typeof vorgabe[schluessel];
if (Array.isArray(vorgabe[schluessel])) {
try {
return JSON.parse(roh) as Einstellungen[K];
} catch {
return vorgabe[schluessel];
}
}
if (art === 'number') return Number(roh) as Einstellungen[K];
if (art === 'boolean') return (roh === 'true') as Einstellungen[K];
return roh as Einstellungen[K];
}
/* Ausgeschrieben statt über `Object.fromEntries` zusammengesetzt: dort geht
der Typ jedes einzelnen Feldes verloren, und es bliebe nur ein doppelter
Cast, der die Prüfung stilllegt statt sie zu bestehen. */
export const P: Einstellungen = {
accent: lesen('accent'),
nav: lesen('nav'),
cardW: lesen('cardW'),
heroSource: lesen('heroSource'),
heroCount: lesen('heroCount'),
heroHeight: lesen('heroHeight'),
combineContinue: lesen('combineContinue'),
hideWatched: lesen('hideWatched'),
hiddenRows: lesen('hiddenRows'),
rowOrder: lesen('rowOrder'),
rate: lesen('rate'),
skipForward: lesen('skipForward'),
skipBackward: lesen('skipBackward'),
quality: lesen('quality'),
audioLanguage: lesen('audioLanguage'),
subtitleLanguage: lesen('subtitleLanguage'),
};
export function setzen<K extends keyof Einstellungen>(schluessel: K, wert: Einstellungen[K]): void {
P[schluessel] = wert;
// Listen müssen als JSON abgelegt werden — ein Array landete sonst als
// "a,b,c" im Speicher und käme als Zeichenkette zurück.
localStorage.setItem(
'flaro.' + schluessel,
Array.isArray(wert) ? JSON.stringify(wert) : String(wert),
);
anwenden();
}
export function anwenden(): void {
const r = document.documentElement.style;
r.setProperty('--accent', P.accent);
r.setProperty('--card-w', P.cardW + 'px');
r.setProperty('--hero-h', P.heroHeight + 'vh');
document.getElementById('app')?.classList.toggle('side', P.nav === 'side');
}
/** Die Palette der App, damit die Farben sich decken. */
export const PALETTE: readonly [string, string][] = [
['Ember', '#E5484D'], ['Tangerine', '#F76B15'], ['Amber', '#FFB224'],
['Lime', '#99D52A'], ['Mint', '#3DD68C'], ['Teal', '#12A594'],
['Sky', '#52A9FF'], ['Indigo', '#7C77E8'], ['Orchid', '#C04AC9'],
['Rose', '#F76190'],
];
export const RATES = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0] as const;
export const SKIPS = [5, 10, 15, 30, 45] as const;
export const QUALITIES: readonly [number, string][] = [
[0, 'Original'], [12000, 'Hoch — 12 Mbit/s'], [8000, 'Mittel — 8 Mbit/s'],
[4000, 'Niedrig — 4 Mbit/s'], [2000, 'Sparsam — 2 Mbit/s'],
];
export const LANGUAGES: readonly [string, string][] = [
['', 'Serverstandard'], ['ger', 'Deutsch'], ['eng', 'Englisch'],
['fre', 'Französisch'], ['spa', 'Spanisch'], ['ita', 'Italienisch'],
['jpn', 'Japanisch'], ['kor', 'Koreanisch'],
];
/* Anordnung der Reihen — dieselbe Rechnung wie `Arrangement` in MediaKit,
samt ihrer wichtigsten Eigenschaft: **unbekannte Einträge landen am Ende
statt zu verschwinden.** Sie zu verwerfen wäre die einfachere Regel — und
der Grund dafür, dass eine neu angelegte Mediathek nie aufgetaucht wäre. */
export function anordnen<T extends { id: string }>(reihen: T[]): T[] {
const sichtbar = reihen.filter((r) => !P.hiddenRows.includes(r.id));
if (!P.rowOrder.length) return sichtbar;
const platz = new Map(P.rowOrder.map((id, i) => [id, i]));
return sichtbar
.map((reihe, i) => ({ reihe, rang: platz.get(reihe.id) ?? Infinity, i }))
// Bei gleichem Rang gilt die ursprüngliche Folge — sonst springen zwei
// unbekannte Reihen bei jedem Laden gegeneinander.
.sort((a, b) => a.rang - b.rang || a.i - b.i)
.map((x) => x.reihe);
}
+508 -497
View File
File diff suppressed because it is too large Load Diff
-58
View File
@@ -1,58 +0,0 @@
/** Was Jellyfin über einen Eintrag schickt — nur das, was wir lesen. */
export interface Item {
Id: string;
Name?: string;
Type?: string;
Overview?: string;
ProductionYear?: number;
RunTimeTicks?: number;
OfficialRating?: string;
IndexNumber?: number;
ParentIndexNumber?: number;
SeriesName?: string;
SeriesId?: string;
SeasonId?: string;
CollectionType?: string;
ImageTags?: Record<string, string>;
BackdropImageTags?: string[];
ParentBackdropItemId?: string;
ParentBackdropImageTags?: string[];
SeriesPrimaryImageTag?: string;
UserData?: { PlayedPercentage?: number; Played?: boolean };
}
export interface Seite<T = Item> {
Items?: T[];
TotalRecordCount?: number;
}
/** Eine Spur, wie der Server sie in der Wiedergabe-Auskunft nennt. */
export interface Spur {
Type: 'Video' | 'Audio' | 'Subtitle';
Index: number;
DisplayTitle?: string;
Language?: string;
}
export interface Quelle {
Id?: string;
Container?: string;
TranscodingUrl?: string;
SupportsTranscoding?: boolean;
MediaStreams?: Spur[];
DefaultAudioStreamIndex?: number;
DefaultSubtitleStreamIndex?: number;
}
export interface Wiedergabeauskunft {
PlaySessionId?: string;
MediaSources?: Quelle[];
}
/** Eine Reihe auf der Startseite. */
export interface Reihe {
id: string;
title: string;
items: Item[];
wide?: boolean;
}
+51 -60
View File
@@ -1,74 +1,65 @@
import type { Item } from './types';
import { medienURL } from './api';
/** Entschärft Text, der in HTML landet. */
export function esc(wert: unknown): string {
return String(wert ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
return String(wert ?? '').replace(/[&<>"']/g, z =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[z]!));
}
/** Laufzeit als „2 Std 16 Min" oder „46 Min". */
export function laufzeit(item: Item): string {
if (!item.RunTimeTicks) return '';
const min = Math.round(item.RunTimeTicks / 600_000_000);
return min >= 60 ? `${Math.floor(min / 60)} Std ${min % 60} Min` : `${min} Min`;
/** Sekunden als 1:02:03 oder 2:03. */
export function dauer(sekunden: number | null): string {
if (!sekunden || sekunden <= 0) return '';
const s = Math.round(sekunden);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60);
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
: `${m}:${String(s % 60).padStart(2, '0')}`;
}
/** Zeit als Uhrwerk: „1:02:03" oder „7:15". */
export function uhr(sekunden: number): string {
if (!isFinite(sekunden)) return '–:--';
const s = Math.max(0, Math.floor(sekunden));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
return (h ? `${h}:${String(m).padStart(2, '0')}` : `${m}`) + ':' + String(r).padStart(2, '0');
export function groesse(bytes: number): string {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`;
return `${Math.round(bytes / 1024)} KB`;
}
/** Der Folgentitel: „S2:E4 · Der Fall". */
export function folgentitel(item: Item): string {
const nummer =
item.ParentIndexNumber != null && item.IndexNumber != null
? `S${item.ParentIndexNumber}:E${item.IndexNumber}`
: '';
return [nummer, item.Name].filter(Boolean).join(' · ');
/** Wie weit ein Stand gediehen ist, als Anteil — oder nichts. */
export function anteil(stand: { positionSeconds: number; runtimeSeconds: number | null } | null | undefined)
: number | null {
if (!stand?.runtimeSeconds || stand.runtimeSeconds <= 0) return null;
return stand.positionSeconds / stand.runtimeSeconds;
}
/** Ob ein Eintrag ein Bild dieser Art hat. */
export function hatBild(item: Item, art: 'Primary' | 'Backdrop'): boolean {
return art === 'Backdrop'
? Boolean(item.BackdropImageTags?.length || item.ParentBackdropImageTags?.length)
: Boolean(item.ImageTags?.[art]);
/** „noch 42 Min." — was auf einer Kachel wirklich interessiert. */
export function verbleibend(stand: { positionSeconds: number; runtimeSeconds: number | null }): string {
if (!stand.runtimeSeconds) return '';
const rest = Math.max(0, stand.runtimeSeconds - stand.positionSeconds);
return rest < 60 ? 'fast durch' : `noch ${Math.round(rest / 60)} Min.`;
}
/** Das Bild eines Eintrags — mit den Ersatzwegen, die Jellyfin nötig macht.
*
* Eine Folge hat selten ein eigenes Hintergrundbild, aber ihre Serie hat
* eins. Ohne diese Kette bliebe die halbe Startseite grau. */
export function bild(item: Item, art: 'Primary' | 'Backdrop', breite = 400): string {
if (art === 'Backdrop') {
if (item.BackdropImageTags?.length) {
return medienURL(`/Items/${item.Id}/Images/Backdrop`, {
tag: item.BackdropImageTags[0]!, maxWidth: breite, quality: 90,
});
}
if (item.ParentBackdropItemId) {
return medienURL(`/Items/${item.ParentBackdropItemId}/Images/Backdrop`, {
tag: item.ParentBackdropImageTags?.[0] ?? '', maxWidth: breite, quality: 90,
});
}
}
if (item.ImageTags?.Primary) {
return medienURL(`/Items/${item.Id}/Images/Primary`, {
tag: item.ImageTags.Primary, maxWidth: breite, quality: 90,
});
}
if (item.SeriesPrimaryImageTag && item.SeriesId) {
return medienURL(`/Items/${item.SeriesId}/Images/Primary`, {
tag: item.SeriesPrimaryImageTag, maxWidth: breite, quality: 90,
});
}
return '';
export function knopf(text: string, klasse = 'btn'): HTMLButtonElement {
const b = document.createElement('button');
b.className = klasse;
b.textContent = text;
return b;
}
/** Die Felder, die die Startseite braucht. */
export const FELDER = 'Overview,Genres,MediaSources,ParentBackdropItemId,ParentBackdropImageTags';
export function feld(platzhalter: string, art = 'text'): HTMLInputElement {
const i = document.createElement('input');
i.className = 'feld';
i.type = art;
i.placeholder = platzhalter;
return i;
}
/** Zeigt einen Fehler, ohne die Seite zu verlassen. */
export function fehlerzeile(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'fehler';
d.textContent = text;
return d;
}
export function leer(text: string): HTMLElement {
const d = document.createElement('div');
d.className = 'leer';
d.textContent = text;
return d;
}
+446
View File
@@ -0,0 +1,446 @@
import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
scanStarten, scanstand, metadatenAuffrischen,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
type Bibliothek, type Wiedergabe,
} from '../api';
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
import { dialog, nachfragen } from '../dialog';
import { icon } from '../icons';
/** Die Verwaltung.
*
* **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block
* in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg,
* das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die
* noch aussteht.
*
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function verwaltung(geaendert: () => void = () => {}): HTMLElement {
const wurzel = document.createElement('div');
wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich());
return wurzel;
}
/** Überschrift mit einem Knopf rechts daneben. */
function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement {
const kopf = document.createElement('div');
kopf.className = 'reihe-kopf';
const h = document.createElement('h2');
h.textContent = titel;
kopf.appendChild(h);
if (knoepfe.length) {
const rechts = document.createElement('div');
rechts.className = 'knopfreihe';
rechts.style.marginLeft = 'auto';
knoepfe.forEach(k => rechts.appendChild(k));
kopf.appendChild(rechts);
}
return kopf;
}
/* ---------- Dashboard ---------- */
/** Was gerade läuft.
*
* **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre
* sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten
* offen hat, ist er der grössere Aufwand — auf beiden Seiten. */
function dashboard(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Wird abgespielt'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
const zustand = document.createElement('div');
zustand.className = 'hinweis';
bereich.append(liste, zustand);
let timer: number | undefined;
void laden();
// Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare
// Verwaltung bis zum Neuladen weiter.
const waechter = new MutationObserver(() => {
if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); }
});
waechter.observe(document.body, { childList: true, subtree: true });
async function laden(): Promise<void> {
try {
const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]);
liste.innerHTML = '';
if (laufend.length === 0) {
liste.appendChild(leer('Derzeit wird nichts gespielt.'));
} else {
laufend.forEach(w => liste.appendChild(wiedergabe(w)));
}
const teile = [`Läuft seit ${laufzeit(stand.uptime)}`];
if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`);
if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`);
zustand.textContent = teile.join(' · ');
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
return; // Nicht weiter nachfragen, wenn es scheitert.
}
timer = window.setTimeout(() => void laden(), 5000);
}
function wiedergabe(w: Wiedergabe): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung laeuft';
const bild = document.createElement('div');
bild.className = 'laeuft-bild';
const adresse = bildAdresse(w.posterImage);
if (adresse) {
const img = document.createElement('img');
img.src = adresse;
img.alt = '';
bild.appendChild(img);
}
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const wie = w.runtimeSeconds
? `${dauer(w.positionSeconds)} von ${dauer(w.runtimeSeconds)}`
: dauer(w.positionSeconds);
text.innerHTML = `<strong>${esc(w.title)}</strong>`
+ (w.subTitle ? `<span class="winzig">${esc(w.subTitle)}</span>` : '')
+ `<span class="winzig">${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}</span>`;
const innen = document.createElement('div');
innen.className = 'knopfreihe';
innen.append(bild, text);
d.appendChild(innen);
if (w.runtimeSeconds) {
const balken = document.createElement('div');
balken.className = 'fortschritt breit';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, (w.positionSeconds / w.runtimeSeconds) * 100)}%`;
balken.appendChild(gefuellt);
d.appendChild(balken);
}
return d;
}
return bereich;
}
/** „3 Std. 12 Min." — aus Sekunden. */
function laufzeit(sekunden: number): string {
const tage = Math.floor(sekunden / 86400);
const stunden = Math.floor((sekunden % 86400) / 3600);
const minuten = Math.floor((sekunden % 3600) / 60);
if (tage > 0) return `${tage} Tg. ${stunden} Std.`;
if (stunden > 0) return `${stunden} Std. ${minuten} Min.`;
return `${minuten} Min.`;
}
/* ---------- Bibliotheken ---------- */
function bibliotheksbereich(geaendert: () => void): HTMLElement {
const bereich = document.createElement('section');
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
anlegen.onclick = () => dialog({
titel: 'Mediathek hinzufügen',
felder: [
{ name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' },
{
name: 'art', beschriftung: 'Art',
auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }],
},
{
name: 'pfad', beschriftung: 'Ordner',
platzhalter: '/media/Filme',
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
},
],
bestaetigen: 'Anlegen',
tun: async wert => {
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'));
await laden();
geaendert();
},
});
bereich.appendChild(abschnitt('Mediatheken', [anlegen]));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
liste.innerHTML = '<div class="spinner"></div>';
try {
const alle = await bibliotheken();
liste.innerHTML = '';
if (alle.length === 0) {
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return;
}
alle.forEach(b => liste.appendChild(block(b)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
}
}
function block(b: Bibliothek): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const serie = b.kind === 'Shows';
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
const anzahl = b.itemCount == null ? ''
: ` · ${b.itemCount} ` + (b.itemCount === 1
? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme'));
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
d.appendChild(kopf);
const stand = document.createElement('div');
stand.className = 'hinweis';
const los = knopf('Scannen', 'btn');
los.onclick = async () => {
los.disabled = true;
try { await scanStarten(b.id); await verfolgen(); }
catch (fehler) { stand.textContent = (fehler as Error).message; }
los.disabled = false;
};
const auffrischen = knopf('Metadaten auffrischen', 'btn');
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
+ 'nötig nach einer Sprachänderung.';
auffrischen.onclick = () => nachfragen({
titel: `Metadaten von „${b.name}" auffrischen?`,
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
+ 'Bei vielen Titeln dauert es.',
bestaetigen: 'Auffrischen',
tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(); },
});
const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
bestaetigen: 'Speichern',
tun: async wert => {
await bibliothekUmbenennen(b.id, wert('name'));
await laden();
geaendert();
},
});
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${b.name}" entfernen?`,
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
bestaetigen: 'Entfernen',
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); },
});
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
async function verfolgen(): Promise<void> {
for (let i = 0; i < 900; i++) {
const s = await scanstand(b.id);
if (s.state === 'running') stand.textContent = 'Wird durchsucht…';
else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…';
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
else if (s.state === 'finished') {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);
if (s.metadata?.skipped) teile.push('Metadaten übersprungen');
else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`);
if (s.metadata?.error) teile.push(s.metadata.error);
stand.textContent = teile.join(' · ');
await laden();
geaendert();
return;
}
await new Promise(r => setTimeout(r, 1000));
}
}
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, umbenennen, entfernen);
d.append(knoepfe, stand);
void verfolgen();
return d;
}
return bereich;
}
/* ---------- Benutzer ---------- */
function benutzerbereich(): HTMLElement {
const bereich = document.createElement('section');
const anlegen = knopf('Benutzer hinzufügen', 'btn');
anlegen.onclick = () => dialog({
titel: 'Benutzer hinzufügen',
felder: [
{ name: 'name', beschriftung: 'Benutzername' },
{
name: 'passwort', beschriftung: 'Passwort', art: 'password',
hinweis: 'Mindestens acht Zeichen.',
},
{ name: 'admin', beschriftung: 'Administrator', schalter: true },
],
bestaetigen: 'Anlegen',
tun: async wert => {
await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true');
await laden();
},
});
bereich.appendChild(abschnitt('Benutzer', [anlegen]));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
liste.innerHTML = '<div class="spinner"></div>';
try {
const alle = await benutzer();
liste.innerHTML = '';
alle.forEach(b => {
const d = document.createElement('div');
d.className = 'einstellung';
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
liste.appendChild(d);
});
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
}
}
return bereich;
}
/* ---------- Server ---------- */
function serverbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Server'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
liste.innerHTML = '';
liste.appendChild(zeile(
'Name', auskunft.serverName ?? '—',
'So nennen ihn die Apps und die Kopfzeile.',
() => dialog({
titel: 'Server umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
bestaetigen: 'Speichern',
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
})));
liste.appendChild(zeile(
'Metadaten',
stand.usingProjectKey ? `Mitgelieferter TMDB-Zugang · ${stand.language}`
: stand.hasApiKey ? `Eigener TMDB-Zugang · ${stand.language}`
: 'Kein Zugang hinterlegt',
stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
+ 'vorhandene Titel ändern sich sonst nicht.',
() => dialog({
titel: 'Metadaten',
felder: [
{
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
platzhalter: 'Read Access Token oder API Key',
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
},
{
name: 'sprache', beschriftung: 'Sprache', wert: stand.language,
hinweis: 'Etwa de-DE. Bestimmt auch, welche Altersfreigabe gilt.',
},
],
bestaetigen: 'Speichern',
tun: async wert => {
await metadatenSetzen({
apiKey: wert('schluessel') || undefined,
language: wert('sprache') || undefined,
});
location.reload();
},
})));
liste.appendChild(zeile('Fassung', auskunft.version, null));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
}
})();
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
liste.innerHTML = '<div class="spinner"></div>';
return bereich;
}
-60
View File
@@ -1,60 +0,0 @@
import type { Item } from '../types';
import { esc, bild, laufzeit } from '../util';
/** Eine Kachel. Querformat für Angefangenes, hochkant für alles andere —
* dieselbe Entscheidung wie in der App, und sie ist keine Einstellung:
* Angefangenes erkennt man am Szenenbild wieder, Neues sucht man nach
* Plakaten aus. */
export function kachel(item: Item, quer: boolean, beimTippen: (item: Item) => void): HTMLElement {
const b = document.createElement('button');
b.className = 'card' + (quer ? ' wide' : '');
const anteil = item.UserData?.PlayedPercentage;
const art = bild(item, quer ? 'Backdrop' : 'Primary', quer ? 500 : 300);
b.innerHTML = `
<div class="art" ${art ? `style="background-image:url('${art}')"` : ''}>
${anteil ? `<div class="bar"><span style="width:${Math.min(anteil, 100)}%"></span></div>` : ''}
</div>
<div class="title">${esc(item.SeriesName || item.Name)}</div>
<div class="sub">${esc(unterzeile(item))}</div>`;
b.onclick = () => beimTippen(item);
return b;
}
function unterzeile(item: Item): string {
if (item.Type === 'Episode' && item.ParentIndexNumber != null) {
return `S${item.ParentIndexNumber}:E${item.IndexNumber} · ${item.Name}`;
}
return String(item.ProductionYear ?? '');
}
/** Das Blatt zu einem Titel. */
export function blatt(item: Item, starten: (item: Item) => void): void {
const hinten = document.createElement('div');
hinten.id = 'sheet';
// Serien und Staffeln sind Ordner — abgespielt wird trotzdem, nur eben
// nicht der Ordner, sondern die Folge, die als Nächstes dran ist.
const ordner = ['Series', 'Season'].includes(item.Type ?? '');
const spielbar = ordner || !['BoxSet', 'Folder', 'CollectionFolder'].includes(item.Type ?? '');
const teile = [item.ProductionYear, laufzeit(item), item.OfficialRating].filter(Boolean);
hinten.innerHTML = `
<div class="panel">
<h2>${esc(item.SeriesName ? item.SeriesName + ' — ' + item.Name : item.Name)}</h2>
<div class="meta">${teile.join(' · ')}</div>
<p>${esc(item.Overview ?? '')}</p>
<div class="btn-row">
${spielbar ? '<button class="btn btn-primary" id="sheet-play"></button>' : ''}
<button class="btn" id="sheet-close">Schließen</button>
</div>
</div>`;
hinten.onclick = (e) => { if (e.target === hinten) hinten.remove(); };
document.body.appendChild(hinten);
hinten.querySelector<HTMLElement>('#sheet-close')!.onclick = () => hinten.remove();
if (!spielbar) return;
const knopf = hinten.querySelector<HTMLButtonElement>('#sheet-play')!;
knopf.textContent = ordner ? '▶ Weiterschauen' : '▶ Abspielen';
knopf.onclick = () => { starten(item); hinten.remove(); };
}
+574
View File
@@ -0,0 +1,574 @@
import {
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
type Mitwirkender,
} from '../api';
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util';
import { icons, icon } from '../icons';
type Art = 'filme' | 'serien';
/** Die Detailseite — im Aufbau von Plex.
*
* Unscharfer Hintergrund über die ganze Seite, Plakat links, rechts der
* Schriftzug des Films statt der Systemschrift, darunter Eckdaten,
* Bewertung, Knöpfe, Beschreibung, die Medienangaben als Tabelle und zuletzt
* die Besetzung mit runden Porträts. */
export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElement {
const wurzel = document.createElement('div');
wurzel.className = 'detail';
void laden();
async function laden(): Promise<void> {
wurzel.innerHTML = '<div class="spinner"></div>';
try {
if (art === 'filme') {
const eintrag = await filmLaden(id);
wurzel.innerHTML = '';
wurzel.append(
hintergrund(eintrag.backdropImage),
zurueckKnopf(),
kopfbereich(eintrag, eintrag.state, eintrag.files[0] ?? null),
dateiliste(eintrag.files, eintrag.id, eintrag.state),
besetzung(eintrag.credits),
zuordnungsbereich(eintrag.title, eintrag.year));
} else {
const eintrag = await serieLaden(id);
wurzel.innerHTML = '';
wurzel.append(
hintergrund(eintrag.backdropImage),
zurueckKnopf(),
kopfbereich(eintrag, null, null),
await staffeln(eintrag),
besetzung(eintrag.credits),
zuordnungsbereich(eintrag.title, eintrag.year));
}
} catch (fehler) {
wurzel.innerHTML = '';
wurzel.appendChild(fehler instanceof Error && /404/.test(fehler.message)
? leer(art === 'filme' ? 'Diesen Film gibt es nicht mehr.' : 'Diese Serie gibt es nicht mehr.')
: fehlerzeile((fehler as Error).message));
}
}
function zurueckKnopf(): HTMLElement {
const b = knopf('Zurück', 'btn');
b.prepend(icon('links'));
b.style.marginBottom = 'var(--s-loose)';
b.onclick = zurueck;
return b;
}
/* ---------- Hintergrund ---------- */
function hintergrund(schluessel: string | null): HTMLElement {
const d = document.createElement('div');
d.className = 'detail-hintergrund';
const adresse = bildAdresse(schluessel);
if (adresse) d.style.backgroundImage = `url("${adresse}")`;
return d;
}
/* ---------- Kopf ---------- */
function kopfbereich(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const kopf = document.createElement('header');
kopf.className = 'detail-kopf';
// Plakat
const rahmen = document.createElement('div');
rahmen.className = 'plakat geladen';
const plakatAdresse = bildAdresse(eintrag.posterImage);
if (plakatAdresse) {
const bild = document.createElement('img');
bild.src = plakatAdresse;
bild.alt = '';
rahmen.appendChild(bild);
}
if (anteil(stand)) {
const balken = document.createElement('div');
balken.className = 'fortschritt';
const gefuellt = document.createElement('div');
gefuellt.style.width = `${Math.min(100, anteil(stand)! * 100)}%`;
balken.appendChild(gefuellt);
rahmen.appendChild(balken);
}
const text = document.createElement('div');
text.className = 'detail-text';
// **Der Schriftzug, wenn es einen gibt.** „Blade Runner 2049" in seiner
// eigenen Schrift sagt mehr als derselbe Titel in der Systemschrift.
const logo = bildAdresse(eintrag.logoImage);
if (logo) {
const bild = document.createElement('img');
bild.className = 'schriftzug';
bild.src = logo;
bild.alt = eintrag.title;
text.appendChild(bild);
} else {
const h = document.createElement('h1');
h.textContent = eintrag.title;
text.appendChild(h);
}
text.appendChild(eckdaten(eintrag));
if (eintrag.tagline) {
const zeile = document.createElement('p');
zeile.className = 'werbezeile';
zeile.textContent = eintrag.tagline;
text.appendChild(zeile);
}
text.appendChild(aktionsreihe(eintrag, stand, datei));
const beschreibung = document.createElement('p');
beschreibung.className = 'beschreibung';
beschreibung.textContent = eintrag.overview ?? 'Keine Beschreibung.';
text.appendChild(beschreibung);
if (datei) text.appendChild(medienangaben(datei));
kopf.append(rahmen, text);
return kopf;
}
/** Jahr · Genres · Laufzeit · Freigabe · Bewertung — eine Zeile. */
function eckdaten(eintrag: Film | Serie): HTMLElement {
const zeile = document.createElement('div');
zeile.className = 'eckdaten';
const teile: string[] = [];
if (eintrag.year) teile.push(String(eintrag.year));
if (eintrag.genres?.length) teile.push(eintrag.genres.join(', '));
if (eintrag.runtimeMinutes) teile.push(`${eintrag.runtimeMinutes} Min`);
if ('episodeCount' in eintrag) {
teile.push(`${eintrag.episodeCount} Folgen`);
if (eintrag.unwatchedCount > 0) teile.push(`${eintrag.unwatchedCount} offen`);
}
if (teile.length) {
const t = document.createElement('span');
t.textContent = teile.join(' · ');
zeile.appendChild(t);
}
if (eintrag.contentRating) {
const f = document.createElement('span');
f.className = 'freigabe';
f.textContent = eintrag.contentRating;
f.title = 'Altersfreigabe';
zeile.appendChild(f);
}
if (eintrag.rating) {
const b = document.createElement('span');
b.className = 'bewertung';
b.innerHTML = icons.stern;
b.append(document.createTextNode(eintrag.rating.toFixed(1)));
b.title = 'Bewertung bei TMDB';
zeile.appendChild(b);
}
return zeile;
}
/* ---------- Knöpfe ---------- */
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'aktionen';
const meldung = document.createElement('div');
meldung.className = 'hinweis';
if (datei) {
const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung);
reihe.appendChild(los);
if (stelle > 0) {
const vonVorn = knopf('Von vorn', 'btn');
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung);
reihe.appendChild(vonVorn);
}
const laden = knopf('Herunterladen', 'btn');
laden.prepend(icon('download'));
laden.onclick = () => void herunterladen(datei, laden, meldung);
reihe.appendChild(laden);
}
// **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit,
// weglegen — drei Dinge, die man oft tut und nie liest.
if ('files' in eintrag) {
reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false,
stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren',
async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); }));
reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false,
stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten',
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
async () => { await weglegen(eintrag.id); await laden(); }));
}
}
const huelle = document.createElement('div');
huelle.append(reihe, meldung);
return huelle;
}
function rundKnopf(
bild: keyof typeof icons, an: boolean, titel: string, tun: () => Promise<void>,
): HTMLButtonElement {
const b = document.createElement('button');
b.className = an ? 'rund an' : 'rund';
b.innerHTML = icons[bild];
b.title = titel;
b.setAttribute('aria-label', titel);
b.onclick = async () => { b.disabled = true; try { await tun(); } catch { b.disabled = false; } };
return b;
}
/* ---------- Medienangaben ---------- */
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
function medienangaben(datei: Datei): HTMLElement {
const liste = document.createElement('dl');
liste.className = 'medientabelle';
const bild = datei.streams.find(s => s.kind === 'Video');
const ton = datei.streams.filter(s => s.kind === 'Audio');
const text = datei.streams.filter(s => s.kind === 'Subtitle');
zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes),
dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
if (bild) zeile('Video', `${bild.height ? `${bild.height}p` : '—'} (${(bild.codec ?? '?').toUpperCase()})`);
if (ton.length) zeile('Ton', ton.map(spurname).join(' · '));
if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
function zeile(name: string, wert: string): void {
const dt = document.createElement('dt');
dt.textContent = name;
const dd = document.createElement('dd');
dd.textContent = wert;
liste.append(dt, dd);
}
return liste;
}
function spurname(s: Datei['streams'][number]): string {
const teile = [s.title, s.language?.toUpperCase(), s.codec?.toUpperCase()]
.filter(Boolean);
const kanaele = s.channels ? `${s.channels}.0` : null;
return [teile.join(' '), kanaele].filter(Boolean).join(' ');
}
/* ---------- Besetzung ---------- */
function besetzung(mitwirkende: Mitwirkender[] | null): HTMLElement {
const bereich = document.createElement('section');
if (!mitwirkende?.length) return bereich;
const h = document.createElement('h2');
h.textContent = 'Besetzung & Stab';
const reihe = document.createElement('div');
reihe.className = 'besetzung';
mitwirkende.forEach(m => {
const k = document.createElement('div');
k.className = 'kopf-kachel';
const portrait = document.createElement('div');
portrait.className = 'portrait';
const adresse = bildAdresse(m.image);
if (adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.loading = 'lazy';
portrait.appendChild(bild);
} else {
// Kein Bild: die Anfangsbuchstaben. Ein leerer Kreis sieht aus wie
// ein Ladefehler.
portrait.textContent = m.name.split(' ').map(t => t[0]).slice(0, 2).join('');
}
const rolle = m.role === 'Director' ? 'Regie'
: m.role === 'Writer' ? 'Drehbuch'
: m.character ?? '';
k.innerHTML += '';
k.append(portrait);
const name = document.createElement('strong');
name.textContent = m.name;
const unten = document.createElement('span');
unten.textContent = rolle;
k.append(name, unten);
reihe.appendChild(k);
});
bereich.append(h, reihe);
return bereich;
}
/* ---------- Dateien ---------- */
function dateiliste(dateien: Datei[], titel: string, stand: Stand | null): HTMLElement {
const bereich = document.createElement('section');
if (dateien.length <= 1) return bereich;
// Nur bei mehreren Fassungen: bei einer stehen die Angaben schon oben.
const h = document.createElement('h2');
h.textContent = 'Fassungen';
bereich.appendChild(h);
const liste = document.createElement('div');
liste.className = 'einstellungen';
dateien.forEach(datei => {
const block = document.createElement('div');
block.className = 'einstellung';
block.innerHTML = `<div class="zeile"><strong>${esc(datei.fileName)}</strong>`
+ `<span class="winzig">${esc([datei.container?.toUpperCase(), groesse(datei.sizeBytes),
dauer(datei.durationSeconds)].filter(Boolean).join(' · '))}</span></div>`;
block.appendChild(aktionsreihe({ ...({} as Film), id: titel, files: dateien } as Film, stand, datei));
liste.appendChild(block);
});
bereich.appendChild(liste);
return bereich;
}
/* ---------- Staffeln und Folgen ---------- */
async function staffeln(serie: Serie): Promise<HTMLElement> {
const bereich = document.createElement('section');
const alle = await folgen(serie.id);
serie.seasons.forEach(nummer => {
const h = document.createElement('h2');
h.textContent = `Staffel ${nummer}`;
bereich.appendChild(h);
const liste = document.createElement('div');
liste.className = 'einstellungen';
alle.items.filter(f => f.season === nummer).forEach(folge => {
liste.appendChild(folgenblock(folge));
});
bereich.appendChild(liste);
});
return bereich;
}
function folgenblock(folge: Folge): HTMLElement {
const block = document.createElement('div');
block.className = 'einstellung';
const zeile = document.createElement('div');
zeile.className = 'zeile';
const nebenbei = folge.state?.watched ? 'Gesehen'
: folge.state && folge.state.positionSeconds > 0
? `noch ${Math.round(((folge.state.runtimeSeconds ?? 0) - folge.state.positionSeconds) / 60)} Min`
: '';
zeile.innerHTML =
`<strong>${String(folge.episode).padStart(2, '0')} · ${esc(folge.title ?? 'Ohne Titel')}</strong>`
+ (nebenbei ? `<span class="winzig">${esc(nebenbei)}</span>` : '');
block.appendChild(zeile);
if (folge.overview) {
const text = document.createElement('div');
text.className = 'hinweis';
text.textContent = folge.overview;
block.appendChild(text);
}
folge.files.forEach(datei => block.appendChild(
aktionsreihe({ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei)));
return block;
}
/* ---------- Abspielen und Herunterladen ---------- */
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
async function bereitstellen(datei: Datei, meldung: HTMLElement): Promise<boolean> {
let auskunft: Lieferung;
try { auskunft = await lieferung(datei.id); }
catch (fehler) { meldung.textContent = (fehler as Error).message; return false; }
if (auskunft.method === 'directFile') return true;
if (auskunft.packageState === 'ready') return true;
// **Hier steht, was passieren wird, bevor es passiert.**
const gruende: string[] = [];
if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`);
if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`);
meldung.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
for (let i = 0; i < 600; i++) {
const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' });
if (antwort.ok) { meldung.textContent = ''; return true; }
if (antwort.status !== 202) { meldung.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
const wie = await lieferung(datei.id);
if (wie.error) { meldung.textContent = wie.error; return false; }
meldung.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
await new Promise(r => setTimeout(r, 1000));
}
meldung.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
return false;
}
async function starten(
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, meldung)) spieler(downloadAdresse(datei.id), titel, ab, laden);
knopfDavon.disabled = false;
}
async function herunterladen(
datei: Datei, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
): Promise<void> {
knopfDavon.disabled = true;
if (await bereitstellen(datei, meldung)) {
// Ein echter Link, kein Blob: der Browser lädt im Hintergrund und kann
// fortsetzen, statt den ganzen Film in den Speicher zu nehmen.
const a = document.createElement('a');
a.href = downloadAdresse(datei.id);
a.download = '';
a.click();
}
knopfDavon.disabled = false;
}
/* ---------- Zuordnung korrigieren ---------- */
function zuordnungsbereich(titel: string, jahr: number | null): HTMLElement {
const bereich = document.createElement('section');
if (!istAdmin()) return bereich;
const oeffner = knopf('Zuordnung korrigieren', 'btn');
oeffner.prepend(icon('stift'));
bereich.appendChild(oeffner);
oeffner.onclick = () => {
oeffner.remove();
bereich.appendChild(zuordnungsmaske(titel, jahr));
};
return bereich;
}
function zuordnungsmaske(titel: string, jahr: number | null): HTMLElement {
const maske = document.createElement('div');
maske.className = 'einstellungen';
const suche = feld('Anderer Suchbegriff');
suche.value = titel;
const jahrfeld = feld('Jahr', 'number');
if (jahr) jahrfeld.value = String(jahr);
const suchen = knopf('Suchen', 'btn');
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
reihe.append(suche, jahrfeld, suchen);
const treffer = document.createElement('div');
maske.append(reihe, treffer);
async function fragen(): Promise<void> {
treffer.innerHTML = '<div class="spinner"></div>';
try {
const liste = await vorschlaege(art === 'filme' ? 'movies' : 'series', id,
suche.value.trim(), jahrfeld.value ? Number(jahrfeld.value) : undefined);
treffer.innerHTML = '';
if (liste.length === 0) { treffer.appendChild(leer('Nichts gefunden.')); return; }
liste.forEach(v => {
const zeile = document.createElement('button');
zeile.className = 'einstellung waehlbar';
zeile.innerHTML =
`<div class="zeile"><strong>${esc(v.title)}</strong>`
+ `<span class="winzig">${esc(v.year ?? '')} · TMDB ${v.tmdbId}</span></div>`
+ `<div class="hinweis">${esc((v.overview ?? '').slice(0, 160))}</div>`;
zeile.onclick = async () => {
zeile.disabled = true;
try {
await zuordnen(art === 'filme' ? 'movies' : 'series', id, v.tmdbId);
await laden();
} catch (fehler) { treffer.appendChild(fehlerzeile((fehler as Error).message)); }
};
treffer.appendChild(zeile);
});
} catch (fehler) {
treffer.innerHTML = '';
treffer.appendChild(fehlerzeile((fehler as Error).message));
}
}
suchen.onclick = () => void fragen();
void fragen();
return maske;
}
return wurzel;
}
/* ---------- Der Spieler ---------- */
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
function spieler(adresse: string, titel: string, ab: number, danach: () => void): void {
const schicht = document.createElement('div');
schicht.className = 'pl-fill';
const video = document.createElement('video');
video.src = adresse;
video.controls = true;
video.autoplay = true;
video.style.cssText = 'width:100%;height:100%;background:#000';
if (ab > 0) video.currentTime = ab;
// **Alle zehn Sekunden, nicht bei jedem Bild.** `timeupdate` feuert
// viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit
// Meldungen zu bewerfen, die sich um 250 ms unterscheiden.
let zuletzt = 0;
const melden = (erzwingen = false, beendet = false) => {
const jetzt = Date.now();
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
zuletzt = jetzt;
const laenge = Number.isFinite(video.duration) ? video.duration : null;
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür
// mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere
// Hälfte des Tauschs.
void fortschritt(titel, video.currentTime, laenge, beendet).catch(() => {});
};
video.ontimeupdate = () => melden();
video.onpause = () => melden(true);
video.onended = () => melden(true, true);
const schliessen = knopf('Schliessen', 'btn');
schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2';
schliessen.onclick = () => {
video.pause();
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
// Dashboard noch eine Minute als „läuft", und wer gerade geschlossen
// hat, sieht sich selbst beim Schauen zu.
melden(true, true);
schicht.remove();
danach();
};
schicht.append(video, schliessen);
schicht.onkeydown = e => { if (e.key === 'Escape') schliessen.click(); };
document.body.appendChild(schicht);
video.focus();
}
+67 -187
View File
@@ -1,207 +1,87 @@
import type { Item, Reihe, Seite } from '../types';
import { api, sitzung } from '../api';
import { P, anordnen } from '../prefs';
import { icon } from '../icons';
import { esc, bild, laufzeit, hatBild, FELDER } from '../util';
import { kachel } from './cards';
import {
inhalt, weiterschauen, naechsteFolgen, empfehlungen,
type Bibliothek,
} from '../api';
import { leer, fehlerzeile } from '../util';
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
/** Welche Reihen es bei diesem Server gibt — für die Einstellungen. */
export let bekannteReihen: { id: string; title: string }[] = [];
type Art = 'filme' | 'serien';
export async function startseite(
wurzel: HTMLElement,
ansichten: Item[],
oeffnen: (item: Item) => void,
): Promise<void> {
/** Die Startseite — waagerechte Reihen, wie bei Plex.
*
* Oben, was angefangen ist. Darunter **je Mediathek eine Reihe** mit dem
* zuletzt Hinzugefügten: wer „Filme" und „Dokumentationen" getrennt angelegt
* hat, hat sie getrennt gemeint, und eine Startseite, die sie wieder
* zusammenwirft, nimmt die Entscheidung zurück.
*
* Eine Reihe ohne Inhalt wird gar nicht erst gezeigt. */
export function startseite(
bibliotheken: Bibliothek[],
oeffnen: (art: Art, id: string) => void,
alleZeigen: (b: Bibliothek) => void,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.innerHTML = '<div class="spinner"></div>';
const hole = async (pfad: string, q: Record<string, string> = {}): Promise<Item[]> => {
void (async () => {
try {
const r = await api<Seite | Item[]>(
pfad + '?' + new URLSearchParams({ Fields: FELDER, Limit: '20', ...q }),
);
// „Latest" liefert ein blankes Feld, alles andere eine Seite mit Items.
return Array.isArray(r) ? r : (r.Items ?? []);
} catch {
return [];
}
};
const [weiter, naechstes, ...neues] = await Promise.all([
hole(`/Users/${sitzung.userId}/Items/Resume`, { MediaTypes: 'Video' }),
hole('/Shows/NextUp', { userId: sitzung.userId }),
...ansichten.map((v) => hole(`/Users/${sitzung.userId}/Items/Latest`, { ParentId: v.Id })),
// **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst
// acht Anfragen nacheinander, und die Startseite baut sich vor den
// Augen des Benutzers Zeile für Zeile auf.
const [weiter, naechste, vorschlag, ...bestaende] = await Promise.all([
weiterschauen(),
naechsteFolgen(),
empfehlungen(24).catch(() => ({ items: [], total: 0 })),
...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })),
]);
let reihen: Reihe[];
if (P.combineContinue) {
const gesehen = new Set<string>();
const zusammen = [...weiter!, ...naechstes!].filter((i) => !gesehen.has(i.Id) && gesehen.add(i.Id));
// Die zusammengelegte Reihe behält die Kennung der ersten — sonst wäre sie
// für die gespeicherte Reihenfolge eine neue und stünde wieder unten.
reihen = [{ id: 'resume', title: 'Weiterschauen', items: zusammen, wide: true }];
} else {
reihen = [
{ id: 'resume', title: 'Weiterschauen', items: weiter!, wide: true },
{ id: 'nextup', title: 'Als Nächstes', items: naechstes!, wide: true },
];
}
reihen.push(...ansichten.map((v, i) => ({
id: 'latest.' + v.Id,
title: `Neu in ${v.Name}`,
// Gesehenes ausblenden gilt nur für Neuzugänge: in „Weiterschauen" wäre
// es sinnlos, dort ist alles angefangen.
items: (neues[i] ?? []).filter((it) => !P.hideWatched || !it.UserData?.Played),
})));
reihen = reihen.filter((r) => r.items.length);
// **Vor** dem Ausblenden merken: die Einstellungen müssen auch die Reihen
// anbieten, die gerade versteckt sind.
bekannteReihen = reihen.map((r) => ({ id: r.id, title: r.title }));
reihen = anordnen(reihen);
const auswahl = P.heroSource === 'latestOnly'
? neues.flat()
: [...weiter!, ...naechstes!, ...neues.flat()];
const oben = aufmacherWahl(auswahl);
wurzel.innerHTML = '';
wurzel.classList.toggle('pad-top', oben.length === 0);
if (oben.length) wurzel.appendChild(aufmacher(oben, oeffnen));
for (const r of reihen) wurzel.appendChild(reihe(r, oeffnen));
if (!reihen.length && !oben.length) {
wurzel.innerHTML = '<div class="empty pad-top">Hier ist noch nichts.</div>';
}
if (bibliotheken.length === 0) {
wurzel.appendChild(leer('Noch keine Mediathek. Lege unter Verwaltung eine an '
+ 'und starte einen Scan.'));
return;
}
/* Wählt aus, was oben groß steht — dieselbe Regel wie in der App.
----------------------------------------------------------------------------
Die Auswahl wechselt alle sechs Stunden, und ein Titel ohne Hintergrundbild
zieht ein schlechteres Los. Bei jedem Laden zu würfeln wäre unruhig — man
tippt auf etwas, geht zurück, und es ist weg. Einmal täglich wäre zu träge. */
function aufmacherWahl(items: Item[]): Item[] {
let keim = (Math.floor(Date.now() / (6 * 3600 * 1000)) * 2654435761) % 2147483647;
const wuerfeln = (): number => (keim = (keim * 16807) % 2147483647) / 2147483647;
const gesehen = new Set<string>();
return items
.filter((it) => !gesehen.has(it.Id) && gesehen.add(it.Id))
// Wer ein Hintergrundbild hat, zieht aus [0,1) — wer keines hat, nur aus
// [0,0.2). Solange es genug mit Bild gibt, kommt er nicht nach vorn; gibt
// es wenige, hat er seine Chance.
.map((it) => ({ it, los: wuerfeln() * (hatBild(it, 'Backdrop') ? 1 : 0.2) }))
.sort((a, b) => b.los - a.los)
.slice(0, P.heroCount)
.map((x) => x.it);
if (weiter.items.length) {
wurzel.appendChild(reihe('Fortsetzen',
weiter.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
}
if (naechste.items.length) {
wurzel.appendChild(reihe('Nächste Folge',
naechste.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
}
function aufmacher(items: Item[], oeffnen: (item: Item) => void): HTMLElement {
const aussen = document.createElement('div');
const rahmen = document.createElement('section');
rahmen.id = 'hero';
const bilder = items.map((it, i) => {
const d = document.createElement('div');
d.className = 'shot' + (i === 0 ? ' on' : '');
d.style.backgroundImage = `url("${bild(it, 'Backdrop', 1600)}")`;
return d;
});
bilder.forEach((b) => rahmen.appendChild(b));
rahmen.insertAdjacentHTML('beforeend', '<div class="veil"></div>');
const koerper = document.createElement('div');
koerper.className = 'body';
rahmen.appendChild(koerper);
const punkte = document.createElement('div');
punkte.className = 'dots';
let bei = 0;
let uhr: ReturnType<typeof setInterval> | null = null;
const anhalten = (): void => { if (uhr) { clearInterval(uhr); uhr = null; } };
const schritt = (um: number): void => {
anhalten();
bei = (bei + um + items.length) % items.length;
malen();
};
items.forEach((_, i) => {
const d = document.createElement('button');
d.className = i ? '' : 'on';
d.setAttribute('aria-label', `Titel ${i + 1}`);
d.onclick = () => { anhalten(); bei = i; malen(); };
punkte.appendChild(d);
let etwasDa = false;
bibliotheken.forEach((b, i) => {
const seite = bestaende[i];
if (!seite?.items.length) return;
etwasDa = true;
wurzel.appendChild(reihe(
// Plex schreibt den Namen der Mediathek in die Überschrift —
// „Zuletzt in Filme hinzugefügt". Bei mehreren Mediatheken ist das
// der Unterschied zwischen einer Reihe und sieben gleich
// aussehenden.
`Zuletzt in ${b.name} hinzugefügt`,
seite.items.map(e => kachel(alsKachel(e), oeffnen)),
seite.total > seite.items.length
? { text: 'Alle zeigen', tun: () => alleZeigen(b) }
: undefined));
});
function malen(): void {
const it = items[bei]!;
const teile = [it.ProductionYear, laufzeit(it), it.OfficialRating].filter(Boolean);
const zeichen = it.ImageTags?.Logo ? bild(it, 'Primary', 600) : '';
koerper.innerHTML = `
${zeichen && it.ImageTags?.Logo ? `<img class="logo" src="${zeichen}" alt="${esc(it.Name)}">`
: `<h2>${esc(it.Name)}</h2>`}
<div class="meta">${teile.join(' · ')}</div>
<div class="summary">${esc(it.Overview ?? '')}</div>
<div class="actions">
<button class="btn btn-primary">${icon('play', 15)} Abspielen</button>
<button class="btn">Details</button>
</div>`;
const knoepfe = koerper.querySelectorAll('.btn');
(knoepfe[0] as HTMLElement).onclick = () => oeffnen(it);
(knoepfe[1] as HTMLElement).onclick = () => oeffnen(it);
bilder.forEach((b, i) => b.classList.toggle('on', i === bei));
[...punkte.children].forEach((d, i) => d.classList.toggle('on', i === bei));
}
malen();
if (items.length > 1) {
const zurueck = document.createElement('button');
zurueck.className = 'hero-nav prev';
zurueck.innerHTML = icon('left', 22);
zurueck.setAttribute('aria-label', 'Vorheriger Titel');
zurueck.onclick = () => schritt(-1);
const vor = document.createElement('button');
vor.className = 'hero-nav next';
vor.innerHTML = icon('right', 22);
vor.setAttribute('aria-label', 'Nächster Titel');
vor.onclick = () => schritt(1);
rahmen.append(zurueck, vor);
// Wischen — erst ab einem ordentlichen Weg und nur, wenn es waagerecht
// gemeint ist. Sonst nimmt die Geste dem Scrollen den Vortritt.
let x0: number | null = null;
let y0 = 0;
rahmen.addEventListener('touchstart', (e) => {
x0 = e.touches[0]!.clientX; y0 = e.touches[0]!.clientY;
}, { passive: true });
rahmen.addEventListener('touchend', (e) => {
if (x0 == null) return;
const dx = e.changedTouches[0]!.clientX - x0;
const dy = e.changedTouches[0]!.clientY - y0;
if (Math.abs(dx) > 48 && Math.abs(dx) > Math.abs(dy)) schritt(dx < 0 ? 1 : -1);
x0 = null;
}, { passive: true });
// Sieben Sekunden — lang genug, um den Satz zu Ende zu lesen.
uhr = setInterval(() => {
if (!document.body.contains(rahmen)) return anhalten();
bei = (bei + 1) % items.length;
malen();
}, 7000);
if (vorschlag.items.length) {
wurzel.appendChild(reihe('Weil du Ähnliches gesehen hast',
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
}
aussen.append(rahmen, punkte);
return aussen;
if (!etwasDa && !weiter.items.length && !naechste.items.length) {
wurzel.appendChild(leer('Noch nichts da. Starte unter Verwaltung einen Scan.'));
}
} catch (fehler) {
wurzel.innerHTML = '';
wurzel.appendChild(fehlerzeile((fehler as Error).message));
}
})();
function reihe(r: Reihe, oeffnen: (item: Item) => void): HTMLElement {
const abschnitt = document.createElement('section');
abschnitt.className = 'row';
abschnitt.innerHTML = `<h3>${esc(r.title)}</h3><div class="strip"></div>`;
const streifen = abschnitt.querySelector('.strip')!;
for (const it of r.items) streifen.appendChild(kachel(it, Boolean(r.wide), oeffnen));
return abschnitt;
return wurzel;
}
+305 -23
View File
@@ -1,28 +1,310 @@
import type { Item, Seite } from '../types';
import { api, sitzung } from '../api';
import { esc, FELDER } from '../util';
import { kachel } from './cards';
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
import { leer, fehlerzeile } from '../util';
import { icons } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
export async function mediathek(
wurzel: HTMLElement,
id: string,
oeffnen: (item: Item) => void,
): Promise<void> {
wurzel.classList.add('pad-top');
wurzel.innerHTML = '<div class="spinner"></div>';
type Art = 'filme' | 'serien';
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
/** Eine Mediathek — mit Reitern, wie bei Plex.
*
* **Drei Sichten auf denselben Bestand.** „Empfehlungen" ist das, womit man
* ankommt; „Mediathek" ist das vollständige Raster für alle, die etwas
* Bestimmtes suchen; „Kategorien" ist der Weg hinein, wenn man noch nicht
* weiss, wonach. Alles in eine Ansicht zu legen hiesse, zwei davon unter ein
* Ausklappmenü zu schieben. */
export function mediathek(
bibliothek: Bibliothek,
servername: string,
reiter: string,
oeffnen: (art: Art, id: string) => void,
): HTMLElement {
const wurzel = document.createElement('div');
const welcher: Reiter =
reiter === 'empfehlungen' || reiter === 'kategorien' ? reiter : 'mediathek';
wurzel.appendChild(kopf());
const buehne = document.createElement('div');
wurzel.appendChild(buehne);
if (welcher === 'empfehlungen') empfehlungen(buehne);
else if (welcher === 'kategorien') kategorien(buehne);
else raster(buehne);
/* ---------- Kopf mit Reitern ---------- */
function kopf(): HTMLElement {
const k = document.createElement('div');
k.className = 'seitenkopf';
const wer = document.createElement('div');
wer.className = 'wer';
const h = document.createElement('h1');
h.textContent = bibliothek.name;
const quelle = document.createElement('div');
quelle.className = 'quelle';
quelle.textContent = servername;
wer.append(h, quelle);
const reiterleiste = document.createElement('nav');
reiterleiste.className = 'reiter';
([
['empfehlungen', 'Empfehlungen'],
['mediathek', 'Mediathek'],
['kategorien', 'Kategorien'],
] as [Reiter, string][]).forEach(([wert, text]) => {
const b = document.createElement('button');
b.textContent = text;
b.classList.toggle('check', wert === welcher);
b.onclick = () => { location.hash = `#/m/${bibliothek.id}/${wert}`; };
reiterleiste.appendChild(b);
});
k.append(wer, reiterleiste);
return k;
}
/* ---------- Reiter „Mediathek" ---------- */
function raster(ziel: HTMLElement): void {
let suche = '';
let ordnung: Ordnung = 'title';
let genre = '';
let tippt: number | undefined;
let gesehen: '' | 'offen' | 'gesehen' = '';
const zeile = document.createElement('div');
zeile.className = 'filterzeile';
const anzahl = document.createElement('span');
anzahl.className = 'anzahl';
anzahl.textContent = '…';
/** Ein Filter als Pille — ohne Rahmen, gesetzt wird er farbig. */
function pille(
vorgabe: string,
werte: [string, string][],
gewaehlt: () => string,
setzen: (wert: string) => void,
): { huelle: HTMLElement; wahl: HTMLSelectElement } {
const huelle = document.createElement('div');
huelle.className = 'pille';
const wahl = document.createElement('select');
[[ '', vorgabe ] as [string, string], ...werte].forEach(([w, t]) => {
const o = document.createElement('option');
o.value = w; o.textContent = t;
wahl.appendChild(o);
});
wahl.onchange = () => {
setzen(wahl.value);
huelle.classList.toggle('gesetzt', wahl.value !== '');
aufraeumen();
void laden();
};
huelle.classList.toggle('gesetzt', gewaehlt() !== '');
huelle.appendChild(wahl);
return { huelle, wahl };
}
const genrePille = pille('Alle Genres', [], () => genre, w => { genre = w; });
const standPille = pille('Alle', [
['offen', 'Noch nicht gesehen'],
['gesehen', 'Gesehen'],
], () => gesehen, w => { gesehen = w as typeof gesehen; });
// Die Sortierung ist kein Filter: sie ist immer gesetzt und bleibt grau.
const sortierHuelle = document.createElement('div');
sortierHuelle.className = 'pille';
const sortierung = document.createElement('select');
([
['title', 'Nach Titel'],
['added', 'Zuletzt hinzugefügt'],
['year', 'Nach Jahr'],
] as [Ordnung, string][]).forEach(([wert, text]) => {
const o = document.createElement('option');
o.value = wert; o.textContent = text;
sortierung.appendChild(o);
});
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
sortierHuelle.appendChild(sortierung);
// Erscheint nur, wenn es etwas zurückzusetzen gibt.
const zuruecksetzen = document.createElement('button');
zuruecksetzen.className = 'zuruecksetzen';
zuruecksetzen.textContent = 'Filter zurücksetzen';
zuruecksetzen.hidden = true;
zuruecksetzen.onclick = () => {
genre = ''; gesehen = '';
genrePille.wahl.value = ''; standPille.wahl.value = '';
genrePille.huelle.classList.remove('gesetzt');
standPille.huelle.classList.remove('gesetzt');
aufraeumen();
void laden();
};
function aufraeumen(): void {
zuruecksetzen.hidden = genre === '' && gesehen === '';
}
const suchhuelle = document.createElement('div');
suchhuelle.className = 'suchhuelle';
suchhuelle.innerHTML = icons.suche;
const suchfeld = document.createElement('input');
suchfeld.type = 'search';
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
suchfeld.oninput = () => {
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
// Anfragen, von denen acht niemanden interessieren.
window.clearTimeout(tippt);
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
};
suchhuelle.appendChild(suchfeld);
zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle,
anzahl, zuruecksetzen, suchhuelle);
const gitter = document.createElement('div');
gitter.className = 'raster';
ziel.append(zeile, gitter);
// Die Genres der Mediathek nachladen — die Liste steht erst, wenn der
// Server sie genannt hat, und solange bleibt „Alle Genres" allein.
void genresVon(bibliothek.id).then(namen => {
namen.forEach(n => {
const o = document.createElement('option');
o.value = n; o.textContent = n;
genrePille.wahl.appendChild(o);
});
}).catch(() => {});
void laden();
async function laden(): Promise<void> {
gitter.innerHTML = '<div class="spinner"></div>';
try {
const r = await api<Seite>(
`/Users/${sitzung.userId}/Items?` + new URLSearchParams({
ParentId: id, Recursive: 'true', IncludeItemTypes: 'Movie,Series',
SortBy: 'SortName', SortOrder: 'Ascending', Limit: '200', Fields: FELDER,
}),
);
const items = r.Items ?? [];
wurzel.innerHTML = items.length ? '<div class="grid"></div>' : '<div class="empty">Leer.</div>';
const raster = wurzel.querySelector('.grid');
if (raster) for (const it of items) raster.appendChild(kachel(it, false, oeffnen));
} catch (e) {
wurzel.innerHTML = `<div class="empty">${esc((e as Error).message)}</div>`;
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });
// **Gesehen/offen siebt hier, nicht am Server.** Er kann es noch
// nicht; es jetzt wegzulassen hiesse, einen Filter anzubieten, den
// es nicht gibt. Der Preis steht in der Zahl daneben: sie zählt,
// was übrig bleibt, nicht was der Server gefunden hat.
const uebrig = gesehen === '' ? seite.items
: seite.items.filter(e => {
const ist = 'state' in e ? e.state?.watched === true
: 'unwatchedCount' in e ? e.unwatchedCount === 0 : false;
return gesehen === 'gesehen' ? ist : !ist;
});
anzahl.textContent = gesehen === ''
? String(seite.total)
: `${uebrig.length} von ${seite.total}`;
gitter.innerHTML = '';
if (uebrig.length === 0) {
gitter.appendChild(leer(suche || genre || gesehen
? 'Nichts gefunden.'
: `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`));
return;
}
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
} catch (fehler) {
gitter.innerHTML = '';
gitter.appendChild(fehlerzeile((fehler as Error).message));
}
}
}
/* ---------- Reiter „Empfehlungen" ---------- */
function empfehlungen(ziel: HTMLElement): void {
ziel.innerHTML = '<div class="spinner"></div>';
void (async () => {
try {
const [neu, beliebt, vorschlag] = await Promise.all([
inhalt(bibliothek, { sort: 'added', take: 24 }),
inhalt(bibliothek, { sort: 'year', take: 24 }),
holeEmpfehlungen(24).catch(() => ({ items: [], total: 0 })),
]);
ziel.innerHTML = '';
if (neu.items.length === 0) {
ziel.appendChild(leer(`„${bibliothek.name}" ist leer.`));
return;
}
ziel.appendChild(reihe(
`Zuletzt in ${bibliothek.name} hinzugefügt`,
neu.items.map(e => kachel(alsKachel(e), oeffnen))));
if (vorschlag.items.length) {
ziel.appendChild(reihe(
'Weil du Ähnliches gesehen hast',
vorschlag.items.map(e => kachel(alsKachel(e), oeffnen))));
}
ziel.appendChild(reihe(
'Nach Jahr',
beliebt.items.map(e => kachel(alsKachel(e), oeffnen))));
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile((fehler as Error).message));
}
})();
}
/* ---------- Reiter „Kategorien" ---------- */
function kategorien(ziel: HTMLElement): void {
ziel.innerHTML = '<div class="spinner"></div>';
void (async () => {
try {
const namen = await genresVon(bibliothek.id);
ziel.innerHTML = '';
if (namen.length === 0) {
ziel.appendChild(leer('Für diese Mediathek sind noch keine Genres bekannt. '
+ 'Frisch unter Verwaltung die Metadaten auf.'));
return;
}
const gitter = document.createElement('div');
gitter.className = 'kategorien';
namen.forEach(name => {
const k = document.createElement('button');
k.className = 'kategorie';
k.textContent = name;
// **Die Farbe aus dem Namen, nicht aus einer Liste.** Eine feste
// Zuordnung Genre→Farbe müsste jedes Genre kennen, das TMDB je
// erfindet. So bekommt jedes eine eigene und immer dieselbe.
k.style.background = farbeAus(name);
k.onclick = () => {
location.hash = `#/m/${bibliothek.id}/mediathek`;
// Der Filter wird über die Adresse gesetzt, sobald das Raster
// steht — sonst schriebe man in ein Element, das es nicht gibt.
window.setTimeout(() => {
const wahl = document.querySelector<HTMLSelectElement>('.filterzeile select');
if (wahl) { wahl.value = name; wahl.dispatchEvent(new Event('change')); }
}, 0);
};
gitter.appendChild(k);
});
ziel.appendChild(gitter);
} catch (fehler) {
ziel.innerHTML = '';
ziel.appendChild(fehlerzeile((fehler as Error).message));
}
})();
}
return wurzel;
}
/** Eine immer gleiche Farbe aus einem Namen. */
function farbeAus(name: string): string {
let summe = 0;
for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360;
return `linear-gradient(135deg, hsl(${summe} 42% 28%), hsl(${(summe + 40) % 360} 38% 20%))`;
}
+40 -42
View File
@@ -1,50 +1,48 @@
import { anmelden } from '../api';
import { esc } from '../util';
import { knopf, feld, fehlerzeile } from '../util';
export function anmeldung(wurzel: HTMLElement, fertig: () => void): void {
wurzel.innerHTML = `
<form class="login-card" id="login-form">
<h1>Flaro</h1>
<p class="sub">Deine Mediathek, schöner.</p>
<div class="field">
<label for="user">Benutzer</label>
<input id="user" type="text" autocomplete="username" required>
</div>
<div class="field">
<label for="pass">Passwort</label>
<input id="pass" type="password" autocomplete="current-password">
</div>
<p class="error" id="login-error"></p>
<button class="btn btn-primary" type="submit" id="login-btn">Anmelden</button>
<p class="note">
Der Zugangsschlüssel bleibt in diesem Browser. Das Passwort geht an
deinen eigenen Server und wird nirgends gespeichert.
</p>
</form>`;
export function anmeldung(fertig: () => void): HTMLElement {
const karte = document.createElement('div');
karte.className = 'login-card';
const form = wurzel.querySelector<HTMLFormElement>('#login-form')!;
const knopf = wurzel.querySelector<HTMLButtonElement>('#login-btn')!;
const fehler = wurzel.querySelector<HTMLElement>('#login-error')!;
const titel = document.createElement('h1');
titel.textContent = 'Flaro';
form.onsubmit = async (e) => {
e.preventDefault();
knopf.disabled = true;
knopf.textContent = 'Verbinde …';
fehler.textContent = '';
const name = feld('Benutzername');
name.autocomplete = 'username';
const passwort = feld('Passwort', 'password');
passwort.autocomplete = 'current-password';
// **Vorgabe ist „bleiben".** Am eigenen Rechner will sich niemand täglich
// neu anmelden; wer an einem fremden sitzt, schaltet es ab. Ohne Haken
// verfällt die Anmeldung mit dem Browserfenster.
const bleiben = document.createElement('label');
bleiben.className = 'bleiben';
const haken = document.createElement('input');
haken.type = 'checkbox';
haken.checked = true;
bleiben.append(haken, document.createTextNode(' Angemeldet bleiben'));
const los = knopf('Anmelden', 'btn btn-primary');
karte.append(titel, name, passwort, bleiben, los);
async function absenden(): Promise<void> {
karte.querySelector('.fehler')?.remove();
los.disabled = true;
try {
await anmelden(
wurzel.querySelector<HTMLInputElement>('#user')!.value,
wurzel.querySelector<HTMLInputElement>('#pass')!.value,
);
await anmelden(name.value.trim(), passwort.value, haken.checked);
fertig();
} catch (e2) {
// Die Serveradresse steht nicht mehr hier, sondern beim Server davor —
// deshalb kann sie auch nicht mehr falsch eingetippt sein. Bleibt die
// Anmeldung selbst, oder ein Server, der nicht läuft.
fehler.textContent = 'Anmeldung fehlgeschlagen: ' + esc((e2 as Error).message);
} finally {
knopf.disabled = false;
knopf.textContent = 'Anmelden';
} catch (fehler) {
karte.appendChild(fehlerzeile((fehler as Error).message));
los.disabled = false;
passwort.select();
}
};
}
los.onclick = () => void absenden();
name.onkeydown = e => { if (e.key === 'Enter') passwort.focus(); };
passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); };
setTimeout(() => name.focus(), 50);
return karte;
}
-499
View File
@@ -1,499 +0,0 @@
import type HlsTyp from 'hls.js';
import type { Item, Quelle, Spur } from '../types';
import { api, medienURL, sitzung, wiedergabeauskunft } from '../api';
import { P, QUALITIES, RATES } from '../prefs';
import { icon } from '../icons';
import { esc, folgentitel, uhr } from '../util';
/* Der Player.
----------------------------------------------------------------------------
Vollbild und eigene Bedienung, nach dem Bauplan der iPad-App: Titel und die
Wahl oben, Transport in der Mitte, unten **nur** der Regler mit den beiden
Zeiten. Und keine Knopfflächen — dort sind es blanke weiße Symbole, der
einzige Hintergrund ist die Abdunklung über dem ganzen Bild. */
interface Lauf {
quality: number;
audio: number | null;
subtitle: number | null;
streams: Spur[];
stelle: number;
sitzung?: Sitzungsmeldung;
}
interface Sitzungsmeldung {
id?: string;
quelleID: string;
itemID: string;
stelle: number;
melden: (weg: string, stelle?: number, extra?: Record<string, unknown>) => void;
}
export function playerOeffnen(item: Item, beimSchliessen?: () => void): void {
document.getElementById('player')?.remove();
const ebene = document.createElement('div');
ebene.id = 'player';
ebene.innerHTML = `
<video playsinline></video>
<div class="pl-chrome">
<div class="pl-top">
<button class="pl-icon" id="pl-close" aria-label="Schließen">${icon('close')}</button>
<div class="pl-title">
<strong>${esc(item.SeriesName || item.Name)}</strong>
${item.SeriesName ? `<span>${esc(folgentitel(item))}</span>` : ''}
</div>
<span class="pl-spacer"></span>
<button class="pl-icon" id="pl-audio" aria-label="Tonspur" hidden>${icon('waveform')}</button>
<button class="pl-icon" id="pl-subs" aria-label="Untertitel" hidden>${icon('captions')}</button>
<button class="pl-icon pl-quality" id="pl-qual" aria-label="Qualität">Original</button>
<button class="pl-icon" id="pl-speed" aria-label="Geschwindigkeit">${icon('speed')}</button>
<button class="pl-icon" id="pl-mute" aria-label="Ton">${icon('sound')}</button>
<button class="pl-icon" id="pl-full" aria-label="Vollbild">${icon('expand')}</button>
</div>
<div class="pl-center">
<button class="pl-round" id="pl-back" aria-label="Zurückspulen">${icon('goBack', 40, P.skipBackward)}</button>
<button class="pl-round pl-play" id="pl-toggle" aria-label="Abspielen">${icon('play', 52)}</button>
<button class="pl-round" id="pl-fwd" aria-label="Vorspulen">${icon('goFwd', 40, P.skipForward)}</button>
</div>
<div class="pl-bottom">
<div class="pl-track" id="pl-track"><div class="pl-buf"></div><div class="pl-fill"></div><i></i></div>
<div class="pl-times"><span id="pl-now">0:00</span><span id="pl-left">–:--</span></div>
</div>
</div>
<div class="pl-menu" id="pl-menu" hidden></div>
<div class="pl-busy" id="pl-busy"><i></i></div>`;
document.body.appendChild(ebene);
document.body.style.overflow = 'hidden';
const v = ebene.querySelector('video')!;
const chrome = ebene.querySelector('.pl-chrome')!;
const el = <T extends HTMLElement>(id: string): T => ebene.querySelector('#' + id) as T;
const lauf: Lauf = {
quality: P.quality, audio: null, subtitle: null, streams: [], stelle: 0,
};
/* ---------- Fehler sichtbar machen ---------- */
let hls: HlsTyp | null = null;
/** Ob gerade auf Daten gewartet wird — am Anfang immer. */
let wartet = true;
const scheitern = (satz: string, hinweis?: string, adresse?: string): void => {
ebene.classList.remove('loading');
el('pl-busy').classList.remove('on');
// Sonst dreht sich der Kreisel im Knopf hinter dem Fehlerkasten weiter,
// als käme noch etwas.
wartet = false;
malen();
ebene.querySelector('.pl-error')?.remove();
const kasten = document.createElement('div');
kasten.className = 'pl-error';
kasten.innerHTML = `<strong>${esc(satz)}</strong>${hinweis ? `<span>${esc(hinweis)}</span>` : ''}
${adresse ? `<code>${esc(adresse.replace(/api_key=[^&]*/, 'api_key=…'))}</code>` : ''}
<button class="btn">Schließen</button>`;
kasten.querySelector('button')!.onclick = schliessen;
ebene.appendChild(kasten);
};
const diagnose = { text: '', url: '' };
v.addEventListener('error', () => {
const c = v.error?.code;
scheitern('Der Browser kann diesen Strom nicht abspielen.',
(c === 4 ? 'Das Format wird nicht unterstützt. ' : `Fehlercode ${c ?? '?'}. `) + diagnose.text,
diagnose.url);
});
/* ---------- Bedienung ein- und ausblenden ---------- */
let ausblenden: ReturnType<typeof setTimeout>;
const zeigen = (): void => {
chrome.classList.add('on');
clearTimeout(ausblenden);
// Solange etwas steht, bleibt die Bedienung: sie auszublenden, während
// niemand spielt, versteckt den einzigen Knopf, der weiterhilft.
if (!v.paused) {
ausblenden = setTimeout(() => {
chrome.classList.remove('on');
kreisel();
}, 3000);
}
kreisel();
};
/** Der freistehende Kreisel — nur, wenn die Bedienung aus ist.
*
* Sonst stünden zwei übereinander: einer in der Mitte des Bildes und einer
* im Abspielknopf, der genau davor sitzt. Genau so sah es vorher aus. */
const kreisel = (): void => {
el('pl-busy').classList.toggle('on', wartet && !chrome.classList.contains('on'));
};
ebene.addEventListener('mousemove', zeigen);
ebene.addEventListener('touchstart', zeigen, { passive: true });
/* ---------- Transport ---------- */
const umschalten = (): void => { void (v.paused ? v.play() : v.pause()); };
el('pl-toggle').onclick = umschalten;
el('pl-back').onclick = () => { v.currentTime = Math.max(0, v.currentTime - P.skipBackward); zeigen(); };
el('pl-fwd').onclick = () => { v.currentTime = Math.min(v.duration || 1e9, v.currentTime + P.skipForward); zeigen(); };
v.addEventListener('click', umschalten);
/** Zeichnet den mittleren Knopf: Kreisel, Dreieck oder Doppelbalken.
*
* Solange auf Daten gewartet wird, steht dort der Kreisel — **kein**
* Dreieck. Ein Dreieck heißt „angehalten, drück mich", und beides stimmte
* nicht: es lief bereits, und der Klick bewirkte nichts. */
const malen = (): void => {
const knopf = el('pl-toggle');
knopf.innerHTML = wartet ? '<i class="pl-wait"></i>' : icon(v.paused ? 'play' : 'pause', 52);
knopf.setAttribute('aria-label', wartet ? 'Lädt' : v.paused ? 'Abspielen' : 'Anhalten');
};
const warten = (an: boolean): void => {
wartet = an;
malen();
kreisel();
};
v.addEventListener('play', () => { malen(); zeigen(); });
v.addEventListener('pause', () => { warten(false); zeigen(); });
v.addEventListener('waiting', () => warten(true));
v.addEventListener('playing', () => {
warten(false);
ebene.classList.remove('loading');
});
/* ---------- Fortschrittsleiste ---------- */
const spur = el('pl-track');
v.addEventListener('timeupdate', () => {
const teil = v.duration ? v.currentTime / v.duration : 0;
spur.querySelector<HTMLElement>('.pl-fill')!.style.width = teil * 100 + '%';
spur.querySelector<HTMLElement>('i')!.style.left = teil * 100 + '%';
el('pl-now').textContent = uhr(v.currentTime);
// Restzeit statt Gesamtlänge: „noch 42 Minuten" beantwortet die Frage,
// die man sich beim Schauen tatsächlich stellt.
el('pl-left').textContent = '−' + uhr((v.duration || 0) - v.currentTime);
});
v.addEventListener('progress', () => {
if (!v.buffered.length || !v.duration) return;
spur.querySelector<HTMLElement>('.pl-buf')!.style.width =
(v.buffered.end(v.buffered.length - 1) / v.duration) * 100 + '%';
});
const springen = (e: MouseEvent | TouchEvent): void => {
const r = spur.getBoundingClientRect();
const x = ((e instanceof MouseEvent ? e.clientX : e.touches[0]!.clientX) - r.left) / r.width;
if (v.duration) v.currentTime = Math.min(Math.max(x, 0), 1) * v.duration;
zeigen();
};
spur.onclick = springen;
spur.onmousedown = (e) => {
springen(e);
const ziehen = (ev: MouseEvent): void => springen(ev);
const los = (): void => {
removeEventListener('mousemove', ziehen);
removeEventListener('mouseup', los);
};
addEventListener('mousemove', ziehen);
addEventListener('mouseup', los);
};
/* ---------- Die drei Listen ----------
Getrennt, weil es drei verschiedene Fragen sind: die Tonspur stellt man
einmal am Anfang um, die Untertitel mitten im Film, die Qualität, wenn es
hakt. Eine gemeinsame Liste hieße jedes Mal an den anderen vorbeisuchen. */
const menue = el('pl-menu');
const menueZu = (): void => { menue.hidden = true; };
const spuren = (art: Spur['Type']): Spur[] => lauf.streams.filter((x) => x.Type === art);
const nenne = (t: Spur): string => t.DisplayTitle || t.Language || `Spur ${t.Index}`;
interface Eintrag { text: string; gewaehlt: boolean; waehlen: () => void }
function liste(knopf: HTMLElement, titel: string, eintraege: Eintrag[]): void {
if (!menue.hidden && menue.dataset.von === knopf.id) return menueZu();
menue.dataset.von = knopf.id;
menue.innerHTML = `<div class="pl-group">${esc(titel)}</div>`;
for (const { text, gewaehlt, waehlen } of eintraege) {
const b = document.createElement('button');
b.className = 'pl-item' + (gewaehlt ? ' on' : '');
b.innerHTML = `<span>${esc(text)}</span>${gewaehlt ? icon('check', 16) : ''}`;
b.onclick = () => (gewaehlt ? menueZu() : waehlen());
menue.appendChild(b);
}
// Unter seinem Knopf, nicht in der Mitte: so ist zu sehen, wozu die Liste
// gehört, wenn mehrere davon nebeneinander sitzen.
const r = knopf.getBoundingClientRect();
menue.style.right = Math.max(12, innerWidth - r.right) + 'px';
menue.style.top = r.bottom + 8 + 'px';
menue.hidden = false;
zeigen();
}
const neuLaden = (): void => {
lauf.stelle = v.currentTime || 0;
ebene.querySelector('.pl-error')?.remove();
ebene.classList.add('loading');
warten(true);
menueZu();
void strom();
};
el('pl-audio').onclick = () => liste(el('pl-audio'), 'Tonspur',
spuren('Audio').map((t) => ({
text: nenne(t), gewaehlt: lauf.audio === t.Index,
waehlen: () => { lauf.audio = t.Index; neuLaden(); },
})));
el('pl-subs').onclick = () => liste(el('pl-subs'), 'Untertitel', [
{ text: 'Aus', gewaehlt: (lauf.subtitle ?? -1) < 0, waehlen: () => { lauf.subtitle = -1; neuLaden(); } },
...spuren('Subtitle').map((t) => ({
text: nenne(t), gewaehlt: lauf.subtitle === t.Index,
waehlen: () => { lauf.subtitle = t.Index; neuLaden(); },
})),
]);
el('pl-qual').onclick = () => liste(el('pl-qual'), 'Qualität',
QUALITIES.map(([wert, text]) => ({
text, gewaehlt: lauf.quality === wert,
waehlen: () => { lauf.quality = wert; neuLaden(); },
})));
el('pl-speed').onclick = () => liste(el('pl-speed'), 'Geschwindigkeit',
RATES.map((r) => ({
text: r === 1 ? 'Normal' : r + '×',
gewaehlt: Math.abs(v.playbackRate - r) < 0.01,
waehlen: () => { v.playbackRate = r; menueZu(); zeigen(); },
})));
ebene.addEventListener('click', (e) => {
const ziel = e.target as HTMLElement;
if (menue.hidden || menue.contains(ziel) || ziel.closest('.pl-icon')) return;
menueZu();
});
/* ---------- Ton ---------- */
const tonMalen = (): void => {
el('pl-mute').innerHTML = icon(v.muted || v.volume === 0 ? 'muted' : 'sound');
el('pl-mute').title = `Lautstärke ${Math.round(v.volume * 100)} %`;
};
el('pl-mute').onclick = () => { v.muted = !v.muted; tonMalen(); };
el('pl-mute').addEventListener('wheel', (e) => {
e.preventDefault();
v.volume = Math.min(1, Math.max(0, v.volume - Math.sign(e.deltaY) * 0.05));
v.muted = v.volume === 0;
tonMalen();
}, { passive: false });
el('pl-full').onclick = () => {
void (document.fullscreenElement ? document.exitFullscreen() : ebene.requestFullscreen?.());
};
/* ---------- Tastatur ---------- */
const tasten = (e: KeyboardEvent): void => {
const ziel = e.target as HTMLElement;
if (ziel.tagName === 'SELECT' || ziel.tagName === 'INPUT') return;
const wege: Record<string, () => void> = {
' ': umschalten, k: umschalten,
ArrowLeft: () => { v.currentTime -= P.skipBackward; },
ArrowRight: () => { v.currentTime += P.skipForward; },
ArrowUp: () => { v.volume = Math.min(1, v.volume + 0.1); tonMalen(); },
ArrowDown: () => { v.volume = Math.max(0, v.volume - 0.1); tonMalen(); },
f: () => el('pl-full').click(),
Escape: () => schliessen(),
};
const tun = wege[e.key];
if (!tun) return;
e.preventDefault();
tun();
zeigen();
};
addEventListener('keydown', tasten);
/* ---------- Zurück aus dem Hintergrund ----------
Wer die Anwendung wechselt, kommt zu einem stehenden Bild bei laufendem
Ton zurück: der Browser hält das Entpacken an, während der Tab verdeckt
ist, und hls.js findet danach nicht von selbst in den Puffer zurück. */
const zurueck = (): void => {
if (document.hidden || v.paused || !hls) return;
const stelle = v.currentTime;
hls.startLoad(stelle);
// Ein winziger Sprung erzwingt einen neuen Schlüsselbildpunkt. Ohne ihn
// bleibt das zuletzt gezeigte Bild stehen, obwohl Segmente ankommen.
if (stelle > 0.5) v.currentTime = stelle - 0.1;
};
document.addEventListener('visibilitychange', zurueck);
/* ---------- Schließen ---------- */
function schliessen(): void {
removeEventListener('keydown', tasten);
document.removeEventListener('visibilitychange', zurueck);
clearTimeout(ausblenden);
if (document.fullscreenElement) void document.exitFullscreen?.();
// **Erst melden, dann abräumen.** Andersherum steht die Stelle schon auf
// null, wenn die Meldung hinausgeht — und Jellyfin löscht daraufhin den
// Fortschritt, statt ihn zu speichern.
lauf.sitzung?.melden('/Sessions/Playing/Stopped');
hls?.destroy();
hls = null;
v.pause();
v.removeAttribute('src');
v.load();
ebene.remove();
document.body.style.overflow = '';
beimSchliessen?.();
}
el('pl-close').onclick = schliessen;
/* ---------- Der Strom ---------- */
async function strom(): Promise<void> {
hls?.destroy();
hls = null;
let auskunft;
try {
auskunft = await wiedergabeauskunft(item.Id, lauf);
} catch (e) {
return scheitern('Der Server hat die Wiedergabe abgelehnt.', (e as Error).message);
}
const quelle: Quelle | undefined = auskunft.MediaSources?.[0];
if (!quelle) return scheitern('Der Server nennt keine abspielbare Quelle.');
// Was zur Auswahl steht, weiß man erst jetzt. Beim ersten Lauf gilt die
// Vorauswahl des Servers.
lauf.streams = quelle.MediaStreams ?? [];
if (lauf.audio == null) {
lauf.audio = quelle.DefaultAudioStreamIndex ?? spuren('Audio')[0]?.Index ?? null;
}
if (lauf.subtitle == null) lauf.subtitle = quelle.DefaultSubtitleStreamIndex ?? -1;
spurenBekannt();
// **Die Adresse kommt vom Server.** Nennt er keine, wird ausdrücklich ein
// HLS-Strom verlangt — nicht die Rohdatei. Ein MKV mit DTS ist für einen
// Browser unlesbar, und genau dieser Rückfall war der Grund, warum lange
// gar nichts lief.
const adresse = quelle.TranscodingUrl
? new URL(quelle.TranscodingUrl, location.origin + '/api').href
: medienURL(`/Videos/${item.Id}/master.m3u8`, {
mediaSourceId: quelle.Id ?? item.Id,
PlaySessionId: auskunft.PlaySessionId ?? '',
DeviceId: sitzung.geraet,
VideoCodec: 'h264', AudioCodec: 'aac,mp3',
TranscodingMaxAudioChannels: '2', SegmentContainer: 'ts',
MinSegments: '1', BreakOnNonKeyFrames: 'true', TranscodingProtocol: 'hls',
...(lauf.audio != null ? { AudioStreamIndex: lauf.audio } : {}),
...((lauf.subtitle ?? -1) >= 0 ? { SubtitleStreamIndex: lauf.subtitle! } : {}),
...(lauf.quality > 0 ? { MaxStreamingBitrate: lauf.quality * 1000 } : {}),
});
diagnose.text = [
quelle.TranscodingUrl ? 'Server nannte eine Adresse' : 'Server nannte keine Adresse (Rückfall auf HLS)',
quelle.Container ? `Container ${quelle.Container}` : null,
].filter(Boolean).join(' · ');
diagnose.url = adresse;
v.addEventListener('loadedmetadata', () => {
v.playbackRate = P.rate;
// Zurück an die Stelle, an der umgeschaltet wurde — sonst begänne jeder
// Wechsel der Tonspur den Film von vorn.
if (lauf.stelle > 1) v.currentTime = lauf.stelle;
void v.play();
}, { once: true });
lauf.sitzung = fortschritt(v, {
id: auskunft.PlaySessionId,
quelleID: quelle.Id ?? item.Id,
itemID: item.Id,
});
if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = adresse;
return;
}
// **Erst hier geladen.** hls.js ist größer als der ganze Rest zusammen,
// und wer nur stöbert, braucht es nie. Safari schon gar nicht — dort
// sind wir oben schon heraus.
const { default: Hls } = await import('hls.js');
const h = new Hls();
hls = h;
h.on(Hls.Events.ERROR, (_e, daten) => {
if (!daten.fatal) return;
scheitern('Die Wiedergabe ist abgebrochen.',
`${daten.type}: ${daten.details}` +
(daten.response?.code ? ` (HTTP ${daten.response.code})` : ''));
h.destroy();
});
h.loadSource(adresse);
h.attachMedia(v);
}
function spurenBekannt(): void {
el('pl-audio').hidden = spuren('Audio').length === 0;
el('pl-subs').hidden = spuren('Subtitle').length === 0;
// Gefülltes Symbol, solange Untertitel laufen — wie auf dem iPad.
el('pl-subs').innerHTML = icon((lauf.subtitle ?? -1) >= 0 ? 'captionsOn' : 'captions');
el('pl-qual').textContent =
(QUALITIES.find(([w]) => w === lauf.quality)?.[1] ?? 'Original').split(' — ').pop()!;
}
zeigen();
tonMalen();
ebene.classList.add('loading');
warten(true);
void strom();
}
/* Meldet dem Server, wo man steht.
----------------------------------------------------------------------------
Die Stelle wird **getrennt vom Element** gehalten. Beim Schließen fällt
`currentTime` auf null, und stünde die Meldung erst danach an, bekäme
Jellyfin „gestoppt bei 0:00" und löschte den Fortschritt.
Nicht ohne Hinsehen: ein verschlucktes `catch` hat lange verborgen, ob die
Meldungen überhaupt ankommen. */
function fortschritt(
video: HTMLVideoElement,
sitz: { id?: string; quelleID: string; itemID: string },
): Sitzungsmeldung {
const s: Sitzungsmeldung = {
...sitz,
stelle: 0,
melden: (weg, stelle, extra = {}) => {
const wo = stelle ?? s.stelle;
void api(weg, {
method: 'POST',
body: JSON.stringify({
ItemId: s.itemID, PlaySessionId: s.id, MediaSourceId: s.quelleID,
PositionTicks: Math.round(wo * 10_000_000),
// Ohne die ordnet Jellyfin die Meldung keiner Wiedergabe zu.
PlayMethod: 'Transcode', CanSeek: true,
...extra,
}),
})
.then(() => console.debug(`[Flaro] ${weg} → ${(wo / 60).toFixed(1)} min angenommen`))
.catch((e: Error) => console.warn(`[Flaro] ${weg} abgelehnt: ${e.message}`));
},
};
video.addEventListener('playing', () => s.melden('/Sessions/Playing'), { once: true });
let zuletzt = 0;
video.addEventListener('timeupdate', () => {
if (video.currentTime > 0) s.stelle = video.currentTime;
if (video.currentTime - zuletzt < 10) return;
zuletzt = video.currentTime;
s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: video.paused });
});
// Anhalten sofort melden: wer pausiert und weggeht, hatte sonst bis zur
// nächsten Zehn-Sekunden-Marke nichts hinterlassen.
video.addEventListener('pause', () => s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: true }));
video.addEventListener('play', () => s.melden('/Sessions/Playing/Progress', s.stelle, { IsPaused: false }));
video.addEventListener('ended', () => s.melden('/Sessions/Playing/Stopped', video.duration || s.stelle));
return s;
}
+59
View File
@@ -0,0 +1,59 @@
import { filme, serien } from '../api';
import { esc, leer, fehlerzeile } from '../util';
import { kachel, alsKachel } from '../card';
/** Suchergebnisse quer durch alle Mediatheken.
*
* **Nicht je Mediathek.** Wer einen Titel sucht, weiss oft gar nicht, in
* welcher er liegt — genau deshalb steht die Suche oben in der Kopfleiste
* und nicht in jeder Mediathek einzeln. */
export function suchseite(
text: string,
oeffnen: (art: 'filme' | 'serien', id: string) => void,
): HTMLElement {
const wurzel = document.createElement('div');
const kopf = document.createElement('div');
kopf.className = 'seitenkopf';
kopf.innerHTML = `<div class="wer"><h1>${esc(text)}</h1>`
+ '<div class="quelle">Suchergebnisse</div></div>';
wurzel.appendChild(kopf);
const buehne = document.createElement('div');
buehne.innerHTML = '<div class="spinner"></div>';
wurzel.appendChild(buehne);
void (async () => {
try {
const [f, s] = await Promise.all([
filme({ search: text, take: 100 }),
serien({ search: text, take: 100 }),
]);
buehne.innerHTML = '';
if (f.total === 0 && s.total === 0) {
buehne.appendChild(leer(`Nichts gefunden für „${text}".`));
return;
}
if (f.total > 0) buehne.appendChild(abschnitt(`Filme · ${f.total}`, f.items));
if (s.total > 0) buehne.appendChild(abschnitt(`Serien · ${s.total}`, s.items));
} catch (fehler) {
buehne.innerHTML = '';
buehne.appendChild(fehlerzeile((fehler as Error).message));
}
})();
function abschnitt(titel: string, eintraege: Parameters<typeof alsKachel>[0][]): HTMLElement {
const bereich = document.createElement('section');
const h = document.createElement('h2');
h.textContent = titel;
const gitter = document.createElement('div');
gitter.className = 'raster';
eintraege.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
bereich.append(h, gitter);
return bereich;
}
return wurzel;
}
-246
View File
@@ -1,246 +0,0 @@
import { P, PALETTE, QUALITIES, RATES, SKIPS, LANGUAGES, setzen, anordnen } from '../prefs';
import { abmelden } from '../api';
import { icon } from '../icons';
import { esc } from '../util';
import { bekannteReihen } from './home';
import { lesbarMachen, alsHex, ausHex } from '../contrast';
/* Die Einstellungen.
----------------------------------------------------------------------------
Dieselben fünf Abschnitte in derselben Reihenfolge wie in der App:
Darstellung, Startseite, Wiedergabe, Sprachen, Server. Downloads und
Diagnose fehlen — dort fehlt die Funktion, nicht die Einstellung. */
export function einstellungen(wurzel: HTMLElement, neuZeichnen: () => void): void {
wurzel.classList.add('pad-top');
wurzel.innerHTML = `
<div class="settings">
<h2>Einstellungen</h2>
<h3 class="group">Darstellung</h3>
<div class="setting">
<h4>Akzentfarbe</h4>
<p>Dieselbe Palette wie in der App.</p>
<div class="swatches" id="set-accent"></div>
<div class="hexrow">
<input type="text" id="set-hex" placeholder="#C04AC9" maxlength="7" spellcheck="false">
<button class="btn" id="set-hex-apply">Übernehmen</button>
</div>
<p class="hint" id="set-hex-note"></p>
</div>
<div class="setting">
<h4>Kachelgröße</h4>
<p>Wie groß Plakate und Vorschaubilder erscheinen — auf der Startseite
und in den Mediatheken. Gilt nur auf diesem Gerät.</p>
<div class="range-row"><input type="range" id="set-zoom" min="100" max="280" step="10"><output id="set-zoom-out"></output></div>
</div>
<h3 class="group">Startseite</h3>
<div class="setting">
<h4>Aufmacher</h4>
<p>Woher die Titel oben kommen. Die Auswahl wechselt alle sechs Stunden;
Titel ohne Hintergrundbild ziehen dabei ein schlechteres Los.</p>
<div class="seg" id="set-herosource">
<button data-v="continueFirst">Angefangenes zuerst</button>
<button data-v="latestOnly">Nur Neuzugänge</button>
</div>
<div class="range-row"><input type="range" id="set-herocount" min="1" max="10" step="1"><output id="set-herocount-out"></output></div>
<div class="range-row"><input type="range" id="set-heroheight" min="40" max="95" step="5"><output id="set-heroheight-out"></output></div>
</div>
<div class="setting">
<h4>Reihen</h4>
<label class="check"><input type="checkbox" id="set-combine"><span>„Weiterschauen“ und „Als Nächstes“ zusammenlegen</span></label>
<label class="check"><input type="checkbox" id="set-hidewatched"><span>Gesehenes aus den Neuzugängen ausblenden</span></label>
</div>
<div class="setting">
<h4>Reihen der Startseite</h4>
<p>Welche Reihen erscheinen und in welcher Folge. Was der Server neu
anlegt, kommt ans Ende, statt zu fehlen.</p>
<div id="set-rows"></div>
</div>
<h3 class="group">Wiedergabe</h3>
<div class="setting">
<h4>Geschwindigkeit</h4>
<p>Womit ein Titel beginnt. Im Player lässt sie sich jederzeit ändern.</p>
<select id="set-rate" class="pick"></select>
</div>
<div class="setting">
<h4>Spulweite</h4>
<div class="pickrow">
<label>Zurück <select id="set-skipback" class="pick"></select></label>
<label>Vor <select id="set-skipfwd" class="pick"></select></label>
</div>
</div>
<div class="setting">
<h4>Qualität</h4>
<p>„Original“ schickt die Datei unverändert, alles andere lässt den
Server umrechnen. Die App unterscheidet WLAN und Mobilfunk — im
Browser lässt sich das nicht verlässlich feststellen, deshalb eine
Stufe.</p>
<select id="set-quality" class="pick"></select>
</div>
<h3 class="group">Sprachen</h3>
<div class="setting">
<h4>Bevorzugte Sprachen</h4>
<p>Welche Spur beim Start gewählt wird, sofern der Titel sie mitbringt.</p>
<div class="pickrow">
<label>Ton <select id="set-audio" class="pick"></select></label>
<label>Untertitel <select id="set-subs" class="pick"></select></label>
</div>
</div>
<h3 class="group">Server</h3>
<div class="setting">
<h4>Verbindung</h4>
<p>Die Adresse steht beim Server, der diese Seite ausliefert — nicht im
Browser.</p>
<button class="btn" id="set-signout">Abmelden</button>
</div>
</div>`;
/* ---- Akzentfarbe ---- */
const felder = wurzel.querySelector('#set-accent')!;
for (const [name, hex] of PALETTE) {
const b = document.createElement('button');
b.className = 'swatch' + (hex.toLowerCase() === P.accent.toLowerCase() ? ' on' : '');
b.style.background = hex;
b.title = name;
b.dataset.hex = hex;
b.onclick = () => { setzen('accent', hex); einstellungen(wurzel, neuZeichnen); };
felder.appendChild(b);
}
const hex = wurzel.querySelector<HTMLInputElement>('#set-hex')!;
const notiz = wurzel.querySelector<HTMLElement>('#set-hex-note')!;
hex.value = P.accent;
const uebernehmen = (): void => {
const rgb = ausHex(hex.value);
if (!rgb) { notiz.textContent = 'Das ist kein Farbwert — erwartet wird etwa #C04AC9.'; return; }
const { farbe, geaendert } = lesbarMachen(rgb, ausHex('#0B0B0D')!);
const wert = alsHex(farbe);
setzen('accent', wert);
hex.value = wert;
notiz.textContent = geaendert
? `Auf ${wert} aufgehellt — die gewählte Farbe wäre auf dem dunklen Grund nicht lesbar gewesen.`
: '';
felder.querySelectorAll<HTMLElement>('.swatch')
.forEach((b) => b.classList.toggle('on', b.dataset.hex === wert));
};
wurzel.querySelector<HTMLElement>('#set-hex-apply')!.onclick = uebernehmen;
hex.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); uebernehmen(); } };
/* ---- Die wiederkehrenden Bauteile ---- */
const regler = (id: string, feld: 'cardW' | 'heroCount' | 'heroHeight', einheit: string): void => {
const r = wurzel.querySelector<HTMLInputElement>('#' + id)!;
const o = wurzel.querySelector<HTMLOutputElement>('#' + id + '-out')!;
r.value = String(P[feld]);
o.textContent = P[feld] + einheit;
r.oninput = () => { o.textContent = r.value + einheit; setzen(feld, Number(r.value)); };
};
regler('set-zoom', 'cardW', ' px');
regler('set-herocount', 'heroCount', ' Titel');
regler('set-heroheight', 'heroHeight', ' % Höhe');
const schalter = wurzel.querySelector('#set-herosource')!;
[...schalter.children].forEach((b) => {
const el = b as HTMLElement;
el.classList.toggle('on', el.dataset.v === P.heroSource);
el.onclick = () => {
setzen('heroSource', el.dataset.v as typeof P.heroSource);
einstellungen(wurzel, neuZeichnen);
};
});
const haken = (id: string, feld: 'combineContinue' | 'hideWatched'): void => {
const c = wurzel.querySelector<HTMLInputElement>('#' + id)!;
c.checked = P[feld];
c.onchange = () => setzen(feld, c.checked);
};
haken('set-combine', 'combineContinue');
haken('set-hidewatched', 'hideWatched');
const auswahl = (id: string, feld: keyof typeof P, werte: readonly (readonly [string | number, string])[]): void => {
const s = wurzel.querySelector<HTMLSelectElement>('#' + id)!;
s.innerHTML = '';
for (const [wert, text] of werte) {
const o = document.createElement('option');
o.value = String(wert);
o.textContent = text;
o.selected = String(wert) === String(P[feld]);
s.appendChild(o);
}
s.onchange = () => {
const roh = typeof P[feld] === 'number' ? Number(s.value) : s.value;
setzen(feld as never, roh as never);
};
};
auswahl('set-rate', 'rate', RATES.map((r) => [r, r === 1 ? 'Normal' : r + '×'] as const));
auswahl('set-skipback', 'skipBackward', SKIPS.map((n) => [n, n + ' s'] as const));
auswahl('set-skipfwd', 'skipForward', SKIPS.map((n) => [n, n + ' s'] as const));
auswahl('set-quality', 'quality', QUALITIES);
auswahl('set-audio', 'audioLanguage', LANGUAGES);
auswahl('set-subs', 'subtitleLanguage', LANGUAGES);
wurzel.querySelector<HTMLElement>('#set-signout')!.onclick = abmelden;
reihenListe(wurzel, neuZeichnen);
}
/* Welche Reihen erscheinen und in welcher Folge.
----------------------------------------------------------------------------
Knöpfe statt Ziehen: auf dem Zeigegerät ist Ziehen schöner, auf einem
Trackpad fummelig und mit der Tastatur gar nicht zu machen. Zwei Pfeile
können alle drei. */
function reihenListe(wurzel: HTMLElement, neuZeichnen: () => void): void {
const ort = wurzel.querySelector('#set-rows');
if (!ort) return;
if (!bekannteReihen.length) {
ort.innerHTML = '<p class="hint">Erst sichtbar, wenn die Startseite einmal geladen war.</p>';
return;
}
// Ausgeblendete hinten dran, damit man sie wiederfindet.
const geordnet = [
...anordnen(bekannteReihen),
...bekannteReihen.filter((r) => P.hiddenRows.includes(r.id)),
];
ort.innerHTML = '';
geordnet.forEach((reihe, stelle) => {
const aus = P.hiddenRows.includes(reihe.id);
const zeile = document.createElement('div');
zeile.className = 'rowline' + (aus ? ' off' : '');
zeile.innerHTML = `
<label class="check" style="flex:1;padding:0">
<input type="checkbox" ${aus ? '' : 'checked'}>
<span>${esc(reihe.title)}</span>
</label>
<button class="tiny" aria-label="Nach oben" ${stelle === 0 ? 'disabled' : ''}>${icon('up', 16)}</button>
<button class="tiny" aria-label="Nach unten" ${stelle === geordnet.length - 1 ? 'disabled' : ''}>${icon('down', 16)}</button>`;
zeile.querySelector('input')!.onchange = (e) => {
const an = (e.target as HTMLInputElement).checked;
setzen('hiddenRows', an
? P.hiddenRows.filter((id) => id !== reihe.id)
: [...P.hiddenRows, reihe.id]);
reihenListe(wurzel, neuZeichnen);
neuZeichnen();
};
const schieben = (um: number): void => {
// Die aktuelle Folge festschreiben, bevor darin verschoben wird.
const ids = geordnet.map((r) => r.id);
const nach = stelle + um;
if (nach < 0 || nach >= ids.length) return;
[ids[stelle], ids[nach]] = [ids[nach]!, ids[stelle]!];
setzen('rowOrder', ids);
reihenListe(wurzel, neuZeichnen);
neuZeichnen();
};
const [hoch, runter] = zeile.querySelectorAll('button');
(hoch as HTMLElement).onclick = () => schieben(-1);
(runter as HTMLElement).onclick = () => schieben(1);
ort.appendChild(zeile);
});
}
+44
View File
@@ -0,0 +1,44 @@
import { einrichten, anmelden } from '../api';
import { knopf, feld, fehlerzeile } from '../util';
/** Der erste Start: ein Administrator muss angelegt werden.
*
* **Die einzige Stelle, an der jemand ohne Anmeldung etwas ändern darf** —
* und sie verschliesst sich selbst, sobald ein Benutzer existiert. */
export function einrichtung(fertig: () => void): HTMLElement {
const karte = document.createElement('div');
karte.className = 'login-card';
const titel = document.createElement('h1');
titel.textContent = 'Flaro einrichten';
const text = document.createElement('p');
text.className = 'hinweis';
text.textContent = 'Lege das erste Konto an. Es wird Administrator und kann '
+ 'später Bibliotheken und weitere Benutzer verwalten.';
const name = feld('Benutzername');
name.autocomplete = 'username';
const passwort = feld('Passwort, mindestens 8 Zeichen', 'password');
passwort.autocomplete = 'new-password';
const los = knopf('Anlegen und anmelden', 'btn btn-primary');
karte.append(titel, text, name, passwort, los);
async function absenden(): Promise<void> {
karte.querySelector('.error')?.remove();
los.disabled = true;
try {
await einrichten(name.value.trim(), passwort.value);
await anmelden(name.value.trim(), passwort.value);
fertig();
} catch (fehler) {
karte.appendChild(fehlerzeile((fehler as Error).message));
los.disabled = false;
}
}
los.onclick = () => void absenden();
passwort.onkeydown = e => { if (e.key === 'Enter') void absenden(); };
setTimeout(() => name.focus(), 50);
return karte;
}
+2 -2
View File
@@ -6,11 +6,11 @@ export default defineConfig({
// Im Entwicklungsbetrieb übernimmt Vite, was sonst der eigene Server tut:
// Anfragen an /api gehen an den Medienserver weiter. Damit ist die
// Herkunft dieselbe wie die der Seite — und CORS nie wieder ein Thema.
// Der Pfad bleibt unverändert: Flaro bedient /api/v1 selbst.
proxy: {
'/api': {
target: process.env.FLARO_SERVER ?? 'http://localhost:8096',
target: process.env.FLARO_SERVER ?? 'http://localhost:18080',
changeOrigin: true,
rewrite: (pfad) => pfad.replace(/^\/api/, ''),
},
},
},