Compare commits

..
84 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 68abd71074 Ein Bild, wo bisher nur eine Zahl stand
Wer zur Zeitleiste greift, sieht jetzt das Standbild der Stelle — im Web
beim Überfahren, in der App beim Ziehen.

**Erzeugt wird beim Abspielen, nicht auf Vorrat.** Ein Lauf über 453
Filme und 4213 Folgen wäre ein Abend ffmpeg und mehrere Gigabyte für
Titel, die niemand anfasst. Startet jemand einen Titel, stellt sich die
Datei hinten an; bis zum ersten Griff zur Leiste ist der Lauf durch.
Gemessen an einer erzeugten Datei: 191 ms für 250 Sekunden Film, ein
Zweistünder liegt damit bei etwa fünf Sekunden.

**Ein ffmpeg-Aufruf, nicht siebenhundert.** `fps=1/10,scale=320:-2,
tile=10x10` macht aus einem Durchlauf fertige Blätter zu hundert
Kacheln. Bezahlbar ist das durch `-skip_frame nokey` — dieselbe
Abkürzung wie beim Abtasten der Abspannhelligkeit: nur Keyframes
dekodieren. Dass die Vorschau dadurch um zwei Sekunden danebenliegen
kann, sieht niemand.

**Der Spieler lädt ein Blatt und verschiebt danach nur den Ausschnitt.**
Beim Fahren über die Leiste wird kein einziger Abruf mehr fällig. Die
Rechnung Sekunde → Kachel steht an drei Stellen — Server, Web, App —
und deshalb auch dreimal dieselbe; weicht eine ab, hält man die Vorschau
für ein unscharfes Bild statt für einen Fehler. Neun Prüfungen decken
die Ränder ab: Sekunde null, letzte Kachel eines Blattes, erste des
nächsten, und hinter dem Ende.

**Die Kachelmasse werden gemessen, nicht gerechnet.** `scale=320:-2`
hält das Seitenverhältnis, ein Breitbildfilm ergibt also flachere
Kacheln. Geraten wäre jede Kachel um ein paar Bildpunkte verschoben.

Aufgeräumt wird mit dem Transcode-Cache: der Namensanfang trägt Kennung
und Änderungszeit der Mediendatei, verwaiste Blätter sind daran ohne
Tabelle erkennbar. Der Ordner taucht unter „Speicher" auf.

**Und die Pfeiltasten spulen wieder.** Hatte die Zeitleiste den Fokus,
überliess der Spieler die Pfeile dem Browser — der verschiebt `value`
und löst `input` aus, aber `change` kommt bei Tastatureingabe nicht
verlässlich, also wurde nie gesprungen. Die Ausnahme fällt weg; Pfeil
heisst überall ±10 Sekunden. Nativ wären es ohnehin 100 Millisekunden
gewesen, weil `step` auf 0,1 steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 17:05:55 +02:00
ScarriffleandClaude Opus 5 ed13d5fa43 Kratzplatz, Markenerkennung, Tonabdruck — erfundene Wörter für Fachbegriffe
„Scratch disk" wörtlich zu übersetzen ist derselbe Fehler wie „Wachhund"
für Watchdog und „Keks" für Cookie. Und „Marke" ist kein Fachbegriff für
das, was gemeint ist: Jellyfin nennt diese Abschnitte Media Segments, und
das ist auch das Wort, in dem sich darüber reden lässt.

    Kratzplatz*       → Transcode*          (was es ist, steht in FLARO_TRANSCODE)
    Marke, Marken     → Segment, Segmente
    Markenerkennung   → Segmenterkennung
    Markenart/-suche  → Segmentart/-suche
    Tonabdruck        → Fingerprint         (Chromaprint nennt es so)
    Tonabgleich       → Fingerprintabgleich
    Schlüsselbild     → Keyframe
    Bildabtaster      → Frameabtaster

Gewöhnliches Deutsch bleibt gewöhnliches Deutsch: Wartung, Freigabe,
Meldung, Abspann, Vorspann. Das war nie das Problem.

**Was sich nicht ändert, und zwar mit Absicht.** Die drei
Einstellungsschlüssel `wartung.kratzplatz.paket.*` bleiben, wie sie sind —
sie stehen in der Datenbank, und ein neuer Name hiesse, dass die Werte
des Nutzers stillschweigend auf die Vorgabe zurückfallen. Dasselbe für
die Endpunktpfade `/marken`: sie sind nicht sichtbar, und ein Umbenennen
wäre Diff ohne Gewinn.

**Die Migration ist von Hand geschrieben.** `dotnet ef` hat ein Gerüst
erzeugt, das beide Tabellen löscht und neu anlegt — es sieht eine
verschwundene und eine neue Tabelle, nicht eine umbenannte. Damit wäre
jedes erkannte Segment und jeder Vermerk „hier wurde schon gesucht" weg
gewesen. `RenameTable` und `RenameIndex` tun dasselbe, ohne eine Zeile
anzufassen; nachgemessen an einer echten SQLite-Datei: Zeilen vorher 1
und 1, nachher 1 und 1, Inhalt unverändert.

Nachgezogen: in `bilder.ts` und `main.ts` heisst „marke" ein Häkchen und
das Logo — die blieben unberührt. Und „Segment" ist sächlich, was eine
Blindersetzung nicht weiss: „eine Segment" stand danach an vierzehn
Stellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:26:57 +02:00
ScarriffleandClaude Opus 5 32f592b83e Ein Abspann darf aus Stücken bestehen
Zwei Fälle fielen durch, beide am Bestand gesehen. Bei einem Marvel-Film
liegt hinter dem Abspann eine Post-Credit-Szene: die Suche begann am
Dateiende, fand nur den kurzen Rest dahinter und verwarf ihn als zu kurz.
Und ein Abspann, den eine Szene in der Mitte teilt, zerfiel in zwei
Reste, von denen keiner lang genug war. Dieselbe Ursache: der Abspann
musste eine zusammenhängende dunkle Strecke bis zum Dateiende sein.

Jetzt bildet die Suche Läufe, fasst sie über Lücken bis vier Minuten
zusammen — so lang ist die längste bekannte Post-Credit-Szene plus
Reserve — und lässt die **Summe** über die Mindestlänge entscheiden statt
jedes einzelne Stück. Ein Lauf unter fünfzehn Sekunden ist eine Blende
und zählt nicht mit.

Dafür mussten die eindeutigen Indizes über (Titel, Art) weichen; sie
standen mit dem Vermerk „eine Art einmal je Titel" da, und genau das
stimmt nicht mehr. `SchreibenAsync` ersetzt jetzt die Menge, statt eine
Zeile umzuschreiben — das ist der einzige Weg, der in beide Richtungen
stimmt, wenn aus zwei Stücken wieder eines wird.

**Drei Stellen trugen das nicht.** Im Web hiess „Abspann überspringen"
bei einem Film immer Schluss — beim ersten von zwei Stücken bekäme
niemand die Szene je zu sehen; geschlossen wird jetzt nur, wenn nichts
mehr folgt. Auf iOS verwarf der Spieler die Abspannmarke eines Films
ganz; diese Regel gehört nicht dorthin, sondern auf den Server, und ist
ersatzlos weg. Und die Gesehen-Schwelle nahm irgendeine Abspannzeile —
ohne `OrderBy` entschied die Datenbank; gemeint ist immer die früheste.

Dazu der Schalter „Zusätzliche Szenen mit überspringen" (Vorgabe aus) bei
den Geräteeinstellungen, und ein Messwerkzeug: ein Knopf auf der
Filmseite lädt die Kurve je Schlüsselbild als Tabelle — Dunkelanteil des
ganzen Bildes und der beiden Randstreifen. Ob bei Outtakes im Abspann die
Ränder dunkel bleiben, ist eine Vermutung; bevor daraus eine Schwelle
wird, will ich die Zahlen sehen.

Fünf neue Prüfungen, darunter die wichtigste: ein durchgehender Abspann
bleibt ein Stück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 16:46:27 +02:00
ScarriffleandClaude Opus 5 791c00dacc Nicht wie hell das Bild ist, sondern wie viel davon schwarz
Gemessen wurde der Mittelwert über das ganze Bild, und daran scheiterte
die Abspannsuche wiederholt: weisse Schrift hebt den Mittelwert. Derselbe
Abspann misst 19, wenn wenig Schrift darin steht, und 37, wenn viel darin
steht — die Schwelle musste zwischen diesen Wert und die Handlung passen,
und der Abstand schrumpfte mit jeder Textzeile. Zweimal nachgezogen,
zweimal wieder getroffen.

Jellyfin zählt stattdessen Pixel, und das ist kein besserer Schwellenwert,
sondern ein besseres Merkmal. Nachgemessen an denselben erzeugten
Abspännen, durch dieselbe Kette wie im Betrieb:

                      Mittelwert   Anteil dunkler Pixel
    Handlung                 128                    0 %
    Abspann, schriftreich     37                   84 %
    Abspann, schlank          19                   87 %
    dunkle Nachtszene         28                   37 %

Schrift belegt ein bis zehn Prozent der Fläche; der Rest bleibt schwarz.
Der Anteil hängt deshalb nicht an der Schriftmenge, und der Abstand zur
Handlung ist nach beiden Seiten riesig.

`signalstats` weicht `blackframe=amount=0:threshold=24` — die Schwelle
für das einzelne Pixel über eine Reihe von Werten eingemessen: darunter
(16) fällt der Abspann selbst auf zehn Prozent ab, darüber (32) fängt die
Handlung an mitzuzählen. Die Ausgabe kommt über denselben
`metadata=print`-Weg wie vorher, nur unter einem anderen Schlüssel.

**Und die relative Schwelle fällt ersatzlos weg.** Sie war nötig, solange
der Mittelwert gemessen wurde: wie hell ein Abspann ist, hing am Film,
also musste sich die Schwelle am Film ausrichten. Der Pixelanteil eicht
sich selbst — `Abspannkante.Schwelle` und `Abspannregeln.Anteil` sind
gestrichen, und mit ihnen die Rechnerei um den Grundton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 15:14:52 +02:00
ScarriffleandClaude Opus 5 121ee62be9 Erst fragen, ob die Datei den Abspann selbst benennt
Zweimal gemessen scheitert die Helligkeitssuche am selben: „Alice im
Wunderland" legt den Abspann über verzierte Hintergründe, „(T)Raumschiff
Surprise" zeigt darin Outtakes. Deine Zeile sagt es genau — Schwelle
49,4, letzte Probe 1, dunkle Strecke drei Sekunden: der Film endet
schwarz, und davor ist volles Bild. Helligkeit kann dazu nichts sagen,
und das ist kein Fehler, sondern ihre Grenze.

Ein Kapitel sagt es dagegen genau. Die Suche fragt jetzt zuerst danach:
ffprobe liest dafür nur den Kopf der Datei, Millisekunden gegen zwanzig
Minuten Schlüsselbilder. Findet sie ein benanntes Kapitel am Ende, ist
sie fertig; sonst geht es wie bisher über das Bild.

**Nur bei einem Namen, der es hergibt** — „Chapter 12" ist keine
Auskunft, und das letzte Kapitel ist nicht automatisch der Abspann:
bei vielen Dateien ist es die Schlussszene. Lieber kein Fund als ein
Knopf, der die letzten zehn Minuten Handlung überspringt. „Opening
Credits" bei Minute zwei trägt dasselbe Wort und ist das Gegenteil,
also zählt nur, was im letzten Drittel liegt.

**Und wo nichts gefunden wird, steht jetzt die Kapitellage dabei** —
„Keine Kapitel.", „14 Kapitel, alle ohne Namen." oder „12 Kapitel,
letztes „Finale"." Ob sich an diesem Weg weiterbauen lohnt, sieht man
erst an vielen Dateien, und genau das misst diese Zeile.

Elf Prüfungen: das Lesen der ffprobe-Antwort ohne ffprobe, und die
Regel, wann ein Kapitel zählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:48:22 +02:00
ScarriffleandClaude Opus 5 f79ecf2757 „Schon durchsucht" und „hat eine Marke" sind zwei verschiedene Dinge
Genau dazwischen fehlte die Unterscheidung. Ein Film, bei dem die Suche
einmal nichts fand, trägt einen Vermerk und keine Marke — und war damit
für jeden weiteren Lauf erledigt, obwohl er der interessanteste Fall ist.
Es gab nur alles oder nichts, und der Knopf bei der Mediathek schickte
immer „alles": 453 Filme mal zwanzig Minuten Schlüsselbilder, auch für
die vierhundert, bei denen längst etwas stand.

Aus `bool erneut` wird `Markenumfang` mit drei Werten. `Neue` ist, was
der Scan tut, und ändert sich nicht. `Luecken` nimmt sich vor, wo keine
Marke steht — gleich ob dort schon einmal gesucht wurde. `Alles` ist der
alte Holzhammer.

Bei der Mediathek jetzt zwei Knöpfe, weil es zwei Fragen sind: „Fehlende
Marken suchen" ist die alltägliche, „Alles neu durchsuchen" die seltene
und teure, und deren Rückfrage nennt die Zahl der Titel. Auf der
Detailseite bleibt es bei einem Knopf mit „alles" — wer ihn bei genau
diesem Titel drückt, ist mit genau diesem Titel unzufrieden.

Bei einer Serie wird eine Staffel ganz gelesen, sobald einer ihrer
Folgen etwas fehlt. Billiger ginge es nur, indem man die Lücke gar nicht
erst angeht, und genau dafür drückt jemand den Knopf.

Vier Prüfungen am Endpunkt, die messen, ob der Lauf den Film anfasst:
`Neue` überspringt den Durchsuchten, `Luecken` nimmt ihn sich vor,
`Alles` sowieso, und ein Film mit Marke bleibt bei `Luecken` in Ruhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:20:47 +02:00
ScarriffleandClaude Opus 5 f2752e6c05 Ein doppelter Platzhalter riss jede Serie mit
`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` — sechs
Platzhalter, fünf Argumente. Vorlagen sind positionsbezogen: auch ein
wiederholter Name verbraucht ein weiteres Argument. Der Logger warf eine
Ausnahme, die riss `StaffelMeldenAsync` mit, und die wiederum den ganzen
Serienlauf. Gefunden war dann alles umsonst — „A Knight of the Seven
Kingdoms" und „Alice in Borderland" haben ihre Marken deshalb verloren,
nachdem sie längst berechnet waren.

**CA2017 hatte das gemeldet.** Als Warnung, unter hundert anderen Zeilen,
und ich habe beim Bauen nach „warning CS" gegriffen. Eine neue
`Directory.Build.props` macht genau diese eine Regel zum Fehler — nicht
alle Warnungen, das wäre eine andere Entscheidung. Gegengeprüft: mit der
kaputten Zeile bricht der Bau jetzt ab.

**Und der zweite Fund, aus „Alice im Wunderland".** Die Zeile meldete
„Schwelle 247,6", und das kann bei 0–255 nicht entstehen: die Schwelle
ist Grundton mal 0,55, das wären 450 gewesen. `signalstats` rechnet in
der Skala des Bildformats — bei acht Bit 0–255, bei zehn Bit 0–1023.
Gemessen am selben erzeugten Bild: YAVG 127 gegen YAVG 500. Alle
Schwellen hier sind an acht Bit gemessen; bei einer 10-Bit-Datei bedeutete
dieselbe Zahl ein Viertel.

`format=gray` vor `signalstats` bringt jede Datei auf dieselbe Skala. Es
rechnet dabei auch den Fernsehbereich 16–235 auf 0–255 um — linear, und
die dunklen Werte trifft das am stärksten: der gemessene Abspann liegt
jetzt bei 28 statt bei 40. Der Boden wandert entsprechend von 35 auf 22.

Dazu: die Schlusszeile je Serie trennt Vorspann und Abspann, statt eine
Zahl aus beidem zu machen — „14 Marken" beantwortet nicht, ob die Serie
ihre Vorspänne bekommen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 14:06:16 +02:00
ScarriffleandClaude Opus 5 259996c5ad Zweimal vermerkt, einmal gespeichert, Serie verloren
„Markensuche für ‚A Knight of the Seven Kingdoms' abgebrochen — An error
occurred while saving the entity changes." Das war meiner, und er hat
mehr gekostet als eine Fehlermeldung: der Lauf brach beim Speichern ab,
und damit war alles weg, was er für diese Serie gefunden hatte.

`VermerkenAsync` sah in der Datenbank nach, ob die Staffel schon
vermerkt ist — und eine Abfrage sieht nicht, was derselbe Lauf eben erst
hinzugefügt und noch nicht gespeichert hat. Seit der Bildweg dazukam,
wird für dieselbe Staffel zweimal vermerkt: einmal nach dem Tonabgleich,
einmal vor dem Bild. Die zweite Abfrage fand nichts, legte eine zweite
Zeile an, und der eindeutige Schlüssel über (Serie, Staffel, Art) warf
sie beim Speichern wieder hinaus. Jetzt wird erst im Änderungsspeicher
nachgesehen, dann in der Datenbank.

**Und die Meldung taugte nichts.** Das Tagebuch hängt nur `Message` an,
und EF Core schreibt dort „See the inner exception for details." — eine
Zeile, die ausdrücklich sagt, dass sie die Auskunft nicht enthält. Alle
vier Abbruchmeldungen nennen jetzt den innersten Grund über
`Fehlertext.FuerMenschen`.

**Dazu der zweite Fund aus demselben Protokoll.** „(T)Raumschiff
Surprise": kein Abspann, 1318 Proben, Schwelle 51,1, letzte Probe 17 —
das Ende ist also dunkel, und trotzdem kein Fund. 1318 Proben auf zwanzig
Minuten heissen eine alle 0,9 Sekunden, und die Nachsicht gegenüber
hellen Stellen mitten im Abspann war in *Proben* angegeben: zwei Stück,
dort also 1,8 Sekunden. Bei einer Datei mit einem Schlüsselbild alle zehn
Sekunden waren dieselben zwei Proben zwanzig Sekunden. Dasselbe
Zugeständnis, zehnfach verschieden — genau der Fehler, den ich eine Zeile
höher bei der Schlusstafel schon behoben hatte. `Nachsicht` zählt jetzt
Sekunden, zehn davon.

**Und die Suche sagt, woran es lag.** „kein Abspann — das Ende ist hell"
gegen „die dunkle Strecke ist mit 20 s zu kurz" sind zwei verschiedene
Ursachen mit zwei verschiedenen Antworten; bisher stand dort beides Mal
dasselbe.

Vier neue Prüfungen, darunter dieselbe Szene bei 0,9 und bei 10 Sekunden
Abtastung. Mit der alten Probenzählung ist die dichte rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:31:48 +02:00
ScarriffleandClaude Opus 5 767491ef03 Serie für Serie, Film für Film — statt einer Zeile mit einer GUID
Die Markensuche lief stundenlang über 95 Serien, 4213 Folgen und 453
Filme und schrieb dabei fünf Zeilen, in denen Kennungen standen statt
Namen. „Markenerkennung für 4f3a…: 12 Marken." beantwortet keine der
Fragen, die in den letzten Tagen tatsächlich aufkamen — welche Staffel
nichts bekommen hat, warum, und ob die Serie überhaupt dran war.

Jetzt nennt jede Zeile den Titel, und es gibt eine je Serie, je Staffel
und je Folge:

    „Biohackers": 2 Staffel(n), 18 Folge(n).
    „Biohackers", Staffel 2: Vorspann in 8 von 8 Folge(n), Abspann in 1 von 8.
    Biohackers S02E01 · Neuanfang: Vorspann 0:31–1:24, Abspann ab 41:12 (über das Bild).
    Biohackers S02E07 · Letzte Fahrt: Vorspann 0:31–1:24, kein Abspann.
    „Biohackers" fertig: 14 Marke(n).

Die Folgenzeilen werden **aus der Datenbank gelesen, nicht mitgezählt**:
so steht dort, was danach wirklich an der Folge hängt, und nicht, was
dieser eine Lauf geschrieben hat. Nur das beantwortet „warum kommt bei
dieser Folge kein Knopf?".

**Drei Gründe statt eines `false`.** `FilmAsync` gab für „keine Datei",
„schon durchsucht" und „nichts gefunden" dasselbe zurück, und von aussen
waren sie nicht zu unterscheiden. Jetzt sagt jeder seinen Namen.

**Und eine Live-Zeile bei der Mediathek**, wie der Scan sie hat. Sie
benutzt denselben Mechanismus — `Scanschritt` mit einer neuen Phase,
derselbe Endpunkt, ein eigenes Feld. Ein eigenes, weil der Scan sich auf
„fertig" setzt, sobald er die Markensuche angestossen hat; in sein Feld
geschrieben sähe er wieder laufend aus.

**Zwei Fehler sind dabei herausgefallen.** In `NachScanAsync` stand
`if (serien.Count == 0) return;` vor dem Filmteil — eine reine
Filmmediathek bekam nach einem Scan also nie eine Abspannsuche. Und die
fpcalc-Prüfung stand ganz oben und galt für beides, obwohl der Bildweg
fpcalc gar nicht braucht: ohne fpcalc fand der Server keinen einzigen
Filmabspann.

Dazu ein Suchfeld im Protokoll und tausend Anfangszeilen statt
dreihundert. Ohne das wäre eine Zeile je Folge keine Auskunft, sondern
eine Mauer — genau der Fall, gegen den sich `ScanCoordinator` für den
Scan schon einmal entschieden hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:11:58 +02:00
ScarriffleandClaude Opus 5 70ab4cc421 Mein Prüfbild war dunkler als jeder echte Abspann
Die Abspannsuche lief gegen eine feste Schwelle von 35. Die stammte aus
einem erzeugten Prüfbild mit 0,8 Prozent weisser Fläche — Helligkeit 18,
weit unter 35, alles schien zu stimmen. Ein echter Abspann hat mehr
Schrift.

Nachgemessen an 1080p mit zwei Spalten Namen, durch dieselbe ffmpeg-Kette
wie im Betrieb:

    Handlung                              126
    Abspann, realistische Schriftdichte     32
    Abspann, dicht gesetzt                  47

Die Fünfunddreissig lag mitten zwischen den beiden Abspannfällen und
schnitt sie auseinander. Dasselbe noch einmal an der erzeugten Prüfdatei,
nachdem ihr Abspann realistische Dichte bekam: 40,2 — und die Prüfung,
die vorher grün war, wurde rot. Das ist der Fehler, lokal reproduziert.

**Dunkel ist keine absolute Grösse.** Entscheidend ist nicht, wie dunkel
der Abspann ist, sondern dass er viel dunkler ist als das, was davor
lief. Gemessen wird jetzt gegen den Grundton des Films — das obere
Viertel der Proben, mal 0,55. Die alte Schwelle bleibt als Boden, damit
ein durchweg dämmriger Film nicht in Fehlalarme rutscht.

**Und die Logotafel.** Die Suche verlangte, dass die allerletzte Probe
dunkel ist. Hinter dem Abspann steht bei vielen Produktionen noch eine
Tafel — Sender, Studio, Förderer —, oft hell. Ein einziges weisses
Schlussbild machte damit den ganzen Abspann unsichtbar. Zwanzig Sekunden
sind jetzt erlaubt, in Sekunden gezählt und nicht in Proben: wie viele
Messpunkte auf eine Tafel fallen, hängt allein an der Schlüsselbilddichte
der Datei.

**Ein Lauf ohne Fund sagt jetzt, warum.** Anzahl Proben, Schwelle,
letzte Probe — ohne diese drei Zahlen ist „kein Abspann erkannt" keine
Auskunft, und man rät wieder.

Sieben neue Prüfungen mit den gemessenen Werten. Die beiden
entscheidenden sind mit der alten Schwelle rot; gegengeprüft, nicht
behauptet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 21:57:39 +02:00
ScarriffleandClaude Opus 5 7e5b3fb0ee Eine Staffel mit einer Folge fiel ganz durch
Nein, die Erkennung sieht sich nicht die ganze Serie an — sie gruppiert
nach Staffel, vermerkt je Staffel, und der eindeutige Schlüssel führt die
Staffel mit. Andere Besetzung, anderer Abspann, andere Musik in Staffel 2
stören den Abgleich also nicht. Der Verdacht war berechtigt, die Stelle
eine andere.

Gefunden hat ihn die Frage trotzdem. Ganz oben in der Staffelschleife
stand `if (geordnet.Count < 2) continue;` — richtig für den Tonabgleich,
der zwei Folgen zum Vergleichen braucht. Seit es den Bildweg gibt, ist es
zu früh: der braucht keine zweite Folge, nur eine Datei. Eine laufende
Serie, von der erst die Premiere der neuen Staffel da ist, bekam deshalb
gar nichts — weder über den Ton, was stimmt, noch über das Bild, was
hätte gehen müssen.

Jetzt läuft der Tonabgleich nur, wenn es etwas zu vergleichen gibt, und
der Bildweg in jedem Fall. Mit Vermerk, sonst liefe er bei jedem Scan
von Neuem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 21:27:41 +02:00
ScarriffleandClaude Opus 5 ef667a9f39 Erst für sich, dann mit der Bedienung
Die Acht-Sekunden-Frist war geraten, und sie war die falsche Antwort auf
eine richtige Beobachtung. Ein Zeitgeber entscheidet für den Zuschauer,
wann er genug gesehen hat; das kann er selbst besser.

Jetzt gilt für Vorspann und Abspann, Web wie App, dieselbe Regel: eine
neue Marke zeigt ihre Knöpfe von selbst, ohne dass man das Bild
anfassen müsste. Sobald die Bedienung einmal eingeblendet war, gehören
sie zu ihr und gehen mit ihr — ein- wie ausblenden. Eine neue Marke
fängt wieder von vorn an; sie ist ein neues Angebot.

**Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
Bedienung ohnehin da, und `zeigen()` läuft im Web bei jeder
Mausbewegung. Beides als „einmal gezeigt" zu werten hiesse, dem Intro
seinen Knopf zu nehmen, bevor es ihn je hatte. Gewertet wird deshalb nur
der Sprung von verborgen nach sichtbar.

Am Ende des Films bleiben die Knöpfe stehen: dort ist „Nächste Folge"
die einzige noch offene Frage, und es gibt kein Bild mehr, das sie
verdecken könnten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:57:31 +02:00
ScarriffleandClaude Opus 5 820789b7f6 Die Ausnahme löste nichts und stand dafür im Bild
„Beim Abspann kommt kein Knopf" hatte ich als zu kurze Frist gedeutet und
die Abspann-Knöpfe dauerhaft gemacht. Die Frage war falsch gestellt: es
kam keiner, weil dem Staffelfinale die Marke fehlte — der Tonabgleich
findet sie dort nicht, weil das Finale anders ausklingt. Das ist
inzwischen behoben, über das Bild.

Geblieben war die Ausnahme, und sie liess einen Knopf sechs Minuten lang
über dem Bild stehen, auch nachdem die übrige Bedienung längst weg war.
Jetzt gilt für Vorspann und Abspann dieselbe Frist: acht Sekunden von
selbst, danach zusammen mit der Bedienung. Am Ende des Films bleiben sie
— dort ist es die einzige offene Frage, und es gibt kein Bild mehr, das
sie verdecken könnten.

Im Web steht die Ausnahme noch; dort fällt sie nicht auf, weil die
Bedienung bei jeder Mausbewegung ohnehin zurückkommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 16:52:34 +02:00
ScarriffleandClaude Opus 5 93f4dfd8e9 Ein frischer JSONDecoder, drei Zeilen neben der Warnung davor
Im Web stand am Abspann „Nächste Folge", in der App nur „Abspann
überspringen". Der Server war es nicht: beide fragen denselben Endpunkt,
und der ordnet nach Staffel und dann nach Nummer — er findet also auch
die erste Folge der nächsten Staffel.

Es war `nachbar`. Die Stelle baute sich einen frischen `JSONDecoder()`,
und der kennt das Datumsformat des Servers nicht. Solange die
Nachbarfolge weder `publishAt` noch `lastPlayedAt` mitbrachte, ging es
gut — mit eingeschalteter Prüfung bringt sie ein `publishAt`. Das `try?`
schluckte den Fehler, und von aussen sah es aus, als gäbe es keine
nächste Folge: kein Knopf, kein Hinweis, nichts.

In `FlaroClient` steht seit je ein eingerichteter Dekoder, ausdrücklich
nicht privat, „damit die Prüfungen denselben Dekoder benutzen" — und
direkt darunter die Warnung, dass ein frischer an genau diesem Format
scheitert. Diese eine Stelle las sie nicht.

Vier Prüfungen dazu, darunter die mit Datumsangaben: mit dem alten
Dekoder ist sie rot, mit dem neuen grün. Gegengeprüft, nicht behauptet.

Die übrigen frischen Dekoder im Quelltext lesen nur, was dieselbe App
vorher selbst geschrieben hat — Zwischenspeicher, Abzüge, Offline-
Angaben. Die laufen rund und bleiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 08:39:31 +02:00
ScarriffleandClaude Opus 5 ce4da992ea Einen Knopf je Serie ist bei fünfundneunzig kein Angebot
Es gab ihn: auf der Serienseite, das Uhrsymbol. Nur gilt er für genau
eine Serie — und nach jeder Änderung an der Erkennung will man ohnehin
alles noch einmal. Der Weg dorthin fehlte da, wo man ihn sucht: bei der
Mediathek, neben „Scannen" und „Metadaten auffrischen".

Der Lauf selbst war schon da. `NachScanAsync` macht genau das, Serien
über den Tonabgleich und Filme über das Bild — er hing bloss am Scan und
liess sich nicht von Hand anstossen. Jetzt nimmt er ein „erneut" entgegen
und ist über einen Endpunkt erreichbar. Die Antwort kommt sofort: über
einen grossen Bestand dauert das Stunden, und eine Anfrage so lange offen
zu halten hiesse, sie in ein Zeitlimit laufen zu lassen. Wie weit es ist,
steht im Protokoll von heute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 21:01:11 +02:00
ScarriffleandClaude Opus 5 1340176bf0 Beim Staffelfinale klingt der Abspann anders
Und genau daran fiel er durch. Der Tonabgleich lebt davon, dass der
Abspann in jeder Folge derselbe ist; beim Finale ist er es oft nicht —
anderer Schluss, andere Musik, kein Treffer. In jeder Staffel fehlte
deshalb genau eine Marke, immer die letzte, und wer dort weitersah, sah
keinen Knopf. Das war der ganze Fehler. Der Spieler war nie schuld, und
meine Schranken-Vermutung von eben war falsch.

Was der Ton nicht findet, sucht jetzt das Bild — derselbe Weg wie bei
einem Film: dunkel werden und bis zum Ende durchhalten. Das Verfahren
lag fertig in `FilmAsync` und war nur dort eingebaut; es steht jetzt für
sich und wird von beiden benutzt. Nur für die Lücken: bei einer Staffel
ohne fehlende Marke läuft der Abtaster gar nicht.

**Drei Punkte heissen „mehr davon", nicht „tu etwas".** Bei einer Folge
taten sie etwas — wegräumen aus „Fortsetzen", ohne Rückfrage. Jeder, der
sie sah, hat ein Menü erwartet, draufgetippt, und dann baute die Seite
sich neu auf und die Punkte waren weg. Jetzt ein Kreuz, und eine Zeile,
die sagt, was geschehen ist.

Und die Markenanzeige nennt die Zeiten, nicht nur die Namen: dass eine
Marke da ist, sagt noch nicht, dass sie an der richtigen Stelle steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 20:49:50 +02:00
ScarriffleandClaude Opus 5 74643b8242 Gefunden und gleich wieder weggeworfen
**Stream, nicht Strom.** Mein Fehler, und die Regel kenne ich: Fachbegriffe
bleiben englisch. Betroffen war eine Meldung im Spieler und vier Zeilen im
Tagesprotokoll.

**Der Abspann fiel durch die eigene Schranke.** `Hoechstlaenge` von fünf
Minuten galt für Vorspann und Abspann gleichermassen. Fünf Minuten sind
für einen Abspann aber keine Obergrenze, sondern die Regel — eine deutsche
Serienproduktion listet Sprecher, Synchronregie und Studio hinterher, und
sechs Minuten kommen vor. Der Tonabgleich fand das Stück, und die Zeile
darunter warf es weg. Von aussen sah das aus wie „nichts gefunden".
Der Abspann darf jetzt so lang sein wie sein Suchfenster; weiter reicht
eine Marke ohnehin nie.

**Vermutung, nicht Messung** — das Stück steht auf einem Server, an den ich
nicht komme. Deshalb dasselbe Commit:

**Man kann jetzt nachsehen.** Neben jeder Folge steht für Verwalter, was
die Erkennung gefunden hat: „Vorspann + Abspann", „Vorspann" oder „keine
Marken". Ob eine Marke fehlt oder bloss nicht angezeigt wird, war von
aussen nicht zu unterscheiden — und beides hat eine andere Ursache. Die
Angabe kostet nichts: sie steht längst in der Antwort des Servers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 20:33:52 +02:00
ScarriffleandClaude Opus 5 a360bed02b Vier Stellen, an denen eine Angabe unterwegs verlorenging
**Der Aufmacher wusste nur die Serie.** `Heroeintrag.titel` ist bei einer
angefangenen Folge der Name der Serie — richtig, denn der Aufmacher zeigt
die Serie. Wer von dort startete, las über dem Spieler „Biohackers" und
nicht, welche Folge läuft. Staffel, Nummer und Folgentitel reisen jetzt
mit. Dasselbe im Aufmacher selbst: „Staffel 1 · Folge 2" statt „S1 · F2".

**Der Abspann ist kein Angebot, sondern eine Frage.** Die Acht-Sekunden-
Frist von gestern war für das Intro gedacht und traf den Abspann mit:
„Nächste Folge" verschwand genau dort, wo man hinsieht. Ein Intro-Knopf
geht, wenn man ihn nicht nimmt; der Abspann-Knopf bleibt, bis die Folge
zu Ende ist. Web und App.

**„deu" stand da, weil es in der Datei steht.** Der Server reichte einen
vorhandenen Spurnamen unbesehen durch — und viele Dateien tragen als
Titel schlicht das Sprachkürzel. Wiederholt er bloss die Sprache, gilt er
nicht, und die Tabelle darunter macht „Deutsch" daraus.

**Und zweimal „Deutsch" ist keine Wahl.** Zwei deutsche Untertitelspuren
sind der Normalfall — Vollfassung und Nur-Fremdsprachiges. Gleichnamige
bekommen jetzt ihren Codec dazu, und wenn der auch gleich ist, die
Nummer. Das kann keine Spur für sich beantworten: ob ein Name eindeutig
ist, hängt an den anderen. Deshalb steht es in `Benennung` und hat fünf
Prüfungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 17:34:11 +02:00
ScarriffleandClaude Opus 5 d152ac3795 Der Regler gehörte der Uhr, nicht dem Finger
`zeitAuffrischen` schrieb `zeitleiste.value` bei jedem `timeupdate` neu —
viermal je Sekunde, auch mitten im Ziehen. Das hatte zwei Gesichter, und
nur das harmlosere war zu sehen: der Knopf sprang unter dem Finger an die
laufende Stelle zurück, und nach dem Loslassen noch einmal, weil ein
Sprung einen Augenblick braucht und der Spieler in dem weiter die alte
Stelle meldet.

Das andere ist schlimmer. Gemessen an einer Nachbildung mit 20-ms-Takt:
gezogen auf 113, und `onchange` las 7 — der Takt hatte `value` überschrieben,
bevor der Browser den Zug festschreiben konnte. Es sprang also nicht nur
zurück, es sprang an die falsche Stelle. Genau deshalb half Festhalten: es
schiebt den letzten `input` näher an den `change`.

Jetzt gehört der Regler dem Zeiger, solange einer darauf liegt, und danach
dem Sprungziel, bis der Spieler dort angekommen ist. Mit Notbremse nach
dreissig Sekunden, damit eine Leiste nie stehen bleibt, wenn ein neu
gebauter Strom woanders herauskommt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:39:28 +02:00
ScarriffleandClaude Opus 5 0186087051 Ein Angebot, das keiner annimmt, nimmt man zurück
„Intro überspringen" soll erscheinen, ohne dass man den Bildschirm
antippen muss — das war der Wunsch, und er war richtig. Nur blieb der
Knopf dann liegen, solange die Marke dauert: bei einem Abspann sechs
Minuten lang ein Knopf über dem Bild, auch nachdem die übrige Bedienung
längst weggeblendet war.

Jetzt hat er eine eigene Frist. Acht Sekunden von selbst, lang genug zum
Bemerken und Treffen; danach gehört er zur Bedienung wie alles andere
und kommt mit einem Antippen zurück. Am Ende des Films bleibt er stehen
— dort ist er die einzige Frage, die noch offen ist.

Im Web dasselbe, plus die Stelle, an der es im Stehen auseinanderfiel:
`markenstand` rechnet nur, während das Bild läuft. Wer pausiert im Intro
die Maus bewegte, bekam die Bedienung zurück und den Knopf nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:16:15 +02:00
ScarriffleandClaude Opus 5 fbd865e93b Heruntergeladen heisst nicht: ohne Angaben
Bei einer lokalen Datei kehrte der Spieler zurück, sobald sie lief — und
liess `item` leer. Daran hing mehr als der Titel: keine Marken, also kein
„Intro überspringen"; keine Nachbarfolgen, also keine Knöpfe zum Blättern.
Nur bei Heruntergeladenem, und darum sah es so lange nach einem Fehler in
der Erkennung aus. Jetzt werden die Angaben nachgeholt, während das Bild
schon läuft.

Dazu, was derselbe Weg betrifft: der Spieler sagt „Staffel 1 · Folge 6"
statt „S1 E6" — der Folgenname wird an einer Stelle gebaut, nicht an
dreien. Im Abspann einer Serie stehen zwei Knöpfe statt einem, weil der
Abspann zwei Fragen stellt.

Und die Abdrücke, mit denen der Tonabgleich geprüft wird, sind jetzt
echte: zwei Aufnahmen desselben dreissigsekündigen Stücks, verschiedene
Bitraten, durch fpcalc gedreht. Vorher war jede Prüfung mit erfundenen
Zahlen bestanden — und genau deshalb war nie belegt, dass der Abgleich an
echten Daten etwas findet. Er findet: 17,8–51,0 s und 72,7–105,9 s, bei
20–50 und 75–105.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:03:57 +02:00
ScarriffleandClaude Opus 5 afa78f9ee0 Ein Knopf, von dem niemand weiss, dass es ihn gibt, ist keiner
„Intro überspringen" lag im Web in der Bedienleiste und verschwand mit
ihr. Am Fernseher sieht man die Leiste gar nicht — der Knopf war also
da und trotzdem unsichtbar. Jetzt liegt er als eigene Schicht über dem
Bild und bleibt stehen, solange die Marke läuft.

**Im Abspann ist „überspringen" ohnehin die falsche Frage.** Was man
dort will, ist die nächste Folge; ans Ende dieser zu springen bringt
niemanden weiter. Gibt es eine, heisst der Knopf jetzt so — im Web wie
in der App, und in der App nicht mehr erst, wenn alles durchgelaufen
ist. Bei einem Film gibt es nichts danach: dort heisst „Abspann
überspringen" jetzt wirklich Schluss, statt auf ein stehendes Bild zu
spulen und den Spieler offen zu lassen.

Dazu der Titel über dem Spieler. Dort stand bei einer Folge „S1 E6" und
bei einer aus der Detailseite gestarteten sogar nur „Wiedergabe". Auf
einer Kachel ist die Kurzform richtig, dort zählt jeder Buchstabe; über
dem Spieler steht eine Zeile in einer leeren Fläche. Jetzt steht da
„Küstenwache · Staffel 1 · Folge 6 · Der Nebel kommt". In der App hiess
es „Episode" statt „Folge" — das eine Wort, das sonst nirgends steht.

Und drei Prüfungen für die Marken auf dem Weg in die App: aus einer
Serverantwort mit Vorspann und Abspann werden zwei Marken an der
richtigen Stelle. Die Übersetzung war nicht der Grund, warum der Knopf
dort fehlte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 15:35:59 +02:00
ScarriffleandClaude Opus 5 7673c73757 Vier Sekunden, die ffprobe zu früh aufhörte zu lesen
Die Untertitel kamen vier Sekunden zu spät. Nicht an ihnen lag es: der
Server meldete dem Spieler einen Anfang, der vor dem lag, an dem der
Strom wirklich begann, und genau diese Differenz landet eins zu eins im
Versatz.

Die Schlüsselbildsuche las mit `-read_intervals` bis eine halbe Sekunde
hinter den Wunsch. ffprobe bricht am Ende eines Fensters auf Paketebene
ab — das letzte Schlüsselbild darin kam regelmässig gar nicht mehr
heraus, und die Suche gab das davor zurück. Bei Bildgruppen von vier
Sekunden sind das vier Sekunden.

Nachgemessen gegen die Stelle, an der ffmpeg tatsächlich anfängt, an
einer Datei mit Schlüsselbildern alle vier Sekunden:

    Wunsch   Suche (0,5 s)   ffmpeg    Abweichung
      52,0            48      52,0         −4,0
      73,3            68      72,0         −4,0
     100,2            96     100,0         −4,0

Mit zehn Sekunden Nachlauf lag dieselbe Messung bei allen sieben Werten
auf 0,000. Was über den Wunsch hinausgelesen wird, verwirft die
Filterung ohnehin.

Die Prüfung vergleicht nicht gegen eine erwartete Zahl, sondern gegen
ffmpeg selbst — mit dem alten Wert fällt sie um.

Dazu der Filmteil der Abspannerkennung: `Abspannkante` als reine
Rechnung mit elf Prüfungen, `Bildabtaster` über ffmpeg, Marken auch an
Filmen, Knöpfe zum Suchen und Verwerfen auf der Filmseite. An einer
erzeugten Datei trifft die Kante auf vier Sekunden genau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 06:42:16 +02:00
ScarriffleandClaude Opus 5 8112c15d4d Jeder Scan hätte jede Tonspur noch einmal gelesen
Gemerkt wurde nur, was gefunden wurde. Eine Staffel ohne Abspann — es
gibt genug davon — sah damit bei jedem Scan aus wie eine, in der noch
niemand nachgesehen hat, und wurde wieder abgetastet: jede Tonspur jeder
Folge noch einmal von der Platte, für immer, ohne Ertrag. Bei
sechstausend Folgen ist das kein Schönheitsfehler.

Jetzt steht auch „gesucht und nichts gefunden" in der Datenbank, je
Staffel und Art. Ausgelöst wird ein zweiter Lauf von der Folgenzahl:
kommt eine Folge dazu, gibt es etwas Neues zu vergleichen, sonst nicht.
Ein Zeitstempel allein täte das nicht — eine Staffel, die sich nie
ändert, hat nie einen Grund für einen zweiten Lauf.

Dazu der Knopf, der ausdrücklich doch noch einmal rechnet: die Uhr auf
der Serienseite sucht ab jetzt immer neu. Von selbst läuft die Suche
nach jedem Scan und überspringt dort das Abgesuchte; wer hier drückt,
will genau das Gegenteil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 05:51:23 +02:00
ScarriffleandClaude Opus 5 9bcdd239d7 Entwurf: den Abspann eines Films im Bild finden
Der Tonabgleich kann bei Filmen grundsätzlich nichts finden — er erkennt
einen Abspann daran, dass er sich über die Folgen einer Staffel
wiederholt, und ein Film hat nichts, womit er sich vergleichen liesse.

Der Entwurf steht auf zwei Messungen statt auf Vermutungen: an einem
erzeugten Film liegt die mittlere Helligkeit in der Handlung bei 125 und
im Abspann bei 18, mit einer scharfen Kante genau am Übergang. Und das
Dekodieren der letzten Minuten kostet 2,89 Sekunden, wenn man jedes
zweite Bild nimmt, aber 0,24, wenn man sich auf Schlüsselbilder
beschränkt.

Dazu steht drin, wo das Verfahren danebenliegt: ein Abspann über
laufendem Bild hat keine dunkle Strecke und bekommt deshalb keinen Knopf.
Das ist die richtige Antwort — ein geratener Knopf schnitte Handlung weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 22:12:15 +02:00
ScarriffleandClaude Opus 5 fd79fb4022 „No such file or directory" über Dateien, die da waren
Im Protokoll stand für jede einzelne Folge, fpcalc könne die Datei nicht
öffnen — während der Spieler dieselbe Datei im selben Augenblick
umpackte. Null Marken, und der Grund stand nicht da.

`-ts` ist bei fpcalc kein Zeitstempel, sondern ein Schalter: „gib
Zeitstempel aus". Die Sekundenzahl dahinter las fpcalc als zweiten
Dateinamen, fand ihn nicht und brach ab, bevor es die echte Datei
überhaupt ansah.

Nachgemessen am Binärprogramm aus dem Abbild: einen Startpunkt gibt es
bei fpcalc gar nicht. `-length` begrenzt nur die Dauer vom Anfang an —
für den Abspann braucht es aber das Ende. Also einmal die ganze Spur je
Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte aus
demselben Abdruck.

Das ist nicht nur richtig, sondern billiger: eine Folge von 45 Minuten
kostet 1,6 Sekunden und ergibt 21 786 Werte — gegenüber zwei
Ausschnitten, die zusammen mehr gekostet hätten und nie funktionierten.
Die Rate von 0,1239 Sekunden je Wert steht jetzt als Prüfung daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 22:09:56 +02:00
ScarriffleandClaude Opus 5 2c535e6f47 Drei Wege, die über ein Neuladen führten
**In „Fortsetzen" startet ein Tipp.** Dort steht ein Titel, *weil* man ihn
angefangen hat, und die Stelle steht daneben — der Umweg über die
Detailseite stellte eine Frage, die schon beantwortet war. Überall sonst
bleibt es beim Weg auf die Seite: wer nachsehen will, wie viele Staffeln
eine Serie hat, soll nicht den Film bekommen.

**Ein Haken nimmt die Kachel mit.** In „Fortsetzen" und „Als Nächstes"
steht, was offen ist. Eine abgehakte Folge ist nicht mehr offen — sie mit
einem Haken stehen zu lassen sah aus, als hätte das Abhaken nicht
gewirkt. Dasselbe für „Aus Fortsetzen entfernen": in der Reihe selbst
heisst entfernen genau das.

**Und ein getauschtes Bild erreicht den Aufmacher.** Wer auf der
Startseite ein Plakat wechselt, sieht denselben Titel gleich darüber in
voller Breite — dort stand bis zum nächsten Neuladen das alte. Die
Kachel frischt sich selbst auf; der Aufmacher steht ganz woanders und
erfuhr nichts davon. Jetzt meldet der Bilderdialog es der ganzen Seite,
und wer es angeht, hört zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:54:50 +02:00
ScarriffleandClaude Opus 5 adca700c4a Marken suchen, ohne dass jemand danach fragt
Eine neue Folge bekommt keine Vorspann-Marke, wenn niemand den Knopf
drückt — und niemand drückt ihn, weil man nicht weiss, dass es etwas zu
drücken gibt. Der Scan ist der Augenblick, in dem neue Folgen entstehen;
jetzt hängt sich der Lauf dort hinten an.

Eine Serie nach der anderen, nicht alle zugleich: jede liest von jeder
ihrer Folgen ein Stück Ton, und zwanzig gleichzeitig wären zwanzig
Leseköpfe auf derselben Platte. Gerechnet wird nur, wo noch nichts
steht — beim ersten Mal über einen grossen Bestand dauert das
entsprechend, danach findet der Lauf nichts mehr zu tun.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:52:24 +02:00
ScarriffleandClaude Opus 5 1d70be0f99 Fünf Aufmacher je Titel, und der Abspann zählt als Schluss
**Der Aufmacher darf wechseln.** Bisher hing hinter jedem Titel genau ein
Hintergrundbild, und das Karussell war nach einer Woche ein Standbild.
Jetzt lassen sich bis zu fünf Aufnahmen aussuchen — im Bilderdialog unter
einem vierten Reiter, gespeist aus denselben Hintergründen.

Welches gerade gilt, entscheidet der Server aus Uhrzeit und Kennung: alle
fünf Stunden ein anderes, überall dasselbe — im Web, in der App, nach
einem Neustart. Ein Zufall je Abruf hiesse, dass das Bild beim Blättern
springt; eine gespeicherte Wahl hiesse einen Schreibvorgang je Titel und
Fenster. Die Kennung geht mit in die Rechnung, damit nicht alle Titel im
selben Augenblick umschalten.

Plakat und Hintergrund bleiben unberührt. Sie stehen hinter Kacheln und
Kopfzeilen, und dort soll nichts wechseln.

**Und der Abspann zählt als Schluss.** Eine Serie hängt sechs Minuten
Abspann an eine Folge von fünfundvierzig; neunzig Prozent liegen dann
mittendrin, und die Folge gilt erst als gesehen, wenn man ihn sich ganz
ansieht. Die Schwelle herunterzudrehen hiesse, jeden anderen Titel zu
früh abzuhaken. Der erkannte Abspann ist die genauere Antwort auf
dieselbe Frage — er zieht die Schwelle nach vorn, nie nach hinten.

Dazu der Verlauf über dem Aufmacher: er war immer noch zu dunkel. Jetzt
trägt der Schattenwurf der Schrift, was vorher eine Decke über dem
ganzen Bild tat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:44:43 +02:00
ScarriffleandClaude Opus 5 8cf2118723 Vorspann und Abspann — erkannt, abgelegt, überspringbar
Beides mit derselben Rechnung, nur in einem anderen Fenster: vorn die
ersten fünfzehn Minuten, hinten die letzten zehn. Was `Tonabgleich`
findet, hängt allein daran, welchen Ausschnitt man ihm gibt — eine
zweite Erkennung für den Abspann wäre dieselbe Suche ein zweites Mal.

Je Staffel verglichen, nicht je Serie: Serien wechseln ihren Vorspann
zwischen Staffeln, und über alle hinweg fände man nichts oder das
Falsche. Gefundenes über fünf Minuten wird verworfen — zwei Folgen, die
dieselbe Datei tragen, teilen vierzig, und darüber stünde sonst ein
Überspringen-Knopf.

Der Lauf geht auf Zuruf, nicht bei jedem Scan: er liest von jeder Folge
ein Stück Ton und dauert Minuten. Der Knopf dafür steht auf der
Serienseite, bei den Verwalterknöpfen.

Die Marken gehen im Folgenvertrag mit — als „intro" und „credits", die
Wörter, die die Apps von Jellyfin her ohnehin kennen. In der App zeichnet
sie der vorhandene Überspringen-Knopf, der bisher nie etwas zu tun
hatte; im Web ist er neu, rechtsbündig über der Zeitleiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:28:36 +02:00
ScarriffleandClaude Opus 5 acba683c94 Eine Folge weiter, eine zurück — ohne den Spieler zu verlassen
Wer eine Serie schaut, will blättern können. Bisher gab es genau einen
Knopf dafür, und der erschien erst am Ende: „Nächste Folge", wenn der
Abspann durchgelaufen war. Zurück ging gar nicht — nur über die
Serienseite, und die beendet die Wiedergabe.

Jetzt stehen beide Knöpfe in der Bedienleiste, in der App wie im Web,
links und rechts vom Abspielknopf. Nur bei Serien: bei einem Film
antwortet der Server mit 404, bei der letzten Folge mit 204, und beides
heisst hier „kein Knopf". Sie erscheinen also von selbst nur dort, wo
sie etwas tun.

Der Server kannte nur den Weg vorwärts. Jetzt gibt es `/previous`
daneben, und beide Richtungen kommen aus derselben Rechnung — als zwei
Endpunkte abgeschrieben liefen sie auseinander, sobald einer etwas
dazubekommt. Jellyfin hat kein Gegenstück zu „NextUp"; dort entsteht
die vorige Folge aus der Folgenliste der Serie.

Beim Wechsel gilt weiter, was für die Serie an Sprache eingestellt ist.
Ohne das spränge die nächste Folge auf die erste Tonspur der Datei
zurück — bei deutschen Veröffentlichungen also auf Deutsch, mitten in
einer Serie, die man im Original schaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 21:11:09 +02:00
ScarriffleandClaude Opus 5 bcbe5557d7 Der Kern des Vorspann-Abgleichs — ohne Anschluss, aber geprüft
Ein Vorspann ist das, was in jeder Folge einer Staffel gleich klingt.
Sonst ist an einer Folge nichts, woran er sich erkennen liesse: er sieht
aus wie Handlung und klingt wie Handlung, er wiederholt sich nur.

`Tonabgleich` sucht das längste Stück, das zwei Abdruckreihen teilen —
reine Rechnung, ohne Datei und ohne ffmpeg, deshalb mit sieben Prüfungen
belegt. Darunter die wichtigere Richtung: zwei Folgen ohne Gemeinsamkeit
ergeben nichts. Ein nicht gefundener Vorspann kostet einen Knopf, ein
erfundener schneidet dem Zuschauer eine halbe Minute Handlung weg.

Die Abdrücke holt `fpcalc` aus Chromaprint, nicht selbstgeschriebene
Signalverarbeitung. Dasselbe tut Jellyfins Intro Skipper, aus demselben
Grund. Das Programk liegt ab jetzt im Abbild; fehlt es, gibt es eben
keine Marken — sie sind eine Zugabe, kein Teil der Wiedergabe.

Noch ruft das niemand auf. Tabelle, Lauf und Knöpfe kommen als Nächstes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:56:37 +02:00
ScarriffleandClaude Opus 5 bfe263d495 Vierundzwanzig Bilder, und der Weg dorthin fehlte sowieso
Zwei Dinge, die zusammengehören.

**Den Bilderdialog gab es nur an der Kachel.** Wer auf der Seite eines
Films stand und sah, dass das Plakat falsch ist, stand genau dort, wo er
nichts dagegen tun konnte — der Weg führte zurück in die Liste und über
das Dreipunktmenü. Jetzt steht „Bearbeiten" in der Knopfreihe der
Detailseite, bei Filmen wie bei Serien, und nur im Kopf: dieselbe Reihe
steht auch über jeder Fassung und jeder Folge.

**Und es waren vierundzwanzig, weil dort vierundzwanzig stand.** Bei
„365 Tage" hat TMDB neunundsechzig Plakate. Dazu ein zweiter, leiserer
Filter: gefragt wurden nur drei Sprachen. Fürs automatische Holen ist
das richtig — dort wird eines genommen, und es soll passen. Wer von Hand
wählt, will sehen, was es gibt; sortiert wird ohnehin nach Sprache, also
steht das Passende weiterhin oben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:49:37 +02:00
ScarriffleandClaude Opus 5 78e347c58d Zwei Zeilen Untertitel, die zweite halb unter dem Bildrand
Gemessen am Bildschirmfoto: zwischen den Zeilen lagen 71 Pixel bei 18
Pixel Texthöhe — fast das Vierfache dessen, was `line-height: 1.25`
ergibt. Und die zweite Zeile war mitten im Buchstaben abgeschnitten.

Beides derselbe Grund. Der bisherige Weg ging über die Norm:
`lineAlign = "end"` macht `line: 90` zur **Unter**kante, und dann steht
auch ein zweizeiliger Untertitel vollständig über dem Rand. Chrome und
Firefox halten sich daran — nachgestellt und abgelichtet. WebKit nicht:
dort ist es die Oberkante, der Kasten wächst nach unten aus dem Bild,
und `line-height` aus `::cue` bleibt ebenfalls wirkungslos.

Jetzt wird dort gar nichts mehr an den Einblendungen verstellt.
Stattdessen rückt der Kasten ab, in den der Browser sie setzt — ein
Element, das WebKit selbst erfunden hat und das es dort also gibt. Eine
Verschiebung des ganzen Kastens kann nichts abschneiden, egal wie viele
Zeilen eine Einblendung hat.

Wer den Kasten nicht kennt — Firefox —, bekommt weiter den Weg über die
Norm. Erkannt wird das daran, ob die Regel im Stilblatt stehen bleibt,
nicht an der Browserkennung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:42:16 +02:00
ScarriffleandClaude Opus 5 5a0f2bf07f Eine Prüfung dafür, dass eine Datei auch ein Bild sein darf
Der Lader gab bei jeder `file:`-Adresse `nil` zurück, und nichts hätte
das gemeldet. Drei Zeilen Prüfung: ein PNG auf der Platte kommt an, eine
fehlende Datei nicht, und Text ist kein Bild.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:28:14 +02:00
ScarriffleandClaude Opus 5 79d7d04583 Ein Download sah aus wie eine Videodatei, nicht wie ein Film
Vier Dinge, die beim Ausprobieren auffielen.

**Die Plakate blieben grau.** Sie lagen auf der Platte, der Lader wollte
sie aber nicht: er verlangte von jeder Antwort eine HTTPURLResponse, und
eine Datei antwortet nicht so. Jede lokale Adresse kam als `nil` zurück.

**Eine Serie hatte ohnehin nie ein Plakat.** Heruntergeladen wird nie die
Serie, nur ihre Folgen — und deren Bild ist das Szenenbild, quer. Jetzt
kommt das Serienplakat unter dem Serienschlüssel mit und geht mit der
letzten Folge wieder.

**Zwanzig Folgen waren zwanzig Kacheln.** Jetzt steht die Serie einmal
da, darunter wie viele Folgen bereitliegen; welche genau, zeigt ihre
Seite — auch ohne Server, dann aus dem, was auf dem Gerät liegt.

**Das Bild wurde schwarz, sobald jemand auf „Bildfüllend" wechselte.**
Daran war ein eigener ViewModifier schuld, der zwischen
`ignoresSafeArea()` und gar nichts umschaltete. Genau das ist in SwiftUI
ein Zweig: der Baum darunter wurde neu gebaut, die Bildebene mit ihm.
`edges: []` ignoriert nichts und bleibt doch derselbe Modifikator.

Dazu „Als Nächstes": die Reihe begann am Anfang der Serie statt dort, wo
der Benutzer steht. Wer bei Folge 2 war und Folge 1 nie abgehakt hatte,
bekam Folge 1 vorgeschlagen — und das Abhaken half nicht, weil die
Kachel sich nur selbst einen Haken malte, statt zu verschwinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 20:20:27 +02:00
ScarriffleandClaude Opus 5 8f0568cb6c Ohne Server stand man vor der Tür, mit dem Film in der Tasche
Fiel der Server aus, endete der Start bei einem Fehlerbildschirm mit
zwei Knöpfen: „Erneut versuchen" und „Abmelden". Beide nützen im
Flugzeug nichts — und dahinter lagen die Dateien, die man genau für
diesen Fall heruntergeladen hatte.

Jetzt geht die App trotzdem auf: Token und Konto liegen auf dem Gerät,
die Mediathekenliste auch, und die Startseite kommt neuerdings aus
demselben Speicher. Oben die Downloads, darunter der Stand vom letzten
Mal, ausgegraut, mit einem Satz, der sagt warum. Die Detailseite nimmt
den Titel vom Gerät, wenn der Server schweigt, und der Abspielknopf
bleibt an, weil die Datei ja da ist.

Und was man offline geschaut hat, zählt: Fortschritt, den der Server
nicht annimmt, wird zurückgelegt und nachgereicht, sobald er wieder
antwortet. Dafür sagt `reportProgress` jetzt, ob die Meldung ankam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:41:22 +02:00
ScarriffleandClaude Opus 5 77593105de Heruntergeladenes lag auf dem Gerät und war nirgends zu sehen
Downloads gab es nur im Downloads-Bereich. Wer wissen wollte, was im
Flugzeug läuft, musste zwei Listen vergleichen: die Mediathek und die
Downloads.

Jetzt sind sie eine Reihe auf der Startseite, tragen in jeder Kachel
eine Marke, und in der Mediathek gibt es „Nur Heruntergeladene" — der
Filter fragt den Server gar nicht erst, weil der nicht weiss, was auf
diesem Gerät liegt.

Dabei kam heraus, dass Downloads nie ein Bild mitbrachten: der
Dateiname dafür stand im Modell, gesetzt hat ihn niemand. Neue holen es
mit, alten wird es beim nächsten Start nachgereicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:28:45 +02:00
ScarriffleandClaude Opus 5 fb8ad1c831 Der Stand lag daneben und wurde trotzdem weggeworfen
Der Fortschrittsbalken ist an **einer** Stelle gebaut und wird in jeder
Kachel verwendet — Web wie App. Fehlte er, dann nicht an der Kachel,
sondern weil der Server `State: null` schickte. Drei Listen taten das,
obwohl die Stände in derselben Abfrage danebenlagen:

  /home/suggestions  — „Weil du Ähnliches gesehen hast"
  /home/seasonal     — die saisonale Reihe
  /movies/{id}/similar — „Ähnliche Titel"

Ausgerechnet die erste entsteht aus dem Sehverhalten: ein halb gesehener
Film sah dort aus wie ein unangetasteter, während dieselbe Kachel in der
Mediathek einen Balken trug. `ZuständeZu` gab es längst, es war nur
privat.

**Und „Als Nächstes" lässt sich jetzt räumen.** Das vorhandene Weglegen
reichte nicht dorthin: die Reihe liest `Watched` und `InProgress`, nie
`Dismissed` — und bei einer nie gestarteten Folge ist `InProgress` ohnehin
`false`. Weggelegt wird deshalb die **Serie**, nicht die Folge: beim
nächsten Laden rückte sonst die übernächste nach, und man legte
zwanzigmal weg. Den Zustand dafür gibt es schon, er wurde nur nie
gelesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:13:09 +02:00
ScarriffleandClaude Opus 5 fa8d1a97e2 Vom Aufmacher war vor allem Grau zu sehen
Zwei Beschwerden, zwei Ursachen.

**„Niedrig" war nicht niedrig.** Mit 62 vh war es mittel mit anderem
Namen; die Spanne reichte nur nach oben, weil die fünfte Stufe damals
oben gebraucht wurde. Darunter steht jetzt „Flach" mit 46 vh — für den,
der den Aufmacher als Kopfzeile will und nicht als halbe Seite.

**Der Verlauf fing auf halber Höhe schon mit 55 % Deckung an** und wurde
erst bei 85 % durchsichtig. Von einem starken Bild blieb dadurch vor
allem Grau übrig. Jetzt kurz und steil: unten dunkel genug, dass die
Schrift trägt, und die obere Hälfte frei. Der Text sitzt unten links —
genau dort muss es tragen, und darüber nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:03:45 +02:00
ScarriffleandClaude Opus 5 0a394b9b3b „Wartet" für immer, obwohl der Server längst fertig war
Der Eintrag ging nach einer 202 zurück auf „wartet" und rief danach
`resume`. Das nimmt aber nur auf, was **angehalten oder gescheitert**
ist — ein wartender lief in die Schutzklausel und kam nie wieder heraus.
Im Protokoll packte ffmpeg um, in der App stand „wartet", und dazwischen
lag nichts.

Jetzt wird die Warteschlange abgearbeitet statt ein einzelner Eintrag
aufgenommen; „wartet" steht darin.

Dazu zwei Dinge, um die du gebeten hast:

**Das Protokoll liest sich wie eine Kommandozeile.** Älteste Zeile oben,
neueste unten, die Ansicht steht am Ende. Mitgeschoben wird nur, wenn man
unten steht — wer hochgescrollt hat, liest etwas, und ihn bei jeder neuen
Zeile ans Ende zu reissen wäre, als nähme man ihm die Seite aus der Hand.

**Ein Tipp auf eine Kachel führt auf die Detailseite**, am Telefon wie am
iPad wie am Mac. Nur in „Weiterschauen" spielt er ab: dort steht der
Titel, *weil* man ihn angefangen hat. Vorher war es umgekehrt, mit dem
Argument „wer eine Kachel antippt, weiss schon, was er sehen will". Im
Gebrauch stimmte das nicht — wer nachsehen wollte, wie viele Staffeln
eine Serie hat, bekam den Film. Ein Umweg von einem Schritt ist
ärgerlich; eine Wiedergabe, die man nicht wollte, ist schlimmer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 20:56:39 +02:00
ScarriffleandClaude Opus 5 ce5dc66047 Eine 500, die der Benutzer sieht und das Protokoll nicht
Im Spieler stand „der Server hat den Download mit Fehler 500 abgelehnt",
und im Tagesprotokoll war davon nichts zu finden. Der Haken, der
Ausnahmen aufschreibt, fängt nur Ausnahmen; ein Endpunkt, der von sich
aus `Results.Problem(… 500)` zurückgibt, läuft daran vorbei.

Jetzt wird jede Antwort ab 500 vermerkt, mit Verfahren, Weg und Abfrage —
egal woher sie kommt. Dieselbe Lehre wie beim ffmpeg-Fehler: eine
Fehlermeldung, die nur der Benutzer sieht, ist eine Sackgasse.

Dazu, weil es beim Suchen im Weg stand: „Hole Metadaten zu Phineas und
Ferb 4×23 — 1334 von 1347" steht nicht mehr im Protokoll. Ein Auffrischen
schrieb vierzehnhundert solche Zeilen und schob alles andere aus der
Ansicht. Das ist Fortschritt, keine Auskunft; er gehört in die
Fortschrittszeile, wo er auch steht. Was bleibt, sind die Vermerke — sie
erklären, warum etwas so zugeordnet wurde — und die Zusammenfassung am
Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 20:41:47 +02:00
ScarriffleandClaude Opus 5 a44d55e71c 163 Byte als Film verbucht, in einer Sekunde heruntergeladen
Muss der Server eine Datei umpacken, antwortet er mit **202** und einem
kurzen Text: noch nicht, komm wieder. Die Prüfung im Download verwarf
alles ausserhalb von 200 bis 299 — und 202 liegt mittendrin. Der
Antworttext lag danach als Film auf dem Gerät.

Jetzt ist 202 ein eigener Fall: der Eintrag geht zurück in die
Warteschlange und fragt alle fünfzehn Sekunden erneut, bis zu fünf
Minuten lang. Es ist nichts schiefgegangen, es ist nur noch nicht fertig
— und im Sekundentakt nachzufragen hiesse, dieselbe Datei hundertmal
anzufangen und wegzuwerfen.

Gezählt wird im Arbeitsspeicher, nicht im Eintrag: nach einem Neustart
beginnt das Zählen von vorn, und das ist richtig — der Server hat
inzwischen vermutlich fertig gepackt.

Dazu, aus derselben Meldung: bei 21∶9 auf einem Telefon reichte das Bild
hinter die Dynamic Island. Im Vollbild bleibt das so — wer es wählt, hat
sich fürs Beschneiden entschieden. Im normalen Modus nicht mehr: dort ist
der Anspruch gerade, dass nichts fehlt, und dann darf auch nichts
verdeckt sein.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 20:33:32 +02:00
ScarriffleandClaude Opus 5 c05004f586 Untertitel kamen an, der Ton nicht — sie sahen woanders nach
Die Vorwahl auf der Filmseite legt beides unter der Kennung des Films ab.
Beim Untertitel habe ich das beim Lesen berücksichtigt, beim Ton nicht:
dort stand `item.seriesID`, und die ist bei einem Film nichts. Die
Einstellung wurde gespeichert und nie wieder gelesen.

Jetzt gibt es eine Stelle, die sagt, wo die Vorwahl eines Titels liegt:
bei einer Folge die Serie, bei einem Film er selbst. Beides ist dieselbe
Frage auf der Ebene, die der Benutzer vor sich hatte, als er sie
beantwortet hat.

Dasselbe betraf das Merken einer von Hand gewählten Spur und die Anzeige
im Spurenblatt: bei einem Film merkte sich der Spieler nichts, weil die
Kennung fehlte. Jetzt lesen alle vier Stellen dieselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-11 13:58:44 +02:00
ScarriffleandClaude Opus 5 94458ef41c Fünf Knöpfe in einer Reihe wurden auf dem Telefon zu Säulen
In einem `HStack` teilen sich die Knöpfe die Breite. Abspielen, Von vorn,
Herunterladen und zwei Sprachknöpfe passten auf einem Telefon nicht
nebeneinander — heraus kamen Säulen aus einzelnen Buchstaben, senkrecht
untereinander. Lesbar war nichts mehr; angetippt hätte man trotzdem das
Richtige.

`Flusszeile` bricht um, statt zu quetschen: was nicht mehr hinpasst,
kommt in die nächste Zeile. Zwei Zeilen sind kein Mangel, sie sind, was
dort hinpasst — und ein Knopf, der auf schmalen Geräten verschwindet,
wäre eine zweite Oberfläche, die gepflegt werden will.

Kein `ViewThatFits`: das wählt zwischen fertigen Entwürfen, und die
müsste man für jede Zahl von Knöpfen von Hand hinschreiben. Hier wird
gerechnet.

Dazu bleibt die Beschriftung eines Sprachknopfs einzeilig. Umbrochen wird
die Reihe, nicht das Wort darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 23:20:57 +02:00
ScarriffleandClaude Opus 5 f85891aba4 Die Sprachwahl gehört neben den Knopf, der sie anwendet
Auf der Filmseite standen „Tonsprache für diesen Titel" und „Untertitel
für diesen Titel" als zwei breite Zeilen unter der Beschreibung — weit
weg von „Abspielen", und mit so viel Platz dazwischen, dass sie wie eine
Einstellung aussahen und nicht wie eine Wahl für diesen Film.

Jetzt stehen sie als „Ton: Deutsch" und „Untertitel: Keine" in der Reihe
mit Abspielen, Von vorn und Herunterladen.

Der Auswahlknopf hat dafür eine zweite Form bekommen statt eines zweiten
Typs: neben „Abspielen" ist kein Platz für eine Zeile mit Spalten, und
Beschriftung plus Wert in einem sagt dasselbe in einem Wort mehr. Auf der
Serienseite bleibt es bei der Zeile — dort stehen sie in einer Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 22:16:12 +02:00
ScarriffleandClaude Opus 5 84612335ec Sprachwahl in der App — bei Serien und bei Filmen
Auf der Serienseite stand ein Menü für den Ton, gefüllt mit zwanzig
Sprachen, von denen die Serie zwei hatte. Für Untertitel gab es keines,
und auf der Filmseite überhaupt nichts.

Jetzt beides an beiden Orten, und zwar nur mit dem, was es wirklich gibt:

Bei einer **Serie** über den neuen Endpunkt — die Schnittmenge über alle
Folgen. Was dadurch wegfällt, steht als Satz darunter, sonst sähe eine
gekürzte Liste aus wie die ganze Wahrheit.

Bei einem **Film** aus den Spuren der Datei. Kein Serverweg nötig: dort
ist die Datei der Titel, und ihre Spuren stehen in der Antwort, die die
Seite ohnehin geholt hat.

Ton und Untertitel teilen sich einen Typ. Sie unterscheiden sich in genau
zwei Punkten — woher der Wert kommt und ob es „Keine" gibt; ein zweiter
Typ wäre dieselbe Datei mit zwei geänderten Zeilen. „Keine" gibt es nur
beim Untertitel: ein Film ohne Tonspur ist kein Wunsch, sondern ein
Fehler.

Die Untertitelsprache liegt je Titel, in einem Topf für Serien und Filme
— eine Filmkennung kollidiert mit keiner Serie, und zwei Wörterbücher für
dieselbe Frage liefen auseinander. Der Spieler liest sie jetzt: vorher
kannte er nur die allgemeine, und eine Serie im Original mit deutschen
Untertiteln liess sich nicht einstellen, ohne sie überall einzuschalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:56:15 +02:00
ScarriffleandClaude Opus 5 95d7eee8c3 Die Staffelwahl klebte am Untertitelknopf
Die Sprachwahl einer Serie steht zwischen dem Kopf und den Staffeln und
hatte als einziger Abschnitt dort keinen Abstand nach unten. Jetzt
denselben wie jeder andere.

Dazu: der Hinweissatz darunter verschwindet, wenn er leer ist. Ein leeres
Feld nimmt sonst Platz für einen Satz, den es nicht gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:45:33 +02:00
ScarriffleandClaude Opus 5 fc36bb7ddb Eine Serie, eine Sprachwahl — nicht zwanzig
Auf der Serienseite im Web stand bei jeder einzelnen Folge ein Knopf für
Ton und einer für Untertitel. Zwanzig Folgen hiessen zwanzigmal dieselbe
Frage, und am Ende wusste niemand, welche Antwort gilt. „Dieser Anime
läuft auf Japanisch mit deutschen Untertiteln" ist eine Aussage über die
Serie, nicht über Folge 7.

Die Wahl steht jetzt einmal oben bei den Angaben zur Serie und an den
Folgen gar nicht mehr. Beim Film bleibt sie, wo sie war — dort ist die
Datei der Titel.

Angeboten werden nur Sprachen, die in **jeder** Folge vorkommen; was
dadurch wegfällt, steht als Satz darunter. Gemerkt wird die Sprache, nicht
die Spurnummer: die unterscheidet sich von Folge zu Folge, und beim Start
wird sie für diese Datei nachgeschlagen.

Dazu die Staffeln. Sie standen alle untereinander — nach Folge 8 kam
wieder eine Folge 1, und wer zur dritten Staffel wollte, scrollte an
zwanzig Folgen vorbei. Die Überschrift ist jetzt das Menü. Bei einer
einzigen Staffel bleibt es bei der Überschrift: ein Menü, das nur sich
selbst anbietet, ist keine Wahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:43:17 +02:00
ScarriffleandClaude Opus 5 22be288fc3 Welche Sprachen eine Serie durchgehend führt
Das Menü auf der Serienseite bot zwanzig Sprachen an, von denen die Serie
zwei hatte. Um das zu ändern, muss jemand wissen, welche es wirklich
gibt — und zwar in einer Abfrage: zwanzig Folgen einzeln zu holen wären
zwanzig Rundreisen, und am Ende müsste der Client dieselbe Rechnung
anstellen.

`GET /api/v1/series/{id}/languages` nennt Ton- und Textsprachen als
**Schnittmenge** über alle Folgen. Eine Sprache anzubieten, die es nur in
Staffel 1 gibt, hiesse: eine Einstellung für die Serie, die ab Staffel 2
stillschweigend etwas anderes tut. Was dabei wegfällt, wird gezählt —
damit die Oberfläche es sagen kann, statt es zu verschweigen.

Zwei Dinge, die sonst falsche Ergebnisse gäben:

Gruppiert wird nach dem **Namen**, nicht nach dem Kürzel. Eine gewachsene
Sammlung trägt „ger" und „deu" nebeneinander; nach dem Kürzel gezählt
fiele Deutsch aus der Schnittmenge, obwohl es überall liegt.

Gezählt werden nur Folgen, die eine **Datei** haben. Eine angekündigte
Folge kann keine Sprache beisteuern; sie als fehlend zu zählen liesse das
Menü bei jeder laufenden Serie leer.

Die Rechnung steht in `Flaro.Core`, nicht im Endpunkt: sie hängt an keiner
Datenbank und lässt sich so prüfen, ohne eine Serie anzulegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:38:19 +02:00
ScarriffleandClaude Opus 5 2c51f639be Untertitel vorher wählen, wie den Ton auch
Auf der Detailseite stand „Ton: Deutsch" und liess sich vor dem Start
umstellen — aus gutem Grund: im Spieler umzuschalten heisst, den Strom
neu aufzubauen. Für Untertitel gab es das nicht, obwohl dort dieselbe
Überlegung gilt und die Spuren ohnehin schon dastehen.

Jetzt steht „Untertitel: Keine" daneben. Auch bei nur einer Spur: anders
als beim Ton gibt es hier immer mindestens zwei Möglichkeiten — diese
eine, oder gar keine.

Der Wunsch wird gemerkt wie beim Ton, mit einem dritten Zustand. „Keine"
ist eine Antwort und kein Fehlen einer: wer sie abgewählt hat, will sie
beim nächsten Film auch nicht, während „nie etwas gesagt" offen lässt,
ob eine erzwungene Spur erscheinen darf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:33:35 +02:00
ScarriffleandClaude Opus 5 ab43656f71 Nach Folge 8 kam wieder Folge 1, und nichts sagte warum
Der Server liefert die Folgen einer Serie am Stück. Auf der Seite stand
unter „Staffeln" eine durchgehende Liste, in der nach der achten Folge
wieder eine erste kam — die der zweiten Staffel, aber das stand nirgends.

Jetzt wählt ein Menü die Staffel, dort wo vorher nur „Folgen" stand. Bei
einer einzigen Staffel bleibt es beim Wort: ein Menü mit einem Eintrag
ist keine Wahl.

Kein `Picker`: diese Seite ist eine `ScrollView`, kein `Form`, und dort
hat ein `Picker` kein Aussehen, auf das man sich verlassen kann —
dieselbe Begründung wie beim Sprachmenü darüber.

„Alle auswählen" meint jetzt die gezeigte Staffel. Vorher hätte ein Griff
sechzehn Folgen heruntergeladen statt acht. Beim Wechsel der Staffel
fällt eine bestehende Auswahl weg: stehen zu lassen, was man nicht mehr
sieht, lädt unsichtbare Folgen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:27:58 +02:00
ScarriffleandClaude Opus 5 fa31adf3d1 Deutsch gewählt, Englisch gesehen
„Die erste ist die richtige" stand hier, und es stimmte — solange der
Server genau eine Textspur auslieferte, nämlich die bestellte. Seit die
Wiedergabe über einen Strom läuft, stehen alle Spuren in der
Abspielliste, und dann ist die erste schlicht die erste.

Gesucht wird jetzt die Spur, die zur Bestellung passt: erst über das
Sprachkürzel — `ger`, `deu` und `de` meinen dasselbe, dafür gibt es
`LanguageCode` längst —, bei gleicher Sprache über das Merkmal
„erzwungen", und zuletzt über den Namen. Findet sich keine, bleibt es bei
der ersten: bei einem umgerechneten Strom gibt es nur sie, und gar kein
Untertitel wäre schlechter als der eine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:13:52 +02:00
ScarriffleandClaude Opus 5 5657cd9e4b Der Sprung ging ins Leere, und die Prüfung dagegen griff nie
Nach vorn ziehen, Regler springt zurück. Im Pausieren nicht — dort wuchs
die Abspielliste weiter und holte die Stelle irgendwann ein.

Der Spieler sollte einen Sprung ausserhalb des Stroms daran erkennen,
dass er hinter dessen Ende liegt. Das Ende stand in `streamEnd`, und das
wird gesetzt, wenn der Player eine Dauer meldet — was er bei einer
wachsenden Liste nie tut. `streamEnd` blieb also nichts, jeder Sprung
galt als innerhalb, AVPlayer bekam ein Ziel ohne Schnipsel und verwarf
es.

Gefragt wird jetzt der Vorrat: so weit, wie Daten da sind, springt der
Spieler selbst; darüber hinaus wird der Strom neu gebaut. Weiss niemand,
wie weit es reicht, wird ebenfalls neu gebaut — ein neuer Strom dauert
ein paar Sekunden, ein Sprung, der nichts tut, ist kaputt.

Entschieden wird vor dem Aufräumen: `seek` verwirft den Vorrat, und
genau an dem hängt die Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 21:01:35 +02:00
ScarriffleandClaude Opus 5 064f8f61af Eine wachsende Abspielliste hat keine Länge, also kam nie eine
Mein letzter Versuch griff an der falschen Stelle: er verbesserte, was
`durationResolved` einträgt — nur kommt dieses Ereignis bei einem Strom
aus Flaro gar nicht. Die Abspielliste wächst und trägt kein
`EXT-X-ENDLIST`; für AVPlayer ist die Dauer damit unbestimmt.

`duration` blieb also auf nichts, und die Ansicht rechnet dann mit einer
Sekunde (`model.duration ?? .seconds(1)`). Darum stand der Regler bei
1:50:07 am Anschlag, egal was noch kam.

Jetzt wird die Länge gesetzt, sobald der Strom steht — aus der Datei, die
der Spieler ohnehin in der Hand hat. Auf den Player zu warten hiesse, auf
etwas zu warten, das er nicht wissen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:50:44 +02:00
ScarriffleandClaude Opus 5 693a6cc867 Cookie heisst Cookie
„Keks", „Keksglas", „Kekse" — eingedeutscht, wo es nichts zu
verdeutschen gibt. Ein Cookie ist ein Cookie; wer danach sucht, sucht
nicht nach Keksen, und wer den Quelltext liest, liest daneben die
Schnittstelle, in der es `HTTPCookieStorage` heisst.

Betrifft Kommentare und Prüfungsnamen, kein Verhalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:42:29 +02:00
ScarriffleandClaude Opus 5 f789c40594 Der Vorrat holte unangemeldet, und das stand im Protokoll
20:32:40  Umpacken … Vorbereitung 3203 ms
    20:32:40  FlaroToken was challenged
    20:32:52  Umkodieren … 0 ms
    20:32:55  erstes Stück nach 2750 ms

Einmal je Wiedergabe eine abgewiesene Anfrage, gefolgt von zwölf
Sekunden Stille und einer zweiten Bestellung. Die zwölf Sekunden sind
AVPlayer, der auf etwas wartet, das nie kommt.

Der Vorrat, der dem Spieler die Schnipsel voraushält, benutzt eine
flüchtige URLSession — und die bringt ihren eigenen, leeren Keksspeicher
mit. Jede Anfrage von dort ging unangemeldet hinaus. Bei Jellyfin fiel
das nie auf, weil der Schlüssel dort in der Adresse steht; Flaro meldet
über ein Keks an. Und bisher kam der Vorrat bei Flaro nie zum Zug: er
springt nur bei einer Abspielliste an, und die App hat bis gestern eine
fertige Datei abgespielt.

Dass die zweite Bestellung lief, passt dazu: nach dem Fehlschlag gibt der
Vorrat auf, und der Spieler holt unmittelbar vom Server — mit dem Keks,
den er selbst hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:35:37 +02:00
ScarriffleandClaude Opus 5 347ddfea01 Das Protokoll kannte nur, was jemand ausdrücklich hineinschrieb
Start, Ende, ein paar Fehler — mehr stand nie drin. Dabei meldet der
Server längst, was er tut: „Scan gestartet", „Strom angelegt",
„Untertitel aus … gezogen", „Bibliothek ohne Treffer". Das ging alles an
`ILogger` und damit in die Container-Ausgabe, wo es zwischen den
EF-Abfragen untergeht.

Eine Senke hängt das Tagebuch jetzt an die Protokollkette. Nur Flaros
eigene Kategorien — `Microsoft.*` käme mit einer Zeile je Datenbank-
abfrage und machte das Protokoll nach einem Scan unlesbar. Debug und
Trace bleiben draussen, die sind fürs Entwickeln.

Damit fallen die beiden Stellen weg, an denen ich gestern von Hand ins
Tagebuch geschrieben habe; sie kommen jetzt über denselben Weg wie alles
andere. Der Name der Quelle steht davor: „Streamkoordinator: …".

Nebenbei: das Tagebuch wird von Hand gebaut statt aus dem Behälter
geholt. Die Senke braucht es, bevor der Behälter steht, und ein
`BuildServiceProvider()` dafür legte eine zweite Ausgabe an — zwei
Tagebücher mit zwei Schlössern, die sich beim Schreiben ins Gehege
kommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:30:23 +02:00
ScarriffleandClaude Opus 5 19ec17da19 Zwölf Sekunden für eine Hülle um nichts
Die Messung sagt es deutlich:

    20:15:53  Umpacken    — Schlüsselbildsuche 2348 ms, Vorbereitung 2394 ms.
    20:16:05  Umkodieren  — Schlüsselbildsuche 0 ms, Vorbereitung 0 ms.
    20:16:08  erstes Stück nach 2750 ms.

Fünfzehn Sekunden, und zwölf davon liegen zwischen zwei Bestellungen für
denselben Film. Der erste Strom wurde angelegt und dann nie abgeholt —
keine Medienliste, kein Stück, kein ffmpeg-Fehler. Danach gab die App auf
und bestellte neu, diesmal mit Umkodieren, und das lief nach knapp drei
Sekunden.

Dazwischen lag die Hauptliste. Es gibt sie allein wegen der
Untertitelspuren, und bei diesem Film waren die noch gar nicht aus der
Datei gezogen — sie war also leer bis auf einen Verweis auf die
Medienliste. Eine Hülle um nichts, und keine umsonst: ohne `CODECS` muss
ein Spieler sie erst aufmachen, um zu sehen, was darin liegt.

Jetzt gibt es sie nur, wenn wenigstens eine Spur abholbar ist. Sonst
bekommt die App die Medienliste unmittelbar — denselben Weg wie der
Browser, der nie gestolpert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:20:16 +02:00
ScarriffleandClaude Opus 5 a6b3217d6e Das Protokoll steht jetzt da, statt sich holen zu lassen
Vorher: eine Liste von Tagen, je Tag drei Knöpfe, und erst nach „Ansehen"
stand irgendwo Text. Wer wissen wollte, was gerade schiefgeht, brauchte
drei Handgriffe für eine Antwort, die auf der ersten Seite hätte stehen
können.

Jetzt ist „Protokolle" eine Liste von Ereignissen, wie man sie aus Home
Assistant kennt: der heutige Tag sofort und offen, neueste Zeile oben —
damit man zum Mitlesen nicht scrollen muss —, und alle vier Sekunden
kommt dazu, was dazugekommen ist. Fehler und Warnungen tragen die Farbe
am Rand statt im Text; eine ganze Zeile in Rot liest sich schlechter als
eine in Grau mit rotem Strich.

Die früheren Tage stehen darunter als Kacheln zum Herunterladen. Der
heutige nicht: er steht schon oben, und eine zweite Tür zum selben Raum
ist keine Hilfe.

Mehrzeiliges — ffmpeg schreibt gern vier Zeilen — hat keinen eigenen
Zeitstempel und gehört zur Zeile darüber. Es wird eingerückt gezeigt
statt verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:15:50 +02:00
ScarriffleandClaude Opus 5 4db2a17e64 „Es dauert fünfzehn Sekunden" liess sich nicht beantworten
Zwischen dem Antippen und dem ersten Bild liegen drei Strecken: die
Schlüsselbildsuche, der Start von ffmpeg und das Warten auf das erste
Stück. Keine davon war irgendwo abzulesen — jede Erklärung wäre Raterei
gewesen.

Jetzt steht je Wiedergabe eine Zeile im Tagesprotokoll, mit den ersten
beiden Zeiten. Dauert das erste Stück länger als zwei Sekunden, kommt
eine zweite Zeile dazu. Eine Zeile je Wiedergabe ist kein Lärm; sie ist
genau das, wonach man im Protokoll sucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:11:02 +02:00
ScarriffleandClaude Opus 5 855cfddf03 Der Regler stand am Ende, obwohl noch zwanzig Minuten kamen
Als Filmlänge nahm der Spieler `item.duration` und fiel sonst auf die
Länge des Stroms zurück. Beides ging daneben: `item.duration` kommt aus
der Liste, und dort stehen keine Dateien — bei einem Titel, der aus einer
Reihe geöffnet wurde, ist es nichts. Übrig blieb die Länge des Stroms,
und eine wachsende Abspielliste ist am Anfang ein paar Sekunden lang.

Die Datei weiss es genau. Sie steht im Spieler ohnehin, weil er aus ihr
abspielt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 20:04:47 +02:00
ScarriffleandClaude Opus 5 b88e58d5eb Das Gerät war da, die VA-Anzeige dahinter nicht
ffmpeg endete mit 234. No VA display found for device
    /dev/dri/renderD128. Device creation failed: -22.

Hier stand `File.Exists("/dev/dri/renderD128")`, und das war keine
Prüfung, sondern eine Vermutung. Auf dem NAS liegt das Gerät
durchgereicht da — eine VA-Anzeige steht nicht dahinter. ffmpeg bricht
dann schon an der globalen Option ab, vor dem ersten Bild, und für den
Zuschauer sieht das aus wie ein kaputter Server: HTTP 500 auf die
Abspielliste, „Die Wiedergabe wurde unterbrochen".

Jetzt wird ffmpeg einmal je Lauf wirklich gefragt: ein Bild aus dem
Nichts, nirgendwohin, mit Frist. Geht die Zeile vor dem ersten Bild nicht
durch, läuft es in Software weiter — langsamer, aber es läuft.
FLARO_HWACCEL=vaapi überspringt die Probe für den, der es ausdrücklich
verlangt.

Dazu, aus derselben Gegend: in der Hauptliste stehen nur Untertitelspuren,
die jetzt sofort abholbar sind. Sie werden beim ersten Bedarf aus der
Datei gezogen, und bei einem Dreistünder dauert das Minuten. Der Browser
verträgt das — er fragt die Spur einzeln an und wartet. AVPlayer bricht
daran die ganze Wiedergabe ab, nicht bloss den Untertitel. Das Füllen
läuft beim Anlegen der Sitzung schon an; beim nächsten Abspielen stehen
sie drin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:59:49 +02:00
ScarriffleandClaude Opus 5 34ade18ed6 „Die Einzelheiten stehen im Protokoll von heute" — standen sie nicht
Der Spieler verweist bei einer gescheiterten Wiedergabe aufs
Tagesprotokoll. Dort stand nichts: `ILogger` landet in den
Container-Ausgaben, und das Tagebuch ist eine eigene Datei, in die nur
Scan, Wartung und unbehandelte Ausnahmen schreiben. Der Hinweis war eine
Sackgasse — gemessen an einem Protokoll, das einen ganzen Tag Betrieb mit
vier Zeilen Start und Ende wiedergab, während eine Wiedergabe mit 500
abgebrochen war.

Jetzt geht der ffmpeg-Fehler auch dorthin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 19:51:41 +02:00
ScarriffleandClaude Opus 5 6ef84b47cf Den Weg der App einmal ganz abgehen
Die App schickt keine Codec-Listen; dann nimmt der Server Apples Profil.
Das führt zu einer anderen Entscheidung als beim Browser — dort wird
umgerechnet, hier umgepackt — und damit auf einen Weg, den keine Prüfung
abging. Jetzt schon: bestellen, Hauptliste, Medienliste, erstes Stück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 17:01:50 +02:00
ScarriffleandClaude Opus 5 6e503d63de „noch 35:37" wird zu „−35:37"
Das Wort stand da, weil die Zahl ganz rechts hinter den Menüs allein
nicht zu deuten war. Neben der verstrichenen Zeit tut das Minuszeichen
dasselbe mit einem Zeichen statt vier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:41:41 +02:00
ScarriffleandClaude Opus 5 1bcf6f12e5 Die App wartete auf eine fertige Datei, statt zu streamen
Gleichzeitig im Web und in der App auf Abspielen getippt: das Web lief,
die App lud dreissig Sekunden. Das war keine Ladezeit, sondern die Zeit,
die der Server braucht, um den ganzen Film einmal durchzuschreiben.

`preparePlayback` kannte nur `/files/{id}/download`. Muss die Datei
umgepackt werden, fragte es im Sekundentakt nach, bis die vollständige
MP4 auf der Platte lag — Grenze zehn Minuten. Der Endpunkt, den das Web
benutzt, ist am Server längst da; die App wusste nur nichts davon.

Jetzt zuerst `/files/{id}/stream`. Das erste Stück ist beim Umpacken in
deutlich unter einer Sekunde da. Der alte Weg bleibt als Rückfall: er ist
langsam, kommt aber ohne Sitzung aus, und eine späte Wiedergabe ist
besser als keine.

**Der Strom beginnt dort, wo man weiterschauen wollte**, und zählt
trotzdem ab null. Ohne das mitzurechnen stünde bei „Fortsetzen ab 1:32"
eine Null auf der Uhr — und genau die ginge als Fortschritt an den
Server. Also: die Zeit der Engine plus Vorlauf ist die Zeit des Films,
und der Spieler springt nicht noch einmal dorthin, wo der Server schon
steht.

**Ein Sprung aus dem Strom heraus baut ihn neu auf.** Die Abspielliste
wächst von der Einstiegsstelle vorwärts; davor und dahinter gibt es
nichts, und ein Sprung ins Leere liesse den Spieler mit einem Kreisel
stehen. Innerhalb des Erzeugten springt er weiterhin selbst — dafür eine
neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
zurückzugehen.

**Und die Sitzung wird geschlossen.** `endPlayback` war eine leere
Methode; das passte, solange es keine Sitzung gab. Mit dem Strom läuft
sonst ein ffmpeg weiter und schreibt den Film zu Ende, während längst
niemand mehr zusieht.

Die Laufzeit kommt weiter aus den Metadaten und nicht aus der Engine:
eine wachsende Liste meldet anfangs ein paar Sekunden, und als Filmlänge
genommen stünde der Regler sofort am Ende.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:22:18 +02:00
ScarriffleandClaude Opus 5 4a5576f939 Eine Hauptliste, damit auch AVPlayer die Untertitel findet
`index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst
nichts. Dem Browser reicht das, weil er die Untertitel als eigene Spur
ans Videoelement gehängt bekommt. AVPlayer kann das nicht — dort gibt es
keinen Weg, einer laufenden Wiedergabe eine Textspur von aussen
beizulegen. Was er findet, findet er in der Abspielliste oder gar nicht.

Also eine Hauptliste: dieselbe Medienliste als Bildspur, daneben je eine
Textspur als eigene Liste mit genau einem Stück — unsere WebVTT-Datei
über den ganzen Rest des Films. Der Vorlauf der Sitzung hängt daran, denn
die Zeitstempel in der Datei zählen ab Dateianfang und der Strom ab null.

Für den Browser ändert sich nichts; er holt weiterhin `index.m3u8`. Eine
Direktwiedergabe bekommt keine Hauptliste: die Datei bringt ihre Spuren
selbst mit, und eine Liste darum herum wäre eine Hülle um nichts.

Vorarbeit für die App, die bisher auf eine fertig umgepackte MP4 wartet —
dreissig Sekunden, wo das Web nach einer anfängt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:17:47 +02:00
ScarriffleandClaude Opus 5 912969d829 Abspielen in die Mitte, die beiden Zeiten daneben
Die verstrichene Zeit stand hinter dem Lautstärkeregler — „1:51:01"
direkt neben einem Schieber liest sich wie dessen Beschriftung — und die
verbleibende ganz rechts hinter den Menüs. Zwei Angaben über dieselbe
Sache, so weit auseinander wie möglich.

Jetzt stehen sie dort, wo man ohnehin hinsieht: links und rechts vom
Knopf, den man drückt.

Drei Spalten statt drei Flex-Kinder, denn die Mitte soll die Mitte des
Bildes sein und nicht die Mitte dessen, was links und rechts übrig
bleibt. Mit `space-between` wanderte der Knopf, sobald rechts ein Menü
dazukam oder links die Lautstärke wegfiel.

Schmal weicht, was man auch anders bekommt: die Lautstärke liegt am
Telefon auf einer Taste, und die verbleibende Zeit lässt sich an der
Leiste ablesen. Der Knopf in der Mitte weicht nicht — dort sucht ihn der
Daumen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:12:50 +02:00
ScarriffleandClaude Opus 5 3dba736def Zwei Angaben über dieselbe Sache, so weit auseinander wie möglich
Die verstrichene Zeit stand hinter dem Lautstärkeregler, die verbleibende
ganz rechts hinter den Menüs. „1:48:20" direkt neben einem Schieber für
Lautstärke liest sich wie dessen Beschriftung — und die zweite Zahl, die
dazugehört, war einen halben Bildschirm entfernt.

Beide stehen jetzt links und rechts von dem Balken, über den sie Auskunft
geben. Schmal weicht die verbleibende Zeit; dort ist der Balken wichtiger
als die zweite Zahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:09:57 +02:00
ScarriffleandClaude Opus 5 dbd66592a6 Untertitel klebten am Bildrand, und ihr Zeilenabstand blieb gross
Zwei Fehler, die sich gegenseitig verstärkt haben.

**Der Zeilenabstand kam nicht mit.** `::cue { font-size }` ändert die
Schrift, nicht die Zeilenhöhe — die rechnet der Browser weiter aus seiner
eigenen Grösse. Bei 50 Prozent stand deshalb winziger Text mit dem
Abstand von grossem darunter; zwischen zwei Zeilen klaffte eine dritte.
`line-height` als Vielfaches der eigenen Grösse hält das Verhältnis.

**Und sie sassen ganz unten.** Ohne Angabe setzt der Browser jede
Einblendung an den unteren Rand des Videos. Dort liegt bei uns die
Bedienleiste — bei zwei Zeilen verschwand die untere darunter. Die
Unterkante sitzt jetzt bei neunzig Prozent der Videohöhe.

Gestellt wird das am `VTTCue`, nicht über CSS: `::cue` gestaltet den
Text, aber nicht seinen Platz. `lineAlign: "end"` ist dabei der Punkt —
damit meint `line` die Unterkante. Ohne ihn stünde ein zweizeiliger
Untertitel wieder tiefer als ein einzeiliger, also genau der Fall, um den
es ging.

Eine Einblendung, die ihre Stelle selbst mitbringt, behält sie:
Schilder und Aufschriften stehen dort, wo sie im Bild stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:08:07 +02:00
ScarriffleandClaude Opus 5 c5ab200846 Die Startseite holte ihre Reihen im Gänsemarsch
Die Weboberfläche holt alles, was auf der Startseite steht, in einem
einzigen `Promise.all` — und sie holt sogar mehr davon: Vorschlag und
saisonale Reihe kennt die App gar nicht. Trotzdem war sie spürbar
langsamer. Der Grund stand in einer Schleife:

    for mediathek in mediatheken {
        ... await client.send(...)
    }

Ein `await` im Schleifenrumpf heisst: Rundreise, warten, nächste
Rundreise. Bei vier Mediatheken viermal hintereinander, nachdem
„Fortsetzen" und „Als Nächstes" längst da waren. Dieselbe Schleife stand
in beiden Hintergründen, für Flaro wie für Jellyfin.

Jetzt laufen sie nebeneinander, in einer `TaskGroup`. Die Reihenfolge der
Mediatheken bleibt erhalten — nach den Antworten zu ordnen hiesse, dass
die Reihen nach jedem Start woanders stünden. Ein Actor steht dem nicht
im Weg: jede Anfrage gibt ihn beim Warten frei, genau wie die beiden
`async let` darüber es schon tun.

Nebenbei: ein Fehler bei einer Mediathek nimmt die anderen nicht mehr
mit. Eine Startseite mit drei von vier Reihen ist besser als keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 13:55:24 +02:00
ScarriffleandClaude Opus 5 8397e042b8 Der Publikumswert steht auf der Seite, also wird er von dort gelesen
OMDb führt im Feld `Ratings` nur eine Zahl je Dienst, und bei Rotten
Tomatoes ist das die der Kritik. Die erweiterten Felder gibt es, sie
stehen aber seit Jahren auf `N/A`; Jellyfin fragt sie an und liest sie
nicht einmal aus — deren Antwortklasse kennt `tomatoUserMeter` gar nicht.
Dort hat den Publikumswert also niemand.

Auf der Seite selbst steht er. Gemessen an `/m/the_matrix`: im Quelltext
liegt `"audienceScore":{… "score":"85" …}` neben `"criticsScore"`, dazu
ein `application/ld+json` mit Titel und Erscheinungsdatum; bei Serien
unter `/tv/…` dasselbe, und ein unbekannter Weg antwortet mit 404. Eine
Prüfung mit `[Explicit]` geht einmal wirklich hinaus und bestätigt es:
`rottenTomatoes 83%, rottenTomatoesAudience 85%`.

Das ist Auslesen einer fremden Webseite, und es hat seinen Preis. Eine
offene Suche gibt es nicht — `/napi/search` antwortet mit 401 —, also
wird der Weg aus dem Titel geraten und hinterher am Jahr geprüft: auf der
Seite steht keine IMDb-Kennung, und `/m/the_lion_king` ist der von 1994.
Weicht das Jahr um mehr als eines ab, wird verworfen; lieber keine Zahl
als die eines fremden Films. Der Originaltitel kommt zuerst dran, sonst
fände ein deutscher Bestand dort fast nichts. Ohne Jahr zuerst, weil der
Normalfall den blanken Namen trägt — mit Jahr zuerst kostete jeder Titel
eine Anfrage zu viel.

Deshalb ein Schalter, und der steht aus, bis jemand ihn umlegt. Dazu eine
Bremse: höchstens eine Anfrage je Sekunde, prozessweit. Achthundert Titel
in einem Schwall von einer Adresse wären eine Zumutung und würden
gesperrt. Der Scan dauert dadurch länger; das ist der Preis fürs
Mitbenutzen.

Die Probe unter Einstellungen → Metadaten fragt die Quelle mit, und dort
ist sie mehr als eine Höflichkeit: wird die Seite umgebaut, merkt man es
genau da und sonst nirgends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:51:22 +02:00
ScarriffleandClaude Opus 5 e05bbfc2cd Einen einzelnen Titel neu holen, statt achthundert
Eine neue Quelle wirkt erst beim nächsten Holen der Metadaten. Das ging
bisher nur je Mediathek: achthundert Titel, eine Viertelstunde — nur um
an einem Film zu sehen, ob ein Schlüssel etwas bringt. Wer auf einer
Detailseite bemerkt, dass IMDb und Rotten Tomatoes fehlen, hatte von dort
keinen Weg dorthin.

Der Server kann eine Auswahl längst; es fehlte bloss die Kennung der
Mediathek in der Antwort, damit die Seite weiss, wohin ihr Titel gehört.
Der runde Knopf holt diesen einen Titel, wartet den Lauf ab und zeichnet
die Seite neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:02:39 +02:00
ScarriffleandClaude Opus 5 18db89399d Ein leeres Jahrfeld ist eine Angabe, kein Versehen
Wer bei der Zuordnung kein Jahr einträgt, sagt damit, dass er keines
weiss. Der Server reichte trotzdem eines nach — das aus dem Dateinamen —,
und das leere Feld hatte überhaupt keine Wirkung. Gerade bei einem falsch
benannten Film ist der Dateiname aber die unzuverlässigste Quelle, die es
gibt: „Plan B (2010)" heisst bei TMDB „DivertiMento – Plan B" und ist von
2011. Mit dem nachgereichten 2010 kam nie ein Treffer, und es gab keinen
Weg daran vorbei.

Ohne Angabe wird jetzt ohne Jahr gesucht. Der Platzhalter sagt es auch.

Auf der Detailseite stand im Feld bisher das Jahr der bestehenden
Zuordnung — also genau der, die gerade korrigiert werden soll. „Doctor
Strange" war auf ein Making-of von 2022 gelaufen; mit dessen Jahr konnte
der richtige Film von 2016 gar nicht erscheinen. Das Feld bleibt jetzt
leer, und leer heisst überall dasselbe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 16:02:29 +02:00
ScarriffleandClaude Opus 5 ac64b20a5b Jede Zahl sagt jetzt, was sie ist — und woher sie kommt
Nebeneinander standen „7,2" und „12", beide ohne Beschriftung. Die 12
konnte alles heissen: zwölf Folgen, zwölf von hundert Punkten, ab zwölf
Jahren. Die Auskunft steckte im Tooltip, und den sieht nur, wer schon
vermutet, dass dort etwas steht. Jetzt trägt jeder Wert seine Quelle:
FSK 12, TMDB 7,2, IMDb 7,4, MC 62.

Bei Rotten Tomatoes ist „RT 87%" ebenfalls keine Auskunft, denn dort
stehen zwei Zahlen — die der Kritik und die des Publikums, oft weit
auseinander. Also „RT Kritik" und „RT Publikum", auch wenn nur eine
der beiden da ist.

Und das Publikum wird mitverlangt: `tomatoes=true` legt die Felder
derselben Antwort bei, kostet keine zweite Anfrage und nichts vom
Tageskontingent. Ob etwas darin steht, ist eine andere Frage — seit 2018
wird berichtet, dass dort `N/A` steht, und die Frage, ob die Felder je
wieder Daten führen, ist bei OMDb seit 2017 unbeantwortet offen. Darum
wird nichts erwartet und nichts erzwungen: steht eine Zahl da, erscheint
sie; steht `N/A`, entsteht keine Wertung. Die Probe unter Einstellungen →
Metadaten zeigt, was wirklich kam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 22:41:41 +02:00
ScarriffleandClaude Opus 5 a609b2d087 Ein Haken, der stehen blieb, als die Rechnung sich änderte
Ein Film stand bei 1:32 und tauchte in „Fortsetzen" trotzdem nicht auf.
Die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis:
die Spalte `InProgress`. Die Bruchrechnung „Stelle kleiner als Anteil der
Laufzeit" lässt sich nicht nach SQL übersetzen, also steht sie als
Wahrheitswert in der Zeile — und berechnet wird sie nur, wenn eine
Fortschrittsmeldung eintrifft. Die kaputte Meldung von vorgestern hatte
dort `false` hinterlassen; die Korrektur am Server verhinderte neuen
Schaden, räumte den alten aber nicht weg.

`Sehstandpflege` rechnet nach jedem Update einmal durch: erst die
Laufzeit aus der Datei nachtragen, dann neu bewerten — in dieser
Reihenfolge, denn eine Neubewertung auf Basis eines Stummels käme wieder
zu `false`. `UpdatedAt` bleibt, wo es war; danach ist die Reihe sortiert.

Und der Knopf heisst jetzt so wie die Reihe, über der er steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 22:41:22 +02:00
ScarriffleandClaude Opus 5 5dfc22bdce „Hinterlegt" war keine Auskunft, und die Übersicht keine Ablage
**Man konnte nicht sehen, ob ein Schlüssel wirkt.** Nach dem Eintragen
stand „Hinterlegt" da, und das war alles. Ob er stimmt, ob der Dienst
antwortet, ob zu einem Film überhaupt etwas zu holen ist — herausfinden
liess sich das nur, indem man die Metadaten über achthundert Titel
auffrischte und danach nachsah, ob sich etwas geändert hatte.

Jetzt gibt es „Verbindung prüfen". Es wird mit „The Matrix" gefragt, nicht
mit einem Film aus der Mediathek: der könnte schlicht unbekannt sein, und
dann wüsste man wieder nicht, ob es am Schlüssel liegt oder am Film. Die
Antwort nennt, was zurückkam — bei fanart.tv die Zahl der Plakate und
davon die in der eingestellten Sprache, denn genau dafür ist die Quelle da.

Gemessen am laufenden Server:

    tmdb    eingerichtet  ok   Erreichbar — Probeabruf ergab The Matrix (1999).
    fanart  —             —    Kein Schlüssel hinterlegt — die Quelle ist aus.
    omdb    —             —    Kein Schlüssel hinterlegt — die Quelle ist aus.

**Und die Übersicht war eine Ablage für alles geworden.** Zu Recht
angemerkt: es gibt eine Seite „Metadaten", und die Zugänge standen trotzdem
unter „Übersicht" — zwischen Servername, doppelten Einträgen, Fassung und
Speicherplatz. Die drei Quellen stehen jetzt dort, wo man sie sucht, jede
mit einem Satz dazu, was sie liefert und was sie dafür braucht.

Aufmacherhöhe und saisonale Reihen bekommen einen eigenen Bereich
„Darstellung". Eine Seite, die „Übersicht" heisst, ist nicht der Ort für
alles, was sonst nirgends passt.

**Am Telefon tat die Aufmacherhöhe gar nichts.** Gemeldet als „auf 100 %
ist es immer noch zu niedrig" — und das stimmte buchstäblich: unter 860
Pixeln Breite stand `height: min(52vh, 420px)` fest und überschrieb die
Einstellung vollständig. Ob „niedrig" oder „sehr hoch", es kamen immer
dieselben 52 vh heraus.

Jetzt hat jede Stufe zwei Höhen, eine fürs breite und eine fürs schmale
Bild; am Telefon fällt sie eine Spur flacher aus, weil der Bildschirm
hochkant ist und derselbe Anteil der ersten Kachelreihe dort den Platz
nähme. Dazu eine fünfte Stufe „Bildschirmhoch" — mehr ginge nicht
sinnvoll, darüber ist es kein Aufmacher mehr, sondern die ganze Seite.

Eine Prüfung hält ausserdem fest, dass ein Film ohne passendes Stichwort
und ohne passendes Genre nicht in der saisonalen Reihe steht. Anlass war
die Meldung, dort stünden harmlose Zeichentrickfilme — die Reihe arbeitet
richtig, sie macht nur sichtbar, wenn ein Titel auf den falschen Eintrag
zugeordnet ist.

765 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 22:11:55 +02:00
ScarriffleandClaude Opus 5 e066f5fe87 Eine Reihe, die nur zu ihrer Zeit dasteht
Zu Halloween, zur Weihnachtszeit, zum Jahreswechsel, im Sommer — und zu
allem, was sonst jemand feiert.

**Nach Stichwörtern, nicht nach Genres.** „Stirb langsam" ist kein
Familienfilm und trägt trotzdem „christmas". Eine Weihnachtsreihe nach
Genres enthielte jeden Zeichentrickfilm und diesen Film nicht. TMDB führt
die Stichwörter ohnehin; sie kommen jetzt über `append_to_response` mit
und landen in einer eigenen Tabelle, nach demselben Muster wie Genres.
Kleingeschrieben abgelegt — TMDB liefert „Christmas" und „christmas" je
nach Eintrag, und zwei Schreibweisen wären zwei Stichwörter.

**Einstellbar und nicht eingebaut.** Halloween und Weihnachten kennt
jeder; der 1. August ist Schweizer Nationalfeiertag, der 4. Juli
amerikanischer, und der Geburtstag des Betreibers steht in keiner
Bibliothek. Eine feste Liste im Quelltext hiesse: für jeden Anlass eine
neue Fassung. Vier sind vorangelegt — als Beispiel, nicht als Meinung
darüber, was jemand feiert.

**Über den Jahreswechsel hinweg.** „28.12. bis 02.01." ist kein Fehler,
sondern der Normalfall für Silvester; ein Vergleich, der nur „von ≤ heute
≤ bis" kennt, fände diesen Zeitraum nie — und die Reihe stünde genau in
den Tagen nicht da, für die sie gedacht ist. Geprüft, Tag für Tag, samt
der Zusicherung, dass sich die vorangelegten Anlässe an keinem Tag im Jahr
überschneiden.

**Leer heisst leer.** Gilt heute kein Anlass oder findet er nichts, kommt
eine Reihe ohne Namen zurück und der Client zeigt gar keine — statt einer
mit Überschrift und nichts darunter. Scheitert der Abruf ganz, fehlt eine
Reihe und nicht die Startseite.

Bedient wird sie als Text, eine Zeile je Anlass: Name, von, bis,
Stichwörter, Genres. Ein Listeneditor mit Zeilen zum Hinzufügen,
Verschieben und Löschen wäre viel Oberfläche für etwas, das man dreimal im
Jahr anfasst — und es ist dieselbe Form, in der es gespeichert wird.

Ein Anlass ohne Stichwort und ohne Genre wird abgewiesen statt
stillschweigend geschluckt: er fände nie etwas, und wer ihn eingetragen
hat, suchte den Fehler woanders.

**Die Stichwörter kommen erst beim nächsten Auffrischen der Metadaten.**
Vorhandene Titel tragen noch keine — bis dahin bleibt die Reihe leer.

764 Tests grün, davon 22 neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:45:09 +02:00
ScarriffleandClaude Opus 5 c13d1e78d2 Eine Mediathek für alles, was in keinem Katalog steht
Urlaubsaufnahmen, Mitschnitte, Vorträge. Jedes Verfahren, das bei Filmen
richtig ist, wäre hier falsch: „Sommer 2019 Teil 2" ist kein Film von 2019,
zwei Aufnahmen desselben Tages sind nicht zwei Fassungen derselben Sache,
und in keinem Katalog steht, was da liegt.

Die neue Art `Videos` nimmt den Dateinamen als Titel, zerlegt nichts, legt
nichts zusammen und fragt niemanden. Der Pfad ist dabei der
Vergleichsschlüssel, nicht der Name — zwei Dateien „Ausflug.mp4" in zwei
Ordnern sind zwei Ausflüge.

**Der Unterordner wird zur Kategorie.** Statt eine eigene Gruppierung zu
bauen, bekommt jedes Video den Namen seines Ordners als Genre. Damit
greifen Filter, Kategorienansicht und Suche, ohne dass es in der Oberfläche
etwas Neues gibt. Eine zweite Gruppierung daneben hiesse: zwei Wege für
dasselbe, und einer davon wäre überall sonst ungepflegt.

Die Anreicherung übergeht jetzt alles, was `MatchLocked` trägt. Das galt
bisher nur für von Hand korrigierte Zuordnungen und gilt damit auch hier —
eine Suche nach „Abend am Feuer" fände irgendeinen fremden Film, und der
stünde danach mit Plakat und Beschreibung in der Urlaubsmediathek.

Geprüft: vier Dateien in zwei Ordnern ergeben vier Titel mit ihren
Dateinamen, keine Jahre, keinen fremden Treffer — und genau zwei
Kategorien, denn was direkt in der Wurzel liegt, bekommt keine.

742 Tests grün, davon zwei neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:36:06 +02:00
ScarriffleandClaude Opus 5 af2b844d65 IMDb, Rotten Tomatoes und Metacritic neben der TMDB-Wertung
IMDbs eigene Schnittstelle kostet Geld, Rotten Tomatoes vergibt Zugänge nur
an Partner, Letterboxd hat gar keine offene. OMDb liefert alle drei in
einer Antwort, über die IMDb-Kennung, die ohnehin schon an jedem Film steht.

**Eine Tabelle und kein Feld je Quelle.** `Movie.Rating` ist ein Skalar
ohne Quellenangabe, und das war richtig, solange es eine Quelle gab. Für
drei weitere wären es drei Spalten — und bei der vierten wieder eine
Migration. Jetzt ist jede Quelle eine Zeile, je Titel und Quelle eine, mit
eindeutigem Index darauf: ein zweiter Lauf schreibt um statt anzuhäufen.

**Zwei Werte je Bewertung, und beide gebraucht.** Der normierte Wert zum
Vergleichen und Sortieren, die Anzeige zum Hinschreiben. Rotten Tomatoes in
„8,7 von 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %,
und genau so kennt man es.

**Wieder keine zweite Metadatenquelle.** `IBewertungsquelle` liefert
Bewertungen, sonst nichts. Bei Titel und Beschreibung wären zwei Anbieter
zwei Wahrheiten; bei einer Bewertung ist jede Quelle eine eigene Zahl, und
da sind mehrere kein Widerspruch, sondern der Sinn der Sache.

**Ohne Kennung wird nicht gefragt.** OMDb kennt nur den Weg über die
IMDb-Kennung; nach einem Titel zu suchen hiesse, denselben Zuordnungsfehler
ein zweites Mal zu riskieren. Und auf Unbekanntes antwortet OMDb mit 200
und `Response: "False"` — das als Treffer durchzulassen legte eine leere
Bewertung ab, die danach als „keine Wertung" dastünde.

Angezeigt wird mit Kürzel statt Stern: vier gleich aussehende Zahlen
nebeneinander sagen nicht, woher sie kommen. Kein Logo — die Marken von
IMDb und Rotten Tomatoes mitzuliefern hiesse, fremde Zeichen auszuliefern.

Kein mitgelieferter Schlüssel, aus demselben Grund wie bei fanart.tv: OMDb
begrenzt auf tausend Abrufe am Tag je Schlüssel. Einzutragen unter
Einstellungen → Übersicht → Metadaten.

740 Tests grün, davon acht neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:26:58 +02:00
ScarriffleandClaude Opus 5 97edb49564 Eine zweite Bildquelle, weil TMDB nicht alles hat
„365 Tage" stand mit polnischem Plakat da. Nachgesehen: TMDB hat zu diesem
Film 26 Plakate und kein einziges deutsches. Die Auswahl von gestern nimmt
seither das sprachlose statt des polnischen — erfinden kann sie keines.

fanart.tv ist die Quelle, bei der jedes Plakat seine Sprache mitführt, und
die Gemeinschaft dort pflegt landessprachliche Fassungen. OMDb wäre keine
Hilfe gewesen: es liefert das Plakat von IMDb, und das ist in aller Regel
das amerikanische.

**Ausdrücklich keine zweite Metadatenquelle.** `IBildquelle` liefert
Bilder, sonst nichts. Titel, Beschreibung und Besetzung kommen weiterhin
von einer Stelle — zwei Anbieter für dieselbe Angabe wären zwei Wahrheiten.

**Gefragt wird nur, wenn es lohnt.** `MovieMetadata` trägt jetzt die
Sprache des gewählten Plakats mit. Hat TMDB schon eines in der
eingestellten Sprache, geht keine Anfrage hinaus — bei achthundert Titeln
ist das der Unterschied zwischen „läuft nebenher" und „hängt am fremden
Dienst". Und nur ein landessprachliches sticht: ein sprachloses von
fanart.tv ist nicht besser als ein sprachloses von TMDB.

**Die Rangfolge liegt jetzt im Kern**, nicht im TMDB-Client. Sie gilt für
alle Quellen, sonst kämen die Bilder zweier Anbieter in zwei Reihenfolgen
zusammen. Im Bilderdialog stehen beide Quellen gemeinsam geordnet: ein
deutsches Plakat von fanart.tv vor einem sprachlosen von TMDB.

**Nur Filme.** Der Serienweg von fanart.tv verlangt eine TVDB-Kennung, und
die führt Flaro nicht. Eine halbe Lösung, die bei Serien stillschweigend
nichts tut, wäre schlechter als eine, die sagt, worauf sie sich beschränkt.

**Kein mitgelieferter Schlüssel.** Bei TMDB gibt es einen als Rückfall;
fanart.tv verlangt einen persönlichen, und einen fremden mitzuliefern
hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel geht keine Anfrage
hinaus — geprüft. Einzutragen unter Einstellungen → Übersicht → Metadaten.

Dass „00" dort „ohne Sprache" heisst, wird beim Lesen übersetzt: sonst
führte die gemeinsame Rangfolge ein sprachloses Plakat als Sprache „00"
und setzte es hinter Englisch statt davor.

732 Tests grün, davon sieben neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:18:27 +02:00
ScarriffleandClaude Opus 5 2bd13f0f19 Breitbild sass oben statt mittig
Ein 21:9-Film war in der Breite richtig und in der Höhe nicht: der schwarze
Rand lag ganz unten statt gleichmässig oben und unten. Im Fenster fällt das
kaum auf, im Vollbild sofort.

Gemessen an einem 2560×1080-Film in einem 1024×768-Fenster:

    Hülle        768 hoch
    Videokasten  459 hoch, bei oben 0

Woran es lag: die Hülle ist ein Raster mit `place-items: center`. Damit
wird das Videoelement nicht gedehnt, und sein `height: 100%` löst sich
gegen eine Zeile auf, die ihre Höhe wiederum aus dem Element nimmt — die
Prozentangabe hatte nichts, woran sie sich halten konnte. Herausgekommen
ist ein Kasten, der weder die volle Höhe hatte noch mittig sass.

Jetzt liegt das Videoelement im grossen Spieler absolut über der ganzen
Hülle. `object-fit: contain` rechnet damit gegen den vollen Bildschirm und
legt den Rand von selbst zu gleichen Teilen oben und unten — in beiden
Richtungen, nicht nur in der einen, die vorher stimmte.

Nachher dieselbe Messung: Kasten 1024 × 768, oben 0.

Die kleine Leiste bleibt unberührt — sie positioniert dasselbe Element
ohnehin absolut und mit eigenen Massen; deshalb das `:not(.pl-klein)`.
Nachgemessen: 128 × 70, `object-fit: cover`, unverändert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:07:31 +02:00
ScarriffleandClaude Opus 5 144470a413 Im Browser gibt es keine Datei mehr, nur den Strom
Auf der Detailseite stand ein Knopf „Herunterladen", der die Datei als MP4
herausgab. Das ist etwas anderes, als einen Strom zu zeigen: wer eine Datei
bekommt, hat sie und kann sie weitergeben — und das soll dieser Server
nicht anbieten.

Weg sind der Knopf, das Bereitstellen davor und die beiden Stellen in der
Schnittstelle, die nur dafür da waren.

**Der Endpunkt bleibt.** Die Apps holen sich eine Mitnahme über
`/api/v1/files/{id}/download`, und dort ist sie an ein Gerät gebunden statt
an einen Ordner. Das heisst aber auch: wer angemeldet ist und die Adresse
kennt, kommt weiterhin an die Datei. Soll das zu — etwa als Recht je
Benutzer oder als Schalter für den ganzen Server —, ist das ein eigener
Schritt und keine Zeile in dieser Änderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:07:20 +02:00
202 changed files with 27831 additions and 888 deletions
+6
View File
@@ -36,6 +36,9 @@ struct FlaroApp: App {
@State private var network = NetworkConditions()
@State private var homeLayout = HomeLayoutSettings()
@State private var diagnostics = PlaybackDiagnostics()
/// Fortschritt, der den Server nicht erreicht hat — siehe
/// `Fortschrittspost`. Hier, weil er die Wiedergabe überleben muss.
@State private var post = Fortschrittspost.shared
@State private var downloads: DownloadCoordinator
private let modelContainer: ModelContainer
@@ -92,6 +95,7 @@ struct FlaroApp: App {
.environment(downloads)
.environment(homeLayout)
.environment(diagnostics)
.environment(post)
.environment(theme)
// Akzentfarbe und Tönung setzt RootView, nicht diese Stelle.
//
@@ -117,6 +121,8 @@ struct FlaroApp: App {
backend: session.backend,
coordinator: downloads
).refill()
// Was im Flugzeug liegengeblieben ist, geht jetzt raus.
await post.zustellen(über: session.backend)
}
}
.modelContainer(modelContainer)
+238 -3
View File
@@ -25,6 +25,18 @@
}
}
},
"%@: %@": {
"comment": "A label that shows the audio or subtitle language. The argument is the language name.",
"isCommentAutoGenerated": true,
"localizations": {
"en": {
"stringUnit": {
"state": "new",
"value": "%1$@: %2$@"
}
}
}
},
"4K": {
"comment": "A label for a 4K video.",
"isCommentAutoGenerated": true
@@ -1241,6 +1253,22 @@
}
}
},
"downloads.episodeCount %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld Folgen geladen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld episodes downloaded"
}
}
}
},
"downloads.failed": {
"extractionState": "stale",
"localizations": {
@@ -1722,7 +1750,7 @@
"de": {
"stringUnit": {
"state": "translated",
"value": "Staffel %lld · Episode %lld"
"value": "Staffel %lld · Folge %lld"
}
},
"en": {
@@ -1886,6 +1914,23 @@
}
}
},
"filter.downloadedOnly": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur Heruntergeladene"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Downloaded only"
}
}
}
},
"filter.filters": {
"extractionState": "stale",
"localizations": {
@@ -2122,6 +2167,22 @@
}
}
},
"home.downloaded": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Heruntergeladen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Downloaded"
}
}
}
},
"home.empty.description": {
"localizations": {
"de": {
@@ -2339,6 +2400,22 @@
}
}
},
"home.offline": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No server reachable. What you see is from last time — only what is on this device can be played."
}
}
}
},
"home.rowsHeader": {
"localizations": {
"de": {
@@ -2468,6 +2545,23 @@
}
}
},
"languages.audioShort": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ton"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio"
}
}
}
},
"languages.avoidAudioDescription": {
"extractionState": "manual",
"localizations": {
@@ -2621,6 +2715,23 @@
}
}
},
"languages.forThisTitle": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Tonsprache für diesen Titel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio language for this title"
}
}
}
},
"languages.headerExplanation": {
"extractionState": "stale",
"localizations": {
@@ -2655,6 +2766,44 @@
}
}
},
"languages.none": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Keine"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "None"
}
}
}
},
"languages.partial %@": {
"comment": "Eine kleine Schriftart, die",
"isCommentAutoGenerated": true
},
"languages.partial %lld": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nur Sprachen, die in jeder Folge vorkommen. %lld weitere gibt es nur in einem Teil."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Only languages present in every episode. %lld more exist in some episodes only."
}
}
}
},
"languages.serverDefault": {
"localizations": {
"de": {
@@ -2704,6 +2853,40 @@
}
}
},
"languages.subtitlesForThisSeries": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel für diese Serie"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles for this series"
}
}
}
},
"languages.subtitlesForThisTitle": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel für diesen Titel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles for this title"
}
}
}
},
"languages.subtitlesHeader": {
"localizations": {
"de": {
@@ -2736,6 +2919,23 @@
}
}
},
"languages.subtitlesShort": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Untertitel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Subtitles"
}
}
}
},
"languages.title": {
"localizations": {
"de": {
@@ -3905,7 +4105,6 @@
}
},
"player.nextEpisode": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
@@ -4041,6 +4240,22 @@
}
}
},
"player.previousEpisode": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Vorige Folge"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Previous episode"
}
}
}
},
"player.quality": {
"localizations": {
"de": {
@@ -5039,6 +5254,10 @@
}
}
},
"season.short %@": {
"comment": "A label that shows the number of the selected season.",
"isCommentAutoGenerated": true
},
"season.short %lld": {
"comment": "Platzhalter: Staffelnummer",
"extractionState": "stale",
@@ -5912,6 +6131,22 @@
}
}
},
"settings.skipExtraScenes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zusätzliche Szenen mit überspringen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Skip extra scenes too"
}
}
}
},
"settings.storageLimit": {
"extractionState": "stale",
"localizations": {
@@ -7874,4 +8109,4 @@
}
},
"version": "1.1"
}
}
+58 -1
View File
@@ -196,9 +196,65 @@ struct DownloadService {
quality: DownloadQuality = .original,
audioStream: MediaStream? = nil
) throws(DownloadRefusal) -> DownloadItem {
try coordinator.enqueue(
let eintrag = try coordinator.enqueue(
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
)
plakatHolen(for: item)
return eintrag
}
/// Holt das Plakat mit herunter.
///
/// **Ohne Verbindung gibt es keine Adresse mehr, von der eines käme.**
/// Ein Download ohne Bild ist offline eine graue Fläche mit einem Titel
/// darunter — und genau dann, wenn man etwas zum Ansehen sucht, ist das
/// Bild die halbe Auskunft. Ein paar Kilobyte je Eintrag.
///
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
func plakatHolen(for item: MediaItem) {
if let adresse = backend?.rawImageURL(path: item.thumb) {
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
}
// **Und das Plakat der Serie, unter deren Schlüssel.**
//
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
if let serie = item.seriesID,
let pfad = item.seriesThumb ?? item.parentThumb,
let adresse = backend?.rawImageURL(path: pfad) {
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
}
}
/// Holt Plakate nach, die früheren Downloads fehlen.
///
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
/// im Modell, gesetzt hat ihn nie jemand — alles, was vorher
/// heruntergeladen wurde, hat kein Bild und stünde in der neuen Reihe
/// als graue Fläche.
///
/// Einmal je Serverwechsel, nur für die Einträge ohne Bild; danach
/// findet die Schleife nichts mehr.
func plakateNachholen() async {
guard let backend else { return }
for eintrag in coordinator.items where fehltEinBild(eintrag) {
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
plakatHolen(for: titel)
}
}
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
/// Serie.
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
if let serie = eintrag.seriesKey,
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
return false
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
@@ -294,6 +350,7 @@ struct DownloadService {
_ = try coordinator.enqueue(
try plan(for: detailed, quality: quality, audioStream: ton)
)
plakatHolen(for: detailed)
accepted += 1
} catch DownloadRefusal.noDownloadableFile {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
+38 -4
View File
@@ -283,6 +283,9 @@ final class FlaroSession {
// einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr
// verloren.
Task { await LibraryCache.shared.clearAll() }
// Dasselbe gilt für den Fortschritt, der noch auf den Server
// wartet: er gehört dem Konto, das gerade gegangen ist.
Fortschrittspost.shared.leeren()
phase = .signedOut
}
@@ -472,14 +475,45 @@ final class FlaroSession {
// 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
// **Und auch kein Grund, vor der Tür stehen zu bleiben.** Vorher
// endete es hier bei einem Fehlerbildschirm mit zwei Knöpfen:
// „Erneut versuchen" und „Abmelden". Beide nützen im Flugzeug
// nichts, und das Heruntergeladene lag unerreichbar dahinter.
//
// Der Token liegt im Schlüsselbund, das Konto in den
// Voreinstellungen — beides reicht, um die App zu betreten.
// Jede einzelne Anfrage scheitert dann weiterhin, und jeder
// Bildschirm sagt das für sich.
await adoptFlaro(client: FlaroClient(
baseURL: baseURL, identity: flaroIdentity, token: token
), account: gemerktesFlaroKonto(baseURL: baseURL))
if case .failed = phase {
// Nicht einmal eine Mediathekenliste gemerkt — dann ist die
// App leer, und der Fehlerbildschirm ist die ehrlichere
// Antwort.
phase = .failed(message: ErrorText.describe(error))
activeKind = .flaro
}
return true
}
}
/// Das Konto aus den Voreinstellungen, ohne den Server zu fragen.
///
/// Alles darin wurde beim letzten erfolgreichen Anmelden geschrieben.
/// Es ist vielleicht veraltet — ein umbenannter Server, ein geänderter
/// Anzeigename —, aber es ist richtiger als gar keins.
private func gemerktesFlaroKonto(baseURL: URL) -> FlaroAccount {
FlaroAccount(
baseURL: baseURL,
serverName: defaults.string(forKey: Key.flaroServerName)
?? baseURL.host() ?? "Flaro",
userID: defaults.string(forKey: Key.flaroUserID) ?? "",
userName: defaults.string(forKey: Key.flaroUserName) ?? "",
isAdministrator: defaults.bool(forKey: Key.flaroIsAdministrator)
)
}
private func rememberFlaro(account: FlaroAccount, token: String?) {
defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind)
defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress)
+4 -1
View File
@@ -101,7 +101,10 @@ enum HeroArtwork {
? [item.posterArtworkPath, item.thumb, item.parentThumb, item.seriesThumb, item.art]
// Quer: das eigene Hintergrundbild, dann das der Staffel, dann
// das der Serie — und erst danach das Vorschaubild der Folge.
: [item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
// **Das Aufmacherbild zuerst.** Es ist das, was für genau diese
// Fläche ausgesucht wurde; der Hintergrund ist die Vorgabe
// dahinter.
: [item.heroArt, item.art, item.parentThumb, item.seriesArt, item.seriesThumb, item.thumb]
var gesehen = Set<String>()
return pfade
+173
View File
@@ -0,0 +1,173 @@
import FlaroDownloads
import Foundation
import MediaKit
/// Was auf dem Gerät liegt, als gewöhnliche Titel.
///
/// **Damit ein Download aussieht wie alles andere.** Bisher waren Downloads
/// eine eigene Welt: eine eigene Liste, eine eigene Zeile, ein eigener
/// Bereich. Wer etwas ansehen wollte, musste erst wissen, dass er dorthin
/// wechseln muss. Als `MediaItem` fügt sich ein Download in jede Reihe und
/// jedes Raster ein, das es ohnehin gibt — Kachel, Fortschrittsbalken,
/// Kontextmenü inbegriffen.
///
/// **Und es ist die Grundlage fürs Offline-Arbeiten.** Ohne Server gibt es
/// keine andere Quelle für einen Titel; alles, was dann noch dasteht, kommt
/// von hier.
@MainActor
enum Heruntergeladenes {
/// Die fertigen Downloads, neueste zuerst.
///
/// **Nur fertige.** Ein halb geladener Film ist nichts zum Ansehen; er
/// steht im Downloads-Bereich mit seinem Balken, und dort gehört er hin.
static func fertige(in coordinator: DownloadCoordinator) -> [DownloadItem] {
coordinator.items
.filter(\.isComplete)
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
}
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
///
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
/// Seite der Serie.
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
var ergebnis: [MediaItem] = []
var gesehen = Set<String>()
for eintrag in fertige(in: coordinator) {
guard let serie = eintrag.seriesKey else {
ergebnis.append(titel(eintrag, in: coordinator))
continue
}
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
// fertige Folge bestimmt, wo die Serie steht.
guard gesehen.insert(serie).inserted else { continue }
ergebnis.append(self.serie(serie, in: coordinator))
}
return ergebnis
}
/// Eine Serie, von der Folgen auf dem Gerät liegen.
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
return MediaItem(
id: schluessel,
kind: .show,
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
// noch besser als eine graue Fläche.
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
?? folgen.lazy
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
.first
)
}
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
/// Kachel.
///
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
/// sonst unter einer Kachel steht.
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
guard titel.kind == .show else { return nil }
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
guard anzahl > 0 else { return nil }
return String(localized: "downloads.episodeCount \(anzahl)")
}
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
///
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
/// hier die ganze Liste, die es gibt.
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
folgenEintraege(derSerie: schluessel, in: coordinator)
.sorted {
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
}
.map { titel($0, in: coordinator) }
}
private static func folgenEintraege(
derSerie schluessel: String,
in coordinator: DownloadCoordinator
) -> [DownloadItem] {
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
}
/// Ein Download als Titel.
///
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
/// ein Pfad, den erst ein Server zu einer Adresse macht; hier steht
/// stattdessen die fertige lokale Adresse. Ansichten, die `rawImageURL`
/// darauf anwenden, müssen das wissen — siehe `bildAdresse`.
static func titel(_ eintrag: DownloadItem, in coordinator: DownloadCoordinator) -> MediaItem {
MediaItem(
id: eintrag.ratingKey,
kind: eintrag.seriesKey == nil ? .movie : .episode,
title: eintrag.title,
duration: eintrag.durationMilliseconds,
thumb: coordinator.artworkURL(forRatingKey: eintrag.ratingKey)?.absoluteString,
index: eintrag.episodeIndex,
parentIndex: eintrag.seasonIndex,
parentTitle: eintrag.seriesTitle,
seriesTitle: eintrag.seriesTitle,
seriesID: eintrag.seriesKey,
versions: [fassung(eintrag)]
)
}
/// Die Datei auf dem Gerät als Fassung.
///
/// **Ohne sie bleibt der Abspielknopf grau.** Die Detailseite schaltet
/// ihn an `currentMedia?.files.first` fest, und das ist richtig: ein
/// Titel ohne Datei lässt sich nicht abspielen. Offline *gibt* es die
/// Datei aber — sie liegt nur woanders. Was der Server beim Herunterladen
/// über sie gesagt hat, steht in `offlineMediaData`.
private static func fassung(_ eintrag: DownloadItem) -> MediaVersion {
let angaben = eintrag.offlineMediaData.flatMap {
try? JSONDecoder().decode(OfflineMedia.self, from: $0)
}
return MediaVersion(
id: eintrag.ratingKey,
container: angaben?.container,
videoCodec: angaben?.videoCodec,
audioCodec: angaben?.audioCodec,
videoResolution: angaben?.videoResolution,
bitrate: angaben?.bitrate,
duration: eintrag.durationMilliseconds,
files: [MediaFile(
id: eintrag.ratingKey,
itemID: eintrag.ratingKey,
container: angaben?.container,
size: eintrag.totalBytes > 0 ? eintrag.totalBytes : nil,
duration: eintrag.durationMilliseconds
)]
)
}
/// Die Adresse eines Bildes, das schon eine ist.
///
/// **Ein Umweg, der nötig ist.** Die Kacheln bauen ihre Bildadresse über
/// den Server (`rawImageURL`), weil `thumb` dort ein Pfad ist. Bei einem
/// Download steht darin bereits eine `file:`-Adresse — durch denselben
/// Bau geschickt käme Unsinn heraus.
static func bildAdresse(_ titel: MediaItem) -> URL? {
guard let roh = titel.thumb, roh.hasPrefix("file:") else { return nil }
return URL(string: roh)
}
/// Ob dieser Titel auf dem Gerät liegt.
///
/// **Eine Serie gilt als da, wenn Folgen von ihr da sind.** Die Serie
/// selbst wird nie heruntergeladen — ihre Folgen schon, und wer die
/// Kachel sieht, will wissen, ob er etwas davon ansehen kann.
static func liegtBereit(_ titelID: String, in coordinator: DownloadCoordinator) -> Bool {
coordinator.items.contains {
$0.isComplete && ($0.ratingKey == titelID || $0.seriesKey == titelID)
}
}
}
+53 -3
View File
@@ -1,3 +1,4 @@
import Foundation
import MediaKit
import Observation
@@ -19,6 +20,14 @@ import Observation
final class HomeFeed {
private(set) var state: LoadState<[MediaRowGroup]> = .loading
/// Ob das Gezeigte vom letzten Mal stammt, weil der Server nicht
/// antwortet.
///
/// Die Startseite ist dann **nicht** falsch, nur alt — und das muss
/// dranstehen. Ein Stand von gestern, der sich als heute ausgibt, ist
/// schlimmer als gar keiner.
private(set) var ausDemSpeicher = false
/// Holt die Reihen vom Server.
///
/// Der Server kommt als Argument, nicht als gespeicherter Wert — dasselbe
@@ -29,6 +38,24 @@ final class HomeFeed {
state = await LoadState.load(keepingPrevious: state) {
try await backend.homeRows()
}
switch state {
case .loaded(let reihen):
ausDemSpeicher = false
await LibraryCache.shared.storeHomeRows(reihen, server: backend.identity)
case .failed:
// **Der Abzug erst hier, nicht vorweg.** Ihn sofort zu zeigen
// hiesse, bei jedem Start eine alte Startseite aufzublitzen, die
// eine Sekunde später ersetzt wird. Er ist ein Rettungsanker,
// kein Beschleuniger.
if let gemerkt = await LibraryCache.shared.homeRows(server: backend.identity),
!gemerkt.isEmpty {
state = .loaded(gemerkt)
ausDemSpeicher = true
}
case .loading:
break
}
#if os(tvOS)
// Das Regal über der App im Menü. Es wird gezeichnet, wenn die App
// **nicht** läuft — also legen wir hier ab, was dort stehen soll.
@@ -38,10 +65,33 @@ final class HomeFeed {
#endif
}
/// Die Kennung der Downloadreihe.
///
/// Fest und nicht vom Server: nur so lässt sie sich wie jede andere
/// Reihe verschieben oder ausblenden.
static let downloadsRow = "flaro.downloads"
/// Die Reihen in der eingestellten Form.
func rows(for layout: HomeLayoutSettings) -> [MediaRowGroup] {
guard case .loaded(let alle) = state else { return [] }
return layout.apply(to: prepared(alle, layout: layout))
///
/// `downloaded` kommt von aussen, weil `HomeFeed` nichts von Downloads
/// weiss und auch nichts davon wissen muss — es ist eine Reihe wie jede
/// andere, nur mit einer anderen Quelle.
/// **Auch ohne geladene Reihen**: scheitert der Server und liegt nichts
/// im Speicher, bleibt immer noch, was auf dem Gerät liegt.
func rows(for layout: HomeLayoutSettings, downloaded: [MediaItem] = []) -> [MediaRowGroup] {
var reihen: [MediaRowGroup] = []
if case .loaded(let alle) = state { reihen = prepared(alle, layout: layout) }
if !downloaded.isEmpty {
reihen.insert(
MediaRowGroup(
id: Self.downloadsRow,
title: String(localized: "home.downloaded"),
items: downloaded
),
at: 0
)
}
return layout.apply(to: reihen)
}
// MARK: - Ordnen
+39
View File
@@ -34,6 +34,7 @@ final class LanguagePreferences {
static let avoidsAudioDescription = "flaro.languages.avoidsAudioDescription"
static let perLibrary = "flaro.languages.library"
static let perSeries = "flaro.languages.series"
static let perSeriesSubtitles = "flaro.languages.seriesSubtitles"
static let rememberedTracks = "flaro.languages.seriesTrack"
}
@@ -98,6 +99,14 @@ final class LanguagePreferences {
/// deutsch ausgezeichnete Spur die mit Audiodeskription und die als
/// englisch ausgezeichnete die eigentliche deutsche. Eine Sprache
/// abzulegen hiesse dort, den Fehler festzuschreiben.
/// Die Untertitelsprache je Titel — Serie oder Film.
///
/// **Ein Topf für beide.** Die Kennung eines Films kollidiert mit keiner
/// Serie; zwei Wörterbücher für dieselbe Frage liefen auseinander.
private var perSeriesSubtitles: [String: String] {
didSet { defaults.set(perSeriesSubtitles, forKey: Key.perSeriesSubtitles) }
}
private var rememberedTracks: [String: AudioTrackChooser.TrackSignature] {
didSet {
guard let daten = try? JSONEncoder().encode(rememberedTracks) else { return }
@@ -117,6 +126,8 @@ final class LanguagePreferences {
(defaults.object(forKey: Key.avoidsAudioDescription) as? Bool) ?? true
self.perLibrary = defaults.dictionary(forKey: Key.perLibrary) as? [String: String] ?? [:]
self.perSeries = defaults.dictionary(forKey: Key.perSeries) as? [String: String] ?? [:]
self.perSeriesSubtitles =
defaults.dictionary(forKey: Key.perSeriesSubtitles) as? [String: String] ?? [:]
self.rememberedTracks = (defaults.data(forKey: Key.rememberedTracks)).flatMap {
try? JSONDecoder().decode([String: AudioTrackChooser.TrackSignature].self, from: $0)
} ?? [:]
@@ -136,6 +147,34 @@ final class LanguagePreferences {
perSeries[id] = code
}
/// Was „keine Untertitel" im Speicher heisst.
///
/// **Abgewählt ist eine Aussage, kein Fehlen einer.** Wer sie für diese
/// Serie abgeschaltet hat, will sie auch in Folge 7 nicht; ein fehlender
/// Eintrag hiesse dagegen „nie etwas gesagt", und dann darf eine
/// erzwungene Spur durchaus erscheinen.
static let noSubtitles = "keine"
func subtitleLanguage(forSeries id: String) -> String? { perSeriesSubtitles[id] }
func setSubtitleLanguage(_ code: String?, forSeries id: String) {
perSeriesSubtitles[id] = code
}
/// Welche Untertitelsprache für diesen Titel gilt — `nil` heisst: keine.
///
/// **Die Ebene schlägt das Allgemeine.** Dieselbe Rangfolge wie beim Ton,
/// nur ohne Mediathek: eine Untertitelsprache je Mediathek hat noch
/// niemand verlangt, und eine Einstellung, die niemand sucht, ist eine
/// Stelle mehr, an der etwas auseinanderlaufen kann.
func subtitleWish(titleID: String?) -> String? {
if let eigen = titleID.flatMap({ perSeriesSubtitles[$0] }) {
return eigen == Self.noSubtitles ? nil : eigen
}
return subtitleLanguage.isEmpty ? nil : subtitleLanguage
}
func rememberedTrack(forSeries id: String) -> AudioTrackChooser.TrackSignature? {
rememberedTracks[id]
}
+10
View File
@@ -32,6 +32,7 @@ final class PlaybackSettings {
static let cellularQuality = "flaro.playback.quality.cellular"
static let autoSkipIntro = "flaro.playback.autoSkipIntro"
static let autoSkipCredits = "flaro.playback.autoSkipCredits"
static let skipExtraScenes = "flaro.playback.skipExtraScenes"
static let autoPlayNext = "flaro.playback.autoPlayNext"
static let locksLandscape = "flaro.playback.locksLandscape"
static let rewindSeconds = "flaro.playback.rewindSeconds"
@@ -58,6 +59,14 @@ final class PlaybackSettings {
var cellularQuality: Quality { didSet { defaults.set(cellularQuality.rawValue, forKey: Key.cellularQuality) } }
var autoSkipIntro: Bool { didSet { defaults.set(autoSkipIntro, forKey: Key.autoSkipIntro) } }
var autoSkipCredits: Bool { didSet { defaults.set(autoSkipCredits, forKey: Key.autoSkipCredits) } }
/// Ob „Abspann überspringen" auch über Post-Credit-Szenen hinweggeht.
///
/// **Eine Gerätesache, keine Servereinstellung.** Ob jemand die Szene
/// sehen will, entscheidet er am Gerät, auf dem er sitzt — wie bei
/// `autoSkipIntro`. Vorgabe aus: wer den Knopf drückt, landet bei der
/// Szene und kann sie wegdrücken, wenn er mag.
var skipExtraScenes: Bool { didSet { defaults.set(skipExtraScenes, forKey: Key.skipExtraScenes) } }
var autoPlayNext: Bool { didSet { defaults.set(autoPlayNext, forKey: Key.autoPlayNext) } }
/// ISO-Sprachcode, etwa `deu`. Leer heißt: nehmen, was der Server vorgibt.
@@ -117,6 +126,7 @@ final class PlaybackSettings {
self.autoSkipIntro = defaults.object(forKey: Key.autoSkipIntro) as? Bool ?? false
self.autoSkipCredits = defaults.object(forKey: Key.autoSkipCredits) as? Bool ?? false
self.skipExtraScenes = defaults.object(forKey: Key.skipExtraScenes) as? Bool ?? false
self.autoPlayNext = defaults.object(forKey: Key.autoPlayNext) as? Bool ?? true
self.locksLandscape = defaults.object(forKey: Key.locksLandscape) as? Bool ?? true
+41 -22
View File
@@ -17,14 +17,18 @@ import MediaKit
/// und wäre für VoiceOver gar nicht mehr da.
struct QuickPlayTile<Card: View>: View {
let item: MediaItem
/// Ob ein Tipp auf die Detailseite führt, statt abzuspielen.
/// Ob ein Tipp sofort abspielt, statt auf die Detailseite zu führen.
///
/// **Für die Suche.** Dort weiß man gerade *nicht*, was man sehen will —
/// sonst hätte man nicht gesucht. Ein Tipp, der sofort abspielt, ist auf
/// der Startseite richtig (die Kachel stand schon da, man kennt sie) und
/// in einer Trefferliste falsch: der erste Treffer ist eine Vermutung des
/// Servers, keine Entscheidung des Nutzers.
var opensDetails: Bool = false
/// **Nur beim Weiterschauen.** Dort ist die Absicht eindeutig: der Titel
/// steht in dieser Reihe, *weil* man ihn angefangen hat.
///
/// **Überall sonst führt der Tipp auf die Seite.** Vorher war es
/// umgekehrt, mit dem Argument „wer eine Kachel antippt, weiss meistens
/// schon, was er sehen will". Im Gebrauch stimmte das nicht: wer nur
/// nachsehen will, wie viele Staffeln eine Serie hat, bekam den Film.
/// Ein Umweg von einem Schritt ist ärgerlich; eine Wiedergabe, die man
/// nicht wollte, ist schlimmer.
var startsPlayback: Bool = false
@ViewBuilder let card: () -> Card
@Environment(FlaroSession.self) private var session
@@ -69,8 +73,9 @@ struct QuickPlayTile<Card: View>: View {
///
/// Drei Gründe, derselbe Ausgang: am Mac ist der Klick seit jeher der Weg
/// auf die Seite (zum Starten gibt es den Knopf beim Überfahren), eine
/// Sammlung hat nichts abzuspielen, und in der Suche ist es gewollt.
private var tapOpensDetails: Bool { isMac || opensDetails || !isPlayable }
/// Sammlung hat nichts abzuspielen, und überall ausser beim
/// Weiterschauen ist die Seite das Ziel.
private var tapOpensDetails: Bool { isMac || !startsPlayback || !isPlayable }
var body: some View {
kachel
@@ -84,6 +89,11 @@ struct QuickPlayTile<Card: View>: View {
.overlay(alignment: .topTrailing) {
if isPlayable, isMac { playButton }
}
// **Links, weil rechts schon etwas sitzt** — am Mac der Abspielknopf
// beim Überfahren, unten der Fortschrittsbalken.
.overlay(alignment: .topLeading) {
if Heruntergeladenes.liegtBereit(item.id, in: downloads) { downloadMarke }
}
.onContinuousHover { phase in
// Zur Laufzeit, nicht beim Übersetzen: „für iPad angepasst" ist
// der iOS-Bau und läuft trotzdem auf dem Mac.
@@ -99,11 +109,9 @@ struct QuickPlayTile<Card: View>: View {
// Kachel steht.
.contextMenu {
if isPlayable {
// **Am Telefon und am iPad führt der Weg zur Detailseite
// hierher.** Antippen spielt dort ab; wer stattdessen
// nachsehen will, was das überhaupt ist, drückt lang. Am Mac
// ist es umgekehrt: dort öffnet der Klick die Seite, und zum
// Starten gibt es den Knopf beim Überfahren.
// **Nur dort, wo der Tipp abspielt.** Sonst führt er
// schon auf die Seite, und ein zweiter Weg dorthin im Menü
// wäre ein Eintrag, der nichts Neues anbietet.
if !tapOpensDetails {
Button("hero.details", systemImage: "info.circle") { push(route) }
Divider()
@@ -199,14 +207,11 @@ struct QuickPlayTile<Card: View>: View {
/// Die Kachel selbst — und was ein Antippen bedeutet.
///
/// **Am Telefon und am iPad spielt es ab.** Wer eine Kachel antippt, weiss
/// meistens schon, was er sehen will; der Umweg über die Detailseite
/// kostete zwei Schritte für eine Absicht. Die Seite ist weiter da — über
/// langes Drücken, wo auch „gesehen" und „entfernen" stehen.
///
/// **Am Mac bleibt der Klick die Detailseite.** Dort gibt es den
/// Abspielknopf beim Überfahren, und langes Drücken ist keine übliche
/// Geste — ein Rechtsklick ist der Weg ins Menü, nicht in den Film.
/// **Der Tipp führt auf die Detailseite**, am Telefon wie am iPad wie am
/// Mac. Nur in „Weiterschauen" spielt er ab: dort steht der Titel,
/// *weil* man ihn angefangen hat. Abspielen ist sonst einen Schritt
/// weiter — über den Knopf auf der Seite, über langes Drücken, oder am
/// Mac über den Knopf beim Überfahren.
///
/// Ein `NavigationLink` schiebt selbst; ein Knopf braucht dafür den Weg
/// aus der Umgebung. Deshalb zwei Fassungen und nicht eine mit `if` im
@@ -247,6 +252,20 @@ struct QuickPlayTile<Card: View>: View {
.accessibilityHidden(true)
}
/// Das Zeichen für „liegt auf diesem Gerät".
///
/// **Damit man es sieht, ohne danach zu suchen.** Bisher stand ein
/// Download nur im Downloads-Bereich; in der Mediathek sah eine
/// heruntergeladene Folge aus wie jede andere, und wer wissen wollte,
/// was im Flugzeug läuft, musste zwei Listen vergleichen.
private var downloadMarke: some View {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white, Color.black.opacity(0.55))
.padding(FlaroSpacing.snug)
.accessibilityLabel(Text("downloads.available"))
}
/// Ob es etwas herunterzuladen gibt — und ob es nicht schon da ist.
///
/// Sammlungen, Serien und Staffeln fallen heraus: dort lädt man nicht
+37 -1
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Die Detailseite eines Titels.
@@ -11,6 +12,7 @@ struct MediaDetailView: View {
let route: MediaRoute
@Environment(FlaroSession.self) private var session
@Environment(DownloadCoordinator.self) private var downloads
@State private var state: LoadState<MediaItem> = .loading
var body: some View {
@@ -50,10 +52,44 @@ struct MediaDetailView: View {
.task(id: route.ratingKey) { await load() }
}
/// Holt den Titel — vom Server, und sonst vom Gerät.
///
/// **Sonst führte ein Download ins Leere.** Ausserhalb von
/// „Fortsetzen" öffnet ein Tipp auf eine Kachel diese Seite; ohne
/// Server endete sie bei einem Fehlerbild, und die Datei, die daneben
/// auf der Platte lag, war von hier aus nicht mehr zu erreichen.
///
/// Der Server bleibt massgeblich: nur wenn er nicht antwortet, tritt
/// das Wenige ein, was ein Download über sich selbst weiss.
private func load() async {
guard let server = session.backend else { return }
guard let server = session.backend else {
state = vomGeraet.map { .loaded($0) }
?? .failed(message: String(localized: "error.serverUnreachable"))
return
}
state = await LoadState.load {
try await server.item(id: route.ratingKey)
}
if case .failed(let message) = state {
state = vomGeraet.map { .loaded($0) } ?? .failed(message: message)
}
}
/// Was über diesen Titel auf dem Gerät liegt.
///
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
/// Nichts.
private var vomGeraet: MediaItem? {
if let eintrag = downloads.items.first(
where: { $0.isComplete && $0.ratingKey == route.ratingKey }
) {
return Heruntergeladenes.titel(eintrag, in: downloads)
}
guard downloads.items.contains(
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
) else { return nil }
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
}
}
@@ -1,6 +1,7 @@
import SwiftUI
import FlaroDesign
import MediaKit
import FlaroPlayback
/// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder
/// einzelne Folge.
@@ -67,6 +68,41 @@ struct PlayableDetailView: View {
return item.versions[selectedVersion]
}
/// Welche Sprachen in dieser Datei liegen.
///
/// **Kein Serverweg nötig.** Bei einem Film ist die Datei der Titel; ihre
/// Spuren stehen in der Antwort, die diese Seite ohnehin geholt hat. Nur
/// bei einer Serie muss gerechnet werden, und dafür gibt es den
/// Endpunkt.
private var sprachen: AvailableLanguages? {
guard let part = currentMedia?.files.first else { return nil }
return AvailableLanguages(
audio: optionen(part.audioStreams),
subtitles: optionen(part.subtitleStreams),
partial: 0)
}
/// Aus Spuren werden Sprachen — jede einmal, auch bei zwei Tonspuren.
private func optionen(_ spuren: [MediaStream]) -> [LanguageOption] {
var gesehen = Set<String>()
var liste: [LanguageOption] = []
for spur in spuren {
guard let roh = spur.languageCode ?? spur.language,
let code = LanguageCode.normalized(roh),
gesehen.insert(code).inserted
else { continue }
let name = Locale.current.localizedString(forLanguageCode: code)?.capitalized
?? spur.language
?? code.uppercased()
liste.append(LanguageOption(code: code, name: name))
}
return liste
}
private var header: some View {
HeroBanner(
title: item.rowTitle,
@@ -133,6 +169,39 @@ struct PlayableDetailView: View {
}
DownloadButton(item: item, mediaIndex: selectedVersion)
// **Die Sprache vor dem Start, nicht erst mitten im Film.**
//
// Im Spieler umzustellen heisst, den Strom neu aufzubauen —
// und bei einem Film, den man immer im Original sehen will,
// jedes Mal von Neuem. Welche Spuren es gibt, steht hier
// ohnehin schon, also gehört die Wahl neben den Knopf, der
// sie anwendet.
//
// **Nur bei einem Film.** Bei einer Folge gilt, was für die
// Serie eingestellt ist; dieselbe Frage zwanzigmal zu
// stellen wäre keine Wahl, sondern eine Prüfung der Geduld.
if item.kind != .episode, let angebot = sprachen {
if !angebot.audio.isEmpty {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.audioShort",
kind: .audio,
form: .knopf,
available: angebot.audio
)
}
if !angebot.subtitles.isEmpty {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.subtitlesShort",
kind: .subtitles,
form: .knopf,
available: angebot.subtitles
)
}
}
}
}
}
+125 -11
View File
@@ -33,6 +33,17 @@ struct SeriesDetailView: View {
@State private var isWorking = false
@State private var summary: String?
/// Welche Staffel die Liste zeigt — `nil` heisst: die erste.
///
/// **Weil der Server die Folgen am Stück liefert.** Eine Serie mit zwei
/// Staffeln ergab eine Liste, in der nach Folge 8 wieder eine Folge 1
/// kam, ohne dass irgendwo stünde, dass dazwischen eine Staffel endet.
@State private var gewaehlteStaffel: Int?
/// Welche Sprachen diese Serie durchgehend führt — `nil`, solange es
/// niemand weiss.
@State private var sprachen: AvailableLanguages?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: FlaroSpacing.section) {
@@ -54,11 +65,35 @@ struct SeriesDetailView: View {
// dieselbe Einstellung zu haben, von denen einer nie gelesen
// wird.
if item.kind == .show {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.forThisSeries"
)
VStack(alignment: .leading, spacing: FlaroSpacing.snug) {
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.forThisSeries",
kind: .audio,
available: sprachen?.audio
)
AudioLanguagePicker(
scope: .series(item.id),
title: "languages.subtitlesForThisSeries",
kind: .subtitles,
available: sprachen?.subtitles
)
// **Was fehlt, wird gesagt.** Eine gekürzte Liste
// sieht sonst aus wie die ganze Wahrheit, und wer
// eine Sprache vermisst, die er in Staffel 1 gesehen
// hat, hält es für einen Fehler.
if let partial = sprachen?.partial, partial > 0 {
Text("languages.partial \(partial)")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
}
}
.padding(.horizontal, FlaroSpacing.screenMargin)
.task(id: item.id) {
sprachen = await session.backend?.availableLanguages(inSeries: item.id)
}
}
childList
@@ -172,23 +207,33 @@ struct SeriesDetailView: View {
.padding(.horizontal, FlaroSpacing.screenMargin)
case .loaded(let entries):
let nummern = staffelnummern(in: entries)
let sichtbar = sichtbareFolgen(in: entries, staffeln: nummern)
VStack(alignment: .leading, spacing: FlaroSpacing.regular) {
// **Der Auswählen-Knopf steht bei dem, was er auswählt.**
// Oben in der Werkzeugleiste wäre er weiter weg von der Liste
// als von allem anderen auf dieser Seite.
HStack {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
if nummern.count > 1 {
staffelwahl(nummern)
} else {
Text(item.kind == .show ? "detail.seasons" : "detail.episodes")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
}
Spacer()
if entries.contains(where: { $0.kind == .episode }) {
selectionControls(for: entries)
if sichtbar.contains(where: { $0.kind == .episode }) {
// **Nur die gezeigte Staffel.** „Alle auswählen" soll
// das meinen, was dasteht — sonst lädt ein Griff
// sechzehn Folgen herunter statt acht.
selectionControls(for: sichtbar)
}
}
.padding(.horizontal, FlaroSpacing.screenMargin)
LazyVStack(spacing: 0) {
ForEach(entries) { entry in
ForEach(sichtbar) { entry in
row(for: entry)
Divider().overlay(Color.flaroSeparator)
}
@@ -197,6 +242,55 @@ struct SeriesDetailView: View {
}
}
/// Welche Staffeln in dieser Liste vorkommen.
///
/// Leer, wenn die Einträge selbst Staffeln sind — dann gibt es nichts zu
/// wählen, die Liste **ist** die Wahl.
private func staffelnummern(in entries: [MediaItem]) -> [Int] {
Set(entries.compactMap { $0.kind == .episode ? $0.parentIndex : nil }).sorted()
}
private func sichtbareFolgen(in entries: [MediaItem], staffeln: [Int]) -> [MediaItem] {
guard staffeln.count > 1 else { return entries }
let gewaehlt = gewaehlteStaffel ?? staffeln[0]
return entries.filter { $0.kind != .episode || $0.parentIndex == gewaehlt }
}
/// Die Staffel als Menü — an der Stelle, an der sonst „Folgen" steht.
///
/// **Kein `Picker`.** Diese Seite ist eine `ScrollView`, kein `Form`;
/// dort hat ein `Picker` kein Aussehen, auf das man sich verlassen kann.
/// Dieselbe Begründung wie bei `AudioLanguagePicker`.
private func staffelwahl(_ nummern: [Int]) -> some View {
let gewaehlt = gewaehlteStaffel ?? nummern[0]
return Menu {
ForEach(nummern, id: \.self) { nummer in
Button {
gewaehlteStaffel = nummer
// Eine Auswahl aus der vorigen Staffel meint hier nichts
// mehr — stehen zu lassen, was man nicht mehr sieht,
// lädt unsichtbare Folgen herunter.
selection.removeAll()
} label: {
Label(
String(localized: "season.short \(nummer)"),
systemImage: nummer == gewaehlt ? "checkmark" : ""
)
}
}
} label: {
HStack(spacing: FlaroSpacing.tight) {
Text("season.short \(gewaehlt)")
.font(.flaroRowHeader)
.foregroundStyle(Color.flaroTextPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
.foregroundStyle(accent)
}
}
}
/// Eine Zeile — als Weg zur Folge oder als Kreuz zum Ankreuzen.
@ViewBuilder
private func row(for entry: MediaItem) -> some View {
@@ -376,11 +470,31 @@ struct SeriesDetailView: View {
return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
}
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
///
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
private func loadChildren() async {
guard let server = session.backend else { return }
guard let server = session.backend else {
children = vomGeraet.isEmpty
? .failed(message: String(localized: "error.serverUnreachable"))
: .loaded(vomGeraet)
return
}
children = await LoadState.load {
try await server.children(of: item.id)
}
if case .failed = children, !vomGeraet.isEmpty {
children = .loaded(vomGeraet)
}
}
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
private var vomGeraet: [MediaItem] {
guard item.kind == .show else { return [] }
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
}
}
+75 -7
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Die Startseite: Aufmacher oben, darunter die Reihen vom Server.
@@ -11,15 +12,26 @@ import MediaKit
struct HomeView: View {
@Environment(FlaroSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(DownloadCoordinator.self) private var downloads
@Environment(Fortschrittspost.self) private var post
@State private var feed = HomeFeed()
/// Was auf dem Gerät liegt, als Reihe ganz oben.
///
/// Wird bei jeder Änderung an den Downloads neu berechnet, weil
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
/// ohne Neuladen der Startseite da.
private var heruntergeladen: [MediaItem] {
Heruntergeladenes.reihe(in: downloads)
}
var body: some View {
Group {
switch feed.state {
case .loading:
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
case .failed(let message):
case .failed(let message) where heruntergeladen.isEmpty:
ContentUnavailableView {
Label("error.title", systemImage: "exclamationmark.triangle")
} description: {
@@ -28,8 +40,12 @@ struct HomeView: View {
Button("action.retry") { Task { await feed.load(from: session.backend) } }
}
case .loaded:
let hubs = feed.rows(for: layout)
// **Mit Downloads ist „kein Server" kein Fehlerbildschirm.**
// Was auf dem Gerät liegt, lässt sich auch ohne ihn ansehen —
// genau dafür wurde es heruntergeladen. Ein Fehlerbild davor zu
// stellen hiesse, dem Nutzer etwas wegzunehmen, das er hat.
case .failed, .loaded:
let hubs = feed.rows(for: layout, downloaded: heruntergeladen)
if hubs.isEmpty {
ContentUnavailableView {
Label("home.empty.title", systemImage: "film.stack")
@@ -59,7 +75,18 @@ struct HomeView: View {
// daneben, und genau diese Kante sah man. Auf den geschobenen
// Bildschirmen bleibt sie, dort trägt sie den Zurück-Weg.
.toolbar(.hidden, for: .navigationBar)
.task(id: session.serverName) { await feed.load(from: session.backend) }
.task(id: session.serverName) {
await feed.load(from: session.backend)
// Siehe `DownloadService.plakateNachholen`: ältere Downloads
// haben kein Bild, und hier ist die Stelle, an der man es merken
// würde.
await DownloadService(backend: session.backend, coordinator: downloads)
.plakateNachholen()
// **Der Server hat geantwortet — dann nimmt er auch Post an.**
// Der Start der App ist dafür der falsche Zeitpunkt: da ist oft
// noch gar keine Verbindung.
if !feed.ausDemSpeicher { await post.zustellen(über: session.backend) }
}
.refreshable { await feed.load(from: session.backend) }
// Was eine Kachel am Server ändert, ändert auch, was hier steht:
// eine abgehakte Folge gehört nicht mehr in „Weiterschauen".
@@ -69,13 +96,22 @@ struct HomeView: View {
private func content(hubs: [MediaRowGroup]) -> some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: FlaroSpacing.section) {
let featured = feed.heroItems(for: layout)
if !featured.isEmpty {
HeroCarousel(items: featured)
if ohneServer {
offlinehinweis
} else {
let featured = feed.heroItems(for: layout)
if !featured.isEmpty {
HeroCarousel(items: featured)
}
}
ForEach(hubs) { hub in
MediaRow(row: hub)
// **Ausgegraut, aber nicht gesperrt.** Was hier steht,
// stammt vom letzten Mal; antippen darf man es
// trotzdem — dann kommt ein Satz, der sagt warum, und
// das ist ehrlicher als ein Knopf, der nichts tut.
.opacity(ohneServer && hub.id != HomeFeed.downloadsRow ? 0.4 : 1)
}
}
.padding(.bottom, FlaroSpacing.section)
@@ -83,4 +119,36 @@ struct HomeView: View {
.scrollIndicators(.never)
.flaroRevealsTopChrome()
}
/// Ob gerade kein Server antwortet.
///
/// Zwei Wege führen hierher: gar nichts geladen, oder ein Abzug vom
/// letzten Mal. Für den Nutzer ist es dieselbe Lage.
private var ohneServer: Bool {
if case .failed = feed.state { return true }
return feed.ausDemSpeicher
}
/// Statt des Aufmachers, wenn der Server fehlt.
///
/// **An seiner Stelle, nicht darüber.** Ein grosses Bild vom letzten Mal
/// über einer Startseite, die nicht stimmt, wäre die falsche Betonung —
/// und die Downloads sollen oben stehen.
private var offlinehinweis: some View {
HStack(alignment: .firstTextBaseline, spacing: FlaroSpacing.regular) {
Image(systemName: "wifi.slash")
.foregroundStyle(Color.flaroTextMuted)
Text("home.offline")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextMuted)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("action.retry") { Task { await feed.load(from: session.backend) } }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(FlaroSpacing.regular)
.background(Color.flaroElevated, in: .rect(cornerRadius: 12))
.padding(.horizontal, FlaroSpacing.screenMargin)
}
}
+26 -7
View File
@@ -1,16 +1,21 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Eine waagerechte Reihe von Titeln.
struct MediaRow: View {
let row: MediaRowGroup
/// Wird durchgereicht — siehe `QuickPlayTile`.
var opensDetails: Bool = false
/// Ob ein Tipp sofort abspielt — siehe `QuickPlayTile`.
///
/// **Die Reihe weiss es, nicht die Kachel.** „Weiterschauen" ist eine
/// Eigenschaft der Reihe; eine Kachel kennt nur ihren Titel.
private var startsPlayback: Bool { row.isContinueWatching }
@Environment(FlaroSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout
@Environment(DownloadCoordinator.self) private var downloads
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
@@ -32,7 +37,7 @@ struct MediaRow: View {
ScrollView(.horizontal) {
LazyHStack(spacing: FlaroSpacing.regular) {
ForEach(row.items) { item in
QuickPlayTile(item: item, opensDetails: opensDetails) { card(for: item) }
QuickPlayTile(item: item, startsPlayback: startsPlayback) { card(for: item) }
}
}
.padding(.horizontal, FlaroSpacing.screenMargin)
@@ -55,8 +60,8 @@ struct MediaRow: View {
if row.isContinueWatching {
LandscapeCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
subtitle: untertitel(for: item),
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.rowArtworkPath,
width: 440,
height: 248
@@ -67,8 +72,8 @@ struct MediaRow: View {
} else {
PosterCard(
title: item.rowTitle,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(
subtitle: untertitel(for: item),
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.posterArtworkPath,
width: 300,
height: 450
@@ -78,4 +83,18 @@ struct MediaRow: View {
.frame(width: posterWidth)
}
}
/// Was unter der Kachel steht.
///
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
/// hat.
private func untertitel(for item: MediaItem) -> String? {
if row.id == HomeFeed.downloadsRow,
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
return zahl
}
return item.cardSubtitle
}
}
+15 -2
View File
@@ -5,6 +5,9 @@ import MediaKit
/// Filter und Sortierung einer Mediathek.
struct LibraryFilterSheet: View {
@Binding var query: MediaQuery
/// Steht getrennt von `query`, weil der Server ihn nicht auswerten kann —
/// siehe `LibraryView.nurHeruntergeladene`.
@Binding var nurHeruntergeladene: Bool
/// Die Mediathek selbst, nicht nur ihre Art.
///
/// Vorher stand hier die Art, und die Genres wurden über *irgendeine*
@@ -23,11 +26,18 @@ struct LibraryFilterSheet: View {
/// Sonst löste jeder Tipp im Sheet sofort eine neue Serverabfrage aus —
/// bei drei Änderungen hintereinander drei verworfene Ladevorgänge.
@State private var draft: MediaQuery
@State private var entwurfHeruntergeladene: Bool
init(query: Binding<MediaQuery>, library: MediaLibrary) {
init(
query: Binding<MediaQuery>,
nurHeruntergeladene: Binding<Bool>,
library: MediaLibrary
) {
self._query = query
self._nurHeruntergeladene = nurHeruntergeladene
self.library = library
self._draft = State(initialValue: query.wrappedValue)
self._entwurfHeruntergeladene = State(initialValue: nurHeruntergeladene.wrappedValue)
}
var body: some View {
@@ -45,6 +55,7 @@ struct LibraryFilterSheet: View {
Section("filter.filters") {
Toggle("filter.unwatchedOnly", isOn: $draft.unwatchedOnly)
Toggle("filter.downloadedOnly", isOn: $entwurfHeruntergeladene)
Picker("filter.genre", selection: $draft.genre) {
Text("filter.any").tag(String?.none)
@@ -80,8 +91,9 @@ struct LibraryFilterSheet: View {
Section {
Button("filter.reset", role: .destructive) {
draft = MediaQuery(sort: draft.sort)
entwurfHeruntergeladene = false
}
.disabled(!draft.isFiltering)
.disabled(!draft.isFiltering && !entwurfHeruntergeladene)
}
}
// Ausdrücklich, nicht nur geerbt.
@@ -102,6 +114,7 @@ struct LibraryFilterSheet: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("action.apply") {
nurHeruntergeladene = entwurfHeruntergeladene
query = draft
dismiss()
}
+60 -6
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import FlaroDownloads
import MediaKit
/// Eine Mediathek zum Durchblättern.
@@ -10,6 +11,7 @@ struct LibraryView: View {
/// Nur für die Kachelgröße. „layout“ heißt hier schon die Wahl
/// zwischen Raster und Liste.
@Environment(HomeLayoutSettings.self) private var sizing
@Environment(DownloadCoordinator.self) private var downloads
@AppStorage("flaro.library.layout") private var layout: LibraryLayout = .grid
@Environment(\.horizontalSizeClass) private var sizeClass
@@ -25,6 +27,14 @@ struct LibraryView: View {
@State private var loadMoreFailed = false
@State private var isLoadingMore = false
@State private var showsFilters = false
/// Ob nur gezeigt wird, was auf diesem Gerät liegt.
///
/// **Kein Teil von `MediaQuery`**, und das ist der Kern der Sache: die
/// Abfrage wertet der Server aus, und der weiss nicht, was auf diesem
/// Gerät liegt. Steht der Filter an, wird gar nicht erst gefragt — die
/// Liste kommt aus den Downloads. Das ist genau, vollständig, und
/// funktioniert ohne Verbindung.
@State private var nurHeruntergeladene = false
var body: some View {
Group {
@@ -48,8 +58,9 @@ struct LibraryView: View {
} description: {
Text("library.empty.description")
} actions: {
if query.isFiltering {
if query.isFiltering || nurHeruntergeladene {
Button("library.clearFilters") {
nurHeruntergeladene = false
query = MediaQuery(sort: query.sort)
}
}
@@ -64,7 +75,11 @@ struct LibraryView: View {
.flaroInlineTitle()
.toolbar { toolbar }
.sheet(isPresented: $showsFilters) {
LibraryFilterSheet(query: $query, library: library)
LibraryFilterSheet(
query: $query,
nurHeruntergeladene: $nurHeruntergeladene,
library: library
)
}
.task(id: library.id) {
// Beim Wechsel auf eine andere Mediathek zuerst leeren: sonst hält
@@ -78,6 +93,11 @@ struct LibraryView: View {
// Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei
// verschiedenen Abfragen vermischen.
.onChange(of: query) { _, _ in Task { await reload() } }
.onChange(of: nurHeruntergeladene) { _, _ in
items = []
page = nil
Task { await reload() }
}
.refreshable { await reload() }
// Siehe `HomeView`: was eine Kachel ändert, muss im Raster ankommen.
.environment(\.flaroItemChanged, { await reload() })
@@ -104,8 +124,12 @@ struct LibraryView: View {
QuickPlayTile(item: item) {
PosterCard(
title: item.title,
subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
subtitle: nurHeruntergeladene
? Heruntergeladenes.untertitel(für: item, in: downloads)
?? item.cardSubtitle
: item.cardSubtitle,
artworkURL: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.resumeProgress
)
}
@@ -232,7 +256,7 @@ struct LibraryView: View {
Button {
showsFilters = true
} label: {
Image(systemName: query.isFiltering
Image(systemName: query.isFiltering || nurHeruntergeladene
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
@@ -276,6 +300,16 @@ struct LibraryView: View {
/// stehen. Eine Liste von gestern ist im Zug brauchbarer als ein
/// Fehlerbildschirm.
private func reload() async {
// **Vor dem Server, nicht danach.** Was auf dem Gerät liegt, weiss
// nur das Gerät — und es weiss es auch im Flugmodus.
if nurHeruntergeladene {
items = geraeteliste
page = MediaPage(items: items, totalCount: items.count)
loadMoreFailed = false
state = .loaded(())
return
}
guard let server = session.backend else { return }
let schlüssel = query.cacheKey
let kennung = server.identity
@@ -319,6 +353,25 @@ struct LibraryView: View {
}
}
/// Die Downloads, die in diese Mediathek gehören.
///
/// **Zugeordnet über die Art der Mediathek**, weil ein Download seine
/// Mediathek nicht kennt: er führt einen Serienschlüssel oder keinen,
/// und mehr braucht es nicht. Eine Folge gehört in eine Serien-,
/// ein Film in eine Filmmediathek.
///
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
private var geraeteliste: [MediaItem] {
Heruntergeladenes.reihe(in: downloads).filter { titel in
switch library.kind {
case .movie: titel.kind != .show
case .show: titel.kind == .show
default: true
}
}
}
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
private func loadMoreIfNeeded(reaching item: MediaItem) async {
guard
@@ -385,7 +438,8 @@ private struct LibraryListRow: View {
var body: some View {
HStack(spacing: FlaroSpacing.regular) {
FlaroArtwork(
url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
url: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 120, height: 180),
aspectRatio: PosterCard.aspectRatio
)
.frame(width: 44)
+2
View File
@@ -19,6 +19,7 @@ struct RootView: View {
@Environment(SubtitleAppearance.self) private var subtitleAppearance
@Environment(NetworkConditions.self) private var network
@Environment(DownloadCoordinator.self) private var downloads
@Environment(Fortschrittspost.self) private var post
@Environment(PlaybackDiagnostics.self) private var diagnostics
/// Der laufende Player — hier gehalten, weil diese Ansicht immer da ist.
@@ -229,6 +230,7 @@ struct RootView: View {
backend: session.backend,
liveTV: session.liveTV,
downloads: downloads,
post: post,
diagnostics: diagnostics,
policy: settings.policy(for: network),
settings: settings,
+94
View File
@@ -1,5 +1,6 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// Der Regler unter dem Film — mit dem, was schon geladen ist.
///
@@ -34,6 +35,11 @@ struct PlaybackScrubber: View {
/// Bei jeder Berührung — damit die Bedienung stehen bleibt.
let onActivity: () -> Void
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
var vorschau: Vorschauplan?
/// Woher ein Blatt kommt.
var blatt: (Int) -> URL? = { _ in nil }
@Environment(\.flaroAccent) private var accent
private static let trackHeight: CGFloat = 5
@@ -71,6 +77,22 @@ struct PlaybackScrubber: View {
.offset(x: gespielt - Self.thumbSize / 2)
.animation(.easeOut(duration: 0.15), value: scrubbing == nil)
}
// **Die Vorschau steht über dem Griff, nicht darunter.** Unter
// dem Finger verdeckte sie genau die Stelle, die man sucht.
.overlay(alignment: .bottomLeading) {
if let plan = vorschau,
let gezogen = scrubbing,
let stelle = plan.stelle(bei: gezogen) {
Vorschaukachel(plan: plan, stelle: stelle, blatt: blatt)
.offset(
x: min(
breite - plan.kachelBreite,
max(0, gespielt - plan.kachelBreite / 2)),
y: -Self.hitHeight / 2 - 8)
.allowsHitTesting(false)
.transition(.opacity)
}
}
.frame(height: Self.hitHeight)
.contentShape(.rect)
.gesture(
@@ -117,3 +139,75 @@ struct PlaybackScrubber: View {
return min(max(Double(x / width), 0), 1) * duration.seconds
}
}
/// Ein Standbild aus dem Film, ausgeschnitten aus einem Blatt.
///
/// **Ausgeschnitten, nicht einzeln geladen.** Ein Blatt trägt hundert
/// Bilder; beim Ziehen über die Leiste wird nur der Ausschnitt verschoben,
/// und nach dem ersten Blatt kommt kein Abruf mehr. Einzelne Bilder zu
/// holen hiesse, bei jeder Fingerbewegung ins Netz zu greifen.
///
/// **Geladen wird von Hand, nicht über `FlaroArtwork`.** Jenes rechnet mit
/// Seitenverhältnissen und schneidet selbst zu — hier wird ein Blatt
/// gebraucht, das genau so gross dasteht, wie es ist, damit der Versatz
/// auf den Bildpunkt stimmt.
struct Vorschaukachel: View {
let plan: Vorschauplan
let stelle: Vorschauplan.Stelle
/// Woher das Blatt kommt — der Spieler weiss, welche Datei läuft.
let blatt: (Int) -> URL?
@State private var bild: PlatformImage?
var body: some View {
Rechteck
.frame(width: plan.kachelBreite, height: plan.kachelHoehe)
.background(.black)
.clipShape(.rect(cornerRadius: FlaroRadius.card))
.overlay(
RoundedRectangle(cornerRadius: FlaroRadius.card)
.stroke(.white.opacity(0.18), lineWidth: 1))
.shadow(color: .black.opacity(0.6), radius: 10, y: 4)
.task(id: stelle.blatt) { await laden() }
}
@ViewBuilder
private var Rechteck: some View {
if let bild {
// `Image(platform:)` liegt in FlaroDesign und ist paketintern;
// dieselbe Fallunterscheidung, zwei Zeilen.
bildAnsicht(bild)
.resizable()
.frame(
width: plan.kachelBreite * Double(plan.spalten),
height: plan.kachelHoehe * Double(plan.zeilen))
// Der Ausschnitt entsteht durch Verschieben hinter einem
// Rahmen in Kachelgrösse — dieselbe Rechnung wie
// `background-position` im Web.
.offset(
x: -plan.kachelBreite * Double(stelle.spalte),
y: -plan.kachelHoehe * Double(stelle.zeile))
.frame(
width: plan.kachelBreite, height: plan.kachelHoehe,
alignment: .topLeading)
.clipped()
} else {
Color.black
}
}
private func bildAnsicht(_ bild: PlatformImage) -> Image {
#if canImport(UIKit)
Image(uiImage: bild)
#else
Image(nsImage: bild)
#endif
}
private func laden() async {
guard let adresse = blatt(stelle.blatt) else { return }
// Der Lader hält Blätter im Speicher: beim Ziehen über die Leiste
// wird dasselbe Blatt hundertmal gebraucht.
bild = await ArtworkLoader.shared.image(for: adresse)
}
}
+328 -39
View File
@@ -61,9 +61,20 @@ final class PlayerModel {
/// Die Marke, in der wir uns gerade befinden — Grundlage für den
/// Überspringen-Knopf.
private(set) var activeMarker: MediaMarker?
/// Wie die Vorschaubilder liegen — `nil`, solange der Server keine hat.
///
/// **Einmal gefragt, bei „noch nicht" einmal nachgefasst.** Die Blätter
/// entstehen beim Abspielen im Hintergrund; nach zehn Sekunden sind sie
/// bei den meisten Dateien da. Weiter zu fragen hiesse, für eine
/// Annehmlichkeit dauernd am Server zu ziehen.
private(set) var vorschau: Vorschauplan?
/// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt,
/// damit sie am Ende ohne Wartezeit bereitsteht.
private(set) var nextEpisode: MediaItem?
/// Die Folge davor — für den Rückwärtsknopf. `nil` heisst: es ist die
/// erste, oder es ist gar keine Folge.
private(set) var previousEpisode: MediaItem?
private(set) var hasReachedEnd = false
/// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen
/// Fortschritt zu melden und keine nächste Folge nachzuschlagen.
@@ -105,6 +116,26 @@ final class PlayerModel {
/// und ausgerechnet da will man zusehen, wie der Vorrat wächst.
private(set) var bufferedUntil: Duration?
/// Ab welcher Stelle des Films der laufende Strom beginnt.
///
/// **Null ist der Normalfall.** Dann ist der Strom der ganze Film, und
/// die Uhr des Spielers ist die Uhr des Films.
///
/// **Darüber heisst: der Server hat schon gesprungen.** Flaros
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
/// null — ohne diesen Wert stünde bei „Fortsetzen ab 1:32" eine Null auf
/// der Uhr, und genau die würde auch gemeldet.
private var streamOffset: Duration = .zero
/// Bis wohin der laufende Strom reicht.
///
/// **Nicht dasselbe wie <see cref="duration"/>.** Das ist die Länge des
/// Films; dies ist, was davon in diesem Strom liegt. Bei einer
/// wachsenden Abspielliste wächst es mit. Ein Sprung darüber hinaus
/// braucht einen neuen Strom, keinen Sprung.
private var streamEnd: Duration?
/// Was der eigene Vorratsspeicher gerade tut — in einem Satz.
///
/// **Damit man es ablesen kann statt zu raten.** Ob ein Speicher
@@ -211,6 +242,9 @@ final class PlayerModel {
/// Protokoll mit Null-Objekt wäre die dritte Möglichkeit und die
/// schlechteste: ein Typ mehr, um drei `if let` zu vermeiden.
private let downloads: DownloadCoordinator?
/// Siehe `Fortschrittspost`: was der Server nicht annimmt, wird
/// zurückgelegt und nachgereicht.
private let post: Fortschrittspost?
private let diagnostics: PlaybackDiagnostics
private let policy: PlaybackPolicy
private let settings: PlaybackSettings
@@ -461,6 +495,8 @@ final class PlayerModel {
backend: (any MediaBackend)?,
liveTV: (any LiveTVBackend)?,
downloads: DownloadCoordinator? = nil,
/// Wohin ein Fortschritt kommt, den der Server nicht annimmt.
post: Fortschrittspost? = nil,
diagnostics: PlaybackDiagnostics,
policy: PlaybackPolicy,
settings: PlaybackSettings,
@@ -474,6 +510,7 @@ final class PlayerModel {
self.backend = backend
self.liveTV = liveTV
self.downloads = downloads
self.post = post
self.diagnostics = diagnostics
self.policy = policy
self.settings = settings
@@ -499,6 +536,18 @@ final class PlayerModel {
// nicht erreichbar ist — der eigentliche Sinn des Herunterladens.
if let downloads, let localURL = downloads.localURL(forRatingKey: request.ratingKey) {
await startLocal(url: localURL)
// **Die Angaben trotzdem holen, nur eben hinterher.**
//
// Hier wurde früher schlicht zurückgekehrt — und damit blieb
// `item` leer. Daran hing mehr, als es aussieht: keine Marken,
// also kein „Intro überspringen"; keine nächste Folge, also
// keine Knöpfe zum Blättern. Bei allem, was heruntergeladen
// war, fehlte das, und nur dort — weshalb es so lange aussah
// wie ein Fehler in der Erkennung.
//
// Nebenher und nicht davor: das Bild soll sofort laufen, und
// ohne Verbindung gibt es ohnehin nichts zu holen.
metadatenNachladen()
return
}
@@ -557,6 +606,7 @@ final class PlayerModel {
await load(source, decision: decision)
preloadNextEpisode()
vorschauHolen()
}
/// Stellt auf eine andere Qualitätsstufe um.
@@ -718,7 +768,58 @@ final class PlayerModel {
await load(PlaybackSource(url: url, origin: .remote), decision: decision)
}
/// Holt Titelangaben nach, während die lokale Datei schon läuft.
///
/// Scheitert es — kein Server, kein Netz —, bleibt es, wie es war: die
/// Datei läuft, es gibt bloss keine Marken und keine Nachbarfolgen.
/// Welche Datei läuft — die Vorschau hängt an ihr, nicht am Titel.
///
/// `MediaVersion.id` ist die Kennung der Mediendatei; eine Fassung
/// besteht aus genau einer.
private var vorschaudatei: String?
/// Holt den Vorschauplan — einmal sofort, einmal nach zehn Sekunden.
private func vorschauHolen() {
guard vorschau == nil, let backend, let item else { return }
guard let datei = version(of: item)?.id else { return }
vorschaudatei = datei
Task { [weak self] in
for warten in [Duration.zero, .seconds(10)] {
if warten > .zero { try? await Task.sleep(for: warten) }
guard let self, self.vorschau == nil else { return }
if let plan = await backend.vorschauplan(fileId: datei) {
self.vorschau = plan
return
}
}
}
}
/// Woher ein Vorschaublatt kommt.
func vorschaublatt(_ nummer: Int) -> URL? {
guard let backend, let datei = vorschaudatei else { return nil }
return backend.vorschaublatt(fileId: datei, blatt: nummer)
}
private func metadatenNachladen() {
guard item == nil, let backend else { return }
Task { [weak self] in
guard let geholt = try? await backend.item(id: self?.request.ratingKey ?? "") else { return }
guard let self, self.item == nil else { return }
self.item = geholt
self.updateActiveMarker()
self.preloadNextEpisode()
self.vorschauHolen()
}
}
private func startLocal(url: URL) async {
// Eine Datei auf dem Gerät ist der ganze Film; es gibt keinen
// Vorlauf, den der Spieler aufrechnen müsste.
streamOffset = .zero
streamEnd = nil
let offline = downloads?.offlineMedia(forRatingKey: request.ratingKey) ?? nil
let decision = PlaybackDecider().decide(
@@ -839,6 +940,26 @@ final class PlayerModel {
playbackSession = target.sessionID
playbackVersionID = target.versionID
// **Wer schon gesprungen ist, springt nicht noch einmal.**
//
// Flaro legt den Strom an der gewünschten Stelle an; Jellyfin
// liefert den ganzen Film und lässt den Spieler springen. Beides
// zugleich hiesse bei einundzwanzig Minuten Fortschritt, bei
// zweiundvierzig zu landen.
streamOffset = target.startOffset
streamEnd = nil
// **Die Länge kommt aus der Datei, nicht vom Player.**
//
// Eine wachsende Abspielliste hat für AVPlayer keine Länge — sie
// trägt kein `EXT-X-ENDLIST`, also bleibt die Dauer unbestimmt
// und `durationResolved` kommt nie. Ohne diese Zeile blieb
// `duration` auf nichts, und die Ansicht rechnet dann mit einer
// Sekunde: der Regler stand bei 1:50:07 am Anschlag.
if let bekannt = part.duration ?? item.duration {
duration = .milliseconds(bekannt)
}
// **Der Vorrat läuft voraus, nicht der Player.**
//
// AVPlayer entscheidet selbst, wie weit er puffert, und bei einer
@@ -856,7 +977,7 @@ final class PlayerModel {
return PlaybackSource(
url: adresse,
origin: .remote,
startTime: start,
startTime: target.startOffset > .zero ? .zero : start,
// **Mit eigenem Vorrat ein kleiner Puffer.**
//
// Er bestimmt, wie viel AVPlayer beisammenhaben will, bevor
@@ -1504,6 +1625,10 @@ final class PlayerModel {
// Der Sprung **ist** angekommen, in dem Moment, in dem man ihn
// auslöst. Alles Weitere ist Warten auf Daten, und dafür gibt es den
// Kreisel.
// **Vor dem Aufräumen entscheiden.** Die Antwort hängt daran, wie
// weit der Vorrat reicht — und der wird gleich verworfen.
let neuerStrom = ausserhalbDesStroms(time)
currentTime = time
seekTarget = time
// Der Vorrat von vorhin gilt für die alte Stelle. Ihn stehen zu lassen
@@ -1512,7 +1637,55 @@ final class PlayerModel {
NowPlaying.shared.update(from: self)
updateActiveMarker()
Task { await engine.seek(to: time) }
// **Ausserhalb dieses Stroms springt nicht der Spieler, sondern der
// Server.**
//
// Flaros Abspielliste beginnt an der gewünschten Stelle und wächst
// von dort vorwärts. Was davor liegt und was noch nicht gerechnet
// ist, gibt es darin nicht; ein Sprung dorthin ginge ins Leere und
// der Spieler bliebe mit einem Kreisel stehen. Also wird der Strom
// neu aufgebaut — `restart` steigt bei `currentTime` wieder ein, und
// das ist genau die Stelle, die eben gesetzt wurde.
if neuerStrom {
Task { await restart() }
return
}
Task { await engine.seek(to: time - streamOffset) }
}
/// Ob diese Stelle in dem Strom liegt, der gerade läuft.
///
/// **Innerhalb des Erzeugten springt der Spieler selbst.** Dafür eine
/// neue Sitzung zu starten hiesse, Sekunden zu warten, um fünf Sekunden
/// zurückzugehen.
private func ausserhalbDesStroms(_ time: Duration) -> Bool {
// **Eine Datei mit bekannter Länge: überallhin.** `streamEnd` steht
// nur, wenn der Player eine Dauer ermitteln konnte, und das kann er
// genau dann, wenn das Ganze schon dasteht — eine Mediendatei oder
// eine fertige Abspielliste.
if streamEnd != nil, streamOffset == .zero { return false }
// Vor dem Anfang des Stroms gibt es in ihm nichts.
if time < streamOffset { return true }
// **Eine wachsende Liste reicht so weit, wie Daten da sind.**
//
// Hier stand ein Vergleich mit `streamEnd`, und der ging nie auf:
// bei einer wachsenden Liste bleibt die Dauer unbestimmt, also ist
// `streamEnd` nichts, und jeder Sprung galt als innerhalb. AVPlayer
// bekam ein Ziel, zu dem es keine Schnipsel gibt, verwarf es, und
// der Regler sprang zurück — beim Ziehen sichtbar, im Pausieren
// nicht, weil die Liste dort weiterwuchs und ihn irgendwann einholte.
//
// **Weiss niemand, wie weit es reicht, wird neu gebaut.** Ein neuer
// Strom dauert ein paar Sekunden; ein Sprung, der nichts tut, ist
// kaputt.
guard let bis = bufferedUntil else { return true }
// Eine halbe Sekunde Nachsicht an der Kante: dort einen neuen Strom
// zu bauen wäre Aufwand für nichts.
return time > bis + .milliseconds(500)
}
/// Springt relativ zur aktuellen Stelle — für die ±10-Sekunden-Knöpfe.
@@ -1523,9 +1696,20 @@ final class PlayerModel {
/// Springt ans Ende der laufenden Marke.
func skipActiveMarker() {
guard let marker = activeMarker else { return }
guard let marker = activeMarker, let item else { return }
skippedMarkers.insert(marker.id)
seek(to: .milliseconds(marker.endMilliseconds))
// **Mit dem Schalter in einem Zug bis ans Ende.** Sonst endet der
// Sprung an dieser Marke, also bei der Post-Credit-Szene — wer sie
// nicht sehen will, stellt es einmal um und drückt einmal.
let ziel = settings.skipExtraScenes
? item.markers
.filter { $0.kind == marker.kind && $0.startMilliseconds >= marker.startMilliseconds }
.map(\.endMilliseconds)
.max() ?? marker.endMilliseconds
: marker.endMilliseconds
seek(to: .milliseconds(ziel))
}
/// Ob es bei der Tonspur überhaupt etwas zu wählen gibt.
@@ -1684,14 +1868,19 @@ final class PlayerModel {
let stelle = currentTime > .zero ? currentTime : request.startTime
guard stelle > .zero || state == .started else { return }
await backend.reportProgress(PlaybackReport(
let bericht = PlaybackReport(
itemID: item.id,
sessionID: playbackSession,
versionID: playbackVersionID,
state: state,
time: stelle,
duration: total
))
)
// **Was nicht ankommt, wird zurückgelegt.** Ohne das war eine Folge,
// die man offline zu Ende gesehen hat, für den Server nie gelaufen.
if await backend.reportProgress(bericht) == false {
post?.zuruecklegen(bericht, server: backend.identity)
}
}
// MARK: - Marken und nächste Folge
@@ -1700,14 +1889,14 @@ final class PlayerModel {
guard let item else { return }
let milliseconds = currentTime.milliseconds
activeMarker = item.markers.first { marker in
guard marker.contains(milliseconds) else { return false }
// Der Abspann zählt nur als Marke, wenn danach noch etwas kommt —
// bei einem Film ist „Abspann überspringen" gleichbedeutend mit
// „Film beenden".
if marker.isCredits && item.kind != .episode { return false }
return true
}
// **Was der Server schickt, gilt.** Hier stand eine eigene Regel:
// bei einem Film wurde die Abspannmarke verworfen, weil
// „überspringen" dort gleichbedeutend mit „beenden" war. Das ist
// eine Entscheidung über Marken, und die trifft der Server — er
// schickt sie nur, wenn er einen Abspann gefunden hat, und seit
// ein Abspann geteilt sein darf, führt der Knopf zur
// Post-Credit-Szene statt ins Nichts.
activeMarker = item.markers.first { $0.contains(milliseconds) }
guard let marker = activeMarker, !skippedMarkers.contains(marker.id) else { return }
let shouldSkip = (marker.isIntro && settings.autoSkipIntro)
@@ -1715,21 +1904,43 @@ final class PlayerModel {
if shouldSkip { skipActiveMarker() }
}
/// **Beide Nachbarn, nicht nur der nächste.**
///
/// Die nächste Folge wurde schon immer vorgeladen — für den Knopf am
/// Ende. Seit es im Spieler Knöpfe zum Blättern gibt, gilt dasselbe für
/// die vorige: wer eine zu weit gesprungen ist, soll zurück können, ohne
/// den Spieler zu verlassen.
private func preloadNextEpisode() {
guard let item, item.kind == .episode, let backend else { return }
Task { [weak self] in
let next = try? await backend.nextEpisode(after: item)
self?.nextEpisode = next
}
Task { [weak self] in
let vorige = try? await backend.previousEpisode(before: item)
self?.previousEpisode = vorige
}
}
/// Der Abspielwunsch für die nächste Folge — die Ansicht reicht ihn an den
/// Koordinator weiter.
func nextEpisodeRequest() -> PlaybackRequest? {
guard let nextEpisode else { return nil }
func nextEpisodeRequest() -> PlaybackRequest? { wunsch(für: nextEpisode) }
/// Dasselbe rückwärts.
func previousEpisodeRequest() -> PlaybackRequest? { wunsch(für: previousEpisode) }
/// **Von vorn, nicht von der gemerkten Stelle.**
///
/// Wer blättert, will die Folge sehen. Beim Vorwärtsblättern ist sie
/// ohnehin meist ungesehen; beim Zurückblättern stünde man sonst im
/// Abspann der Folge, die man gerade noch einmal anfangen wollte. Wer
/// fortsetzen will, nimmt den Weg über die Serienseite — dort steht die
/// Stelle dabei.
private func wunsch(für folge: MediaItem?) -> PlaybackRequest? {
guard let folge else { return nil }
return PlaybackRequest(
ratingKey: nextEpisode.id,
title: nextEpisode.playbackTitle,
ratingKey: folge.id,
title: folge.playbackTitle,
mediaIndex: 0,
startTime: .zero
)
@@ -1871,7 +2082,14 @@ final class PlayerModel {
String(describing: failure),
].joined(separator: "\n\n"))
}
case .timeChanged(let time):
case .timeChanged(let stromzeit):
// **Die Uhr des Spielers ist nicht die Uhr des Films.**
//
// Beginnt der Strom bei 1:32, zählt der Spieler trotzdem
// ab null. Ab hier wird in Filmzeit gerechnet — alles
// darunter, von der Anzeige bis zur Fortschrittsmeldung,
// meint denselben Punkt im Film.
let time = streamOffset + stromzeit
// **Nachzügler verwerfen.** Nach einem Sprung meldet der
// Player noch ein paar Mal die alte Stelle, bis er dort
// ankommt. Ungeprüft übernommen zöge das den Regler wieder
@@ -1901,7 +2119,22 @@ final class PlayerModel {
}
updateActiveMarker()
case .durationResolved(let value):
duration = value
streamEnd = streamOffset + value
// **Die Länge des Films, nicht die des Stroms.**
//
// Eine wachsende Abspielliste meldet anfangs ein paar
// Sekunden. Als Filmlänge genommen stünde der Regler
// sofort am Ende, und ein Sprung nach vorn wäre
// unmöglich.
// **Die Datei weiss es genau, der Titel oft gar nicht.**
// `item.duration` kommt aus der Liste, und dort stehen
// keine Dateien — bei einem Titel, der aus einer Reihe
// geöffnet wurde, ist es nichts. Dann blieb nur die Länge
// des Stroms, und die ist am Anfang ein paar Sekunden:
// Regler am Ende, obwohl noch zwanzig Minuten kommen.
duration = currentPart?.duration.map { Duration.milliseconds($0) }
?? item?.duration.map { Duration.milliseconds($0) }
?? streamEnd
NowPlaying.shared.update(from: self)
case .reachedEnd:
isPlaying = false
@@ -1943,14 +2176,52 @@ final class PlayerModel {
/// Einstellungen eine Untertitelsprache steht. Wer keine eingestellt
/// hatte, schaltete die Untertitel ein und sah nichts.
///
/// **Die erste ist die richtige.** Rechnet der Server um, liegt genau
/// eine im Stream — nämlich die bestellte. Es gibt nichts zu verwechseln.
/// **Die erste ist nicht mehr die richtige.**
///
/// Das galt, solange der Server genau eine Spur auslieferte — die
/// bestellte. Flaro legt seit dem Strom **alle** Textspuren als eigene
/// Spuren in die Abspielliste, und dann ist die erste schlicht die
/// erste: wer Deutsch wählte, sah Englisch.
///
/// Gesucht wird deshalb die Spur, die zur Bestellung passt — erst über
/// das Sprachkürzel, dann über den Namen. Findet sich keine, bleibt es
/// bei der ersten: bei einem umgerechneten Strom gibt es nur sie, und
/// gar keinen Untertitel zu zeigen wäre schlechter als den einen.
private func showOrderedSubtitle() {
guard selectedSubtitleStream != nil, selectedSubtitleTrack == nil else { return }
guard let erste = subtitleTracks.first else { return }
guard let bestellt = selectedSubtitleStream, selectedSubtitleTrack == nil else { return }
guard let treffer = passendeSpur(zu: bestellt) ?? subtitleTracks.first else { return }
diagnostics.note("Untertitel eingeschaltet: \(erste.displayName)")
selectSubtitleTrack(erste)
diagnostics.note("Untertitel eingeschaltet: \(treffer.displayName)")
selectSubtitleTrack(treffer)
}
/// Die Spur im Strom, die zu der bestellten aus den Metadaten gehört.
private func passendeSpur(zu bestellt: MediaStream) -> MediaTrack? {
// **Die Sprache zuerst.** Sie steht in der Abspielliste als
// `LANGUAGE` und ist das, was ein Spieler verlässlich durchreicht.
if let code = bestellt.languageCode ?? bestellt.language,
let treffer = subtitleTracks.first(where: {
LanguageCode.matches(code, $0.languageCode)
&& $0.isForced == bestellt.isForced
})
{
return treffer
}
// **Dann der Name.** Zwei Spuren in derselben Sprache — Dialoge und
// Schilder etwa — unterscheiden sich nur darüber.
if let name = bestellt.displayTitle,
let treffer = subtitleTracks.first(where: { $0.displayName == name })
{
return treffer
}
// Ohne Sprachkürzel bleibt die Sprache allein.
if let code = bestellt.languageCode ?? bestellt.language {
return subtitleTracks.first { LanguageCode.matches(code, $0.languageCode) }
}
return nil
}
/// Wählt die Untertitelspur, sofern eine gewünscht ist.
@@ -1964,9 +2235,15 @@ final class PlayerModel {
/// Nur beim ersten Mal: hat jemand während der Wiedergabe umgestellt, darf
/// eine später gemeldete Spurliste das nicht zurücksetzen.
private func applyPreferredLanguages() {
guard selectedSubtitleTrack == nil, !languages.subtitleLanguage.isEmpty,
// **Die Ebene schlägt das Allgemeine.** Hier stand nur die
// allgemeine Sprache; eine Serie, die man im Original mit deutschen
// Untertiteln sieht, liess sich damit nicht einstellen, ohne sie für
// alles andere auch einzuschalten. Bei einer Folge gilt die Serie,
// bei einem Film der Titel selbst.
guard selectedSubtitleTrack == nil,
let wunsch = languages.subtitleWish(titleID: titelbereich),
let treffer = subtitleTracks.first(where: {
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
LanguageCode.matches(wunsch, $0.languageCode)
})
else { return }
selectSubtitleTrack(treffer)
@@ -1984,6 +2261,14 @@ final class PlayerModel {
/// Netz und ohne Einstellungen, damit sich der ZDF-Fall wörtlich
/// hinschreiben und durchspielen lässt. Hier steht nur, woher der Wunsch
/// kommt.
/// Unter welcher Kennung die Vorwahl dieses Titels liegt.
///
/// **Bei einer Folge die Serie, bei einem Film er selbst.** „Diese Serie
/// auf Japanisch" gilt für alle Folgen; „dieser Film auf Englisch" gilt
/// für ihn. Beides ist dieselbe Frage auf der Ebene, die der Benutzer
/// vor sich hatte, als er sie beantwortet hat.
private var titelbereich: String? { item?.seriesID ?? item?.id }
private func chooseAudioStream(
in part: MediaFile,
of item: MediaItem,
@@ -1993,10 +2278,9 @@ final class PlayerModel {
subtitleStreams = part.subtitleStreams
// Untertitel sind eine Zutat, keine Voraussetzung: der Wunsch gilt,
// wenn es einen passenden gibt, und sonst bleibt es ohne.
selectedSubtitleStream = languages.subtitleLanguage.isEmpty ? nil
: subtitleStreams.first {
LanguageCode.matches(languages.subtitleLanguage, $0.languageCode)
}
selectedSubtitleStream = languages.subtitleWish(titleID: titelbereich).flatMap { wunsch in
subtitleStreams.first { LanguageCode.matches(wunsch, $0.languageCode) }
}
guard !audioStreams.isEmpty else {
selectedAudioStream = nil
@@ -2012,7 +2296,12 @@ final class PlayerModel {
selectedAudioStream = AudioTrackChooser.choose(
from: part.streams,
wish: languages.wish(seriesID: item.seriesID, libraryID: libraryID)
// **Bei einem Film seine eigene Kennung.** Hier stand nur
// `item.seriesID`, und die ist bei einem Film nichts: die
// Vorwahl auf der Filmseite wurde abgelegt und nie wieder
// gelesen. Untertitel gingen durch, Ton nicht — weil die beiden
// an verschiedenen Stellen nachsehen.
wish: languages.wish(seriesID: titelbereich, libraryID: libraryID)
)
if let gewählt = selectedAudioStream {
@@ -2032,8 +2321,8 @@ final class PlayerModel {
guard stream != selectedAudioStream else { return }
selectedAudioStream = stream
if let seriesID = item?.seriesID {
languages.remember(stream, forSeries: seriesID)
if let bereich = titelbereich {
languages.remember(stream, forSeries: bereich)
}
// **Der billige Weg, wenn er offensteht.** Wird die Datei unverändert
@@ -2081,16 +2370,16 @@ final class PlayerModel {
/// Was für diese Serie gemerkt ist — für die Anzeige im Spurenblatt.
var rememberedTrackName: String? {
guard let seriesID = item?.seriesID,
let signatur = languages.rememberedTrack(forSeries: seriesID)
guard let bereich = titelbereich,
let signatur = languages.rememberedTrack(forSeries: bereich)
else { return nil }
return signatur.title ?? signatur.languageCode
}
/// Vergisst die gemerkte Spur dieser Serie.
func forgetRememberedTrack() {
guard let seriesID = item?.seriesID else { return }
languages.forgetTrack(forSeries: seriesID)
guard let bereich = titelbereich else { return }
languages.forgetTrack(forSeries: bereich)
}
private func playerSubtitle(for item: MediaItem) -> String? {
+115 -5
View File
@@ -52,6 +52,15 @@ struct PlayerView: View {
@State private var trackPicker: TrackPickerSheet.Kind?
@State private var showsQualityChoice = false
@State private var hideTask: Task<Void, Never>?
/// Ob die Marken-Knöpfe inzwischen an der Bedienung hängen.
///
/// **Erst für sich, dann mit der Bedienung.** Eine neue Marke zeigt
/// ihre Knöpfe von selbst — sonst müsste man erst das Bild antippen,
/// um das Intro überspringen zu können, und dann hätte man die
/// Bedienung ohnehin. Sobald man sie aber einmal eingeblendet hat,
/// gehören die Knöpfe dazu und gehen mit ihr. Keine Frist, kein
/// Zeitgeber: der Zuschauer sagt selbst, wann er genug gesehen hat.
@State private var markenGebunden = false
@State private var scrubbing: Double?
/// Welche Richtung gerade als Hinweis eingeblendet ist.
@State private var seekHint: Double?
@@ -106,8 +115,29 @@ struct PlayerView: View {
// Jetzt rechnet das Layout. Der schwarze Rand ist schlicht
// der Hintergrund dahinter, und wer ihn anklickt, klickt
// nicht aufs Bild — das muss niemand mehr feststellen.
// **Im Vollbild über alles, sonst nicht unter die
// Insel.**
//
// Bei 21∶9 auf einem Telefon reicht das Bild über die
// ganze Breite — und damit hinter die Dynamic Island, die
// links einen Streifen davon verdeckt. Wer Vollbild
// wählt, hat sich fürs Beschneiden entschieden; dort ist
// Rand an Rand folgerichtig. Im normalen Modus ist der
// Anspruch gerade, dass nichts fehlt — dann darf auch
// nichts verdeckt sein. Plex macht es genauso.
Color.clear
.ignoresSafeArea()
//
// **Ein Modifikator mit leeren Kanten, kein `if`.**
// Hier stand ein eigener `ViewModifier`, der
// zwischen `ignoresSafeArea()` und gar nichts
// umschaltete — und genau das ist in SwiftUI ein
// Zweig: beim Umschalten wurde der Baum darunter
// neu aufgebaut, die Bildebene mit ihm, und der
// Spieler stand ohne sie da. Schwarz, sobald
// jemand auf „Bildfüllend" wechselte. `edges: []`
// ignoriert nichts und ist doch derselbe
// Modifikator — die Ansicht bleibt dieselbe.
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
.overlay {
surface(for: model)
.contentShape(.rect)
@@ -341,6 +371,17 @@ struct PlayerView: View {
.environment(settings)
.environment(\.flaroAccent, accent)
}
// Eine neue Marke ist ein neues Angebot: sie steht wieder für sich,
// bis die Bedienung das nächste Mal auftaucht.
.onChange(of: model.activeMarker?.id, initial: true) { _, _ in
markenGebunden = false
}
// **Der Übergang zählt, nicht der Zustand.** Beim Start liegt die
// Bedienung ohnehin da; das als „einmal gezeigt" zu werten hiesse,
// dem Intro seinen Knopf zu nehmen, bevor es ihn je hatte.
.onChange(of: showsControls) { vorher, jetzt in
if !vorher && jetzt { markenGebunden = true }
}
.onChange(of: showsQualityChoice) { _, isShown in
// Nach dem Schließen die Bedienelemente stehen lassen und die
// Ausblendfrist neu starten. Ohne das verschwänden sie sofort,
@@ -594,7 +635,25 @@ struct PlayerView: View {
}
private func transport(for model: PlayerModel) -> some View {
HStack(spacing: FlaroSpacing.section * 2) {
// **Enger, seit zwei Knöpfe dazukommen.** Bei fünf Knöpfen in einer
// Reihe wird der alte Abstand auf einem Telefon im Hochformat zu
// breit; bei drei bleibt er, wie er war.
let folgen = model.previousEpisode != nil || model.nextEpisode != nil
return HStack(spacing: folgen ? FlaroSpacing.section : FlaroSpacing.section * 2) {
// **Eine Folge zurück — nur bei Serien, nur wenn es eine gibt.**
//
// Bei einem Film bleibt die Reihe, wie sie war: ein Knopf, der
// nie etwas tut, ist schlechter als keiner.
if model.previousEpisode != nil {
Button {
if let wunsch = model.previousEpisodeRequest() { playback.play(wunsch) }
} label: {
Image(systemName: "backward.end.fill").font(.title2)
}
.accessibilityLabel(Text("player.previousEpisode"))
}
Button {
model.skip(by: .seconds(-settings.rewindSeconds))
scheduleControlsHide()
@@ -638,6 +697,15 @@ struct PlayerView: View {
Image(systemName: "goforward.\(settings.forwardSeconds)").font(.largeTitle)
}
.accessibilityLabel(Text("player.forward10"))
if model.nextEpisode != nil {
Button {
if let wunsch = model.nextEpisodeRequest() { playback.play(wunsch) }
} label: {
Image(systemName: "forward.end.fill").font(.title2)
}
.accessibilityLabel(Text("player.nextEpisode"))
}
}
.foregroundStyle(.white)
}
@@ -662,7 +730,9 @@ struct PlayerView: View {
// springen lässt das Bild flackern und hält bei großen
// Dateien den Puffer dauerhaft leer.
onCommit: { model.seek(to: $0) },
onActivity: { scheduleControlsHide() }
onActivity: { scheduleControlsHide() },
vorschau: model.vorschau,
blatt: { model.vorschaublatt($0) }
)
HStack {
@@ -683,12 +753,31 @@ struct PlayerView: View {
/// Überspringen-Knopf und „Nächste Folge".
@ViewBuilder
/// Ob jetzt „Nächste Folge" dransteht.
///
/// Am Ende immer, im Abspann auch — dort ist es die Antwort auf die
/// Frage, die der Abspann stellt. Gibt es keine nächste Folge, bleibt
/// es beim Überspringen.
private func naechsteAnbieten(_ model: PlayerModel) -> Bool {
guard model.nextEpisodeRequest() != nil else { return false }
return model.hasReachedEnd || model.activeMarker?.isCredits == true
}
private func overlayActions(for model: PlayerModel) -> some View {
VStack {
Spacer()
HStack {
Spacer()
if model.hasReachedEnd, model.nextEpisodeRequest() != nil {
// **Im Abspann ist „überspringen" die falsche Frage — aber
// nicht die einzige.**
//
// Was man dort meistens will, ist die nächste Folge; ans
// Ende dieser zu springen bringt niemanden weiter. Wer den
// Abspann aber sehen wollte und es sich anders überlegt,
// soll ihn trotzdem überspringen können. Zwei Antworten,
// zwei Knöpfe — welche gemeint ist, weiss nur der
// Zuschauer.
if naechsteAnbieten(model) {
Button {
if let next = model.nextEpisodeRequest() {
playback.play(next)
@@ -701,7 +790,9 @@ struct PlayerView: View {
}
.buttonStyle(.borderedProminent)
.tint(accent)
} else if let marker = model.activeMarker {
}
if let marker = model.activeMarker {
Button {
model.skipActiveMarker()
} label: {
@@ -721,9 +812,28 @@ struct PlayerView: View {
// Über dem Schieberegler, damit sich beides nicht überdeckt, wenn
// die Bedienelemente eingeblendet sind.
.padding(.bottom, (showsControls ? 110 : FlaroSpacing.section) + safeArea.bottom)
// **Am Ende bleibt es stehen, sonst nicht.**
//
// Ist der Film aus, ist der Knopf die einzige Frage, die noch
// offen ist — der darf warten. Mitten in einer Marke ist er ein
// Angebot, und ein Angebot, das nicht angenommen wird, nimmt
// man zurück.
.opacity(markenSichtbar(model) ? 1 : 0)
.allowsHitTesting(markenSichtbar(model))
}
.animation(.snappy, value: model.activeMarker?.id)
.animation(.snappy, value: model.hasReachedEnd)
.animation(.easeIn(duration: 0.28), value: markenGebunden)
}
/// **Dieselbe Regel für Vorspann und Abspann, Web wie App.**
///
/// Sichtbar, solange die Knöpfe noch für sich stehen; danach genau
/// dann, wenn die Bedienung da ist. Am Ende des Films bleiben sie
/// trotzdem: dort ist „Nächste Folge" die einzige noch offene Frage,
/// und es gibt kein Bild mehr, das sie verdecken könnten.
private func markenSichtbar(_ model: PlayerModel) -> Bool {
model.hasReachedEnd || showsControls || !markenGebunden
}
/// Die Abdeckung während des Umstellens.
+1 -1
View File
@@ -48,7 +48,7 @@ struct SearchView: View {
// weiß aber noch nicht, was er sehen will; der erste
// Treffer ist eine Vermutung des Servers. Starten geht
// weiter, über langes Drücken.
MediaRow(row: hub, opensDetails: true)
MediaRow(row: hub)
}
}
.padding(.vertical, FlaroSpacing.loose)
+132 -31
View File
@@ -1,13 +1,18 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// „Welche Tonsprache gilt hier?" — an genau zwei Orten dieselbe Frage.
/// „Welche Sprache gilt hier?" — an mehreren Orten dieselbe Frage.
///
/// **Warum ein gemeinsamer Typ.** Die Frage steht einmal auf der Serienseite
/// und einmal bei der Mediathek, und die Antwort bedeutet beide Male dasselbe:
/// diese Ebene setzt sich gegen die darüber durch, oder sie tut es nicht. Zwei
/// Auswahlknöpfe mit demselben Inhalt liefen unweigerlich auseinander — einer
/// bekäme eine Sprache dazu, der andere nicht.
/// **Warum ein gemeinsamer Typ.** Die Frage steht auf der Serienseite, auf
/// der Filmseite und bei der Mediathek, und die Antwort bedeutet jedes Mal
/// dasselbe: diese Ebene setzt sich gegen die darüber durch, oder sie tut es
/// nicht. Mehrere Auswahlknöpfe mit demselben Inhalt liefen unweigerlich
/// auseinander — einer bekäme eine Sprache dazu, der andere nicht.
///
/// **Ton und Untertitel im selben Typ.** Sie unterscheiden sich in genau
/// zwei Punkten: woher der Wert kommt, und ob es „keine" gibt. Ein zweiter
/// Typ dafür wäre dieselbe Datei mit zwei geänderten Zeilen.
///
/// **Ein Menü, kein `Picker`.** Die Serienseite ist eine `ScrollView`, kein
/// `Form`; dort hätte ein `Picker` kein Aussehen, auf das man sich verlassen
@@ -15,25 +20,98 @@ import FlaroDesign
struct AudioLanguagePicker: View {
/// Für welche Ebene gewählt wird.
enum Scope {
/// Eine Serie oder ein Film — beides eine Kennung, beides ein Titel.
case series(String)
case library(String)
}
/// Ton oder Text.
enum Kind {
case audio
case subtitles
}
/// Wie der Knopf aussieht.
enum Form {
/// Eine Zeile: Beschriftung links, Wahl rechts. Für Listen.
case zeile
/// Ein Knopf wie „Ton: Deutsch". Für die Reihe neben „Abspielen".
case knopf
}
let scope: Scope
let title: LocalizedStringKey
var kind: Kind = .audio
var form: Form = .zeile
/// Welche Sprachen es wirklich gibt — `nil` heisst: alle anbieten.
///
/// **Eine Liste mit zwanzig Sprachen für eine Serie mit zweien** ist
/// keine Auswahl, sondern eine Suche. Weiss niemand, welche es gibt,
/// bleibt es bei der vollen Liste: eine zu lange ist besser als eine
/// leere.
var available: [LanguageOption]?
@Environment(LanguagePreferences.self) private var languages
@Environment(\.flaroAccent) private var accent
var body: some View {
HStack(spacing: FlaroSpacing.regular) {
Text(title)
.font(.flaroBody)
switch form {
case .zeile:
HStack(spacing: FlaroSpacing.regular) {
Text(title)
.font(.flaroBody)
.foregroundStyle(Color.flaroTextPrimary)
Spacer(minLength: FlaroSpacing.regular)
menü { auswahl }
}
case .knopf:
// **Beschriftung und Wert in einem.** Neben „Abspielen" ist kein
// Platz für eine Zeile mit Spalten; „Ton: Deutsch" sagt dasselbe
// in einem Wort mehr.
menü {
HStack(spacing: FlaroSpacing.tight) {
Image(systemName: kind == .audio ? "waveform" : "captions.bubble")
Text("\(Text(title)): \(currentLabel)")
.font(.flaroCardTitle)
// **Eine Zeile, keine Säule.** Umbrochen wird die
// Reihe, nicht der einzelne Knopf — sonst steht
// „Untertitel" wieder Buchstabe für Buchstabe
// untereinander.
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
}
.padding(.horizontal, FlaroSpacing.regular)
.padding(.vertical, FlaroSpacing.snug)
.background(Color.flaroElevated, in: Capsule())
.foregroundStyle(Color.flaroTextPrimary)
}
}
}
Spacer(minLength: FlaroSpacing.regular)
/// Das Menü selbst — zweimal dieselben Einträge, zwei Aufhänger.
private func menü(@ViewBuilder label: () -> some View) -> some View {
Menu { auswahlEintraege } label: { label() }
}
Menu {
private var auswahl: some View {
HStack(spacing: FlaroSpacing.tight) {
Text(currentLabel)
.font(.flaroBody)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
}
.foregroundStyle(accent)
}
@ViewBuilder
private var auswahlEintraege: some View {
Group {
// „Wie allgemein" zuerst und als Vorgabe: die meisten Ebenen
// sollen nichts Eigenes sagen, sondern die Frage nach oben
// durchreichen.
@@ -43,42 +121,65 @@ struct AudioLanguagePicker: View {
Label("languages.inherit", systemImage: current == nil ? "checkmark" : "")
}
Divider()
ForEach(LanguagePreferences.offeredCodes, id: \.self) { code in
// **„Keine" nur beim Untertitel.** Beim Ton gibt es sie
// nicht: ein Film ohne Tonspur ist kein Wunsch, sondern ein
// Fehler.
if kind == .subtitles {
Button {
set(code)
set(LanguagePreferences.noSubtitles)
} label: {
Label(name(of: code), systemImage: current == code ? "checkmark" : "")
Label(
"languages.none",
systemImage: current == LanguagePreferences.noSubtitles
? "checkmark" : ""
)
}
}
} label: {
HStack(spacing: FlaroSpacing.tight) {
Text(currentLabel)
.font(.flaroBody)
Image(systemName: "chevron.up.chevron.down")
.font(.flaroCaption)
Divider()
ForEach(options, id: \.code) { option in
Button {
set(option.code)
} label: {
Label(
option.name,
systemImage: current == option.code ? "checkmark" : ""
)
}
}
.foregroundStyle(accent)
}
}
}
/// Was zur Wahl steht — das Vorhandene, sonst die allgemeine Liste.
private var options: [LanguageOption] {
available ?? LanguagePreferences.offeredCodes.map {
LanguageOption(code: $0, name: name(of: $0))
}
}
private var current: String? {
switch scope {
case .series(let id): languages.audioLanguage(forSeries: id)
case .library(let id): languages.audioLanguage(forLibrary: id)
switch (scope, kind) {
case (.series(let id), .audio): languages.audioLanguage(forSeries: id)
case (.series(let id), .subtitles): languages.subtitleLanguage(forSeries: id)
case (.library(let id), .audio): languages.audioLanguage(forLibrary: id)
// Je Mediathek gibt es keine Untertitelsprache — siehe `subtitleWish`.
case (.library, .subtitles): nil
}
}
private var currentLabel: String {
current.map(name(of:)) ?? String(localized: "languages.inherit")
guard let current else { return String(localized: "languages.inherit") }
if current == LanguagePreferences.noSubtitles { return String(localized: "languages.none") }
return options.first { $0.code == current }?.name ?? name(of: current)
}
private func set(_ code: String?) {
switch scope {
case .series(let id): languages.setAudioLanguage(code, forSeries: id)
case .library(let id): languages.setAudioLanguage(code, forLibrary: id)
switch (scope, kind) {
case (.series(let id), .audio): languages.setAudioLanguage(code, forSeries: id)
case (.series(let id), .subtitles): languages.setSubtitleLanguage(code, forSeries: id)
case (.library(let id), .audio): languages.setAudioLanguage(code, forLibrary: id)
case (.library, .subtitles): break
}
}
@@ -102,6 +102,7 @@ struct PlaybackSettingsView: View {
Section {
Toggle("settings.autoSkipIntro", isOn: $settings.autoSkipIntro)
Toggle("settings.autoSkipCredits", isOn: $settings.autoSkipCredits)
Toggle("settings.skipExtraScenes", isOn: $settings.skipExtraScenes)
Toggle("settings.autoPlayNext", isOn: $settings.autoPlayNext)
} header: {
Text("settings.episodes")
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
using session: URLSession,
attempts: Int = 3
) async -> PlatformImage? {
// **Eine Datei auf der Platte ist kein Server.**
//
// Hier stand nur der Weg über HTTP, und die Antwort einer
// `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter
// gab also `nil` zurück, immer. Sichtbar wurde das erst, als die
// Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und
// die Kacheln blieben trotzdem grau. Kein Wiederholen, kein
// Zeitablauf: eine Datei ist entweder da oder nicht.
if url.isFileURL {
guard let data = try? Data(contentsOf: url) else { return nil }
return PlatformImage(data: data)
}
for attempt in 1...attempts {
if Task.isCancelled { return nil }
@@ -0,0 +1,103 @@
import SwiftUI
/// Eine Reihe, die umbricht statt zu quetschen.
///
/// **Der Anlass.** Im Kopf einer Detailseite stehen Abspielen, Von vorn,
/// Herunterladen und zwei Sprachknöpfe nebeneinander. In einem `HStack`
/// teilen sie sich die Breite — auf einem Telefon wurde daraus eine Reihe
/// von Säulen aus einzelnen Buchstaben, untereinander gesetzt. Lesbar war
/// nichts mehr, und angetippt hätte man trotzdem das Richtige.
///
/// **Umbrechen ist die Antwort, nicht Weglassen.** Ein Knopf, der auf einem
/// schmalen Gerät verschwindet, ist eine zweite Oberfläche, die gepflegt
/// werden will. Zwei Zeilen sind kein Mangel; sie sind, was dort hinpasst.
///
/// **Warum eigener `Layout` und kein `ViewThatFits`.** Das wählt zwischen
/// fertigen Entwürfen — man müsste jede Aufteilung von Hand hinschreiben,
/// und bei fünf Knöpfen sind das fünf. Hier wird gerechnet.
public struct Flusszeile: Layout {
private let abstand: CGFloat
private let zeilenabstand: CGFloat
public init(abstand: CGFloat = FlaroSpacing.regular, zeilenabstand: CGFloat? = nil) {
self.abstand = abstand
self.zeilenabstand = zeilenabstand ?? abstand
}
public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize {
let breite = proposal.width ?? .infinity
let zeilen = umbrechen(subviews, in: breite)
let hoehe = zeilen.reduce(CGFloat.zero) { $0 + $1.hoehe }
+ CGFloat(max(0, zeilen.count - 1)) * zeilenabstand
// **Die breiteste Zeile, nicht die angebotene Breite.** Sonst nimmt
// die Reihe das ganze Fenster ein, auch wenn zwei Knöpfe darin
// stehen — und alles daneben rutscht weg.
let genutzt = zeilen.map(\.breite).max() ?? 0
return CGSize(width: min(genutzt, breite), height: hoehe)
}
public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) {
var y = bounds.minY
for zeile in umbrechen(subviews, in: bounds.width) {
var x = bounds.minX
for index in zeile.teile {
let groesse = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(
at: CGPoint(x: x, y: y + (zeile.hoehe - groesse.height) / 2),
proposal: ProposedViewSize(groesse)
)
x += groesse.width + abstand
}
y += zeile.hoehe + zeilenabstand
}
}
private struct Zeile {
var teile: [Int] = []
var breite: CGFloat = 0
var hoehe: CGFloat = 0
}
private func umbrechen(_ subviews: Subviews, in breite: CGFloat) -> [Zeile] {
var zeilen: [Zeile] = []
var jetzt = Zeile()
for index in subviews.indices {
let groesse = subviews[index].sizeThatFits(.unspecified)
let dazu = jetzt.teile.isEmpty ? groesse.width : jetzt.breite + abstand + groesse.width
// **Das erste Element bleibt immer stehen.** Ein Knopf, der
// allein schon breiter ist als die Zeile, käme sonst in eine
// leere Zeile und von dort in die nächste, endlos.
if !jetzt.teile.isEmpty, dazu > breite {
zeilen.append(jetzt)
jetzt = Zeile()
jetzt.teile = [index]
jetzt.breite = groesse.width
jetzt.hoehe = groesse.height
continue
}
jetzt.teile.append(index)
jetzt.breite = dazu
jetzt.hoehe = max(jetzt.hoehe, groesse.height)
}
if !jetzt.teile.isEmpty { zeilen.append(jetzt) }
return zeilen
}
}
@@ -291,7 +291,10 @@ public struct HeroBanner<Actions: View>: View {
.frame(maxWidth: 640 * scale, alignment: .leading)
}
HStack(spacing: FlaroSpacing.regular) {
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
// Reihe von Säulen aus einzelnen Buchstaben.
Flusszeile {
actions
}
.padding(.top, FlaroSpacing.tight)
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
forgetTransfer(item.ratingKey)
fileStore.remove(fileName: item.localFileName)
fileStore.remove(fileName: item.artworkFileName)
let serie = item.seriesKey
context.delete(item)
save()
reloadItems()
// **Das Serienplakat geht mit der letzten Folge.** Es hängt an
// keinem Eintrag — ohne diese Zeile bliebe es für immer liegen.
if let serie, !items.contains(where: { $0.seriesKey == serie }) {
fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie))
}
refreshUsage()
}
@@ -422,6 +429,58 @@ public final class DownloadCoordinator {
return fileStore.url(forFileName: fileName)
}
/// Das lokale Plakat eines Downloads — `nil`, wenn keines geholt wurde.
///
/// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt
/// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim
/// Herunterladen mitkam, bleibt eine graue Fläche.
/// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst
/// der Download gesucht und dann sein Dateiname gelesen — damit gab es
/// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat
/// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter
/// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben
/// wird.
public func artworkURL(forRatingKey key: String) -> URL? {
let name = fileStore.artworkFileName(forRatingKey: key)
guard fileStore.exists(fileName: name) else { return nil }
return fileStore.url(forFileName: name)
}
/// Holt das Plakat zu einem Eintrag nach und legt es ab.
///
/// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den
/// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst
/// nichts zu sehen. Es sind ein paar Kilobyte.
/// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel
/// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie
/// heruntergeladen wird.
public func fetchArtwork(for ratingKey: String, from url: URL) async {
let name = fileStore.artworkFileName(forRatingKey: ratingKey)
guard !fileStore.exists(fileName: name) else {
vermerken(name, bei: ratingKey)
return
}
guard let (daten, antwort) = try? await URLSession.shared.data(from: url),
(antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
!daten.isEmpty
else { return }
guard fileStore.store(daten, as: name) != nil else { return }
vermerken(name, bei: ratingKey)
}
/// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen
/// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die
/// Datei stehen, bis die letzte Folge der Serie geht.
private func vermerken(_ name: String, bei ratingKey: String) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
item.artworkFileName != name
else { return }
item.artworkFileName = name
save()
}
public func offlineMedia(forRatingKey key: String) -> OfflineMedia? {
guard let data = items.first(where: { $0.ratingKey == key })?.offlineMediaData else { return nil }
return try? JSONDecoder().decode(OfflineMedia.self, from: data)
@@ -458,6 +517,13 @@ public final class DownloadCoordinator {
}
}
/// Wie oft je Eintrag schon auf ein Umpacken gewartet wurde.
///
/// **Im Arbeitsspeicher und nicht im Eintrag.** Nach einem Neustart
/// beginnt das Zählen von vorn — und das ist richtig: der Server hat
/// inzwischen vermutlich fertig gepackt.
private var wartend: [String: Int] = [:]
private func start(_ item: DownloadItem) {
guard let url = URL(string: item.sourceURLString) else {
fail(item, message: "Ungültige Adresse")
@@ -526,6 +592,7 @@ public final class DownloadCoordinator {
}
func handleCompletion(ratingKey: String, temporaryFile: URL) {
wartend[ratingKey] = nil
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
let sourceURL = URL(string: item.sourceURLString)
else { return }
@@ -608,6 +675,49 @@ public final class DownloadCoordinator {
}
/// Der Server hat die Anfrage abgelehnt.
/// Wie oft auf ein Umpacken gewartet wird, bevor aufgegeben wird.
///
/// **Grosszügig, und das mit Absicht.** Ein Dreistünder in 4K braucht auf
/// einem NAS Minuten; abzubrechen, während die Arbeit läuft, wäre die
/// eine Antwort, die niemandem hilft. Zwanzig Versuche im Abstand von
/// fünfzehn Sekunden sind fünf Minuten.
private static let hoechstensWarten = 20
private static let wartezeit = Duration.seconds(15)
/// Der Server packt noch um — später noch einmal fragen.
///
/// **Nicht sofort.** Ein Umpacken dauert Minuten, und im Sekundentakt
/// nachzufragen hiesse, dieselbe Datei hundertmal anzufangen und wieder
/// wegzuwerfen.
func handleNotReady(ratingKey: String) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
let versuche = (wartend[ratingKey] ?? 0) + 1
wartend[ratingKey] = versuche
guard versuche <= Self.hoechstensWarten else {
wartend[ratingKey] = nil
fail(item, message: String(localized: "downloads.failed.notReady", bundle: .module))
Task { await startNextIfPossible() }
return
}
// **Zurück in die Warteschlange, nicht in den Fehler.** Es ist
// nichts schiefgegangen; es ist nur noch nicht fertig.
item.state = .waiting
save()
// **Nicht `resume`.** Das nimmt nur auf, was angehalten oder
// gescheitert ist — ein wartender Eintrag lief dort in die
// Schutzklausel und blieb für immer auf „wartet" stehen, während der
// Server längst fertig gepackt hatte. Die Warteschlange abzuarbeiten
// ist das, was hier gebraucht wird: `.waiting` steht darin.
Task { [weak self] in
try? await Task.sleep(for: Self.wartezeit)
await self?.startNextIfPossible()
}
}
func handleServerRejection(ratingKey: String, status: Int) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
fail(item, message: String(localized: "downloads.failed.status \(status)", bundle: .module))
@@ -740,6 +850,21 @@ final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, @unch
) {
guard let key = downloadTask.taskDescription else { return }
// **202 heisst: noch nicht, komm wieder.**
//
// Der Server packt eine Datei um, die so nicht ausgeliefert werden
// kann, und antwortet währenddessen mit 202 und einem kurzen Text.
// Das ist ein Erfolgscode, also ging er durch die Prüfung darunter
// glatt hindurch: 163 Byte Antworttext lagen danach als Film auf dem
// Gerät, „in einer Sekunde heruntergeladen".
if let response = downloadTask.response as? HTTPURLResponse,
response.statusCode == 202 {
Task { @MainActor [weak coordinator] in
coordinator?.handleNotReady(ratingKey: key)
}
return
}
// `URLSessionDownloadTask` gilt auch bei 404 oder 500 als erfolgreich
// und legt die Fehlerseite als Datei ab. Ohne diese Prüfung wurde eine
// zwei Kilobyte große HTML-Seite als fertiger Film verbucht — in einer
@@ -58,6 +58,29 @@ public struct DownloadFileStore: Sendable {
return "\(key).\(ext)"
}
/// Der Name des Plakats zu einem Titel.
///
/// **Eine eigene Datei, kein Anhängsel am Film.** Sie ist ein paar
/// Kilobyte gross und wird gebraucht, solange der Eintrag steht — auch
/// während der Film noch lädt und noch gar keine Datei hat.
public func artworkFileName(forRatingKey key: String) -> String { "\(key).bild" }
/// Legt ein geholtes Plakat ab. Schlägt es fehl, gibt es eben keines.
///
/// **Wirft nicht.** Ein Download ohne Bild ist ein Download; ein
/// Download, der am Bild scheitert, wäre keiner.
@discardableResult
public func store(_ data: Data, as name: String) -> URL? {
let ziel = url(forFileName: name)
do {
try createRootIfNeeded()
try data.write(to: ziel, options: .atomic)
return ziel
} catch {
return nil
}
}
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
///
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
@@ -1,6 +1,40 @@
{
"sourceLanguage": "en",
"strings": {
"downloads.failed.notReady": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server packt diese Datei noch um. Versuch es in ein paar Minuten noch einmal."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server is still repackaging this file. Try again in a few minutes."
}
}
}
},
"downloads.failed.status %lld": {
"comment": "Der Server hat die Download-Anfrage abgelehnt",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server refused the download with error %lld."
}
}
}
},
"downloads.failed.tooSmall %@": {
"comment": "Die heruntergeladene Datei ist zu klein, um ein Video zu sein",
"localizations": {
@@ -34,23 +68,6 @@
}
}
}
},
"downloads.failed.status %lld": {
"comment": "Der Server hat die Download-Anfrage abgelehnt",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat den Download mit Fehler %lld abgelehnt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server refused the download with error %lld."
}
}
}
}
},
"version": "1.0"
@@ -135,14 +135,14 @@ public final class AVPlayerEngine: VideoPlayerEngine {
//
// Serverneutral: wo kein Cookie liegt, ist die Liste leer und es
// ändert sich nichts.
let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? []
let cookies = 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,
AVURLAssetHTTPCookiesKey: cookies,
])
let item = AVPlayerItem(asset: asset)
@@ -80,6 +80,17 @@ public actor StreamCache {
configuration.urlCache = nil
configuration.httpMaximumConnectionsPerHost = 4
configuration.timeoutIntervalForRequest = 30
// **Die Anmeldung muss mit.**
//
// Eine flüchtige Sitzung bringt ihren eigenen, leeren Cookie-Speicher
// mit — jede Anfrage von hier ging also unangemeldet hinaus. Bei
// Jellyfin fiel das nie auf: dort steht der Schlüssel in der Adresse.
// Flaro meldet über ein Cookie an, und im Protokoll stand genau das:
// `FlaroToken was challenged`, einmal je Wiedergabe, gefolgt von
// zwölf Sekunden Stille und einer zweiten Bestellung.
configuration.httpCookieStorage = HTTPCookieStorage.shared
configuration.httpShouldSetCookies = true
self.session = URLSession(configuration: configuration)
self.directory = FileManager.default.temporaryDirectory
@@ -86,38 +86,79 @@ public actor FlaroBackend: MediaBackend {
// Einstellungen — „Gesehenes in Neuzugängen ausblenden" und
// „Aufmacher nur aus Neuzugängen" —, und beide taten an einem
// Flaro-Server deshalb nichts.
for mediathek in mediatheken {
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
let abfrage = [
URLQueryItem(name: "library", value: mediathek.id),
URLQueryItem(name: "sort", value: "added"),
URLQueryItem(name: "take", value: "24"),
]
let titel: [MediaItem]
if mediathek.kind == .show {
guard let seite = try? await client.send(
FlaroRequest(path: pfad, query: abfrage),
as: Wire.Page<Wire.Series>.self) else { continue }
titel = seite.items.map { $0.neutral() }
} else {
guard let seite = try? await client.send(
FlaroRequest(path: pfad, query: abfrage),
as: Wire.Page<Wire.Movie>.self) else { continue }
titel = seite.items.map { $0.neutral() }
// **Nebeneinander, nicht nacheinander.**
//
// Hier stand eine Schleife mit `await` darin: bei vier Mediatheken
// vier Rundreisen hintereinander, jede erst nach der vorigen. Die
// Weboberfläche holt dieselben Reihen in einem `Promise.all` — und
// sie holt sogar mehr davon. Genau das war der Unterschied, den man
// der App ansah.
//
// Ein Actor steht dem nicht im Weg: jede Anfrage gibt ihn beim
// Warten wieder frei, so wie die beiden darüber es schon tun.
let neuzugaenge = await withTaskGroup(of: (Int, MediaRowGroup?).self) { gruppe in
for (platz, mediathek) in mediatheken.enumerated() {
gruppe.addTask { (platz, await self.neuzugang(in: mediathek)) }
}
guard !titel.isEmpty else { continue }
reihen.append(MediaRowGroup(
id: "latest.\(mediathek.id)",
title: String(
localized: "flaro.row.latestIn \(mediathek.title)",
bundle: .module),
libraryID: mediathek.id,
items: titel))
// **Die Reihenfolge der Mediatheken, nicht die der Antworten.**
// Sonst stünden die Reihen nach jedem Start woanders — je
// nachdem, welche Abfrage zuerst zurückkam.
var gesammelt: [(Int, MediaRowGroup?)] = []
for await ergebnis in gruppe { gesammelt.append(ergebnis) }
return gesammelt.sorted { $0.0 < $1.0 }.compactMap(\.1)
}
reihen.append(contentsOf: neuzugaenge)
return reihen
}
/// Die Reihe „Zuletzt hinzugefügt" einer Mediathek — oder nichts.
///
/// **Ein Fehler bei einer Mediathek nimmt nicht die anderen mit.** Die
/// Startseite mit drei von vier Reihen ist besser als gar keine.
private func neuzugang(in mediathek: MediaLibrary) async -> MediaRowGroup? {
let pfad = mediathek.kind == .show ? "/api/v1/series" : "/api/v1/movies"
let abfrage = [
URLQueryItem(name: "library", value: mediathek.id),
URLQueryItem(name: "sort", value: "added"),
URLQueryItem(name: "take", value: "24"),
]
let titel: [MediaItem]
if mediathek.kind == .show {
guard let seite = try? await client.send(
FlaroRequest(path: pfad, query: abfrage),
as: Wire.Page<Wire.Series>.self) else { return nil }
titel = seite.items.map { $0.neutral() }
} else {
guard let seite = try? await client.send(
FlaroRequest(path: pfad, query: abfrage),
as: Wire.Page<Wire.Movie>.self) else { return nil }
titel = seite.items.map { $0.neutral() }
}
guard !titel.isEmpty else { return nil }
return MediaRowGroup(
id: "latest.\(mediathek.id)",
title: String(localized: "flaro.row.latestIn \(mediathek.title)", bundle: .module),
libraryID: mediathek.id,
items: titel)
}
public func availableLanguages(inSeries id: String) async -> AvailableLanguages? {
guard let roh = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(id)/languages"),
as: Wire.Sprachauswahl.self)
else { return nil }
return AvailableLanguages(
audio: roh.audio.map { LanguageOption(code: $0.code, name: $0.name) },
subtitles: roh.subtitles.map { LanguageOption(code: $0.code, name: $0.name) },
partial: roh.partial)
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
@@ -198,11 +239,29 @@ public actor FlaroBackend: MediaBackend {
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
try await nachbar(von: item, weg: "next")
}
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
try await nachbar(von: item, weg: "previous")
}
/// Die Folge vor oder nach dieser.
///
/// 204 heisst: es gibt keine. Kein Fehler, nur das Ende der Serie —
/// oder ihr Anfang.
private func nachbar(von item: MediaItem, weg: String) 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"))
let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/\(weg)"))
guard !daten.isEmpty else { return nil }
guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil }
// **Der eingerichtete Dekoder, nicht ein frischer.** Ein frischer
// kennt das Datumsformat des Servers nicht; sobald die Nachbarfolge
// ein `publishAt` oder ein `lastPlayedAt` mitbringt — und mit
// eingeschalteter Prüfung bringt sie es —, scheiterte das Lesen.
// Das `try?` schluckte den Fehler, und von aussen sah es aus, als
// gäbe es keine nächste Folge: der Knopf blieb weg.
guard let folge = try? FlaroClient.decoder.decode(
Wire.Episode.self, from: daten) else { return nil }
return folge.neutral()
}
@@ -249,6 +308,32 @@ public actor FlaroBackend: MediaBackend {
/// 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.
/// Wie die Vorschaubilder einer Datei liegen.
///
/// **Der Server erzeugt sie beim Abspielen.** Steht noch nichts da,
/// antwortet er mit `fertig: false` — dann gibt es hier nichts, und
/// der Spieler fragt später noch einmal.
public func vorschauplan(fileId: String) async -> Vorschauplan? {
guard let auskunft = try? await client.send(
FlaroRequest(path: "/api/v1/files/\(fileId)/vorschau"),
as: Wire.Vorschau.self), auskunft.fertig
else { return nil }
return Vorschauplan(
intervall: auskunft.intervall,
spalten: auskunft.spalten,
zeilen: auskunft.zeilen,
kachelBreite: Double(auskunft.kachelBreite),
kachelHoehe: Double(auskunft.kachelHoehe),
blaetter: auskunft.blaetter)
}
/// **Ohne Kopfzeilen, wie bei den Plakaten.** Die Anmeldung hängt am
/// Cookie; der Bildlader holt die Adresse roh und kommt damit durch.
public nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? {
client.url(path: "/api/v1/files/\(fileId)/vorschau/\(blatt).jpg")
}
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
rawImageURL(path: path)
}
@@ -302,6 +387,50 @@ public actor FlaroBackend: MediaBackend {
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
// **Erst der Strom, und nur bei dessen Scheitern die fertige Datei.**
//
// Hier stand ausschliesslich der zweite Weg: `/download`, und dann
// im Sekundentakt nachfragen, bis die ganze MP4 auf der Platte liegt.
// Gemessen an einem Spielfilm waren das dreissig Sekunden Schwarz,
// während die Weboberfläche nach einer Sekunde lief — die holt sich
// eine HLS-Sitzung, und deren erstes Stück ist beim Umpacken in
// deutlich unter einer Sekunde da.
if let strom = try? await client.send(
.json(.post, "/api/v1/files/\(intent.file.id)/stream", Wire.StreamRequest(
height: intent.transcode?.resolution.flatMap(Self.hoehe),
// Ohne diese Angabe wählt der Server, und bei einem
// umgerechneten Strom kommt danach nur noch eine Spur an.
audioStream: intent.audioStreamIndex,
startSeconds: max(0, intent.startTime.seconds),
// Nur wenn der Spieler es ausdrücklich verlangt: das Profil
// entscheidet der Server, und er entscheidet es besser.
transcode: intent.transcode?.prefersRemux == false
&& intent.transcode?.maximumBitrate != nil)),
as: Wire.Stromauskunft.self),
// **Die Hauptliste, wo es sie gibt.** Nur dort stehen die
// Textspuren; die Medienliste trägt bloss Bild und Ton.
let adresse = client.url(path: strom.masterUrl ?? strom.url)
{
return PlaybackTarget(
url: adresse,
// Eine Direktwiedergabe legt nichts an, das aufzuräumen wäre.
sessionID: strom.method == "direct" ? nil : strom.sessionId,
versionID: intent.file.id,
startOffset: .seconds(strom.startSeconds))
}
return try await mitgenommeneDatei(intent)
}
/// Der alte Weg: warten, bis die fertige Datei auf der Platte liegt.
///
/// **Bleibt als Rückfall.** Er ist langsam, aber er kommt ohne
/// Stromsitzung aus — und wenn die nicht zustande kommt, ist eine späte
/// Wiedergabe besser als keine.
private func mitgenommeneDatei(
_ 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")
@@ -318,8 +447,6 @@ public actor FlaroBackend: MediaBackend {
// 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.
@@ -347,16 +474,27 @@ public actor FlaroBackend: MediaBackend {
}
}
// **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 {}
/// „1080p" wird zu 1080 — der Server kennt nur Zahlen.
private static func hoehe(_ aufloesung: String) -> Int? {
Int(aufloesung.filter(\.isNumber))
}
public func reportProgress(_ report: PlaybackReport) async {
/// Schliesst die Sitzung, damit nichts weiterrechnet.
///
/// **Das war eine leere Methode**, und sie passte zu einer Zeit, in der
/// es gar keine Sitzung gab: eine fertige Datei auf der Platte rechnet
/// nicht weiter. Mit dem Strom gibt es wieder einen Prozess, der ohne
/// diesen Aufruf den ganzen Film zu Ende schreibt, während längst
/// niemand mehr zusieht.
public func endPlayback(sessionID: String) async {
_ = try? await client.send(FlaroRequest(
method: .delete, path: "/api/v1/stream/\(sessionID)"))
}
public func reportProgress(_ report: PlaybackReport) async -> Bool {
// 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.
@@ -364,10 +502,15 @@ public actor FlaroBackend: MediaBackend {
// 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)))
do {
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)))
return true
} catch {
return false
}
}
// MARK: - Zustand melden
@@ -95,10 +95,14 @@ enum Wire {
let overview: String?
let posterImage: String?
let backdropImage: String?
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
let heroImage: String?
let files: [File]
let state: ItemState?
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
let publishAt: Date?
/// Der Abspann, sofern er im Bild gefunden wurde.
let markers: [Marker]?
}
struct Series: Decodable, Sendable {
@@ -108,6 +112,8 @@ enum Wire {
let overview: String?
let posterImage: String?
let backdropImage: String?
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
let heroImage: String?
let episodeCount: Int
let seasons: [Int]
let unwatchedCount: Int
@@ -127,6 +133,20 @@ enum Wire {
/// Zu welcher Serie sie gehört — ohne das ist eine Folge für sich
/// nicht darstellbar: Titel und Bild der Serie fehlten.
let seriesId: String?
/// Vorspann und Abspann, sofern der Server sie gefunden hat.
///
/// Fehlt bei älteren Servern ganz — deshalb optional und nicht eine
/// leere Liste: ein `[]` im Vertrag zwänge jeden Server, das Feld zu
/// schicken.
let markers: [Marker]?
}
/// Ein überspringbarer Abschnitt.
struct Marker: Decodable, Sendable {
/// „intro" oder „credits" — dieselben Wörter wie bei Jellyfin.
let kind: String
let startSeconds: Double
let endSeconds: Double
}
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
@@ -140,6 +160,8 @@ enum Wire {
let year: Int?
let posterImage: String?
let backdropImage: String?
/// Das Bild, das jetzt im Aufmacher gilt — vom Server gewählt.
let heroImage: String?
let state: ItemState
/// Zu welcher Serie die Folge gehört.
let seriesId: String?
@@ -178,6 +200,51 @@ enum Wire {
// --- Was hinausgeht --------------------------------------------------
/// Was das Gerät kann und was gewünscht ist.
///
/// **Ohne Codec-Angaben nimmt der Server Apples Profil an**, und das ist
/// hier genau richtig: dieses Gerät *ist* Apple. Eine eigene Liste wäre
/// eine zweite Wahrheit neben der, die der Server ohnehin pflegt.
struct StreamRequest: Encodable, Sendable {
var height: Int?
var audioStream: Int?
var startSeconds: Double
var transcode: Bool
}
struct Stromspur: Decodable, Sendable {
let index: Int
let label: String
let language: String?
let isDefault: Bool
}
struct Stromauskunft: Decodable, Sendable {
let sessionId: String
let url: String
/// Dieselbe Wiedergabe mit Untertitelspuren — fehlt bei Direktwiedergabe.
let masterUrl: String?
/// „direct", „remux" oder „transcode".
let method: String
/// Ab welcher Sekunde des Films dieser Strom beginnt.
let startSeconds: Double
let height: Int?
let audioStream: Int?
let audioStreams: [Stromspur]
let subtitleStreams: [Stromspur]
}
struct Sprachangabe: Decodable, Sendable {
let code: String
let name: String
}
struct Sprachauswahl: Decodable, Sendable {
let audio: [Sprachangabe]
let subtitles: [Sprachangabe]
let partial: Int
}
struct LoginRequest: Encodable, Sendable {
let userName: String
let password: String
@@ -194,3 +261,16 @@ enum Wire {
struct FavouriteRequest: Encodable, Sendable { let favourite: Bool }
struct RatingRequest: Encodable, Sendable { let rating: Int? }
}
extension Wire {
/// Was der Server über die Vorschaubilder sagt.
struct Vorschau: Decodable, Sendable {
let fertig: Bool
let intervall: Double
let spalten: Int
let zeilen: Int
let kachelBreite: Int
let kachelHoehe: Int
let blaetter: Int
}
}
@@ -16,13 +16,23 @@ extension Wire.Movie {
duration: files.first?.durationSeconds.map { Int($0 * 1000) },
thumb: posterImage,
art: backdropImage,
heroArt: heroImage,
playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
versions: [Wire.fassung(id: id, files: files)],
// **Nur der Abspann.** Einen Vorspann gibt es bei einem Film
// nicht zu finden: erkannt wird er daran, dass er sich über
// die Folgen einer Staffel wiederholt.
markers: (markers ?? []).map {
MediaMarker(
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
startMilliseconds: Int($0.startSeconds * 1000),
endMilliseconds: Int($0.endSeconds * 1000))
}
)
}
}
@@ -37,6 +47,7 @@ extension Wire.Series {
year: year,
thumb: posterImage,
art: backdropImage,
heroArt: heroImage,
// **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
@@ -72,7 +83,16 @@ extension Wire.Episode {
isFavourite: state?.favourite ?? false,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)]
versions: [Wire.fassung(id: id, files: files)],
// **Vorspann und Abspann, wenn der Server sie kennt.**
// Ältere Server schicken das Feld gar nicht; dann ist die
// Liste leer und die Spieler zeigen schlicht keinen Knopf.
markers: (markers ?? []).map {
MediaMarker(
kind: MediaMarker.Kind(rawValue: $0.kind) ?? .unknown,
startMilliseconds: Int($0.startSeconds * 1000),
endMilliseconds: Int($0.endSeconds * 1000))
}
)
}
}
@@ -98,6 +118,7 @@ extension Wire.Started {
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
seriesThumb: folge ? posterImage : nil,
heroArt: heroImage,
index: episode,
parentIndex: season,
seriesTitle: folge ? title : nil,
@@ -106,7 +106,7 @@ extension JellyfinBackend {
))
}
public func reportProgress(_ report: PlaybackReport) async {
public func reportProgress(_ report: PlaybackReport) async -> Bool {
var fields: [String: Any] = [
"ItemId": report.itemID,
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
@@ -126,13 +126,18 @@ extension JellyfinBackend {
default: "/Sessions/Playing/Progress"
}
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
_ = try? await client.send(JellyfinRequest(
method: .post,
path: path,
body: body,
timeout: .seconds(10)
))
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return false }
do {
try await client.send(JellyfinRequest(
method: .post,
path: path,
body: body,
timeout: .seconds(10)
))
return true
} catch {
return false
}
}
// MARK: - Hilfen
@@ -130,19 +130,34 @@ public func isInLocalNetwork() async -> Bool {
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
// an Folgen vorbei.
for library in libraries {
let items = try? await latestItems(limit: 20, inLibrary: library.id)
rows.append(MediaRowGroup(
id: "latest.\(library.id)",
title: String(
localized: "row.latestIn \(library.title)",
bundle: .module
),
libraryID: library.id,
items: items ?? []
))
//
// **Nebeneinander, nicht nacheinander.** Hier stand ein `await` in
// der Schleife: bei vier Mediatheken vier Rundreisen hintereinander.
// Die Reihenfolge der Mediatheken bleibt trotzdem erhalten — sonst
// stünden die Reihen nach jedem Start woanders.
let latest = await withTaskGroup(of: (Int, MediaRowGroup).self) { group in
for (place, library) in libraries.enumerated() {
group.addTask {
let items = try? await self.latestItems(limit: 20, inLibrary: library.id)
return (place, MediaRowGroup(
id: "latest.\(library.id)",
title: String(
localized: "row.latestIn \(library.title)",
bundle: .module
),
libraryID: library.id,
items: items ?? []
))
}
}
var collected: [(Int, MediaRowGroup)] = []
for await result in group { collected.append(result) }
return collected.sorted { $0.0 < $1.0 }.map(\.1)
}
rows.append(contentsOf: latest)
rows.append(MediaRowGroup(
id: "favourites",
title: String(localized: "row.favourites", bundle: .module),
@@ -333,6 +348,20 @@ public func isInLocalNetwork() async -> Bool {
return next.first { $0.id != item.id }
}
/// Die Folge davor.
///
/// **Über die Folgenliste der Serie, nicht über einen eigenen
/// Endpunkt.** Jellyfin hat ein „NextUp", aber kein Gegenstück dazu —
/// und die Liste gibt es ohnehin, weil die Serienseite sie zeigt. Eine
/// Anfrage für einen Knopf, der selten gedrückt wird, ist der bessere
/// Tausch gegenüber gar keinem Knopf.
public func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
guard let seriesID = item.seriesID else { return nil }
let folgen = try await episodes(inSeries: seriesID)
guard let stelle = folgen.firstIndex(where: { $0.id == item.id }), stelle > 0 else { return nil }
return folgen[stelle - 1]
}
// MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
@@ -0,0 +1,126 @@
import Foundation
import Observation
import OSLog
/// Fortschritt, der den Server nicht erreicht hat.
///
/// **Was ohne sie verlorenging.** Eine Folge im Flugzeug zur Hälfte
/// anzusehen, war für den Server ein Vorgang, der nie stattgefunden hat:
/// jede Meldung lief in die tote Leitung, und beim nächsten Verbinden stand
/// der Titel wieder bei null — auf dem Fernseher, im Web, überall. Genau
/// dieser Fall ist der Grund, aus dem man etwas herunterlädt.
///
/// **Eine Meldung je Titel, die letzte gewinnt.** Wer eine Stunde schaut,
/// erzeugt an die hundert Meldungen; nachzureichen ist davon genau eine
/// sinnvoll — die, an der er aufgehört hat. Alles andere wäre ein Protokoll
/// der Vergangenheit, das der Server erst wieder zusammenrechnen müsste.
///
/// Auf der Platte, nicht im Speicher: wer die App im Flugmodus beendet, hat
/// sonst umsonst geschaut.
@MainActor
@Observable
public final class Fortschrittspost {
/// Der Briefkasten der App.
///
/// Einer, wie bei `LibraryCache`: zwei Instanzen auf dieselbe Datei
/// würden einander überschreiben. Der Bauweg mit eigener Ablage bleibt
/// für Prüfungen offen.
public static let shared = Fortschrittspost()
/// Ein zurückgelegter Stand.
public struct Eintrag: Codable, Sendable, Equatable {
/// Zu welchem Server er gehört.
///
/// **Sonst ginge er an den falschen.** Wer sich abmeldet und an
/// einem anderen Server anmeldet, bekäme sonst fremde Kennungen
/// mit dem eigenen Sehfortschritt gemeldet.
let server: String
let titelID: String
let sekunden: Double
let laufzeitSekunden: Double?
let zeitpunkt: Date
}
private let ablage: URL
private let logger = Logger(subsystem: "app.flaro", category: "Fortschrittspost")
private var offen: [String: Eintrag]
/// Wie viele Stände noch auf einen Server warten.
public var wartend: Int { offen.count }
public init(ablage: URL? = nil) {
self.ablage = ablage ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appending(path: "flaro-fortschritt.json")
self.offen = Self.lesen(from: self.ablage) ?? [:]
}
/// Legt einen Stand zurück, weil er nicht ankam.
public func zuruecklegen(_ report: PlaybackReport, server: String) {
offen[report.itemID] = Eintrag(
server: server,
titelID: report.itemID,
sekunden: report.time.seconds,
laufzeitSekunden: report.duration > .zero ? report.duration.seconds : nil,
zeitpunkt: Date()
)
schreiben()
}
/// Versucht, alles Liegengebliebene loszuwerden.
///
/// **Was weiterhin scheitert, bleibt liegen.** Ein halb geleerter
/// Briefkasten ist kein Fehler — beim nächsten Versuch geht der Rest.
///
/// Nacheinander und nicht nebeneinander: es sind selten mehr als eine
/// Handvoll, und zwanzig gleichzeitige Anfragen an einen Server, der
/// gerade erst wieder da ist, wären der falsche erste Gruß.
public func zustellen(über backend: (any Fortschrittsempfaenger)?) async {
guard let backend, !offen.isEmpty else { return }
let kennung = backend.identity
// **Nicht ewig aufheben.** Ein Stand von vor einem halben Jahr sagt
// nichts mehr darüber, wo jemand heute weiterschauen will — und ein
// Server, der nie wiederkommt, hinterliesse sonst eine Datei, die
// nur wächst.
let frist = Date().addingTimeInterval(-30 * 24 * 60 * 60)
offen = offen.filter { $0.value.zeitpunkt > frist }
for eintrag in offen.values.sorted(by: { $0.zeitpunkt < $1.zeitpunkt })
where eintrag.server == kennung {
let angekommen = await backend.reportProgress(PlaybackReport(
itemID: eintrag.titelID,
state: .stopped,
time: .seconds(eintrag.sekunden),
duration: .seconds(eintrag.laufzeitSekunden ?? 0)
))
guard angekommen else { break }
// **Nur den, der tatsächlich ankam.** Währenddessen kann
// derselbe Titel neu zurückgelegt worden sein; den zu
// entfernen hiesse, den neueren Stand wegzuwerfen.
if offen[eintrag.titelID] == eintrag { offen[eintrag.titelID] = nil }
}
schreiben()
}
/// Alles vergessen — beim Abmelden.
public func leeren() {
offen = [:]
try? FileManager.default.removeItem(at: ablage)
}
// MARK: - Platte
private func schreiben() {
do {
try JSONEncoder().encode(offen).write(to: ablage, options: .atomic)
} catch {
logger.warning("Nicht gespeichert: \(error.localizedDescription, privacy: .public)")
}
}
private static func lesen(from url: URL) -> [String: Eintrag]? {
guard let daten = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode([String: Eintrag].self, from: daten)
}
}
@@ -111,6 +111,41 @@ public actor LibraryCache {
}
}
/// Die Reihen der Startseite.
///
/// **Weil es sonst nichts gibt, womit sie sich füllen liesse.** Die
/// Mediathekenliste und die Titel darin liegen schon auf der Platte; die
/// Startseite dagegen entstand bei jedem Aufruf neu am Server, und ohne
/// ihn blieb sie leer. Im Flugzeug ist das der Unterschied zwischen
/// „sehen, was es gibt" und einem Fehlerbildschirm.
public func homeRows(server: String) -> [MediaRowGroup]? {
guard let daten = try? Data(contentsOf: homeRowsFile(server: server)) else { return nil }
return try? JSONDecoder().decode([MediaRowGroup].self, from: daten)
}
public func storeHomeRows(_ rows: [MediaRowGroup], server: String) {
// Je Reihe eine Bildschirmbreite, mehr sieht ohnehin niemand, bevor
// der Server wieder antwortet.
let gekürzt = rows.map {
MediaRowGroup(
id: $0.id,
title: $0.title,
moreKey: $0.moreKey,
libraryID: $0.libraryID,
items: $0.items.prefix(20).map(\.listEssentials)
)
}
let ziel = homeRowsFile(server: server)
do {
try FileManager.default.createDirectory(
at: ziel.deletingLastPathComponent(), withIntermediateDirectories: true
)
try JSONEncoder().encode(gekürzt).write(to: ziel, options: .atomic)
} catch {
logger.warning("Startseite nicht gespeichert: \(error.localizedDescription, privacy: .public)")
}
}
// MARK: - Aufräumen
/// Alles zu einem Server vergessen — beim Abmelden.
@@ -139,6 +174,10 @@ public actor LibraryCache {
directory(server: server).appending(path: "libraries.json")
}
private func homeRowsFile(server: String) -> URL {
directory(server: server).appending(path: "home.json")
}
/// Ein Name, der als Datei taugt.
///
/// Kennungen kommen vom Server: bei Jellyfin GUIDs, bei Plex Zahlen, und
@@ -6,9 +6,13 @@ import Foundation
/// Anmeldung, Jellyfins Verwaltung, die Eigenheiten des Programmführers —
/// steht **nicht** hier, sondern als eigene Fähigkeit. Ein gemeinsamer Nenner,
/// der alles umfasst, wäre kein Nenner mehr.
public protocol MediaBackend: Sendable {
/// Wie der Server heißt, so wie er sich selbst nennt.
nonisolated var serverName: String { get }
/// Wer Sehfortschritt entgegennimmt.
///
/// **Ein Ausschnitt aus `MediaBackend`, und zwar genau der, den die
/// `Fortschrittspost` braucht.** Ohne ihn müsste jeder, der das Nachreichen
/// prüfen will, einen vollständigen Server nachbauen — vierzig Methoden für
/// die zwei, die zur Sache gehören.
public protocol Fortschrittsempfaenger: Sendable {
/// Wer dieser Server ist — für alles, was ihn über einen Start hinaus
/// wiedererkennen muss.
///
@@ -17,6 +21,24 @@ public protocol MediaBackend: Sendable {
/// beides überlebt und beim nächsten Start noch gleich lautet. Es ist
/// eine Kennung und geht nirgends an die Oberfläche.
nonisolated var identity: String { get }
/// Meldet, wo man gerade steht.
///
/// 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.
///
/// **Gibt aber zurück, ob sie ankam.** Im Flugzeug geht jede einzelne
/// verloren, und am Ende stünde ein zur Hälfte gesehener Film auf dem
/// Server weiter bei null. Der Aufrufer legt sie dann beiseite und
/// meldet sie nach — siehe `Fortschrittspost`.
@discardableResult
func reportProgress(_ report: PlaybackReport) async -> Bool
}
public protocol MediaBackend: Sendable, Fortschrittsempfaenger {
/// Wie der Server heißt, so wie er sich selbst nennt.
nonisolated var serverName: String { get }
/// Was dieser Server darüber hinaus beherrscht.
nonisolated var capabilities: Set<BackendCapability> { get }
@@ -60,9 +82,26 @@ public protocol MediaBackend: Sendable {
/// dreizehn Anfragen — für einen Klick auf eine Kachel ist das zu viel.
/// Ein Server, der es in einer Anfrage kann, soll das dürfen.
func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem]
/// Welche Sprachen eine Serie durchgehend führt — für die Vorwahl.
func availableLanguages(inSeries id: String) async -> AvailableLanguages?
/// Die nächste Folge nach dieser, sofern es eine gibt.
func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem?
/// Die Folge davor — für den Rückwärtsknopf im Spieler.
///
/// **Mit Vorgabe, anders als die nächste.** Die nächste Folge braucht
/// jeder Server: ohne sie gibt es kein Weiterlaufen am Ende. Die vorige
/// ist eine Bequemlichkeit, und ein Server, der sie nicht kennt, soll
/// deshalb nicht unbaubar werden.
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem?
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
func vorschauplan(fileId: String) async -> Vorschauplan?
/// Die Adresse eines Vorschaublattes.
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL?
// MARK: - Bilder
/// Ein Bild in der gewünschten Größe.
@@ -125,13 +164,6 @@ public protocol MediaBackend: Sendable {
/// niemand mehr zusieht.
func endPlayback(sessionID: String) async
/// Meldet, wo man gerade steht.
///
/// 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.
func reportProgress(_ report: PlaybackReport) async
// MARK: - Zustand melden
func markWatched(id: String) async throws(MediaBackendError)
@@ -211,6 +243,19 @@ public struct MediaPage: Sendable, Equatable {
}
public extension MediaBackend {
/// Vorgabe: kein Weg zurück.
func previousEpisode(before item: MediaItem) async throws(MediaBackendError) -> MediaItem? { nil }
/// Wie die Vorschaubilder einer Datei liegen — `nil`, wenn es keine gibt.
///
/// **Mit Vorgabe, damit nicht jeder Server es können muss.** Jellyfin
/// hat sein eigenes Trickplay mit anderem Zuschnitt; bis das angebunden
/// ist, sagt es hier schlicht nichts.
func vorschauplan(fileId: String) async -> Vorschauplan? { nil }
/// Die Adresse eines Vorschaublattes.
nonisolated func vorschaublatt(fileId: String, blatt: Int) -> URL? { nil }
/// Vorgabe: der Server kann es nicht.
///
/// So muss nicht jeder Server eine Adresse erfinden, die es bei ihm nicht
@@ -227,6 +272,13 @@ public extension MediaBackend {
subtitleStreamIndex: Int?
) -> URL? { nil }
/// Welche Sprachen eine Serie durchgehend führt.
///
/// **`nil` heisst: der Server weiss es nicht.** Dann bleibt es bei der
/// vollen Liste — eine leere Auswahl anzubieten wäre schlimmer als eine
/// zu lange.
func availableLanguages(inSeries id: String) async -> AvailableLanguages? { nil }
/// Der allgemeine Weg: Staffeln, dann je Staffel die Folgen.
///
/// Langsam, aber überall gültig — wer es besser kann, überschreibt es.
@@ -261,3 +313,41 @@ public struct MediaFilterOption: Sendable, Equatable, Identifiable {
self.title = title
}
}
/// Eine Sprache, wie sie in einem Menü steht.
public struct LanguageOption: Sendable, Equatable, Identifiable, Hashable {
public let code: String
public let name: String
public var id: String { code }
public init(code: String, name: String) {
self.code = code
self.name = name
}
}
/// Welche Sprachen ein Titel durchgehend führt.
///
/// **Durchgehend, nicht irgendwo.** Bei einer Serie ist das die
/// Schnittmenge über alle Folgen: eine Sprache anzubieten, die es nur in
/// Staffel 1 gibt, hiesse eine Einstellung für die Serie, die ab Staffel 2
/// stillschweigend etwas anderes tut.
public struct AvailableLanguages: Sendable, Equatable {
public let audio: [LanguageOption]
public let subtitles: [LanguageOption]
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
///
/// **Damit die Oberfläche es sagen kann.** Eine gekürzte Liste sieht
/// sonst aus wie die ganze Wahrheit, und wer eine Sprache vermisst, die
/// er in Staffel 1 gesehen hat, hält es für einen Fehler.
public let partial: Int
public init(audio: [LanguageOption], subtitles: [LanguageOption], partial: Int) {
self.audio = audio
self.subtitles = subtitles
self.partial = partial
}
}
@@ -48,6 +48,13 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
/// die Serie, und so heißt es jetzt auch.
public let seriesThumb: String?
public let seriesArt: String?
/// Das Bild, das jetzt im Aufmacher gilt.
///
/// **Vom Server gewählt, nicht hier.** Wer zu einem Titel mehrere
/// Aufnahmen ausgesucht hat, bekommt alle paar Stunden eine andere — und
/// zwar überall dieselbe. `nil` heisst: keine ausgesucht, dann gilt das
/// Hintergrundbild.
public let heroArt: String?
// Einordnung bei Folgen: `index` ist die Folgennummer, `parentIndex` die
// Staffel.
@@ -126,6 +133,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
parentThumb: String? = nil,
seriesThumb: String? = nil,
seriesArt: String? = nil,
heroArt: String? = nil,
index: Int? = nil,
parentIndex: Int? = nil,
parentTitle: String? = nil,
@@ -166,6 +174,7 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
self.parentThumb = parentThumb
self.seriesThumb = seriesThumb
self.seriesArt = seriesArt
self.heroArt = heroArt
self.index = index
self.parentIndex = parentIndex
self.parentTitle = parentTitle
@@ -49,7 +49,7 @@ public struct MediaLibrary: Sendable, Equatable, Identifiable, Codable {
}
/// Eine Reihe auf der Startseite oder in den Suchergebnissen.
public struct MediaRowGroup: Sendable, Equatable, Identifiable {
public struct MediaRowGroup: Sendable, Equatable, Identifiable, Codable {
/// Eine stabile Kennung für diese Reihe.
///
/// Sie entscheidet, welche Reihe der Nutzer ausgeblendet oder verschoben
@@ -112,10 +112,29 @@ public struct PlaybackTarget: Sendable, Equatable {
/// ordnet es die Meldung keiner Wiedergabe zu.
public let versionID: String?
public init(url: URL, sessionID: String? = nil, versionID: String? = nil) {
/// Ab welcher Stelle des Films dieser Strom beginnt.
///
/// **Null heisst: der Strom ist der ganze Film.** Dann springt der
/// Spieler selbst, und das ist der Normalfall — bei Jellyfin immer.
///
/// **Ein Wert darüber heisst: der Server hat schon gesprungen.** Flaros
/// Abspielliste wächst von der gewünschten Stelle an vorwärts; was davor
/// liegt, gibt es in diesem Strom nicht. Der Spieler zählt trotzdem ab
/// null — ohne diesen Wert ginge jede angezeigte Zeit und jede gemeldete
/// Stelle um den Vorlauf daneben, und ein Sprung dorthin wäre ein Sprung
/// ins Leere.
public let startOffset: Duration
public init(
url: URL,
sessionID: String? = nil,
versionID: String? = nil,
startOffset: Duration = .zero
) {
self.url = url
self.sessionID = sessionID
self.versionID = versionID
self.startOffset = startOffset
}
}
@@ -0,0 +1,55 @@
import Foundation
/// Wie die Vorschaubilder eines Titels zusammengesetzt sind.
///
/// **Dieselbe Rechnung wie im Server und im Web.** Dort heisst der Typ
/// genauso; die Kachel zu einer Sekunde wird an drei Stellen ausgerechnet,
/// und wenn eine davon abweicht, sieht man es nicht als Fehler — man hält
/// die Vorschau für ein ungenaues Bild.
///
/// **Die Kachelmasse stehen nicht fest.** Skaliert wird auf eine feste
/// Breite, die Höhe folgt dem Seitenverhältnis des Films. Deshalb misst
/// der Server sie und teilt sie mit, statt dass sie hier stünden.
public struct Vorschauplan: Sendable, Equatable, Decodable {
public let intervall: Double
public let spalten: Int
public let zeilen: Int
public let kachelBreite: Double
public let kachelHoehe: Double
public let blaetter: Int
public init(
intervall: Double, spalten: Int, zeilen: Int,
kachelBreite: Double, kachelHoehe: Double, blaetter: Int
) {
self.intervall = intervall
self.spalten = spalten
self.zeilen = zeilen
self.kachelBreite = kachelBreite
self.kachelHoehe = kachelHoehe
self.blaetter = blaetter
}
/// Wie eine Kachel adressiert wird: welches Blatt, und wo darin.
public struct Stelle: Sendable, Equatable {
public let blatt: Int
public let spalte: Int
public let zeile: Int
}
public var proBlatt: Int { spalten * zeilen }
/// Welche Kachel die Stelle zeigt — oder nichts.
///
/// **Hinter dem letzten Bild bleibt das letzte stehen.** Das Raster
/// endet dort, wo ffmpeg aufgehört hat; eine Sekunde darüber hinaus —
/// bei ungenauer Laufzeit durchaus möglich — soll die Vorschau nicht
/// verschwinden.
public func stelle(bei sekunde: Double) -> Stelle? {
guard blaetter > 0, spalten > 0, zeilen > 0, intervall > 0 else { return nil }
let nummer = min(Int(max(0, sekunde) / intervall), proBlatt * blaetter - 1)
let rest = nummer % proBlatt
return Stelle(blatt: nummer / proBlatt, spalte: rest % spalten, zeile: rest / spalten)
}
}
@@ -0,0 +1,52 @@
import Foundation
import Testing
@testable import FlaroDesign
/// Ein Bild von der Platte muss ankommen.
///
/// **Der Fehler, für den diese Prüfung steht.** Der Lader verlangte von jeder
/// Antwort eine `HTTPURLResponse` — eine `file:`-Adresse antwortet aber mit
/// einer schlichten `URLResponse`, und die Prüfung gab darauf `nil` zurück.
/// Sichtbar wurde das erst, als die Downloads ihre Plakate mitbrachten: die
/// Dateien lagen da, und die Kacheln blieben trotzdem grau.
@Suite("Bilder von der Platte")
struct ArtworkLoaderTests {
/// Ein gültiges PNG, ein Bildpunkt gross.
private static let winzigesPNG = Data(base64Encoded: """
iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmM\
IQAAAABJRU5ErkJggg==
""")!
private func ablegen(_ daten: Data, als name: String) throws -> URL {
let ziel = FileManager.default.temporaryDirectory
.appending(path: "artwork-\(UUID().uuidString)-\(name)")
try daten.write(to: ziel)
return ziel
}
@Test("Eine Datei wird gelesen, nicht abgefragt")
func dateiWirdGelesen() async throws {
let datei = try ablegen(Self.winzigesPNG, als: "plakat.bild")
defer { try? FileManager.default.removeItem(at: datei) }
let bild = await ArtworkLoader.shared.image(for: datei)
#expect(bild != nil)
}
@Test("Eine Datei, die es nicht gibt, ergibt kein Bild")
func fehlendeDatei() async {
let nirgends = FileManager.default.temporaryDirectory
.appending(path: "gibt-es-nicht-\(UUID().uuidString).bild")
let bild = await ArtworkLoader.shared.image(for: nirgends)
#expect(bild == nil)
}
@Test("Was keine Bilddaten sind, ergibt kein Bild")
func keinBild() async throws {
let datei = try ablegen(Data("kein Bild, nur Text".utf8), als: "text.bild")
defer { try? FileManager.default.removeItem(at: datei) }
let bild = await ArtworkLoader.shared.image(for: datei)
#expect(bild == nil)
}
}
@@ -6,11 +6,11 @@ import MediaKit
/// Adressen und Zugang — alles, was ohne Netz entsteht.
@Suite("Flaro: Adressen und Zugang")
struct AdressenTests {
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
private func backend(_ transport: Attrappe, cookieglas: Cookieglas? = nil) -> FlaroBackend {
FlaroBackend(
client: FlaroClient(
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
transport: transport, cookieJar: keksglas),
transport: transport, cookieJar: cookieglas),
account: FlaroAccount(
baseURL: Proben.adresse, serverName: "Wohnzimmer",
userID: "u1", userName: "guido", isAdministrator: true))
@@ -32,17 +32,17 @@ struct AdressenTests {
}
@Test("Ein Zugang wird als Cookie abgelegt")
func tokenWirdZuKeks() async {
let glas = Keksglas()
func tokenWirdZuCookie() async {
let glas = Cookieglas()
_ = 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)
let cookie = glas.cookies.first
#expect(cookie?.name == "flaro_session")
#expect(cookie?.value == "geheim-123")
#expect(cookie?.path == "/")
#expect(cookie?.domain.hasSuffix("nas") == true)
}
/// **Nach einem Neustart meldet sich niemand neu an.**
@@ -51,8 +51,8 @@ struct AdressenTests {
/// 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()
func cookieAuchOhneAnmeldung() async throws {
let glas = Cookieglas()
let transport = Attrappe([
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
("/api/v1/system/info", 200, Proben.info),
@@ -62,7 +62,7 @@ struct AdressenTests {
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" })
#expect(glas.cookies.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
}
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
@@ -104,7 +104,7 @@ struct AdressenTests {
let (token, konto, _) = try await FlaroSignIn.signIn(
baseURL: Proben.adresse, userName: "guido", password: "geheim",
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
identity: Proben.kennung, transport: transport, cookieJar: Cookieglas())
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
@@ -46,15 +46,15 @@ final class Attrappe: FlaroTransport, @unchecked Sendable {
}
}
/// Ein Keksglas, das nur mitschreibt.
/// Ein Cookieglas, 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 {
final class Cookieglas: FlaroCookieJar, @unchecked Sendable {
private let sperre = NSLock()
private var inhalt: [HTTPCookie] = []
var kekse: [HTTPCookie] { sperre.withLock { inhalt } }
var cookies: [HTTPCookie] { sperre.withLock { inhalt } }
func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } }
@@ -0,0 +1,73 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Vorspann und Abspann auf dem Weg vom Server in den Spieler.
///
/// **Der Anlass:** im Web erschien der Überspringen-Knopf, in der App nie.
/// Dazwischen liegt genau diese Übersetzung — und ohne Prüfung liesse sich
/// nicht sagen, ob sie der Grund ist oder etwas dahinter.
@Suite("Flaro: Marken")
struct MarkenTests {
private static let folge = """
{
"id": "folge-1", "season": 1, "episode": 6,
"title": "Bestimmung", "overview": null, "stillImage": null,
"files": [{
"id": "datei-1", "fileName": "F.mkv", "sizeBytes": 1,
"container": "mkv", "durationSeconds": 2700, "streams": []
}],
"state": null, "publishAt": null, "seriesId": "serie-1",
"markers": [
{ "kind": "intro", "startSeconds": 30.5, "endSeconds": 65 },
{ "kind": "credits", "startSeconds": 2500, "endSeconds": 2700 }
]
}
"""
@Test("Eine Folge bringt Vorspann und Abspann mit")
func folgeMitMarken() throws {
let roh = try #require(
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(Self.folge.utf8)))
let titel = roh.neutral()
#expect(titel.markers.count == 2)
let intro = try #require(titel.markers.first { $0.kind == .intro })
#expect(intro.startMilliseconds == 30_500)
#expect(intro.endMilliseconds == 65_000)
#expect(intro.contains(45_000))
let abspann = try #require(titel.markers.first { $0.kind == .credits })
#expect(abspann.startMilliseconds == 2_500_000)
}
/// Ein älterer Server schickt das Feld gar nicht — das ist kein Fehler.
@Test("Ohne Marken bleibt die Liste leer")
func ohneMarken() throws {
let ohne = """
{
"id": "folge-1", "season": 1, "episode": 6,
"title": "Bestimmung", "overview": null, "stillImage": null,
"files": [], "state": null, "publishAt": null, "seriesId": "serie-1"
}
"""
let roh = try #require(
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(ohne.utf8)))
#expect(roh.neutral().markers.isEmpty)
}
/// Ein unbekanntes Wort wirft die Marke nicht weg — sie zählt nur nicht.
@Test("Eine unbekannte Art wird nicht zur falschen")
func unbekannteArt() throws {
let fremd = Self.folge.replacingOccurrences(of: "\"intro\"", with: "\"werbung\"")
let roh = try #require(
try? FlaroClient.decoder.decode(Wire.Episode.self, from: Data(fremd.utf8)))
let titel = roh.neutral()
#expect(titel.markers.count == 2)
#expect(titel.markers.first?.kind == .unknown)
}
}
@@ -0,0 +1,100 @@
import Foundation
import Testing
import MediaKit
@testable import FlaroServerKit
/// Die Folge nach dieser — über die Staffelgrenze hinweg.
///
/// **Der Anlass.** Im Web stand am Abspann „Nächste Folge", in der App nur
/// „Abspann überspringen". Der Server war es nicht: beide fragen denselben
/// Endpunkt, und er ordnet nach Staffel und dann nach Nummer, findet also
/// auch die erste Folge der nächsten Staffel.
///
/// **Es war der Dekoder.** `nachbar` baute sich einen frischen
/// `JSONDecoder()`, und der kennt das Datumsformat des Servers nicht.
/// Solange die Nachbarfolge weder `publishAt` noch `lastPlayedAt` hatte,
/// ging es gut — mit eingeschalteter Prüfung hat sie ein `publishAt`. Das
/// `try?` schluckte den Fehler, und von aussen sah es aus, als gäbe es
/// keine nächste Folge.
@Suite("Flaro: Nachbarfolge")
struct NachbarfolgeTests {
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))
}
private static func folge(
id: String, staffel: Int, nummer: Int,
publishAt: String = "null", zuletzt: String = "null"
) -> String {
"""
{
"id": "\(id)", "season": \(staffel), "episode": \(nummer),
"title": "Neuanfang", "overview": null, "stillImage": null,
"files": [{
"id": "d-\(id)", "fileName": "F.mkv", "sizeBytes": 1,
"container": "mkv", "durationSeconds": 2700, "streams": []
}],
"state": { "positionSeconds": 0, "runtimeSeconds": 2700,
"watched": false, "playCount": 0, "favourite": false,
"rating": null, "dismissed": false,
"lastPlayedAt": \(zuletzt), "inProgress": false },
"publishAt": \(publishAt), "seriesId": "serie-1", "markers": []
}
"""
}
private static let folgeJetzt = MediaItem(
id: "s1e10", kind: .episode, title: "Finale", index: 10, parentIndex: 1)
/// Das Staffelfinale: die nächste Folge ist die erste der nächsten Staffel.
@Test("Über die Staffelgrenze hinweg")
func ueberDieStaffelgrenze() async throws {
let backend = backend(Attrappe(rumpf: Self.folge(id: "s2e1", staffel: 2, nummer: 1)))
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
#expect(naechste.id == "s2e1")
#expect(naechste.parentIndex == 2)
#expect(naechste.index == 1)
}
/// **Der Fehler selbst.** Mit `publishAt` und `lastPlayedAt` scheiterte
/// das Lesen stillschweigend, und der Knopf blieb weg. Mit dem frischen
/// `JSONDecoder()` von vorher ist diese Prüfung rot.
@Test("Auch mit Datumsangaben — daran scheiterte es")
func mitDatumsangaben() async throws {
let backend = backend(Attrappe(rumpf: Self.folge(
id: "s2e1", staffel: 2, nummer: 1,
publishAt: "\"2026-10-01T18:00:00Z\"",
zuletzt: "\"2026-10-11T21:14:03.482Z\"")))
let naechste = try #require(await backend.nextEpisode(after: Self.folgeJetzt))
#expect(naechste.id == "s2e1")
#expect(naechste.availableFrom != nil)
}
/// Nach der letzten Folge kommt keine — 204, und das ist kein Fehler.
@Test("Hinter der letzten Folge kommt nichts")
func hinterDerLetzten() async throws {
let backend = backend(Attrappe(rumpf: "", status: 204))
#expect(try await backend.nextEpisode(after: Self.folgeJetzt) == nil)
}
/// Ein Film hat keine Nachbarn — und fragt gar nicht erst.
@Test("Ein Film fragt nicht nach Nachbarn")
func filmFragtNicht() async throws {
let attrappe = Attrappe(rumpf: Self.folge(id: "x", staffel: 1, nummer: 1))
let film = MediaItem(id: "f1", kind: .movie, title: "Arrival")
#expect(try await backend(attrappe).nextEpisode(after: film) == nil)
#expect(attrappe.anfragen.isEmpty)
}
}
@@ -0,0 +1,117 @@
import Foundation
import Testing
@testable import MediaKit
/// Ein Server, der auf Ansage annimmt oder nicht.
private final class Briefkasten: Fortschrittsempfaenger, @unchecked Sendable {
nonisolated let identity: String
/// Ob Meldungen angenommen werden.
var nimmtAn: Bool
private(set) var angekommen: [PlaybackReport] = []
init(identity: String = "server-a", nimmtAn: Bool = true) {
self.identity = identity
self.nimmtAn = nimmtAn
}
func reportProgress(_ report: PlaybackReport) async -> Bool {
guard nimmtAn else { return false }
angekommen.append(report)
return true
}
}
private func bericht(_ titel: String, sekunden: Double) -> PlaybackReport {
PlaybackReport(
itemID: titel,
state: .stopped,
time: .seconds(sekunden),
duration: .seconds(3600)
)
}
@Suite("Nachgereichter Fortschritt")
@MainActor
struct FortschrittspostTests {
/// Eine eigene Datei je Prüfung — sonst sähe die zweite, was die erste
/// liegen gelassen hat.
private func ablage() -> URL {
FileManager.default.temporaryDirectory
.appending(path: "post-\(UUID().uuidString).json")
}
@Test("Was ankam, wird nicht aufgehoben")
func nichtsLiegenLassen() async {
let post = Fortschrittspost(ablage: ablage())
let server = Briefkasten()
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
#expect(post.wartend == 1)
await post.zustellen(über: server)
#expect(post.wartend == 0)
#expect(server.angekommen.count == 1)
#expect(server.angekommen.first?.itemID == "film")
}
@Test("Was nicht ankommt, bleibt liegen")
func liegenbleiben() async {
let post = Fortschrittspost(ablage: ablage())
let server = Briefkasten(nimmtAn: false)
post.zuruecklegen(bericht("film", sekunden: 600), server: server.identity)
await post.zustellen(über: server)
#expect(post.wartend == 1)
server.nimmtAn = true
await post.zustellen(über: server)
#expect(post.wartend == 0)
}
@Test("Je Titel nur der letzte Stand")
func nurDerLetzteStand() async {
let post = Fortschrittspost(ablage: ablage())
let server = Briefkasten()
post.zuruecklegen(bericht("film", sekunden: 60), server: server.identity)
post.zuruecklegen(bericht("film", sekunden: 1800), server: server.identity)
#expect(post.wartend == 1)
await post.zustellen(über: server)
#expect(server.angekommen.count == 1)
#expect(server.angekommen.first?.time == .seconds(1800))
}
@Test("Ein fremder Server bekommt nichts")
func nichtAnDenFalschen() async {
let post = Fortschrittspost(ablage: ablage())
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
let anderer = Briefkasten(identity: "server-b")
await post.zustellen(über: anderer)
#expect(anderer.angekommen.isEmpty)
#expect(post.wartend == 1)
}
@Test("Der Briefkasten überlebt den Neustart")
func aufDerPlatte() async {
let datei = ablage()
let erste = Fortschrittspost(ablage: datei)
erste.zuruecklegen(bericht("folge", sekunden: 900), server: "server-a")
let zweite = Fortschrittspost(ablage: datei)
#expect(zweite.wartend == 1)
let server = Briefkasten()
await zweite.zustellen(über: server)
#expect(server.angekommen.first?.time == .seconds(900))
}
@Test("Leeren wirft alles weg")
func leeren() async {
let post = Fortschrittspost(ablage: ablage())
post.zuruecklegen(bericht("film", sekunden: 600), server: "server-a")
post.leeren()
#expect(post.wartend == 0)
}
}
@@ -0,0 +1,65 @@
import Foundation
import Testing
@testable import MediaKit
@Suite("Die gemerkte Startseite")
struct LibraryCacheHomeTests {
private func speicher() -> LibraryCache {
LibraryCache(root: FileManager.default.temporaryDirectory
.appending(path: "cache-\(UUID().uuidString)", directoryHint: .isDirectory))
}
private func titel(_ nummer: Int) -> MediaItem {
MediaItem(id: "t\(nummer)", kind: .movie, title: "Titel \(nummer)", thumb: "/bild/\(nummer)")
}
@Test("Was gespeichert wurde, kommt zurück")
func hinUndZurueck() async {
let cache = speicher()
let reihen = [
MediaRowGroup(id: "continue", title: "Weiterschauen",
libraryID: "mediathek", items: [titel(1), titel(2)]),
MediaRowGroup(id: "latest.1", title: "Neu", moreKey: "/mehr", items: [titel(3)]),
]
await cache.storeHomeRows(reihen, server: "server-a")
let gemerkt = await cache.homeRows(server: "server-a")
#expect(gemerkt?.map(\.id) == ["continue", "latest.1"])
#expect(gemerkt?.first?.title == "Weiterschauen")
#expect(gemerkt?.first?.libraryID == "mediathek")
#expect(gemerkt?.last?.moreKey == "/mehr")
#expect(gemerkt?.first?.items.map(\.id) == ["t1", "t2"])
// Ohne den Bildpfad wäre der Abzug wertlos: die Plakate liegen
// bereits auf der Platte, nur findet sie dann niemand.
#expect(gemerkt?.first?.items.first?.thumb == "/bild/1")
}
@Test("Ein anderer Server hat seine eigene Startseite")
func getrenntJeServer() async {
let cache = speicher()
await cache.storeHomeRows(
[MediaRowGroup(id: "continue", title: "Weiterschauen", items: [titel(1)])],
server: "server-a"
)
let fremd = await cache.homeRows(server: "server-b")
#expect(fremd == nil)
}
@Test("Ohne gespeicherten Stand kommt nichts")
func nochNichtsDa() async {
let gemerkt = await speicher().homeRows(server: "server-a")
#expect(gemerkt == nil)
}
@Test("Lange Reihen werden gekürzt")
func nichtAlles() async {
let cache = speicher()
let lang = (1...80).map(titel)
await cache.storeHomeRows(
[MediaRowGroup(id: "latest", title: "Neu", items: lang)], server: "server-a"
)
let gemerkt = await cache.homeRows(server: "server-a")
#expect(gemerkt?.first?.items.count == 20)
}
}
+167
View File
@@ -0,0 +1,167 @@
# Den Abspann im Bild finden — Entwurf
Der Tonabgleich findet Vorspann und Abspann, **weil sie sich wiederholen**:
dieselbe Musik in Folge 1 und Folge 2. Ein Film hat nichts, womit er sich
vergleichen liesse. Sein Abspann kommt genau einmal vor.
Was bleibt, ist das Bild. Dieser Entwurf sagt, was daran messbar ist, was es
kostet, und wo das Verfahren danebenliegt.
Kapitelmarken sind hier bewusst **nicht** der Weg: sie sind exakt, wo sie
stehen, aber bei einer gemischten Sammlung stehen sie selten.
---
## 1 · Das Signal, nachgemessen
Ein Abspann ist dunkel. Das ist keine Vermutung — nachgestellt mit einem
erzeugten Film (90 Sekunden Bild, danach 40 Sekunden weisse Schrift auf
schwarzem Grund), abgetastet mit zwei Bildern je Sekunde:
| Stelle | mittlere Helligkeit (`YAVG`, 0–255) |
|---|---|
| 0–89,5 s (Handlung) | **125** |
| ab 90,0 s (Abspann) | **18** |
Der Sprung liegt auf dem Bild genau, an dem der Abspann anfängt. Kein
Übergang, keine Grauzone — und das ist der Punkt: gesucht wird nicht „ein
dunkles Bild", sondern **die Kante**.
Dieselbe Richtung geht Jellyfins Intro Skipper. Dort heisst es
„Add support for using black frames to locate ending credits"; die Suche
läuft rückwärts vom Ende und hört auf, wenn der Bereich auf vier Sekunden
geschrumpft ist.
## 2 · Was es kostet
Das Rechnen ist umsonst, das **Dekodieren** ist der Preis. Gemessen an einer
erzeugten Datei, 1080p, h264, 180 Sekunden:
| Weg | Zeit |
|---|---|
| Jedes zweite Bild dekodieren (`fps=2`) | **2,89 s** |
| Nur Schlüsselbilder (`-skip_frame nokey`) | **0,24 s** |
Hochgerechnet auf die letzten 15 Minuten eines Films: der erste Weg kostet
rund anderthalb Minuten je Film, der zweite ein paar Sekunden. *Das ist eine
Hochrechnung, keine Messung an echtem Material.*
Es geht noch billiger, und so macht es Jellyfin: **nicht am Stück abtasten,
sondern zielen.** Binäre Suche mit Sonden von zwei Sekunden zwischen „sicher
Handlung" und „sicher Abspann" — zehn Sonden genügen für eine Genauigkeit von
vier Sekunden, und zehn mal zwei Sekunden Dekodierung sind nichts.
**Vorschlag: beides.** Erst eine grobe Reihe von Schlüsselbildern über die
letzten zwanzig Minuten (ein Durchlauf, wenige Sekunden) — die sagt, *ob* es
überhaupt eine dunkle Strecke am Ende gibt und ungefähr wo. Dann eine binäre
Suche mit kurzen Sonden auf die Kante. Der erste Schritt verhindert, dass die
binäre Suche in einem Film ohne dunklen Abspann wild herumspringt; der zweite
macht sie genau.
## 3 · Der Zuschnitt
Dieselbe Trennung wie beim Tonabgleich: **das Schwierige rein, das Dreckige
dünn.**
**`Bildabtaster` (Flaro.Media)** — der einzige Teil, der ffmpeg anfasst.
```
helligkeiten(pfad, ab, bis, schritt) -> [(sekunde, helligkeit)]
istDunkel(pfad, ab, dauer) -> bool
```
Beides über `signalstats` beziehungsweise `blackdetect`, als eigener Prozess
wie `FfprobeRunner`. Fehlt ffmpeg oder scheitert der Lauf: leere Antwort,
keine Marke, kein Abbruch.
**`Abspannkante` (Flaro.Core)** — reine Rechnung, ohne Datei:
```
Suchen(messpunkte, laufzeit, regeln) -> Kante?
```
Nimmt die Reihe aus Schritt eins und gibt zurück, wo die dunkle Strecke
anfängt — oder nichts. Hier liegen alle Schwellen, und hier liegen die
Prüfungen. Dieselbe Rolle wie `Tonabgleich`.
**`Markenerkennung`** bekommt einen zweiten Zweig: Serien über den Ton,
Filme über das Bild. Die Tabelle, der Vertrag, die Knöpfe in App und Web
stehen schon — ein Abspann ist ein Abspann, egal woher er kommt.
Dafür braucht `Marke` eine Spalte `MovieId` neben `EpisodeId` (heute nur
Folgen), und `MovieResponse` die Marken, wie `EpisodeResponse` sie schon hat.
## 4 · Die Schwellen
Alles hier ist eine Setzung und gehört an **eine** Stelle, damit man sie
gemeinsam verschieben kann:
| Grösse | Vorschlag | Warum |
|---|---|---|
| Helligkeit „dunkel" | `YAVG < 35` | Gemessen: Abspann 18, Handlung 125. Fünfunddreissig liegt weit von beiden. |
| Anteil dunkler Bilder in einer Sonde | 85 % | Eine Schrifttafel blitzt hell auf; eine einzelne helle Sonde darf die Strecke nicht brechen. |
| Kürzester Abspann | 45 s | Darunter lohnt kein Knopf, und zufällige Dunkelheit ist häufiger. |
| Längster Abspann | 12 min | Darüber ist es kein Abspann, sondern ein Fehlfund. |
| Suchbereich | letzte 20 min | Bei einem Dreistünder wäre „die letzten 10 %" zu viel, bei einem Kurzfilm zu wenig. |
| Genauigkeit | 4 s | Wie bei Jellyfin. Feiner zu suchen kostet Sonden und bringt niemandem etwas. |
## 5 · Wo es danebenliegt — und was dann passiert
**Das muss vorher dastehen, nicht hinterher.**
- **Abspann über laufendem Bild.** Marvel, Pixar, jede zweite Komödie: die
Namen laufen über eine Szene. Keine dunkle Strecke, kein Fund. → Kein
Knopf. Das ist die richtige Antwort; ein geratener Knopf schnitte Handlung
weg.
- **Dunkle Schlussszene.** Ein Film, der im Dunkeln endet, bekommt die Kante
zu früh. Dagegen hilft die Mindestlänge und die Obergrenze — und dass die
Kante die **früheste** dunkle Stelle einer durchgehenden Strecke bis zum
Ende sein muss, nicht irgendeine.
- **Schwarzblende mitten im Film.** Kommt vor, hält aber selten bis zum Ende
durch. Die Forderung „bis zum Dateiende dunkel" fängt das.
- **Szene nach dem Abspann.** Dann ist die Strecke unterbrochen, und der Fund
fällt heraus. Lieber keiner als einer, der über die Szene springt.
Grobe Erwartung, **ungemessen**: bei Serien greift das Verfahren oft, bei
Kinofilmen vielleicht bei der Hälfte. Mehr lässt sich erst sagen, wenn es an
echtem Material gelaufen ist — und dafür braucht es einen Weg, die Funde
nachzusehen.
## 6 · Nachsehen können
Ohne das wird aus dem Verfahren eine Blackbox, die manchmal recht hat.
Vorschlag: auf der Detailseite steht bei einem Film mit Marke, **wo** sie
liegt, mit einem Knopf „stimmt nicht" — der die Marke löscht und den Titel
für weitere Läufe sperrt. Drei Zeilen Oberfläche, und man hat eine Messung
statt eines Gefühls.
## 7 · Stufen
| | Schritt | Prüfbar an |
|---|---|---|
| 1 | `Abspannkante` als reine Rechnung samt Prüfungen | erfundenen Messreihen |
| 2 | `Bildabtaster` über ffmpeg | der erzeugten Prüfdatei aus Abschnitt 1 |
| 3 | `Marke.MovieId`, Wanderung, Marken im Filmvertrag | Serverprüfungen |
| 4 | Filme im Lauf nach dem Scan | am Bestand, im Protokoll |
| 5 | „stimmt nicht" auf der Detailseite | von Hand |
## Prüfliste
- **V.1** Ein Film mit schwarzem Abspann bekommt eine Marke, und sie liegt
innerhalb von fünf Sekunden an der richtigen Stelle.
- **V.2** Ein Film mit Abspann über laufendem Bild bekommt **keine** Marke.
- **V.3** Ein Film, der im Dunkeln endet, ohne Abspann, bekommt keine Marke.
- **V.4** Der Lauf über hundert Filme belastet die Platte nicht länger als
der Scan selbst.
- **V.5** Der Überspringen-Knopf erscheint bei einem Film genauso wie bei
einer Folge.
- **V.6** Mit erkanntem Abspann gilt ein Film als gesehen, sobald er anfängt
— dieselbe Regel wie bei Folgen, sie steht schon.
---
## Quellen
- [intro-skipper auf GitHub](https://github.com/intro-skipper/intro-skipper)
- [Intro Skipper, README](https://cdn.jsdelivr.net/gh/intro-skipper/intro-skipper@10.11/README.md)
- [Forenbeitrag zu Abspann-Erkennung](https://forum.jellyfin.org/t-how-to-skip-ending-credits-with-confusedpolarbear-intro-skipper)
Binary file not shown.
@@ -1,2 +1,5 @@
21:35:00 INFO Server gestartet — 1.0.0 · DEV
21:55:29 INFO Server fährt herunter.
22:00:35 INFO Server gestartet — 1.0.0 · DEV
22:11:06 INFO Server fährt herunter.
22:14:53 INFO Server gestartet — 1.0.0 · DEV
@@ -0,0 +1,17 @@
06:38:47 INFO Server fährt herunter.
06:44:02 INFO Server gestartet — 1.0.0 · DEV
06:57:23 INFO Server fährt herunter.
06:59:45 INFO Server gestartet — 1.0.0 · DEV
17:38:34 INFO Server fährt herunter.
17:57:11 INFO Server gestartet — 1.0.0 · DEV
19:05:40 INFO Server fährt herunter.
19:08:22 INFO Server gestartet — 1.0.0 · DEV
21:03:32 INFO Server fährt herunter.
21:08:04 INFO Server gestartet — 1.0.0 · DEV
21:10:04 INFO Wartung — Sicherung: flaro-2026-10-08-1910.db, 0 MB
21:10:04 INFO Wartung — Optimieren: nichts zusammenzupacken
21:10:04 INFO Wartung — Bilder: kein Bildspeicher da
21:45:40 INFO Server fährt herunter.
21:45:44 INFO Server gestartet — 1.0.0 · DEV
22:06:03 INFO Server fährt herunter.
22:12:27 INFO Server gestartet — 1.0.0 · DEV
@@ -0,0 +1 @@
21:17:00 INFO Wartung — Sicherung: flaro-2026-10-11-1917.db, 0 MB
@@ -0,0 +1 @@
21:26:01 INFO Wartung — Sicherung: flaro-2026-10-14-1926.db, 0 MB
@@ -0,0 +1,2 @@
21:10:21 INFO Wartung — Optimieren: nichts zusammenzupacken
21:10:21 INFO Wartung — Bilder: kein Bildspeicher da
+23
View File
@@ -0,0 +1,23 @@
<Project>
<!--
Eine kaputte Protokollvorlage ist kein Schönheitsfehler.
`log.LogInformation("… {Folgen} … {Folgen}.", a, b, c, d, e)` wirft zur
Laufzeit — jeder Platzhalter verbraucht ein Argument, auch ein
wiederholter. Geschieht das in einem Hintergrundlauf, reisst die
Ausnahme den ganzen Lauf mit, und alles, was er bis dahin gefunden
hatte, ist verloren. Genau so ist es der Markenerkennung ergangen.
CA2017 hatte das längst gemeldet — als Warnung unter hundert anderen
Zeilen, und damit übersehen. Als Fehler ist es nicht zu übersehen.
Nur diese eine Regel, nicht alle Warnungen: ein pauschales
`TreatWarningsAsErrors` wäre eine andere Entscheidung und gehört nicht
nebenbei getroffen.
-->
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CA2017</WarningsAsErrors>
</PropertyGroup>
</Project>
+1
View File
@@ -49,6 +49,7 @@ ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
libchromaprint-tools \
&& if [ "$TARGETARCH" = "amd64" ]; then \
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
fi \
+13 -2
View File
@@ -26,9 +26,20 @@ public static class Aufmacherhoehe
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
public const string Vorgabe = "mittel";
private static readonly string[] Stufen = ["niedrig", "mittel", "hoch", "sehrhoch"];
/// <remarks>
/// <b>Die fünfte kam nach.</b> Am Telefon war selbst „sehr hoch" zu
/// flach — dort überschrieb eine feste Regel die Einstellung ganz. Das
/// ist behoben; die zusätzliche Stufe gibt darüber hinaus Luft.
///
/// <b>Und eine sechste darunter.</b> „Niedrig" war mit 62 vh nicht
/// niedrig, sondern mittel mit anderem Namen — die Spanne reichte nur
/// nach oben. Wer den Aufmacher als Kopfzeile will und nicht als halbe
/// Seite, hatte keine Stufe dafür.
/// </remarks>
private static readonly string[] Stufen =
["flach", "niedrig", "mittel", "hoch", "sehrhoch", "voll"];
/// <summary>Macht aus dem Gespeicherten eine der vier Stufen.</summary>
/// <summary>Macht aus dem Gespeicherten eine der Stufen.</summary>
///
/// <remarks>
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
@@ -13,7 +13,7 @@ namespace Flaro.Api.Auth;
/// <remarks>
/// <para>
/// <c>Authorization: Bearer &lt;token&gt;</c>. Der Server hasht das Vorgezeigte
/// und sucht den Fingerabdruck — das Token selbst steht nirgends in der
/// und sucht den Fingerfingerprint — das Token selbst steht nirgends in der
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
/// </para>
/// <para>
@@ -69,8 +69,8 @@ public sealed class TokenAuthenticationHandler(
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);
var fingerfingerprint = Secrets.Fingerprint(token);
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerfingerprint);
var jetzt = clock.GetUtcNow();
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
@@ -1,3 +1,5 @@
using System.Globalization;
using Flaro.Core.Media;
using Flaro.Core.Metadata;
using Flaro.Data;
using Flaro.Metadata;
@@ -34,28 +36,55 @@ public static class ArtworkEndpoints
.RequireAuthorization(FlaroPolicies.Administrator);
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) =>
string art, Guid id, FlaroContext db, TmdbClient tmdb, FanartClient fanart,
ITmdbSettingsSource spracheVon, CancellationToken ct) =>
{
var serie = art == "series";
var kennung = serie
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
var aktuell = serie
var roh = serie
? await db.Series.Where(s => s.Id == id)
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
.Select(s => new { s.PosterImage, s.BackdropImage, s.LogoImage,
s.PosterPinned, s.BackdropPinned, s.LogoPinned, s.Aufmacherbilder })
.FirstOrDefaultAsync(ct)
: await db.Movies.Where(m => m.Id == id)
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
.Select(m => new { m.PosterImage, m.BackdropImage, m.LogoImage,
m.PosterPinned, m.BackdropPinned, m.LogoPinned, m.Aufmacherbilder })
.FirstOrDefaultAsync(ct);
var aktuell = roh is null ? null : new Bildstand(
roh.PosterImage, roh.BackdropImage, roh.LogoImage,
roh.PosterPinned, roh.BackdropPinned, roh.LogoPinned,
Aufmacherwahl.Lesen(roh.Aufmacherbilder));
if (aktuell is null) return Results.NotFound();
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
// Zuordnung das Problem, nicht das Bild.
var kandidaten = kennung is { } k
? await tmdb.BilderAsync(k, serie, ct)
: [];
IReadOnlyList<Bildkandidat> kandidaten = [];
if (kennung is { } k)
{
var ausTmdb = await tmdb.BilderAsync(k, serie, ct);
// **fanart.tv nur bei Filmen.** Deren Serienweg verlangt eine
// TVDB-Kennung, und die speichert Flaro nicht. Lieber eine
// Beschränkung, die man sieht, als eine, die stillschweigend
// nichts tut.
var ausFanart = serie
? []
: await fanart.BilderAsync(k.ToString(CultureInfo.InvariantCulture), ct);
var sprache = (await spracheVon.GetAsync(ct)).Language;
var kuerzel = sprache.Split('-')[0];
// **Gemeinsam geordnet, nicht hintereinandergehängt.** Ein
// deutsches Plakat von fanart.tv gehört vor ein sprachloses
// von TMDB — das ist der Grund, warum es die zweite Quelle
// überhaupt gibt.
kandidaten = Bildrang.Geordnet(ausTmdb.Concat(ausFanart), kuerzel).ToList();
}
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
});
@@ -83,6 +112,53 @@ public static class ArtworkEndpoints
return gesetzt ? Results.NoContent() : Results.NotFound();
});
// **Die Mehrfachauswahl fürs Karussell.**
//
// Poster, Hintergrund und Schriftzug bleiben einzeln: sie stehen
// hinter Kacheln und Kopfzeilen, und dort soll nichts wechseln. Der
// Aufmacher dagegen ist eine Bühne — wer fünf Aufnahmen ausgesucht
// hat, soll sie auch zu sehen bekommen.
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork/aufmacher", async (
string art, Guid id, AufmacherWahlRequest anfrage,
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
{
if (anfrage.Urls.Count > Aufmacherwahl.Hoechstens)
return Einwand.Bitte($"Höchstens {Aufmacherwahl.Hoechstens} Bilder.");
// Erst alle in den Zwischenspeicher, dann setzen: sonst zeigte
// die Auswahl auf etwas, das es hier nicht gibt. Was sich nicht
// laden lässt, fällt heraus — ein einzelnes totes Bild soll
// nicht die ganze Auswahl verhindern.
var schluessel = new List<string>();
foreach (var url in anfrage.Urls.Take(Aufmacherwahl.Hoechstens))
{
if (string.IsNullOrWhiteSpace(url)) continue;
// **Was schon hier liegt, bleibt liegen.** Der Client
// schickt eine Mischung: die bereits gewählten Bilder als
// Zwischenspeicherschlüssel und die neuen als Adresse. Einen
// Schlüssel noch einmal herunterladen zu wollen hiesse, eine
// Adresse daraus zu raten, die es nicht gibt.
if (!url.Contains("://", StringComparison.Ordinal))
{
schluessel.Add(url);
continue;
}
if (await ablage.StoreAsync(new RemoteImage(url), ct) is { } k) schluessel.Add(k);
}
var zeile = Aufmacherwahl.Schreiben(schluessel);
var betroffen = art == "series"
? await db.Series.Where(s => s.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct)
: await db.Movies.Where(m => m.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Aufmacherbilder, zeile), ct);
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
});
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
string art, Guid id, string kind, HttpRequest anfrage,
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
@@ -128,8 +128,8 @@ public static class AuthEndpoints
string handle, FlaroContext db, TimeProvider clock) =>
{
var jetzt = clock.GetUtcNow();
var fingerabdruck = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
var fingerfingerprint = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerfingerprint);
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
return Results.Ok(new CodeState("expired", null));
+201 -10
View File
@@ -26,7 +26,7 @@ public sealed record SystemInfo(
/// lägen die Dateien woanders als die Datenbank sie vermutet.
/// </param>
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
/// <summary>Wie viel auf dem Kratzplatz liegen bleiben darf.</summary>
/// <summary>Wie viel auf dem Transcode-Cache liegen bleiben darf.</summary>
///
/// <param name="CapGb">Obergrenze in Gigabyte; <c>0</c> heisst unbegrenzt.</param>
/// <param name="UnusedDays">Ab wann Unbenutztes auch unterhalb der Grenze geht; <c>0</c> heisst nie.</param>
@@ -42,7 +42,7 @@ public sealed record MaintenanceSettingsResponse(
int OptimizeDays, int ImageDays);
/// <param name="Teile">
/// Die Unterordner, wo es welche gibt — heute nur beim Kratzplatz.
/// Die Unterordner, wo es welche gibt — heute nur beim Transcode-Cache.
/// <b>Mit Dateizahl</b>, denn bei den Untertiteln ist die Anzahl das
/// Problem und nicht die Grösse, und das sieht man an Bytes nicht.
/// Additiv: <c>Bilder</c> bleibt stehen, damit kein Client bricht.
@@ -224,13 +224,23 @@ public sealed record SprachStand(string? Language, int Translated, int Total);
/// <summary>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
public sealed record Bildstand(
string? Poster, string? Backdrop, string? Logo,
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
bool PosterPinned, bool BackdropPinned, bool LogoPinned,
/// <summary>Die für den Aufmacher gewählten Bilder, höchstens fünf.</summary>
IReadOnlyList<string>? Heroes = null);
public sealed record Bildauswahl(
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
public sealed record BildWahlRequest(string Kind, string? Url);
/// <summary>Die Mehrfachauswahl fürs Aufmacher-Karussell.</summary>
///
/// <remarks>
/// Eine Liste und kein einzelner Wert: hier wird nicht ein Bild gesetzt,
/// sondern die ganze Auswahl ersetzt. Eine leere Liste nimmt sie zurück.
/// </remarks>
public sealed record AufmacherWahlRequest(IReadOnlyList<string> Urls);
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
///
/// <remarks>
@@ -312,7 +322,16 @@ public sealed record ScanStatus(
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
int? Missing = null,
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
string? Step = null);
string? Step = null,
/// <summary>Woran die Segmentsuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
///
/// <remarks>
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Segmentsuche
/// hängt sich hinter den Scan und läuft weiter, wenn der längst
/// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
/// laufend aus, und die Verwaltung käme nie zur Ruhe.
/// </remarks>
string? Segmente = null);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress(
@@ -436,7 +455,45 @@ public sealed record MovieResponse(
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
bool SortTitlePinned = false,
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
///
/// <remarks>
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
/// kennt, zeigt weiter dasselbe wie bisher.
/// </remarks>
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null,
/// <summary>Zu welcher Mediathek der Titel gehört.</summary>
///
/// <remarks>
/// <b>Damit die Detailseite einen einzelnen Titel auffrischen kann.</b>
/// Das Auffrischen hängt an der Mediathek, nicht am Titel; ohne diese
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
/// erfahren, wohin dieser eine Film gehört.
/// </remarks>
Guid? LibraryId = null,
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
///
/// <remarks>
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
/// gilt wie bisher der Hintergrund.
/// </remarks>
string? HeroImage = null,
/// <summary>Der Abspann, sofern er im Bild gefunden wurde.</summary>
///
/// <remarks>
/// Bei einem Film gibt es keinen Vorspann zu finden — erkannt wird er
/// daran, dass er sich über die Folgen einer Staffel wiederholt, und
/// ein Film hat nichts, womit er sich vergleichen liesse.
/// </remarks>
IReadOnlyList<MarkerResponse>? Markers = null);
public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image);
@@ -498,7 +555,45 @@ public sealed record SeriesResponse(
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
bool SortTitlePinned = false,
/// <summary>Bewertungen von anderswo — leer, wenn keine Quelle eingerichtet ist.</summary>
///
/// <remarks>
/// <b>Neben <c>Rating</c>, nicht statt dessen.</b> Das Feld ist die
/// Wertung von TMDB und bleibt, wo es ist; ein Client, der nur das
/// kennt, zeigt weiter dasselbe wie bisher.
/// </remarks>
IReadOnlyList<Fremdwertungsantwort>? ExternalRatings = null,
/// <summary>Zu welcher Mediathek der Titel gehört.</summary>
///
/// <remarks>
/// <b>Damit die Detailseite einen einzelnen Titel auffrischen kann.</b>
/// Das Auffrischen hängt an der Mediathek, nicht am Titel; ohne diese
/// Kennung müsste der Client erst alle Mediatheken durchgehen, um zu
/// erfahren, wohin dieser eine Film gehört.
/// </remarks>
Guid? LibraryId = null,
/// <summary>Das Bild, das jetzt im Aufmacher gilt.</summary>
///
/// <remarks>
/// <b>Vom Server gewählt, nicht vom Client.</b> Wer mehrere Aufnahmen
/// ausgesucht hat, bekommt alle fünf Stunden eine andere — und zwar
/// überall dieselbe: im Web, in der App, nach einem Neustart. Siehe
/// <c>Aufmacherwahl</c>. <c>null</c> heisst „keine ausgesucht"; dann
/// gilt wie bisher der Hintergrund.
/// </remarks>
string? HeroImage = null);
/// <summary>Ein überspringbarer Abschnitt — Vorspann oder Abspann.</summary>
///
/// <remarks>
/// <c>Kind</c> ist <c>"intro"</c> oder <c>"credits"</c>: dieselben Wörter,
/// die Jellyfin benutzt. Die Apps kennen sie bereits, und zwei Namen für
/// dieselbe Sache wären zwei Übersetzungen an der Grenze jeder App.
/// </remarks>
public sealed record MarkerResponse(string Kind, double StartSeconds, double EndSeconds);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
@@ -515,12 +610,42 @@ public sealed record EpisodeResponse(
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
/// </remarks>
Guid? SeriesId = null);
Guid? SeriesId = null,
/// <summary>Vorspann und Abspann, sofern erkannt.</summary>
///
/// <remarks>
/// Leer heisst „nicht erkannt", nicht „gibt es nicht". Wer sie sucht,
/// stösst den Lauf an; bis er durch ist, bleibt die Liste leer und die
/// Spieler zeigen schlicht keinen Knopf.
/// </remarks>
IReadOnlyList<MarkerResponse>? Markers = null);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
/// <summary>Die saisonale Reihe — mit ihrem Namen, weil er wechselt.</summary>
///
/// <param name="Occasion">
/// Was über der Reihe steht. <c>null</c> heisst: heute hat nichts Saison,
/// oder der Anlass findet nichts — dann zeigt der Client gar keine Reihe.
/// </param>
public sealed record Saisonreihe(string? Occasion, Page<MovieResponse> Items);
/// <param name="HasFanartKey">
/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
/// </param>
public sealed record MetadataSettingsResponse(
bool HasApiKey, string Language, bool UsingProjectKey);
bool HasApiKey, string Language, bool UsingProjectKey,
bool HasFanartKey = false, bool HasOmdbKey = false,
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
///
/// <remarks>
/// <b>Kein Schlüssel, sondern ein Schalter.</b> Dort gibt es keine
/// Zugänge zu vergeben; eingeschaltet wird das Auslesen der Seite.
/// </remarks>
bool RottenTomatoes = false);
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
///
@@ -532,7 +657,9 @@ public sealed record MetadataSettingsResponse(
/// </remarks>
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
public sealed record MetadataSettingsRequest(
string? ApiKey, string? Language, string? FanartApiKey = null, string? OmdbApiKey = null,
bool? RottenTomatoes = null);
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
///
@@ -709,12 +836,30 @@ public sealed record SpurAuskunft(int Index, string Label, string? Language, boo
public sealed record StreamResponse(
Guid SessionId,
string Url,
/// <summary>Dieselbe Wiedergabe als Hauptliste, mit Untertitelspuren.</summary>
///
/// <remarks>
/// <b>Für Abspieler, die ihre Untertitel aus HLS beziehen.</b> Der
/// Browser hängt sie als eigene Spur ans Videoelement und braucht nur
/// <see cref="Url"/>; AVPlayer kennt diesen Weg nicht — dort gibt es
/// keine Möglichkeit, einer laufenden Wiedergabe eine Textspur von
/// aussen beizulegen. Bei einer Direktwiedergabe steht hier nichts: dort
/// liegen die Spuren in der Datei selbst.
/// </remarks>
string? MasterUrl,
string Method,
double StartSeconds,
int? Height,
int? AudioStream,
IReadOnlyList<SpurAuskunft> AudioStreams,
IReadOnlyList<SpurAuskunft> SubtitleStreams);
/// <summary>Eine Bewertung von anderswo, fertig zum Hinschreiben.</summary>
///
/// <param name="Source">„imdb", „rottenTomatoes", „metacritic".</param>
/// <param name="Display">Was dastehen soll — in der Form, die man von der Quelle kennt.</param>
public sealed record Fremdwertungsantwort(string Source, double Value, string Display);
public sealed record RatingRequest(int? Rating);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
@@ -786,7 +931,10 @@ public sealed record ContinueEntry(
/// Seite und will das Bild der Serie; dort stand bisher das Folgenbild,
/// und wer das Serienbild selbst ausgesucht hatte, fand ein anderes vor.
/// </remarks>
string? SeriesBackdropImage = null);
string? SeriesBackdropImage = null,
/// <summary>Das Bild, das jetzt im Aufmacher gilt — siehe <c>Aufmacherwahl</c>.</summary>
string? HeroImage = null);
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
///
@@ -904,3 +1052,46 @@ public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTi
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
public sealed record EinloesenRequest(string UserName, string Password);
/// <summary>Eine Sprache, wie sie in einem Menü steht.</summary>
///
/// <param name="Code">Das Kürzel, unter dem die Einstellung abgelegt wird.</param>
/// <param name="Name">Wie sie dasteht — „Deutsch", „English".</param>
public sealed record Sprachangabe(string Code, string Name);
/// <summary>Welche Sprachen eine Serie durchgehend führt.</summary>
///
/// <param name="Audio">Tonsprachen, die in jeder Folge vorkommen.</param>
/// <param name="Subtitles">Textsprachen, die in jeder Folge vorkommen.</param>
/// <param name="Partial">
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
///
/// <remarks>
/// <b>Damit die Oberfläche es sagen kann, statt es zu verschweigen.</b> Ohne
/// diese Zahl sähe eine gekürzte Liste aus wie die ganze Wahrheit — und wer
/// eine Sprache vermisst, die er in Staffel 1 gesehen hat, hielte es für
/// einen Fehler.
/// </remarks>
/// </param>
public sealed record Sprachauswahl(
IReadOnlyList<Sprachangabe> Audio,
IReadOnlyList<Sprachangabe> Subtitles,
int Partial);
/// <summary>Wie die Vorschaubilder eines Titels zusammengesetzt sind.</summary>
///
/// <remarks>
/// <b>Die Kachelmasse stehen nicht fest.</b> Skaliert wird auf eine feste
/// Breite, die Höhe folgt dem Seitenverhältnis — ein Breitbildfilm ergibt
/// flachere Kacheln. Deshalb werden sie gemessen und mitgeteilt; geraten
/// wäre jede Kachel um ein paar Bildpunkte verschoben, und das sieht man
/// nicht als Fehler, sondern hält es für ein unscharfes Bild.
/// </remarks>
public sealed record VorschauAuskunft(
bool Fertig,
double Intervall,
int Spalten,
int Zeilen,
int KachelBreite,
int KachelHoehe,
int Blaetter);
@@ -43,7 +43,7 @@ public static class EinladungEndpoints
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
// wird nur sein Fingerfingerprint; aus der Datenbank lässt sich kein
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
// wiedergefunden, sondern neu gemacht.
var token = Secrets.Create();
@@ -1,5 +1,8 @@
using System.Globalization;
using System.Text;
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Media;
using Flaro.Core.Media;
using System.Security.Claims;
using Flaro.Core.Playback;
@@ -77,6 +80,119 @@ public static class LibraryContentEndpoints
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus.
/// </remarks>
/// <summary>Die Folge vor oder nach dieser.</summary>
///
/// <remarks>
/// <b>Eine Rechnung, zwei Wege.</b> Vorwärts und rückwärts unterscheiden
/// sich in zwei Zeichen — als zwei Endpunkte abgeschrieben liefen sie
/// auseinander, sobald einer von beiden etwas dazubekommt.
/// </remarks>
private static async Task<IResult> Nachbarfolge(
Guid id, ClaimsPrincipal wer, FlaroContext db, bool vorwaerts)
{
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 geschwister = db.Episodes.AsNoTracking().Where(f => f.SeriesId == diese.SeriesId);
var gefiltert = vorwaerts
? geschwister
.Where(f => f.SeasonNumber > diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
: geschwister
.Where(f => f.SeasonNumber < diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber < diese.EpisodeNumber))
.OrderByDescending(f => f.SeasonNumber).ThenByDescending(f => f.EpisodeNumber);
var nachbar = await gefiltert
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
.FirstOrDefaultAsync();
// Keine weitere Folge ist kein Fehler — es war die erste oder die letzte.
if (nachbar is null) return Results.NoContent();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == nachbar.Id)
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [nachbar.Id]);
var segmente = await SegmenteZu(db, [nachbar.Id]);
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und bei
// kleineren Serien für gar keine.
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == diese.SeriesId)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
return Results.Ok(new EpisodeResponse(
nachbar.Id, nachbar.SeasonNumber, nachbar.EpisodeNumber,
nachbar.Title, nachbar.Overview, nachbar.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(nachbar.Id),
nachbar.PublishAt,
SeriesId: diese.SeriesId,
Markers: segmente[nachbar.Id].ToList()));
}
/// <summary>Die Segmente zu einer Reihe von Folgen.</summary>
///
/// <remarks>
/// Als Nachschlagewerk und nicht je Folge einzeln: eine Staffel mit
/// zwanzig Folgen wären sonst zwanzig Abfragen für zwanzig kurze Zeilen.
/// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> SegmenteZu(
FlaroContext db, IReadOnlyCollection<Guid> folgen)
{
if (folgen.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId != null && folgen.Contains(m.EpisodeId!.Value))
// In Zeitfolge: ein geteilter Abspann kommt als zwei Segmente, und
// die Spieler nehmen die erste passende.
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
/// <summary>Dasselbe für Filme.</summary>
///
/// <remarks>
/// Getrennt und nicht über eine gemeinsame Kennung: ein Segment gehört
/// entweder zu einer Folge oder zu einem Film, und eine Abfrage, die
/// beides zugleich sucht, fände zu einer Filmkennung auch eine Folge
/// mit derselben — unmöglich heute, aber nichts hält es fest.
/// </remarks>
private static async Task<ILookup<Guid, MarkerResponse>> FilmsegmenteZu(
FlaroContext db, IReadOnlyCollection<Guid> filme)
{
if (filme.Count == 0)
return Array.Empty<(Guid, MarkerResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.Segmente.AsNoTracking()
.Where(m => m.MovieId != null && filme.Contains(m.MovieId!.Value))
.OrderBy(m => m.VonSekunden)
.Select(m => new { Id = m.MovieId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync();
return roh.ToLookup(
m => m.Id,
m => new MarkerResponse(
m.Art == Segmentart.Intro ? "intro" : "credits", m.VonSekunden, m.BisSekunden));
}
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
{
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
@@ -168,6 +284,21 @@ public static class LibraryContentEndpoints
}
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
/// <summary>Die Bewertungen von anderswo zu einem Titel.</summary>
///
/// <remarks>
/// <b>Nur beim Einzelabruf.</b> In einer Liste mit fünfzig Kacheln
/// stünden sie nirgends, kosteten aber fünfzig Abfragen — dieselbe
/// Überlegung wie bei Genres und Besetzung.
/// </remarks>
private static async Task<IReadOnlyList<Fremdwertungsantwort>> FremdwertungenZu(
FlaroContext db, Guid? film, Guid? serie) =>
await db.Fremdbewertungen.AsNoTracking()
.Where(b => film != null ? b.MovieId == film : b.SeriesId == serie)
.OrderByDescending(b => b.Wert)
.Select(b => new Fremdwertungsantwort(b.Quelle, b.Wert, b.Anzeige))
.ToListAsync();
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)
@@ -192,7 +323,14 @@ public static class LibraryContentEndpoints
/// 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(
/// <remarks>
/// <b>Nicht mehr privat.</b> Drei Listen ausserhalb dieser Klasse gaben
/// `State: null` zurück, obwohl die Stände in derselben Datenbank
/// liegen: Vorschläge, saisonale Reihe und ähnliche Titel. Ein halb
/// gesehener Film stand dort ohne Balken, während dieselbe Kachel in der
/// Mediathek einen hatte.
/// </remarks>
internal static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer,
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
{
@@ -285,11 +423,21 @@ public static class LibraryContentEndpoints
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
app.MapGet("/api/v1/libraries/{id:guid}/scan",
(Guid id, ScanCoordinator koordinator, Segmenterkennung erkennung) =>
{
// **Auch ohne Scan kann etwas laufen.** Die Segmentsuche lässt
// sich von Hand anstossen; ohne diese Zeile stünde die
// Verwaltung dann auf „idle", während der Server stundenlang
// arbeitet.
var segmente = erkennung.Stand(id)?.Text();
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
return Results.Ok(new ScanStatus(
segmente is null ? "idle" : "marken",
null, null, null, null, null, null, null, null, null, null, null,
Segmente: segmente));
var bericht = zustand.Report;
var metadaten = zustand.Enrichment;
@@ -303,7 +451,8 @@ public static class LibraryContentEndpoints
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
metadaten.Skipped, metadaten.Error),
bericht?.Missing,
zustand.Schritt?.Text()));
zustand.Schritt?.Text(),
segmente));
}).WithName("Scanstand").RequireAuthorization();
}
@@ -429,6 +578,7 @@ public static class LibraryContentEndpoints
// Korrektur stand „WALL·E" weiter unter E, bis man die
// Seite neu lud.
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
m.Aufmacherbilder,
})
.ToListAsync();
@@ -442,6 +592,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: ids);
var segmente = await FilmsegmenteZu(db, ids);
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
@@ -453,7 +604,10 @@ public static class LibraryContentEndpoints
PublishAt: m.PublishAt,
TitlePinned: m.TitlePinned,
SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned)).ToList();
SortTitlePinned: m.SortTitlePinned,
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow),
Markers: segmente[m.Id].ToList()))
.ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
@@ -509,6 +663,7 @@ public static class LibraryContentEndpoints
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
.Min(f => f.PublishAt),
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
s.Aufmacherbilder,
})
.ToListAsync();
@@ -537,7 +692,8 @@ public static class LibraryContentEndpoints
State: stände.GetValueOrDefault(s.Id),
TitlePinned: s.TitlePinned,
SortTitle: s.SortTitleValue,
SortTitlePinned: s.SortTitlePinned);
SortTitlePinned: s.SortTitlePinned,
HeroImage: Aufmacherwahl.Jetziges(s.Aufmacherbilder, s.Id, DateTimeOffset.UtcNow));
}).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
@@ -593,13 +749,16 @@ public static class LibraryContentEndpoints
})
.ToListAsync();
var ständeÄhnliche = await ZuständeZu(db, wer, filme: kennungen.ToList());
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
var items = kennungen
.Select(k => filme.FirstOrDefault(f => f.Id == k))
.Where(f => f != null)
.Select(f => new MovieResponse(
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
[], null, f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
[], ständeÄhnliche.GetValueOrDefault(f.Id),
f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
OriginalTitle: f.OriginalTitle))
.ToList();
@@ -639,7 +798,11 @@ public static class LibraryContentEndpoints
TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned,
PublishAt: film.PublishAt));
PublishAt: film.PublishAt,
ExternalRatings: await FremdwertungenZu(db, film: id, serie: null),
LibraryId: film.LibraryId,
HeroImage: Aufmacherwahl.Jetziges(film.Aufmacherbilder, id, DateTimeOffset.UtcNow),
Markers: (await FilmsegmenteZu(db, [id]))[id].ToList()));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -674,7 +837,10 @@ public static class LibraryContentEndpoints
TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned,
PublishAt: folgen.Min(f => f.PublishAt)));
PublishAt: folgen.Min(f => f.PublishAt),
ExternalRatings: await FremdwertungenZu(db, film: null, serie: id),
LibraryId: serie.LibraryId,
HeroImage: Aufmacherwahl.Jetziges(serie.Aufmacherbilder, id, DateTimeOffset.UtcNow)));
}).RequireAuthorization();
// **Eine Folge für sich.**
@@ -704,6 +870,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [id]);
var segmente = await SegmenteZu(db, [id]);
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
@@ -715,54 +882,175 @@ public static class LibraryContentEndpoints
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id),
folge.PublishAt,
SeriesId: folge.SeriesId));
SeriesId: folge.SeriesId,
Markers: segmente[id].ToList()));
}).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) =>
// **Die Nachbarfolge — vorwärts wie rückwärts.** Reihenfolge ist
// Staffel, dann Folge; nicht die Kennung: die ist zeitlich geordnet
// und sagt, wann der Scan sie fand, nicht wann sie läuft.
//
// **Beide Richtungen, seit es im Spieler Knöpfe dafür gibt.** Wer
// eine Folge zu weit gesprungen ist, soll zurück können, ohne über
// die Serienseite zu gehen.
// **Vorspann und Abspann suchen — auf Zuruf.**
//
// Der Lauf dauert Minuten: er liest von jeder Folge einer Staffel
// ein Stück Ton und vergleicht die Abdrücke. Deshalb 202 und ein
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
// Dasselbe für einen Film — dort sucht der Server im Bild.
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Alles) =>
{
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();
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) 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, f.PublishAt })
.FirstOrDefaultAsync();
_ = erkennung.FilmAsync(id, umfang);
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
// Keine weitere Folge ist kein Fehler — es war die letzte.
if (naechste is null) return Results.NoContent();
// **Die Messkurve eines Films — zum Nachsehen, nicht zum Erkennen.**
//
// Die Erkennung sagt „kein Abspann" und nennt die Gründe; was sie
// nicht sagen kann, ist, wie die Kurve aussieht. Genau die braucht
// es, bevor eine neue Schwelle gesetzt wird — zum Beispiel für die
// Frage, ob bei Outtakes im Abspann wenigstens die Ränder dunkel
// bleiben.
app.MapGet("/api/v1/movies/{id:guid}/markenkurve", async (
Guid id, FlaroContext db, Frameabtaster abtaster, CancellationToken ct) =>
{
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == id)
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
(m, d) => new { m.Title, d.Path, d.Duration })
.FirstOrDefaultAsync(ct);
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit)
return Einwand.Bitte("Zu diesem Film gibt es keine Datei mit Laufzeit.");
var ab = Math.Max(0, laufzeit.TotalSeconds - Segmenterkennung.Suchbereich);
var kurve = await abtaster.KurveAsync(film.Path, ab, laufzeit.TotalSeconds - ab, ct);
var text = new StringBuilder("sekunde;gesamt;links;rechts\n");
foreach (var punkt in kurve)
{
text.Append(CultureInfo.InvariantCulture,
$"{punkt.Sekunde:0.###};{punkt.Gesamt:0.#};{punkt.Links:0.#};{punkt.Rechts:0.#}\n");
}
var name = string.Concat((film.Title ?? "film")
.Select(c => char.IsLetterOrDigit(c) ? c : '-'));
return Results.File(Encoding.UTF8.GetBytes(text.ToString()),
"text/csv", $"markenkurve-{name}.csv");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Ein Segment wieder wegnehmen.**
//
// Ohne diesen Weg wäre die Erkennung eine Blackbox, die manchmal
// recht hat: ein Fehlgriff bliebe stehen, und niemand könnte etwas
// dagegen tun ausser die Datenbank anzufassen.
app.MapDelete("/api/v1/movies/{id:guid}/marken", async (Guid id, FlaroContext db) =>
{
var weg = await db.Segmente.Where(m => m.MovieId == id).ExecuteDeleteAsync();
// Der Vermerk bleibt: „gesucht" stimmt weiterhin, und ohne ihn
// legte der nächste Scan dieselbe falsches Segment wieder an.
return weg > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Bei einem einzelnen Titel ist „alles" die Vorgabe.** Wer den
// Knopf bei genau dieser Serie drückt, ist mit genau dieser Serie
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
// die Antwort zu verweigern, die er haben will.
app.MapPost("/api/v1/series/{id:guid}/marken", async (
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Alles) =>
{
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
return erkennung.Starten(id, umfang)
? Results.Accepted()
: Einwand.Belegt("Für diese Serie werden gerade Segmente gesucht.");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu
// drücken ist bei fünfundneunzig Serien kein Angebot — und nach
// jeder Änderung an der Erkennung will man ohnehin alles noch
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im
// Hintergrund weiter; die Antwort kommt sofort.
app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
Guid id, FlaroContext db, Segmenterkennung erkennung,
Segmentumfang umfang = Segmentumfang.Neue) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
return Results.NotFound();
erkennung.MediathekStarten(id, umfang);
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
app.MapGet("/api/v1/episodes/{id:guid}/previous", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
Nachbarfolge(id, wer, db, vorwaerts: false)).RequireAuthorization();
// **Welche Sprachen eine Serie durchgehend hat.**
//
// Für die Vorwahl auf der Serienseite. Die Folgen einzeln abzufragen
// wären bei zwanzig Folgen zwanzig Rundreisen, und am Ende müsste
// der Client dieselbe Rechnung anstellen.
//
// **Die Schnittmenge, nicht die Vereinigung.** Eine Sprache
// anzubieten, die es nur in Staffel 1 gibt, hiesse: eine Einstellung
// für die Serie, die ab Staffel 2 stillschweigend etwas anderes tut.
// Was dabei wegfällt, wird gezählt, damit die Oberfläche es sagen
// kann statt es zu verschweigen.
app.MapGet("/api/v1/series/{id:guid}/languages", async (Guid id, FlaroContext db) =>
{
if (!await db.Series.AnyAsync(x => x.Id == id)) return Results.NotFound();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == id).Select(f => f.Id).ToListAsync();
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, d.VersionLabel })
.Where(f => f.EpisodeId != null && folgen.Contains(f.EpisodeId!.Value))
.Select(f => new { f.Id, Folge = f.EpisodeId!.Value })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]);
// **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt
// nichts; das Plakat der Serie sagt wenigstens, worum es geht.
// TMDB liefert Folgenbilder nur für einen Teil der Folgen, und
// bei kleineren Serien für gar keine.
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == diese.SeriesId)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
// **Nur Folgen, die überhaupt eine Datei haben.** Eine Folge, die
// noch nicht da ist, kann keine Sprache beisteuern — sie als
// fehlend zu zählen liesse die Schnittmenge leer werden.
var mitDatei = dateien.Select(d => d.Folge).Distinct().Count();
if (mitDatei == 0) return Results.Ok(new Sprachauswahl([], [], 0));
return Results.Ok(new EpisodeResponse(
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(naechste.Id),
naechste.PublishAt,
SeriesId: diese.SeriesId));
var zuFolge = dateien.ToDictionary(d => d.Id, d => d.Folge);
var kennungen = dateien.Select(d => d.Id).ToList();
var spuren = await db.MediaStreams.AsNoTracking()
.Where(x => kennungen.Contains(x.MediaFileId))
.Where(x => x.Kind == MediaStreamKind.Audio || x.Kind == MediaStreamKind.Subtitle)
.Select(x => new { x.MediaFileId, x.Kind, x.Language, x.Codec })
.ToListAsync();
var (ton, tonFehlt) = Sprachschnitt.Von(
spuren.Where(x => x.Kind == MediaStreamKind.Audio)
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
mitDatei);
var (text, textFehlt) = Sprachschnitt.Von(
spuren.Where(x => x.Kind == MediaStreamKind.Subtitle)
// Bildbasierte Untertitel lassen sich nicht nach WebVTT
// wandeln; sie anzubieten wäre eine Zusage, die niemand
// einlösen kann.
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
.Select(x => (zuFolge[x.MediaFileId], x.Language)),
mitDatei);
return Results.Ok(new Sprachauswahl(
[.. ton.Select(x => new Sprachangabe(x.Code, x.Name))],
[.. text.Select(x => new Sprachangabe(x.Code, x.Name))],
tonFehlt + textFehlt));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async (
@@ -789,6 +1077,7 @@ public static class LibraryContentEndpoints
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids);
var segmente = await SegmenteZu(db, ids);
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
@@ -801,7 +1090,8 @@ public static class LibraryContentEndpoints
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id),
f.PublishAt,
SeriesId: id)).ToList();
SeriesId: id,
Markers: segmente[f.Id].ToList())).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
@@ -470,7 +470,7 @@ public static class ManagementEndpoints
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows.");
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows, Videos.");
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
@@ -253,15 +253,21 @@ public static class MatchEndpoints
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
var vorschläge = await anbieter.SearchMoviesAsync(
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(),
// **Das Jahr aus dem Dateinamen, nicht das aus der Zuordnung.**
// **Kein Jahr heisst: ohne Jahr suchen.**
//
// Hier stand `film.Year` — und das kommt von genau der
// Zuordnung, die gerade korrigiert werden soll. „Doctor
// Strange" war fälschlich auf ein Making-of von 2022
// gelaufen; die Suche hängte daraufhin `year=2022` an, und der
// richtige Film von 2016 konnte gar nicht mehr auftauchen. Ein
// falscher Treffer verhinderte so seine eigene Korrektur.
year ?? film.ParsedYear);
// Die Maske trägt das Jahr aus dem Dateinamen schon ein; wer
// es herausnimmt, sagt damit, dass er es nicht weiss. Hier
// stand ein Rückfall auf eben dieses Jahr — das leere Feld
// hatte also keine Wirkung, und ein falsch benannter Film
// liess sich nicht finden. „Plan B (2010)" heisst bei TMDB
// „DivertiMento – Plan B" und ist von 2011; mit dem Jahr aus
// dem Dateinamen kam nie ein Treffer.
//
// Davor stand hier `film.Year`, also das Jahr aus der
// Zuordnung, die gerade korrigiert werden soll — ein falscher
// Treffer verhinderte so seine eigene Korrektur. Auch das ist
// damit erledigt.
year);
return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
@@ -319,8 +325,8 @@ public static class MatchEndpoints
var vorschläge = await anbieter.SearchSeriesAsync(
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
// Siehe oben: aus dem Dateinamen, nicht aus der Zuordnung.
year ?? serie.ParsedYear);
// Siehe oben: ein leeres Jahr ist eine Angabe, kein Fehlen.
year);
return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
@@ -1,5 +1,7 @@
using System.Security.Claims;
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Core.Media;
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
@@ -96,7 +98,30 @@ public static class PlaybackEndpoints
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position;
var ergebnis = ProgressRule.Apply(gemeldet, laenge, regeln);
// **Der Abspann, wenn er erkannt wurde.** Er zieht die Schwelle
// fürs Häkchen nach vorn — siehe `ProgressRule.Apply`.
//
// **Das früheste Stück, nicht irgendeines.** Teilt eine
// Post-Credit-Szene den Abspann, gibt es zwei Zeilen; ohne
// `OrderBy` entschied die Datenbank, welche gilt. Gemeint ist
// immer die erste — dort ist die Handlung vorbei.
var abspannAb = zustand.EpisodeId is { } folge
? await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId == folge && m.Art == Segmentart.Abspann)
.OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: zustand.MovieId is { } film
? await db.Segmente.AsNoTracking()
.Where(m => m.MovieId == film && m.Art == Segmentart.Abspann)
.OrderBy(m => m.VonSekunden)
.Select(m => (double?)m.VonSekunden)
.FirstOrDefaultAsync()
: null;
var ergebnis = ProgressRule.Apply(
gemeldet, laenge, regeln,
abspannAb is { } sek ? TimeSpan.FromSeconds(sek) : null);
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
@@ -346,18 +371,52 @@ public static class PlaybackEndpoints
.Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
.ToDictionaryAsync(x => x.Id);
// **Weggelegt gilt hier für die ganze Serie.**
//
// Das Weglegen einer einzelnen Folge nützt in dieser Reihe
// nichts: beim nächsten Laden rückt die übernächste nach, und
// man legt zwanzigmal weg. Gemeint ist ohnehin „diese Serie
// nicht vorschlagen". Der Zustand dafür gibt es schon — eine
// Zeile mit `SeriesId` —, er wurde hier nur nie gelesen.
var serienzustaende = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer && z.SeriesId != null
&& serienIds.Contains(z.SeriesId!.Value))
.ToDictionaryAsync(z => z.SeriesId!.Value);
var liste = new List<ContinueEntry>();
foreach (var x in angefasst)
{
var naechste = folgen
if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue;
var derSerie = folgen
.Where(f => f.SeriesId == x.SeriesId)
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.ToList();
// **Die Reihe fängt dort an, wo der Benutzer steht — nicht
// am Anfang der Serie.**
//
// Hier stand „die erste Folge ohne Häkchen". Wer bei Folge 2
// mittendrin ist und Folge 1 nie abgehakt hat — weil er sie
// nicht ganz zu Ende gesehen hat, oder weil er gleich bei
// Folge 2 eingestiegen ist —, bekam Folge 1 vorgeschlagen.
// Und das Abhaken von Folge 1 half nicht: dann kam Folge 2,
// obwohl die längst in „Weiterschauen" steht.
//
// Massgeblich ist die **letzte** angefasste Folge. Ist sie
// noch offen, läuft sie — dann gehört die Serie nach
// „Weiterschauen" und nicht hierher. Ist sie durch, ist die
// Folge danach dran.
var letzte = derSerie.FindLastIndex(
f => Angefasst(zustände.GetValueOrDefault(f.Id)));
if (letzte < 0) continue;
if (zustände.GetValueOrDefault(derSerie[letzte].Id) is not { Watched: true }) continue;
var naechste = derSerie.Skip(letzte + 1)
.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);
@@ -450,6 +509,13 @@ public static class PlaybackEndpoints
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
.ToListAsync();
// **Mit Stand.** Hier stand `null`, und damit fehlte der
// Fortschrittsbalken ausgerechnet in einer Reihe, die aus dem
// Sehverhalten gebaut wird — ein halb gesehener Film sah dort
// aus wie ein unangetasteter.
var vorschlagsstände = await LibraryContentEndpoints.ZuständeZu(
db, wer, filme: vorschlagIds);
var items = treffer.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
vorschlagsdateien.Where(d => d.MovieId == m.Id)
@@ -457,11 +523,83 @@ public static class PlaybackEndpoints
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
d.Duration?.TotalSeconds, [], d.VersionLabel))
.ToList(),
null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
vorschlagsstände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
PublishAt: m.PublishAt)).ToList();
return Results.Ok(new Page<MovieResponse>(items, items.Count));
});
/* ---------- Was gerade Saison hat ---------- */
// **Eine Reihe, die nur zu ihrer Zeit dasteht.**
//
// Nach Stichwörtern der Metadatenquelle, nicht nach Genres: „Stirb
// langsam" ist kein Familienfilm und trägt trotzdem „christmas".
// Eine Weihnachtsreihe nach Genres enthielte jeden
// Zeichentrickfilm und diesen Film nicht.
//
// **Leer heisst leer.** Findet der Anlass nichts, kommt eine leere
// Seite ohne Namen zurück — der Client zeigt dann gar keine Reihe,
// statt einer mit Überschrift und nichts darunter.
start.MapGet("/seasonal", async (
ClaimsPrincipal wer, FlaroContext db, Saisoneinstellungen einstellungen,
TimeProvider uhr, CancellationToken ct, int take = 24) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var heute = uhr.GetUtcNow();
var anlass = (await einstellungen.AnlaesseAsync(ct)).FirstOrDefault(a => a.Gilt(heute));
if (anlass is null) return Results.Ok(new Saisonreihe(null, new Page<MovieResponse>([], 0)));
var stichworte = anlass.Stichworte.Select(w => w.ToLowerInvariant()).ToList();
var genres = anlass.Genres;
var treffer = await db.Movies.AsNoTracking()
.Where(m => db.Stichwortlinks.Any(l => l.MovieId == m.Id
&& db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name)))
|| db.GenreLinks.Any(l => l.MovieId == m.Id
&& db.Genres.Any(g => g.Id == l.GenreId && genres.Contains(g.Name))))
// **Stichworttreffer vor Genretreffer.** Ein Film, der
// „christmas" trägt, gehört sicherer in die Reihe als einer,
// der nur im passenden Genre liegt.
.Select(m => new
{
Film = m,
Sicher = db.Stichwortlinks.Count(l => l.MovieId == m.Id
&& db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name))),
})
.OrderByDescending(x => x.Sicher)
.ThenByDescending(x => x.Film.Rating ?? 0)
.Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(x => x.Film)
.ToListAsync(ct);
if (treffer.Count == 0) return Results.Ok(new Saisonreihe(null, new Page<MovieResponse>([], 0)));
var saisonIds = treffer.Select(m => m.Id).ToList();
var saisondateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.MovieId != null && saisonIds.Contains(d.MovieId!.Value))
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
.ToListAsync(ct);
var saisonstände = await LibraryContentEndpoints.ZuständeZu(
db, wer, filme: saisonIds);
var saisonitems = treffer.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
saisondateien.Where(d => d.MovieId == m.Id)
.Select(d => new MediaFileResponse(
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
d.Duration?.TotalSeconds, [], d.VersionLabel))
.ToList(),
saisonstände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
PublishAt: m.PublishAt)).ToList();
return Results.Ok(new Saisonreihe(
anlass.Name, new Page<MovieResponse>(saisonitems, saisonitems.Count)));
});
}
/// <summary>Der Zustand eines Titels, den noch niemand angefasst hat.</summary>
@@ -471,6 +609,18 @@ public static class PlaybackEndpoints
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
/// </remarks>
/// <summary>Ob der Benutzer diese Folge überhaupt schon angefasst hat.</summary>
///
/// <remarks>
/// Eine Zeile allein genügt nicht: sie entsteht auch, wenn jemand nur
/// einen Stern vergibt. Angefasst heisst gesehen, angefangen oder
/// ausdrücklich weggelegt.
/// </remarks>
private static bool Angefasst(UserItemState? zustand) =>
zustand is not null
&& (zustand.Watched || zustand.InProgress || zustand.Dismissed
|| zustand.Position > TimeSpan.Zero);
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>
@@ -485,7 +635,7 @@ public static class PlaybackEndpoints
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, m.PublishAt })
.Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt, m.Aufmacherbilder })
.ToDictionaryAsync(m => m.Id);
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
@@ -493,7 +643,7 @@ public static class PlaybackEndpoints
{
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
SerienBild = s.BackdropImage,
SerienBild = s.BackdropImage, SerienAufmacher = s.Aufmacherbilder,
})
.ToDictionaryAsync(f => f.Id);
@@ -508,7 +658,8 @@ public static class PlaybackEndpoints
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
FileId: dateien.GetValueOrDefault(m.Id).Id,
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
PublishAt: m.PublishAt));
PublishAt: m.PublishAt,
HeroImage: Aufmacherwahl.Jetziges(m.Aufmacherbilder, m.Id, DateTimeOffset.UtcNow)));
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,
@@ -517,7 +668,12 @@ public static class PlaybackEndpoints
FileId: dateien.GetValueOrDefault(f.Id).Id,
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
PublishAt: f.PublishAt,
SeriesBackdropImage: f.SerienBild));
SeriesBackdropImage: f.SerienBild,
// **Das der Serie, nicht das der Folge.** Der Aufmacher
// zeigt, worum es geht; eine einzelne Folge hat dafür
// selten ein eigenes Bild und nie fünf.
HeroImage: Aufmacherwahl.Jetziges(
f.SerienAufmacher, f.SerienId, DateTimeOffset.UtcNow)));
}
return liste;
}
@@ -1,6 +1,7 @@
using Flaro.Api.Auth;
using Flaro.Core;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Core.Playback;
using Flaro.Data;
using Flaro.Metadata;
@@ -15,7 +16,11 @@ public static class SettingsEndpoints
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
{
var zeilen = await db.Settings.AsNoTracking()
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName)
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName
|| s.Key == DatabaseTmdbSettings.LanguageName
|| s.Key == DatabaseFanartSettings.ApiKeyName
|| s.Key == DatabaseOmdbSettings.ApiKeyName
|| s.Key == DatabaseRottenTomatoesSettings.SchalterName)
.ToDictionaryAsync(s => s.Key, s => s.Value);
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
@@ -30,7 +35,14 @@ public static class SettingsEndpoints
Language: aufgelöst.Language,
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
// benutzt wird — und dass ein eigener ihn ablösen würde.
UsingProjectKey: aufgelöst.UsingProjectKey));
UsingProjectKey: aufgelöst.UsingProjectKey,
HasFanartKey: !string.IsNullOrWhiteSpace(
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)),
HasOmdbKey: !string.IsNullOrWhiteSpace(
zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName)),
RottenTomatoes: string.Equals(
zeilen.GetValueOrDefault(DatabaseRottenTomatoesSettings.SchalterName),
"true", StringComparison.OrdinalIgnoreCase)));
}).RequireAuthorization(FlaroPolicies.Administrator);
// **In welcher Sprache der Bestand vorliegt.**
@@ -88,6 +100,15 @@ public static class SettingsEndpoints
MetadataSettingsRequest anfrage, FlaroContext db) =>
{
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
if (anfrage.FanartApiKey is not null)
await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim());
if (anfrage.OmdbApiKey is not null)
await Setzen(db, DatabaseOmdbSettings.ApiKeyName, anfrage.OmdbApiKey.Trim());
// **Ein Schalter, kein Schlüssel.** Rotten Tomatoes vergibt keine
// Zugänge; eingeschaltet wird hier das Lesen ihrer Seite.
if (anfrage.RottenTomatoes is { } an)
await Setzen(db, DatabaseRottenTomatoesSettings.SchalterName, an ? "true" : "false");
if (anfrage.Language is not null)
{
var sprache = anfrage.Language.Trim();
@@ -152,10 +173,50 @@ public static class SettingsEndpoints
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
}).RequireAuthorization(FlaroPolicies.Administrator);
/* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */
// **Die Quellen einmal fragen und sagen, was zurückkam.**
//
// Vorher stand nach dem Eintragen eines Schlüssels nur „Hinterlegt"
// da. Ob er stimmt und ob der Dienst antwortet, liess sich nur
// herausfinden, indem man die Metadaten über achthundert Titel
// auffrischte und danach nachsah, ob sich etwas geändert hat.
app.MapPost("/api/v1/settings/metadata/test", async (
Quellenprobe probe, CancellationToken ct) =>
Results.Ok(await probe.LaufenAsync(ct)))
.RequireAuthorization(FlaroPolicies.Administrator);
/* ---------- Saisonale Reihen ---------- */
// **Einstellbar und nicht eingebaut.** Halloween und Weihnachten
// kennt jeder; der 1. August ist Schweizer Nationalfeiertag, der
// 4. Juli amerikanischer, und der Geburtstag des Betreibers steht
// in keiner Bibliothek.
app.MapGet("/api/v1/settings/seasonal", async (Saisoneinstellungen einstellungen) =>
Results.Ok(await einstellungen.AnlaesseAsync()))
.RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/seasonal", async (
List<Saisonanlass> anlaesse, FlaroContext db) =>
{
// **Unbrauchbares wird abgewiesen, nicht stillschweigend
// geschluckt.** Ein Anlass ohne Stichwörter fände nie etwas, und
// wer ihn eingetragen hat, suchte den Fehler woanders.
var schlecht = anlaesse.Where(a => !a.Brauchbar).ToList();
if (schlecht.Count > 0)
{
return Einwand.Bitte(
$"„{schlecht[0].Name}“ ist unvollständig. Jeder Anlass braucht einen Namen, "
+ "einen Zeitraum in der Form MM-TT und mindestens ein Stichwort oder Genre.");
}
await Setzen(db, Saisoneinstellungen.Schluessel, Saisoneinstellungen.Schreiben(anlaesse));
await db.SaveChangesAsync();
return Results.Ok(anlaesse);
}).RequireAuthorization(FlaroPolicies.Administrator);
/* ---------- Wartung: was auf dem Transcode-Cache liegen bleiben darf ---------- */
app.MapGet("/api/v1/settings/maintenance", async (
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) =>
Transcodeeinstellungen kratz, Wartungsplaneinstellungen plan) =>
{
var regeln = await kratz.RegelnAsync();
var w = await plan.RegelnAsync();
@@ -169,23 +230,23 @@ public static class SettingsEndpoints
app.MapPut("/api/v1/settings/maintenance", async (
MaintenanceSettingsRequest anfrage, FlaroContext db,
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
Transcodeeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
{
var vorher = await einstellungen.RegelnAsync();
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
// kennt, soll die anderen nicht zurücksetzen.
var nachher = Kratzplatzregeln.Geprueft(
var nachher = Transcoderegeln.Geprueft(
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
var zahl = System.Globalization.CultureInfo.InvariantCulture;
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
await Setzen(db, Transcodeeinstellungen.ObergrenzeSchluessel,
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
await Setzen(db, Transcodeeinstellungen.UnbenutztSchluessel,
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
await Setzen(db, Transcodeeinstellungen.SchonfristSchluessel,
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
var wVorher = await plan.RegelnAsync();
var wNachher = Wartungsregeln.Geprueft(
+305 -21
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Security.Claims;
using System.Text;
using Flaro.Core.Delivery;
@@ -26,6 +27,34 @@ namespace Flaro.Api.Endpoints;
/// </remarks>
public static class StreamEndpoints
{
/// <summary>Wie die Untertitelgruppe in der Hauptliste heisst.</summary>
private const string Untertitelgruppe = "untertitel";
/// <summary>Was in der Hauptliste als Bandbreite steht.</summary>
///
/// <remarks>
/// <b>Eine Zahl, die niemand liest, und trotzdem Pflicht.</b> HLS
/// verlangt sie auf jedem <c>EXT-X-STREAM-INF</c>, damit ein Spieler
/// zwischen Stufen wählen kann. Es gibt hier nur eine Stufe — die Wahl
/// trifft der Benutzer vorher im Menü —, also ist jede Angabe so gut wie
/// jede andere. Acht Megabit sind reichlich und schrecken keinen Spieler
/// ab, der sie für eine Mindestanforderung hält.
/// </remarks>
private const int Bandbreite = 8_000_000;
/// <summary>Was in einer Abspielliste zwischen Anführungszeichen stehen darf.</summary>
///
/// <remarks>
/// In einem M3U-Attribut gibt es kein Maskieren: ein Anführungszeichen
/// beendet den Wert, und der Rest der Zeile wird zu Unsinn. Ein Titel wie
/// <c>Kommentar "Regie"</c> stünde sonst als halbe Zeile da.
/// </remarks>
private static string M3uText(string roh) =>
roh.Replace("\"", "'", StringComparison.Ordinal)
.Replace("\n", " ", StringComparison.Ordinal)
.Replace("\r", " ", StringComparison.Ordinal)
.Replace(",", " ", StringComparison.Ordinal);
public static void MapStream(this IEndpointRouteBuilder app)
{
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
@@ -33,7 +62,7 @@ public static class StreamEndpoints
app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr,
Untertitelvorrat vorrat) =>
Untertitelvorrat vorrat, Vorschauvorrat vorschau) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
@@ -55,6 +84,13 @@ public static class StreamEndpoints
// Antwort statt eines Fehlers.
vorrat.Anstellen(gewuenschte.Id, vordraengeln: true);
// **Und die Vorschaubilder gleich mit.** Denselben Grund: sie
// entstehen aus einem Durchlauf über die Datei, und bis jemand
// zur Zeitleiste greift, ist der längst durch. Auf Vorrat über
// den ganzen Bestand wären es Gigabyte für Titel, die niemand
// anfasst.
vorschau.Anstellen(gewuenschte.Id);
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
@@ -64,7 +100,7 @@ public static class StreamEndpoints
// **Was der Browser ohnehin öffnet, geht unverändert hinaus.**
//
// Eine MP4 mit H.264 und AAC durch ffmpeg zu schicken wäre ein
// Prozess und ein Kratzplatzordner für nichts — und der
// Prozess und ein Transcodeordner für nichts — und der
// Browser verlöre dabei das, was er von sich aus am besten
// kann: springen über Bereichsanfragen, ohne dass irgendwo
// etwas neu gerechnet wird.
@@ -89,7 +125,7 @@ public static class StreamEndpoints
erzwingen: anfrage.Transcode,
tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
return Results.Ok(await Auskunft(db, sitzung));
return Results.Ok(await Auskunft(db, vorrat, sitzung));
}
catch (InvalidOperationException fehler)
{
@@ -99,9 +135,139 @@ public static class StreamEndpoints
}
}).RequireAuthorization();
// **Die Hauptliste — für Abspieler, die ihre Untertitel aus HLS
// beziehen.**
//
// `index.m3u8` ist eine Medienliste: eine Reihe von Schnipseln, sonst
// nichts. Der Browser braucht nicht mehr, weil er die Untertitel als
// eigene Spur ans Videoelement gehängt bekommt. AVPlayer kann das
// nicht — dort gibt es keinen Weg, einer laufenden Wiedergabe eine
// Textspur von aussen beizulegen. Was er findet, findet er in der
// Abspielliste oder gar nicht.
//
// Also hier: dieselbe Medienliste als Bildspur, daneben je eine
// Untertitelspur. Für den Browser ändert sich nichts; er holt
// weiterhin `index.m3u8`.
app.MapGet("/api/v1/stream/{sitzung:guid}/master.m3u8", async (
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator,
FlaroContext db, Untertitelvorrat vorrat, CancellationToken ct) =>
{
// Dieselbe Begründung wie bei der Medienliste darunter: eine
// Liste, die sich ändern kann, darf nicht zwischengespeichert
// werden.
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
koordinator.Lebenszeichen(sitzung);
var spuren = await db.MediaStreams.AsNoTracking()
.Where(x => x.MediaFileId == laufend.DateiId && x.Kind == MediaStreamKind.Subtitle)
.OrderBy(x => x.Index)
.ToListAsync(ct);
var datei = await db.MediaFiles.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == laufend.DateiId, ct);
// **Nur, was jetzt sofort abholbar ist.**
//
// Untertitel werden beim ersten Bedarf aus der Datei gezogen; bei
// einem Dreistünder auf einem NAS dauert das Minuten. Der Browser
// verträgt das, er fragt die Spur einzeln an und wartet. AVPlayer
// nicht: eine Spur, die in der Hauptliste steht und beim Abruf
// noch nicht da ist, bricht ihm die **ganze** Wiedergabe ab, nicht
// bloss den Untertitel.
//
// Das Füllen ist beim Anlegen der Sitzung schon angestossen — beim
// nächsten Abspielen stehen sie drin.
var text = datei is null ? [] : spuren
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
.Where(x => vorrat.Liegtvor(datei.Id, datei.ModifiedAt, x.Index))
.ToList();
var liste = new StringBuilder();
liste.AppendLine("#EXTM3U");
liste.AppendLine("#EXT-X-VERSION:6");
foreach (var spur in text)
{
// **Nichts ist voreingestellt.** `DEFAULT=YES` hiesse: dieser
// Untertitel läuft, ohne dass jemand ihn bestellt hat. Was
// gezeigt wird, entscheidet der Spieler.
liste.Append("#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"")
.Append(Untertitelgruppe)
.Append("\",NAME=\"").Append(M3uText(Benennung(spur))).Append('"');
if (spur.Language is { Length: > 0 } sprache)
liste.Append(",LANGUAGE=\"").Append(M3uText(sprache)).Append('"');
liste.Append(",DEFAULT=NO,AUTOSELECT=NO,FORCED=")
.Append(spur.IsForced ? "YES" : "NO")
.Append(",URI=\"untertitel/").Append(spur.Index).Append(".m3u8\"")
.AppendLine();
}
// **BANDWIDTH ist Pflicht, auch wenn es nur eine Stufe gibt.**
// Eine Hauptliste ohne diese Angabe weist AVPlayer ab.
liste.Append("#EXT-X-STREAM-INF:BANDWIDTH=").Append(Bandbreite);
if (text.Count > 0) liste.Append(",SUBTITLES=\"").Append(Untertitelgruppe).Append('"');
liste.AppendLine();
liste.AppendLine("index.m3u8");
return Results.Text(liste.ToString(), "application/vnd.apple.mpegurl", Encoding.UTF8);
}).RequireAuthorization();
// **Eine Untertitelspur als Abspielliste.**
//
// HLS kennt Text nur als eigene Spur mit eigener Liste. Unsere Spur
// ist eine einzige WebVTT-Datei über den ganzen Film — also eine
// Liste mit genau einem Stück, so lang wie der Rest des Films.
//
// **`ab` muss mit.** Die Zeitstempel in der Datei zählen ab
// Dateianfang, der Strom ab null. Ohne diesen Wert läge bei
// „Fortsetzen ab 8:37" jeder Satz acht Minuten in der Zukunft.
app.MapGet("/api/v1/stream/{sitzung:guid}/untertitel/{spur:int}.m3u8", async (
Guid sitzung, int spur, HttpResponse antwort,
Streamkoordinator koordinator, FlaroContext db, CancellationToken ct) =>
{
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
koordinator.Lebenszeichen(sitzung);
var textspuren = await Untertitelvorrat.TextspurenAsync(db, laufend.DateiId, ct);
if (!textspuren.Contains(spur)) return Results.NotFound();
var datei = await db.MediaFiles.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == laufend.DateiId, ct);
if (datei is null) return Results.NotFound();
// Ohne bekannte Laufzeit eine grosszügige Länge: zu kurz hiesse,
// dass der Rest des Films ohne Untertitel dasteht.
var rest = datei.Duration is { TotalSeconds: > 0 } dauer
? Math.Max(1, dauer.TotalSeconds - laufend.Ab)
: 86400;
var ab = laufend.Ab.ToString("0.###", CultureInfo.InvariantCulture);
var laenge = rest.ToString("0.###", CultureInfo.InvariantCulture);
var liste =
$"""
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:{Math.Ceiling(rest)}
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:{laenge},
/api/v1/files/{laufend.DateiId}/subtitles/{spur}.vtt?ab={ab}
#EXT-X-ENDLIST
""";
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
}).RequireAuthorization();
// **Die Playlist.** Sie wächst, während ffmpeg schreibt.
app.MapGet("/api/v1/stream/{sitzung:guid}/index.m3u8", async (
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator, CancellationToken ct) =>
Guid sitzung, HttpResponse antwort, Streamkoordinator koordinator,
Tagebuch tagebuch, CancellationToken ct) =>
{
// **Diese Datei darf nie zwischengespeichert werden**, und das ist
// kein Vorsichtsmass, sondern aus einem Fehler gelernt: der Film
@@ -143,7 +309,19 @@ public static class StreamEndpoints
// **Als Inhalt, nicht als Datei.** `Results.File` hängt
// `Last-Modified` an und lädt damit zum Zwischenspeichern ein.
if (Streamkoordinator.Playlist(jetzt.Ordner) is { } liste)
{
// **Die zweite Hälfte der Wartezeit.** Die erste steht
// beim Anlegen der Sitzung; hier steht, wie lange ffmpeg
// danach bis zum ersten Stück gebraucht hat. Nur wenn es
// auffällt — vier Zeilen je Wiedergabe wären Lärm.
if (versuch >= 8)
{
tagebuch.Schreiben(
$"Wiedergabe: erstes Stück nach {versuch * 250} ms.");
}
return Results.Text(liste, "application/vnd.apple.mpegurl", Encoding.UTF8);
}
// **Hört ffmpeg auf, ohne ein Segment geschrieben zu haben**,
// kommt auch keines mehr. Weiterzuwarten hiesse, eine halbe
@@ -197,7 +375,7 @@ public static class StreamEndpoints
// **Schliessen heisst aufräumen.** Ohne das liefe ffmpeg weiter, bis
// die Geduld der Sitzung abgelaufen ist — und schriebe solange den
// ganzen Film auf den Kratzplatz.
// ganzen Film auf den Transcode-Cache.
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
{
koordinator.Beenden(sitzung);
@@ -229,6 +407,68 @@ public static class StreamEndpoints
// in dessen Zukunft, und zwar für den ganzen Rest des Films. Gemessen
// im Spieler: bei „Fortsetzen ab 8:37" stand der Satz von Sekunde 0
// auf dem Bild.
// **Die Vorschau beim Überfahren der Zeitleiste.**
//
// Zwei Endpunkte: die Auskunft sagt, wie die Blätter aussehen, das
// Blatt liefert die Bilder. Der Spieler holt die Auskunft einmal
// und danach nur noch Blätter — beim Fahren über die Leiste wird
// kein einziger Abruf mehr fällig, nur der Ausschnitt verschoben.
app.MapGet("/api/v1/files/{id:guid}/vorschau", async (
Guid id, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
CancellationToken ct) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null || !File.Exists(datei.Path))
return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
// **Mit Freigabeprüfung, anders als beim Untertitel.** Ein
// Standbild zeigt den Inhalt — bei einem Titel, der erst
// nächste Woche gilt, wäre es genau das, was die Freigabe
// verhindern soll.
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
// Wer fragt, will sie auch: anstellen, falls noch nichts da ist.
vorschau.Anstellen(id);
var plan = vorschau.Liegtvor(id, datei.ModifiedAt);
return Results.Ok(new VorschauAuskunft(
plan is not null,
plan?.Intervall ?? Vorschaubilder.Intervall,
plan?.Spalten ?? Vorschaubilder.Spalten,
plan?.Zeilen ?? Vorschaubilder.Zeilen,
plan?.KachelBreite ?? 0,
plan?.KachelHoehe ?? 0,
plan?.Blaetter ?? 0));
}).RequireAuthorization();
app.MapGet("/api/v1/files/{id:guid}/vorschau/{nummer:int}.jpg", async (
Guid id, int nummer, FlaroContext db, Vorschauvorrat vorschau, TimeProvider uhr,
CancellationToken ct) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null) return Einwand.Fehlt("Diese Datei liegt nicht mehr da.");
if (await DeliveryEndpoints.FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
var blatt = vorschau.Blatt(id, datei.ModifiedAt, nummer);
return blatt is null
? Einwand.Fehlt("Dieses Blatt gibt es nicht.")
: Results.File(blatt, "image/jpeg");
})
// **Für immer gültig.** Der Dateiname trägt die Änderungszeit der
// Mediendatei; wird sie ersetzt, ändert sich die Adresse. Dasselbe
// Versprechen wie bei den Bildern aus `ImageEndpoints`.
.AddEndpointFilter(async (zusammenhang, weiter) =>
{
var antwort = await weiter(zusammenhang);
zusammenhang.HttpContext.Response.Headers.CacheControl =
"private, max-age=31536000, immutable";
return antwort;
})
.RequireAuthorization();
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
Guid id, int spur, FlaroContext db, Untertitelvorrat vorrat,
CancellationToken ct, double ab = 0) =>
@@ -294,6 +534,11 @@ public static class StreamEndpoints
// dass es nichts zu beenden und nichts aufzuräumen gibt.
SessionId: Guid.Empty,
Url: $"/api/v1/files/{datei}/download",
// **Keine Hauptliste.** Eine durchgereichte Datei bringt ihre
// Spuren selbst mit; eine Liste darum herum wäre eine Hülle um
// nichts.
MasterUrl: null,
Method: "direct",
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
@@ -302,13 +547,10 @@ public static class StreamEndpoints
StartSeconds: 0,
Height: null,
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList(),
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
SubtitleStreams: Spurliste(spuren
.Where(s => s.Kind == MediaStreamKind.Subtitle)
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
}
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
@@ -335,16 +577,35 @@ public static class StreamEndpoints
new HashSet<string>(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase));
}
private static async Task<StreamResponse> Auskunft(FlaroContext db, Stromsitzung sitzung)
private static async Task<StreamResponse> Auskunft(
FlaroContext db, Untertitelvorrat vorrat, Stromsitzung sitzung)
{
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == sitzung.DateiId)
.OrderBy(s => s.Index)
.ToListAsync();
// **Die Hauptliste nur, wenn etwas drinsteht.**
//
// Sie gibt es allein wegen der Untertitelspuren; ohne sie ist sie
// eine Hülle um die Medienliste. Und eine Hülle ist nicht umsonst:
// eine Hauptliste ohne `CODECS` muss der Spieler erst aufmachen, um
// zu sehen, was darin liegt. Gemessen an einem Film, dessen Spuren
// noch nicht herausgezogen waren: zwölf Sekunden vergingen, ohne dass
// die Medienliste auch nur abgeholt wurde, dann gab die App auf und
// bestellte neu.
var datei = await db.MediaFiles.AsNoTracking()
.FirstOrDefaultAsync(f => f.Id == sitzung.DateiId);
var hatText = datei is not null && spuren
.Where(x => x.Kind == MediaStreamKind.Subtitle)
.Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec))
.Any(x => vorrat.Liegtvor(datei.Id, datei.ModifiedAt, x.Index));
return new StreamResponse(
SessionId: sitzung.Kennung,
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
MasterUrl: hatText ? $"/api/v1/stream/{sitzung.Kennung}/master.m3u8" : null,
Method: sitzung.Art switch
{
Stromart.Direkt => "direct",
@@ -354,13 +615,10 @@ public static class StreamEndpoints
StartSeconds: sitzung.Ab,
Height: sitzung.Hoehe,
AudioStream: sitzung.Tonspur,
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio)
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList(),
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle)
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
SubtitleStreams: Spurliste(spuren
.Where(s => s.Kind == MediaStreamKind.Subtitle)
.Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
}
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
@@ -371,10 +629,36 @@ public static class StreamEndpoints
/// nichts führt, ist schlechter als kein Eintrag.
/// </remarks>
/// <summary>Beschriftet eine Spurliste — unterscheidbar.</summary>
///
/// <remarks>
/// Das Auftrennen gleicher Namen steht in <c>Benennung</c>: die Apps
/// zeigen dieselben Listen, und zweimal formuliert driften sie
/// auseinander.
/// </remarks>
private static List<SpurAuskunft> Spurliste(IEnumerable<MediaStream> spuren)
{
var liste = spuren.ToList();
var namen = Flaro.Core.Media.Benennung.Unterscheiden(
liste.Select(s => new Flaro.Core.Media.Benennung.Spurzeile(
Benennung(s), s.Codec, s.Index)).ToList());
return liste
.Select((s, i) => new SpurAuskunft(s.Index, namen[i], s.Language, s.IsDefault))
.ToList();
}
/// <summary>Wie eine Spur im Menü heisst.</summary>
private static string Benennung(MediaStream spur)
{
if (spur.Title is { Length: > 0 } eigen) return eigen;
// **Ein Spurname, der nur die Sprache wiederholt, ist keiner.**
// Viele Dateien tragen als Titel schlicht „deu" — durchgereicht
// stand genau das in der Auswahl, roh und dreibuchstabig, obwohl
// die Tabelle darunter „Deutsch" daraus gemacht hätte.
var istKuerzel = spur.Language is { Length: > 0 } kennung
&& string.Equals(spur.Title?.Trim(), kennung.Trim(), StringComparison.OrdinalIgnoreCase);
if (!istKuerzel && spur.Title is { Length: > 0 } eigen) return eigen;
var sprache = Sprache(spur.Language);
var teile = new List<string> { sprache };
@@ -251,12 +251,12 @@ public static class SystemEndpoints
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
// nachsieht — nicht der letzte.
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Kratzplatzpflege pflege) =>
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade, Transcodepflege pflege) =>
{
Speicherort Messen(string name, string variable, string pfad) =>
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null,
// **Beim Kratzplatz aufgeschlüsselt.** „39 GB belegt" sagt
// **Beim Transcode-Cache aufgeschlüsselt.** „39 GB belegt" sagt
// nicht, ob das Segmente, umgepackte Filme oder Untertitel
// sind — und davon hängt ab, ob es ein Problem ist.
name == "Umpacken" ? pflege.Belegung() : null);
@@ -269,7 +269,7 @@ public static class SystemEndpoints
});
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Kratzplatz aufräumen.**
// **Den Transcode-Cache aufräumen.**
//
// POST und nicht DELETE wie beim Bildspeicher darüber: dort wird ein
// Ordner geleert, hier eine Regel angewandt. Etwas, das Verwaistes
@@ -301,8 +301,8 @@ public static class SystemEndpoints
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/storage/transcode/cleanup", async (
Kratzplatzpflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Kratzplatzpflege.Anlass.VonHand, ct)))
Transcodepflege pflege, CancellationToken ct) =>
Results.Ok(await pflege.LaufenAsync(Transcodepflege.Anlass.VonHand, ct)))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Den Bildspeicher leeren.**
+81
View File
@@ -35,3 +35,84 @@ public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource
zeilen.GetValueOrDefault(LanguageName));
}
}
/// <summary>Der Schlüssel für fanart.tv, aus der Datenbank.</summary>
///
/// <remarks>
/// <b>Kein mitgelieferter.</b> Bei TMDB gibt es einen Projektschlüssel als
/// Rückfall; fanart.tv verlangt einen persönlichen, und einen fremden
/// mitzuliefern hiesse, ihn fremd zu benutzen. Ohne eigenen Schlüssel ist
/// die Quelle schlicht aus.
/// </remarks>
public sealed class DatabaseFanartSettings(FlaroContext db) : IFanartSettingsSource
{
public const string ApiKeyName = "metadata.fanart.apiKey";
private string? _gemerkt;
private bool _gelesen;
public async Task<string?> SchluesselAsync(CancellationToken ct = default)
{
if (_gelesen) return _gemerkt;
var zeile = await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == ApiKeyName, ct);
_gelesen = true;
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
}
}
/// <summary>Der Schlüssel für OMDb, aus der Datenbank.</summary>
///
/// <remarks>
/// Wie bei fanart.tv: kein mitgelieferter. OMDb begrenzt je Schlüssel auf
/// tausend Abrufe am Tag — einen fremden mitzuliefern hiesse, dessen
/// Kontingent zu verbrauchen.
/// </remarks>
public sealed class DatabaseOmdbSettings(FlaroContext db) : IOmdbSettingsSource
{
public const string ApiKeyName = "metadata.omdb.apiKey";
private string? _gemerkt;
private bool _gelesen;
public async Task<string?> SchluesselAsync(CancellationToken ct = default)
{
if (_gelesen) return _gemerkt;
var zeile = await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == ApiKeyName, ct);
_gelesen = true;
return _gemerkt = string.IsNullOrWhiteSpace(zeile?.Value) ? null : zeile.Value.Trim();
}
}
/// <summary>Ob die Seite von Rotten Tomatoes gelesen werden darf.</summary>
///
/// <remarks>
/// <b>Kein Schlüssel, sondern ein Schalter — und der steht auf aus.</b>
/// Rotten Tomatoes vergibt keine Zugänge; die beiden Werte stehen nur auf
/// der Seite selbst. Das ist Auslesen einer fremden Webseite: es bricht,
/// wenn dort umgebaut wird, und es geschieht ohne Einverständnis des
/// Betreibers. Eine solche Quelle gehört nicht stillschweigend
/// eingeschaltet, sondern bewusst.
/// </remarks>
public sealed class DatabaseRottenTomatoesSettings(FlaroContext db) : IRottenTomatoesSettingsSource
{
public const string SchalterName = "metadata.rottenTomatoes.enabled";
private bool? _gemerkt;
public async Task<bool> EingeschaltetAsync(CancellationToken ct = default)
{
if (_gemerkt is { } schon) return schon;
var zeile = await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == SchalterName, ct);
return (_gemerkt = string.Equals(zeile?.Value, "true", StringComparison.OrdinalIgnoreCase))
.Value;
}
}
+3 -3
View File
@@ -177,8 +177,8 @@ public sealed class PackageCoordinator(
// Umpacken ist der eine, in dem der Platz wirklich gebraucht
// wird — und `PlatzPruefen` sagt danach die Wahrheit statt
// „kein Platz", obwohl zwanzig Gigabyte Altlast herumliegen.
await bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.VorDemUmpacken);
await bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
.LaufenAsync(Transcodepflege.Anlass.VorDemUmpacken);
PlatzPruefen(datei.SizeBytes);
@@ -237,7 +237,7 @@ public sealed class PackageCoordinator(
if (platte.AvailableFreeSpace >= gebraucht) return;
throw new InvalidOperationException(
$"Auf dem Kratzplatz ({pfade.Transcode}) sind noch " +
$"Auf dem Transcode-Cache ({pfade.Transcode}) sind noch " +
$"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " +
$"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein.");
}
+119 -8
View File
@@ -75,7 +75,7 @@ builder.Services.AddRateLimiter(o =>
// und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<Schluesselbilder>();
builder.Services.AddSingleton<Keyframes>();
builder.Services.AddSingleton<FfmpegRemuxer>();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
@@ -91,6 +91,7 @@ builder.Services.AddScoped<Uebersetzer>();
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
builder.Services.AddScoped<Wiedergabeeinstellungen>();
builder.Services.AddScoped<Saisoneinstellungen>();
builder.Services.AddHttpClient<TmdbClient>(c =>
{
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
@@ -98,6 +99,70 @@ builder.Services.AddHttpClient<TmdbClient>(c =>
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
// **Eine zweite Bildquelle, und ausdrücklich keine zweite Metadatenquelle.**
// fanart.tv liefert Plakate mit Sprachangabe — genau das, was TMDB für
// manche Filme nicht hat. Titel, Beschreibung und Besetzung kommen
// weiterhin von einer Stelle; zwei Anbieter für dieselbe Angabe wären zwei
// Wahrheiten.
builder.Services.AddScoped<IFanartSettingsSource, DatabaseFanartSettings>();
builder.Services.AddHttpClient<FanartClient>(c =>
{
c.BaseAddress = new Uri(FanartClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IBildquelle>(sp => sp.GetRequiredService<FanartClient>());
// **Und eine für Bewertungen.** IMDbs eigene Schnittstelle kostet Geld,
// Rotten Tomatoes vergibt Zugänge nur an Partner, Letterboxd hat gar keine
// offene — OMDb liefert alle drei über die IMDb-Kennung, die ohnehin schon
// an jedem Film steht.
builder.Services.AddScoped<IOmdbSettingsSource, DatabaseOmdbSettings>();
builder.Services.AddHttpClient<OmdbClient>(c =>
{
c.BaseAddress = new Uri(OmdbClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
});
// **Rotten Tomatoes dazu, aber nur auf Wunsch.** Dort gibt es keine
// Schnittstelle — die beiden Werte stehen auf der Seite selbst, und OMDb
// gibt davon nur den der Kritik weiter. Das Auslesen einer fremden Webseite
// bricht, wenn dort umgebaut wird; deshalb ein Schalter, und der steht aus.
builder.Services.AddScoped<IRottenTomatoesSettingsSource, DatabaseRottenTomatoesSettings>();
// **Eine Anfrage je Sekunde, prozessweit.** Achthundert Titel in einem
// Schwall von einer Adresse wären eine Zumutung und würden gesperrt. Der
// Scan dauert dadurch länger; das ist der Preis fürs Mitbenutzen.
builder.Services.AddSingleton(new Anklopfbremse(TimeSpan.FromSeconds(1)));
builder.Services.AddHttpClient<RottenTomatoesClient>(c =>
{
c.BaseAddress = new Uri(RottenTomatoesClient.BaseAddress);
c.Timeout = TimeSpan.FromSeconds(20);
// Ohne Browserkennung antwortet die Seite mit einer Sperrseite statt
// mit dem Film.
c.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/131.0.0.0 Safari/537.36");
});
// **Die Reihenfolge ist die Rangfolge.** Beide führen den Wert der Kritik;
// OMDb aus zweiter Hand, Rotten Tomatoes als Quelle selbst. Wer hinten
// steht, überschreibt.
builder.Services.AddScoped<IBewertungsquelle>(sp => new Bewertungsquellen(
[
sp.GetRequiredService<OmdbClient>(),
sp.GetRequiredService<RottenTomatoesClient>(),
]));
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
// Auskunft darüber, ob der Dienst antwortet.
builder.Services.AddScoped<Quellenprobe>();
// **Damit ein festgehaltenes Ergebnis nicht ewig falsch bleibt.** Ob ein
// Titel in „Fortsetzen" steht, ist eine gespeicherte Spalte — berechnet
// wird sie nur, wenn eine Fortschrittsmeldung eintrifft. Nach einem Update
// einmal durchrechnen fängt genau das ab.
builder.Services.AddSingleton<Sehstandpflege>();
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
@@ -105,6 +170,13 @@ builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>
builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
// Vorspann und Abspann: der Leser ist zustandslos, die Erkennung hält den
// Vermerk, welche Serie gerade abgetastet wird.
builder.Services.AddSingleton<Fingerprintleser>();
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
builder.Services.AddSingleton<Frameabtaster>();
builder.Services.AddSingleton<Kapitelleser>();
builder.Services.AddSingleton<Segmenterkennung>();
builder.Services.AddSingleton<PackageCoordinator>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
@@ -112,9 +184,11 @@ builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>();
builder.Services.AddSingleton<Untertitelvorrat>();
// Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Kratzplatzeinstellungen>();
builder.Services.AddSingleton<Kratzplatzpflege>();
builder.Services.AddSingleton<Vorschaubilder>();
builder.Services.AddSingleton<Vorschauvorrat>();
// Der Transcode-Cache räumt sich auf — kein Dienst, kein Takt, vier Anlässe.
builder.Services.AddScoped<Transcodeeinstellungen>();
builder.Services.AddSingleton<Transcodepflege>();
// **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt
// an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand
@@ -125,7 +199,20 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService<Wartungsplan>());
// 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.AddSingleton<Tagebuch>();
// **Von Hand gebaut, nicht aus dem Behälter geholt.** Die Protokollsenke
// braucht ihn, bevor der Behälter steht; ein `BuildServiceProvider()` dafür
// legte eine zweite Ausgabe an — zwei Tagebücher mit zwei Schlössern, die
// sich beim Schreiben ins Gehege kommen.
var tagebuch = new Tagebuch(pfade, TimeProvider.System);
builder.Services.AddSingleton(tagebuch);
// **Was der Server ohnehin meldet, gehört auch ins Tagesprotokoll.**
//
// „Scan gestartet", „Strom angelegt", „Untertitel gezogen" standen längst
// im Quelltext — sie gingen nur an die Container-Ausgabe, wo sie zwischen
// den EF-Abfragen untergehen. Die Senke nimmt nur Flaros eigene Kategorien;
// `Microsoft.*` bliebe sonst mit einer Zeile je Datenbankabfrage darin.
builder.Logging.AddProvider(new Tagebuchsenke(tagebuch));
// **Die Schlüssel der Datenverschlüsselung gehören ins Volume.**
//
@@ -182,6 +269,28 @@ using (var bereich = app.Services.CreateScope())
// sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
// sucht.
// **Jede Fünfhunderter-Antwort wird vermerkt, egal woher sie kommt.**
//
// Der Haken darunter fängt nur Ausnahmen. Ein Endpunkt, der von sich aus
// `Results.Problem(... 500)` zurückgibt, läuft daran vorbei — und genau so
// eine Antwort stand in der App („der Server hat den Download mit Fehler
// 500 abgelehnt"), während im Protokoll nichts davon zu finden war. Eine
// Fehlermeldung, die der Benutzer sieht und der Betreiber nicht, ist eine
// Sackgasse; dieselbe Lehre wie beim ffmpeg-Fehler vor ein paar Tagen.
app.Use(async (zusammenhang, weiter) =>
{
await weiter(zusammenhang);
if (zusammenhang.Response.StatusCode >= 500)
{
zusammenhang.RequestServices.GetRequiredService<Tagebuch>().Schreiben(
$"{zusammenhang.Response.StatusCode} auf "
+ $"{zusammenhang.Request.Method} {zusammenhang.Request.Path}"
+ zusammenhang.Request.QueryString,
Tagebuch.Art.Fehler);
}
});
app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang =>
{
var fehler = zusammenhang.Features
@@ -318,7 +427,6 @@ if (Directory.Exists(oberfläche))
// macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten
// im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll.
{
var tagebuch = app.Services.GetRequiredService<Tagebuch>();
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
leben.ApplicationStarted.Register(() =>
@@ -343,8 +451,11 @@ if (Directory.Exists(oberfläche))
// Stromordner noch da und jedes Teilstück eines abgebrochenen
// Umpackens — und beim Start ist sicher, dass niemand davon noch
// gebraucht wird.
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
_ = app.Services.GetRequiredService<Transcodepflege>()
.LaufenAsync(Transcodepflege.Anlass.Start, leben.ApplicationStopping);
_ = app.Services.GetRequiredService<Sehstandpflege>()
.NachUpdateAsync(leben.ApplicationStopping);
_ = Task.Run(async () =>
{
+148
View File
@@ -0,0 +1,148 @@
using Flaro.Core.Metadata;
using Flaro.Metadata;
namespace Flaro.Api;
/// <summary>Was eine Quelle auf Nachfrage geantwortet hat.</summary>
///
/// <param name="Source">„tmdb", „fanart" oder „omdb".</param>
/// <param name="Configured">Ob überhaupt ein Zugang hinterlegt ist.</param>
/// <param name="Ok">Ob die Probe etwas Brauchbares ergeben hat.</param>
/// <param name="Detail">Ein Satz, der sagt, was herauskam.</param>
public sealed record Quellenbefund(string Source, bool Configured, bool Ok, string Detail);
/// <summary>Fragt die Quellen einmal und sagt, was zurückkam.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> Nach dem Eintragen eines Schlüssels stand in der
/// Oberfläche „Hinterlegt" — und das war alles. Ob er stimmt, ob der Dienst
/// antwortet und ob zu einem Film überhaupt etwas zu holen ist, liess sich
/// nicht feststellen; man trug ein, frischte die Metadaten über
/// achthundert Titel auf und sah danach nach, ob sich etwas geändert hatte.
/// </para>
/// <para>
/// <b>Mit einem bekannten Film, nicht mit einem aus der Mediathek.</b> „The
/// Matrix" hat bei allen drei Quellen Einträge; ein Film aus dem eigenen
/// Bestand könnte schlicht unbekannt sein, und dann wüsste man wieder
/// nicht, ob es am Schlüssel liegt oder am Film.
/// </para>
/// </remarks>
public sealed class Quellenprobe(
TmdbClient tmdb,
FanartClient fanart,
OmdbClient omdb,
RottenTomatoesClient tomaten,
ITmdbSettingsSource spracheVon)
{
/// <summary>„The Matrix" — bei allen Quellen vorhanden.</summary>
private const int ProbeTmdbId = 603;
private const string ProbeImdbId = "tt0133093";
private const string ProbeTitel = "The Matrix";
private const int ProbeJahr = 1999;
public async Task<IReadOnlyList<Quellenbefund>> LaufenAsync(CancellationToken ct = default) =>
[
await TmdbProbe(ct),
await FanartProbe(ct),
await OmdbProbe(ct),
await TomatenProbe(ct),
];
private async Task<Quellenbefund> TmdbProbe(CancellationToken ct)
{
if (!await tmdb.IsConfiguredAsync(ct))
return new("tmdb", false, false, "Kein Zugang hinterlegt.");
var film = await tmdb.FindMovieAsync("The Matrix", 1999, ct);
return film is null
? new("tmdb", true, false,
"Der Dienst hat nicht geantwortet oder den Zugang abgelehnt.")
: new("tmdb", true, true,
$"Erreichbar — Probeabruf ergab {film.Title} ({film.Year}).");
}
private async Task<Quellenbefund> FanartProbe(CancellationToken ct)
{
if (!await fanart.EingerichtetAsync(ct))
return new("fanart", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus.");
var bilder = await fanart.FilmbilderAsync(ProbeTmdbId, ct);
var plakate = bilder.Where(b => b.Kind == "poster").ToList();
if (plakate.Count == 0)
{
return new("fanart", true, false,
"Erreicht, aber nichts bekommen. Stimmt der Schlüssel?");
}
// **Die Zahl in der eingestellten Sprache ist die eigentliche
// Auskunft.** „42 Plakate" sagt nur, dass etwas kam; „davon 3
// deutsche" sagt, ob die Quelle den Zweck erfüllt, für den sie da ist.
var sprache = (await spracheVon.GetAsync(ct)).Language.Split('-')[0];
var passend = plakate.Count(b =>
string.Equals(b.Language, sprache, StringComparison.OrdinalIgnoreCase));
return new("fanart", true, true,
$"Erreichbar — {plakate.Count} Plakate zu The Matrix, davon {passend} in {sprache}.");
}
private async Task<Quellenbefund> OmdbProbe(CancellationToken ct)
{
if (!await omdb.EingerichtetAsync(ct))
return new("omdb", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus.");
var wertungen = await omdb.HolenAsync(
new Wertungsfrage(ProbeImdbId, ProbeTitel, ProbeJahr), ct);
if (wertungen.Count == 0)
{
return new("omdb", true, false,
"Erreicht, aber nichts bekommen. Stimmt der Schlüssel, "
+ "oder ist das Tageskontingent aufgebraucht?");
}
return new("omdb", true, true, $"Erreichbar — zu The Matrix: {Aufzaehlung(wertungen)}.");
}
/// <summary>Die Seite von Rotten Tomatoes, falls das Lesen erlaubt wurde.</summary>
///
/// <remarks>
/// <b>Hier ist die Probe mehr als eine Höflichkeit.</b> Bei den anderen
/// beantwortet sie die Frage, ob ein Schlüssel stimmt; hier beantwortet
/// sie die Frage, ob das Auslesen überhaupt noch geht. Wird die Seite
/// umgebaut oder der Zugriff gesperrt, merkt man es genau hier — und
/// sonst nirgends.
/// </remarks>
private async Task<Quellenbefund> TomatenProbe(CancellationToken ct)
{
if (!await tomaten.EingerichtetAsync(ct))
return new("rottenTomatoes", false, false, "Ausgeschaltet.");
var wertungen = await tomaten.HolenAsync(
new Wertungsfrage(ProbeImdbId, ProbeTitel, ProbeJahr), ct);
if (wertungen.Count == 0)
{
return new("rottenTomatoes", true, false,
"Nichts bekommen. Entweder ist der Zugriff gesperrt, "
+ "oder die Seite ist umgebaut worden.");
}
return new("rottenTomatoes", true, true,
$"Erreichbar — zu The Matrix: {Aufzaehlung(wertungen)}.");
}
private static string Aufzaehlung(IEnumerable<Fremdwertung> wertungen) =>
string.Join(", ", wertungen.Select(w => $"{Kurz(w.Quelle)} {w.Anzeige}"));
private static string Kurz(string quelle) => quelle switch
{
"imdb" => "IMDb",
"rottenTomatoes" => "RT Kritik",
"rottenTomatoesAudience" => "RT Publikum",
"metacritic" => "MC",
_ => quelle,
};
}
@@ -0,0 +1,62 @@
using System.Text.Json;
using Flaro.Core.Library;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Die saisonalen Anlässe, aus den Einstellungen.</summary>
///
/// <remarks>
/// <b>Als JSON in einer Zeile und nicht als Tabelle.</b> Das hier ist
/// Konfiguration, kein Bestand: eine Handvoll Einträge, die zusammen
/// gelesen und zusammen geschrieben werden. Eine Tabelle dafür hiesse eine
/// Migration für etwas, das niemand einzeln abfragt.
/// </remarks>
public sealed class Saisoneinstellungen(FlaroContext db)
{
public const string Schluessel = "startseite.saisonal.anlaesse";
private IReadOnlyList<Saisonanlass>? _gemerkt;
public async Task<IReadOnlyList<Saisonanlass>> AnlaesseAsync(CancellationToken ct = default)
{
if (_gemerkt is { } fertig) return fertig;
var zeile = await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(s => s.Key == Schluessel, ct);
return _gemerkt = Lesen(zeile?.Value);
}
/// <summary>Liest die gespeicherte Liste — oder gibt die Vorgabe zurück.</summary>
///
/// <remarks>
/// <b>Unlesbares ist kein Grund, die Startseite zu verlieren.</b> Steht
/// dort Unsinn, gilt die Vorgabe; eine Ausnahme hier nähme dem Server
/// die ganze Reihe und nicht nur den falschen Eintrag.
/// </remarks>
public static IReadOnlyList<Saisonanlass> Lesen(string? roh)
{
if (string.IsNullOrWhiteSpace(roh)) return Saisonanlass.Vorgabe;
try
{
var gelesen = JsonSerializer.Deserialize<List<Saisonanlass>>(
roh, new JsonSerializerOptions(JsonSerializerDefaults.Web));
// Eine leere Liste ist eine Entscheidung — „keine saisonalen
// Reihen" — und nicht dasselbe wie „nichts eingestellt".
return gelesen is null ? Saisonanlass.Vorgabe : gelesen.Where(a => a.Brauchbar).ToList();
}
catch (JsonException)
{
return Saisonanlass.Vorgabe;
}
}
public static string Schreiben(IEnumerable<Saisonanlass> anlaesse) =>
JsonSerializer.Serialize(
anlaesse.Where(a => a.Brauchbar).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
+19 -3
View File
@@ -99,7 +99,15 @@ public sealed class ScanCoordinator(
var tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
var melder = new Progress<Scanschritt>(schritt =>
{
tagebuch.Schreiben(schritt.Text());
// **Nicht jede Zeile ins Protokoll.**
//
// „Hole Metadaten zu Phineas und Ferb 4×23 — 1334 von 1347"
// ist Fortschritt, keine Auskunft: ein Auffrischen schrieb
// vierzehnhundert solche Zeilen und schob alles andere aus
// der Ansicht. Was bleibt, sind die Vermerke — sie erklären,
// warum etwas so zugeordnet wurde — und die Zusammenfassung
// am Ende.
if (schritt.Phase == Scanphase.Vermerk) tagebuch.Schreiben(schritt.Text());
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
@@ -164,8 +172,16 @@ public sealed class ScanCoordinator(
// Verwaiste entstehen: Dateien verschwinden, Änderungszeiten
// wandern, und die dazugehörigen Pakete und Untertitel gehören
// zu einem Stand, den es nicht mehr gibt.
_ = bereich.ServiceProvider.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.NachDemScan);
_ = bereich.ServiceProvider.GetRequiredService<Transcodepflege>()
.LaufenAsync(Transcodepflege.Anlass.NachDemScan);
// **Und zuletzt die Segmente.** Ein Scan ist der Augenblick, in
// dem neue Folgen dazukommen — und eine Folge ohne Vorspann-
// Segment bekommt nie eine, wenn niemand danach fragt. Der Lauf
// hängt sich hinten an und nimmt sich Serie für Serie vor;
// gerechnet wird nur, wo noch nichts steht.
_ = bereich.ServiceProvider.GetRequiredService<Segmenterkennung>()
.NachScanAsync(libraryId);
_zustand[libraryId] = _zustand[libraryId] with
{
+988
View File
@@ -0,0 +1,988 @@
using System.Collections.Concurrent;
using Flaro.Core.Library;
using Flaro.Core.Media;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Wie weit ein Segmentlauf greift.</summary>
///
/// <remarks>
/// <para>
/// <b>„Schon durchsucht" und „hat ein Segment" sind zwei verschiedene
/// Dinge</b>, und genau dazwischen fehlte bisher die Unterscheidung. Ein
/// Film, bei dem die Suche einmal nichts fand, trägt einen Vermerk und
/// kein Segment: der nächste Lauf übersprang ihn, obwohl er der
/// interessanteste Fall ist.
/// </para>
/// <para>
/// Nach jeder Verbesserung an der Erkennung will man genau diese Titel
/// noch einmal — und eben nicht die vierhundert, bei denen längst etwas
/// steht.
/// </para>
/// </remarks>
public enum Segmentumfang
{
/// <summary>Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan.</summary>
Neue,
/// <summary>
/// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon
/// ein Segment steht.
/// </summary>
Luecken,
/// <summary>Alles, auch was längst ein Segment hat.</summary>
Alles,
}
/// <summary>Sucht Vorspann und Abspann einer Staffel.</summary>
///
/// <remarks>
/// <para>
/// <b>Beides mit derselben Rechnung, nur in einem anderen Fenster.</b>
/// <see cref="Fingerprintabgleich"/> sucht das längste Stück, das zwei Tonabdrücke
/// teilen — was dabei herauskommt, entscheidet allein, welchen Ausschnitt
/// der Folge man ihm gibt. Vorn ist es der Vorspann, hinten der Abspann.
/// Eine zweite Erkennung dafür zu schreiben hiesse, dieselbe Suche zweimal
/// zu haben.
/// </para>
/// <para>
/// <b>Je Staffel, nicht je Serie.</b> Serien wechseln ihren Vorspann
/// zwischen Staffeln — neue Besetzung, neues Lied. Über alle Staffeln
/// verglichen fände man entweder nichts oder das Falsche.
/// </para>
/// <para>
/// <b>Im Hintergrund, mit einem Zustand zum Nachsehen.</b> Eine Staffel
/// abzutasten dauert Minuten; das in einer Anfrage zu tun hiesse, auf ein
/// Zeitlimit zu laufen und am Ende nichts zu haben.
/// </para>
/// </remarks>
public sealed class Segmenterkennung(
IServiceScopeFactory scopes,
TimeProvider clock,
ILogger<Segmenterkennung> log)
{
/// <summary>Wie viel vom Anfang nach einem Vorspann abgesucht wird.</summary>
///
/// <remarks>
/// Fünfzehn Minuten. Ein Vorspann liegt fast immer in den ersten fünf;
/// manche Serie stellt aber eine Viertelstunde Handlung davor. Weiter
/// zu suchen kostet Zeit und schafft vor allem Gelegenheiten, zwei
/// zufällig ähnliche Takte für einen Vorspann zu halten.
/// </remarks>
public const double Introfenster = 15 * 60;
/// <summary>Wie viel vom Ende nach einem Abspann abgesucht wird.</summary>
public const double Abspannfenster = 10 * 60;
/// <summary>Wie weit vom Ende eines Films nach dem Abspann gesucht wird.</summary>
///
/// <remarks>
/// Zwanzig Minuten. „Die letzten zehn Prozent" wäre bei einem
/// Dreistünder zu viel und bei einem Kurzfilm zu wenig.
/// </remarks>
public const double Suchbereich = 20 * 60;
/// <summary>Was ein gefundenes Stück höchstens lang sein darf.</summary>
///
/// <remarks>
/// <b>Die wichtigste Schranke.</b> Zwei Folgen, die dieselbe Datei
/// zweimal enthalten — ein Scanfehler —, teilen vierzig Minuten. Ohne
/// Obergrenze stünde dann ein Überspringen-Knopf über dem halben Film.
/// </remarks>
public const double Hoechstlaenge = 5 * 60;
/// <summary>Dasselbe für den Abspann — und deutlich grosszügiger.</summary>
///
/// <remarks>
/// <b>Fünf Minuten sind für einen Abspann keine Obergrenze, sondern
/// die Regel.</b> Eine deutsche Serienproduktion listet Sprecher,
/// Synchronregie und Studio hinterher; sechs Minuten kommen vor. Mit
/// derselben Schranke wie beim Vorspann wurde der Fund gemacht und
/// gleich wieder weggeworfen — gefunden, verworfen, und am Ende stand
/// kein Knopf da, obwohl die Erkennung gearbeitet hatte.
///
/// Weiter als das Suchfenster kann ein Segment ohnehin nicht reichen;
/// mehr als dieses Fenster zu erlauben hiesse nichts zu erlauben.
/// </remarks>
public const double Hoechstabspann = Abspannfenster;
/// <summary>Welche Staffeln gerade abgetastet werden.</summary>
private readonly ConcurrentDictionary<Guid, bool> _laufend = new();
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
/// <summary>Woran der Lauf je Mediathek gerade ist — für die Verwaltung.</summary>
///
/// <remarks>
/// <para>
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die
/// Segmenterkennung angestossen hat — in dasselbe Feld zu schreiben
/// liesse einen beendeten Scan wieder laufend aussehen.
/// </para>
/// <para>
/// <b>Nur der letzte Schritt</b>, wie bei <c>ScanState.Schritt</c>: eine
/// Liste über viertausend Folgen wäre ein Leck, und wer die
/// Vorgeschichte braucht, liest das Protokoll.
/// </para>
/// </remarks>
private readonly ConcurrentDictionary<Guid, Scanschritt> _stand = new();
public Scanschritt? Stand(Guid mediathek) =>
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary>
public bool Starten(Guid serieId, Segmentumfang umfang = Segmentumfang.Neue)
{
if (!_laufend.TryAdd(serieId, true)) return false;
_ = Task.Run(async () =>
{
try
{
await LaufenAsync(serieId, umfang, CancellationToken.None);
}
catch (Exception fehler)
{
log.LogWarning(fehler, "Segmentsuche für {Serie} abgebrochen: {Grund}",
serieId, Fehlertext.FuerMenschen(fehler));
}
finally
{
_laufend.TryRemove(serieId, out _);
}
});
return true;
}
/// <summary>Nach einem Scan: jede Serie der Mediathek, der noch Segmente fehlen.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier, weil es sonst nie passiert.</b> Eine neue Folge bekommt
/// kein Segment, wenn niemand danach fragt — und niemand fragt, weil man
/// nicht weiss, dass es etwas zu fragen gibt. Der Scan ist der
/// Augenblick, in dem neue Folgen entstehen.
/// </para>
/// <para>
/// <b>Eine nach der anderen, nicht alle zugleich.</b> Jede liest von
/// jeder ihrer Folgen ein Stück Ton; zwanzig Serien gleichzeitig hiesse
/// zwanzig Leseköpfe auf derselben Platte. Beim ersten Mal über einen
/// grossen Bestand dauert das entsprechend — danach findet der Lauf
/// nichts mehr zu tun und ist in Sekunden vorbei.
/// </para>
/// </remarks>
public async Task NachScanAsync(
Guid mediathek, Segmentumfang umfang = Segmentumfang.Neue,
CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var mediathekname = await db.Libraries.AsNoTracking()
.Where(x => x.Id == mediathek).Select(x => x.Name).FirstOrDefaultAsync(ct)
?? "Mediathek";
var serien = await db.Series.AsNoTracking()
.Where(x => x.LibraryId == mediathek)
.Select(x => new { x.Id, x.Title })
.OrderBy(x => x.Title)
.ToListAsync(ct);
try
{
// **Ohne fpcalc nur die Serien überspringen, nicht alles.** Der
// Bildweg braucht es nicht; ein Server ohne fpcalc konnte früher
// trotzdem keinen einzigen Filmabspann finden, weil diese
// Prüfung ganz oben stand und für beides galt.
if (serien.Count > 0 && await leser.VorhandenAsync(ct))
{
await SerienAsync(db, mediathekname, serien
.Select(s => (s.Id, s.Title)).ToList(), mediathek, umfang, ct);
}
else if (serien.Count > 0)
{
log.LogInformation(
"Kein fpcalc vorhanden — in „{Mediathek}“ bleibt der Vorspann ungesucht.",
mediathekname);
}
// **Nicht mehr hinter einem `return` für serienlose Mediatheken.**
// Stand hier früher `if (serien.Count == 0) return;`, und damit
// kam eine reine Filmmediathek nie bis zu dieser Zeile: der
// Abspann eines Films wurde nach einem Scan nie gesucht.
await FilmeAsync(db, mediathekname, mediathek, umfang, ct,
schritt => _stand[mediathek] = schritt);
}
finally
{
_stand.TryRemove(mediathek, out _);
}
}
/// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
private async Task SerienAsync(
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien,
Guid mediathek, Segmentumfang umfang, CancellationToken ct)
{
var folgen = await db.Episodes.AsNoTracking()
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct);
log.LogInformation(
"Segmentsuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). "
+ "Das kann Stunden dauern.", mediathekname, serien.Count, folgen);
var segmente = 0;
var ohneFund = 0;
var angefasst = 0;
for (var i = 0; i < serien.Count; i++)
{
if (ct.IsCancellationRequested) return;
var (id, titel) = serien[i];
var nummer = i + 1;
// Der Zähler gehört der Mediathek, der Name der Folge: so steht
// in der Zeile beides — wie weit, und woran gerade.
void Melden(Scanschritt schritt) =>
_stand[mediathek] = schritt with { Erledigt = nummer, Gesamt = serien.Count };
Melden(new Scanschritt(Scanphase.Segmente, titel));
if (!_laufend.TryAdd(id, true)) continue;
try
{
var stand = await LaufenAsync(id, umfang, ct, Melden);
segmente += stand.Segmente;
if (!stand.Angefasst) continue;
angefasst++;
if (stand.Segmente == 0) ohneFund++;
}
catch (Exception fehler)
{
// **Mit dem innersten Text.** Das Tagebuch hängt nur
// `Message` an, und bei EF Core steht dort „See the inner
// exception for details." — eine Zeile, die ausdrücklich
// sagt, dass sie die Auskunft nicht enthält.
log.LogWarning(fehler, "Segmentsuche für „{Serie}“ abgebrochen: {Grund}",
titel, Fehlertext.FuerMenschen(fehler));
}
finally { _laufend.TryRemove(id, out _); }
}
log.LogInformation(
"Segmentsuche in „{Mediathek}“ durch: {Segmente} Segment(n) in {Angefasst} von "
+ "{Serien} Serie(n) gesucht, {Ohne} ohne Fund.",
mediathekname, segmente, angefasst, serien.Count, ohneFund);
}
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
///
/// <remarks>
/// <b>Der Knopf darf nicht warten.</b> Über fünfundneunzig Serien
/// dauert das Stunden; eine Anfrage so lange offen zu halten hiesse,
/// sie in ein Zeitlimit laufen zu lassen. Wie es steht, sieht man im
/// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine
/// Segment steht.
/// </remarks>
public void MediathekStarten(Guid mediathek, Segmentumfang umfang)
{
_ = Task.Run(async () =>
{
try { await NachScanAsync(mediathek, umfang, CancellationToken.None); }
catch (Exception fehler)
{
log.LogWarning(fehler, "Segmentlauf für Mediathek {Kennung} abgebrochen: {Grund}",
mediathek, Fehlertext.FuerMenschen(fehler));
}
});
}
/// <summary>Die Filme einer Mediathek, denen noch ein Abspann fehlt.</summary>
///
/// <remarks>
/// Getrennt von den Serien, weil der Weg ein anderer ist — und weil
/// eine Mediathek immer nur eines von beidem enthält. Die Schleife
/// läuft dann ins Leere, und das kostet eine Abfrage.
/// </remarks>
private async Task FilmeAsync(
FlaroContext db, string mediathekname, Guid mediathek, Segmentumfang umfang,
CancellationToken ct, Action<Scanschritt> melden)
{
// **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem
// `Where` mit einem Aufzählungswert übersetzt EF Core zwar meist,
// aber „meist" ist hier kein Argument: so ist sicher, was in SQL
// ankommt.
var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek);
abfrage = umfang switch
{
Segmentumfang.Alles => abfrage,
// Die Lücke: gesucht wurde vielleicht, gefunden nichts.
Segmentumfang.Luecken => abfrage.Where(m =>
!db.Segmente.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
_ => abfrage.Where(m =>
!db.Segmentsuchen.Any(x => x.MovieId == m.Id && x.Art == Segmentart.Abspann)),
};
var offen = await abfrage
.Select(m => new { m.Id, m.Title, m.Year })
.OrderBy(m => m.Title)
.ToListAsync(ct);
if (offen.Count == 0) return;
log.LogInformation("Abspannsuche in „{Mediathek}“: {Anzahl} Film(e).",
mediathekname, offen.Count);
var gefunden = 0;
for (var i = 0; i < offen.Count; i++)
{
if (ct.IsCancellationRequested) return;
var film = offen[i];
var titel = Filmname(film.Title, film.Year);
melden(new Scanschritt(Scanphase.Segmente, titel, i + 1, offen.Count));
try { if (await FilmAsync(film.Id, umfang, ct)) gefunden++; }
catch (Exception fehler)
{
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen: {Grund}",
titel, Fehlertext.FuerMenschen(fehler));
}
}
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Film(en) gefunden.",
gefunden, offen.Count);
}
/// <summary>„Arrival (2016)" — der Name, unter dem ein Film im Protokoll steht.</summary>
private static string Filmname(string? titel, int? jahr)
{
var name = string.IsNullOrWhiteSpace(titel) ? "Ohne Titel" : titel;
return jahr is { } j ? $"{name} ({j})" : name;
}
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum hier ein anderer Weg.</b> Bei einer Folge verrät sich der
/// Abspann dadurch, dass er in jeder Folge derselbe ist. Ein Film hat
/// nichts, womit er sich vergleichen liesse — sein Abspann kommt genau
/// einmal vor. Was bleibt, ist, dass er dunkel ist und bis zum Ende
/// durchhält.
/// </para>
/// <para>
/// <b>Erst grob, dann zielen.</b> Ein Überblick über die letzten zwanzig
/// Minuten sagt, <i>ob</i> es eine dunkle Strecke gibt; er kommt aus
/// Keyframesn und ist deshalb billig, aber auf fünf bis zehn
/// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden.
/// </para>
/// </remarks>
public async Task<bool> FilmAsync(
Guid filmId, Segmentumfang umfang, CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var film = await db.Movies.AsNoTracking()
.Where(m => m.Id == filmId)
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
(m, d) => new { m.Id, m.Title, m.Year, d.Path, d.Duration })
.FirstOrDefaultAsync(ct);
// **Drei Gründe, nicht einer.** Hier stand dreimal dasselbe `false`
// — keine Datei, schon durchsucht, nichts gefunden —, und von
// aussen waren die drei nicht zu unterscheiden. „Warum hat dieser
// Film keinen Abspann?" liess sich damit nicht beantworten.
if (film is null)
{
log.LogInformation("Abspann: zu {Kennung} liegt keine Datei — übersprungen.", filmId);
return false;
}
var titel = Filmname(film.Title, film.Year);
if (film.Duration is not { TotalSeconds: > 0 } laufzeit)
{
log.LogInformation("„{Film}“: keine Laufzeit in der Datei — übersprungen.", titel);
return false;
}
if (umfang == Segmentumfang.Luecken && await db.Segmente.AsNoTracking()
.AnyAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct))
{
log.LogInformation("„{Film}“: hat schon einen Abspann — übersprungen.", titel);
return false;
}
if (umfang == Segmentumfang.Neue && await db.Segmentsuchen.AsNoTracking()
.AnyAsync(x => x.MovieId == filmId && x.Art == Segmentart.Abspann, ct))
{
log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel);
return false;
}
await VermerkenAsync(db, filmId, Segmentart.Abspann, ct);
var gefunden = await BildabspannAsync(
db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path,
laufzeit.TotalSeconds, ct);
if (!gefunden)
{
// Nichts gefunden ist ein Ergebnis. Eine altes Segment, die jetzt
// nicht mehr bestätigt wird, gehört weg — sonst bliebe ein
// Fehlgriff für immer stehen.
var alt = await db.Segmente
.FirstOrDefaultAsync(m => m.MovieId == filmId && m.Art == Segmentart.Abspann, ct);
if (alt is not null) db.Segmente.Remove(alt);
}
await db.SaveChangesAsync(ct);
return gefunden;
}
/// <summary>Sucht den Abspann im Bild — bei einem Film wie bei einer Folge.</summary>
///
/// <remarks>
/// <para>
/// <b>Dunkel und bis zum Ende durchhalten</b> — mehr braucht es nicht,
/// und mehr hat ein Abspann auch nicht gemeinsam. Erst ein grober
/// Überblick über die letzten zwanzig Minuten aus Keyframesn
/// (billig, aber auf fünf bis zehn Sekunden ungenau), dann eine Handvoll
/// kurzer Sonden für die Kante.
/// </para>
/// <para>
/// <b>Warum das auch bei Folgen gebraucht wird.</b> Der Fingerprintabgleich lebt
/// davon, dass der Abspann in jeder Folge derselbe ist — und genau das
/// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik,
/// kein Treffer. Das Bild weiss davon nichts; dort sieht das Finale aus
/// wie jede andere Folge.
/// </para>
/// </remarks>
/// <param name="name">
/// Wie der Titel im Protokoll heissen soll — „Arrival (2016)" oder
/// „Biohackers S02E01". Diese Stelle kennt sonst nur den Dateipfad, und
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
/// </param>
private async Task<bool> BildabspannAsync(
FlaroContext db, Frameabtaster abtaster, Kapitelleser kapitelleser,
Guid titelId, bool istFilm, string name, string pfad, double laufzeit,
CancellationToken ct)
{
var regeln = Abspannregeln.Vorgabe;
// **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet
// Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit
// zwanzig Minuten Keyframes. Und es findet, woran die
// Helligkeit scheitert: einen Abspann über laufendem Bild, wie bei
// „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff
// Surprise".
var kapitel = await kapitelleser.LesenAsync(pfad, ct);
if (Abspannkapitel.Suchen(kapitel, laufzeit, regeln) is { } ausKapitel)
{
log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(ausKapitel));
return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann,
ausKapitel, laufzeit, ct);
}
var ab = Math.Max(0, laufzeit - Suchbereich);
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
// **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt"
// allein ist keine Auskunft: ob die Datei zu wenige Keyframes
// hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel
// steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht.
var stücke = Abspannkante.Suchen(proben, regeln, out var grund);
if (stücke.Count == 0)
{
// **Mit der Kapitellage.** Ob eine Datei gar keine Kapitel hat
// oder nur namenlose, entscheidet, ob sich an diesem Weg
// weiterbauen lohnt — und das sieht man nur an vielen Dateien.
log.LogInformation(
"„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), "
+ "letzte Probe {Letzte:0.#} % dunkel (nötig: {Noetig} %). {Kapitel}",
name, grund, proben.Count,
proben.Count > 0 ? proben[^1].Dunkelanteil : double.NaN,
regeln.Mindestanteil, Kapitellage(kapitel));
return false;
}
// **Nur die erste Kante wird nachgemessen.** Sie ist die, die der
// Zuschauer zuerst sieht, und die, an der „gilt als gesehen" hängt.
// Die übrigen Grenzen liegen an Keyframesn und sind damit auf
// fünf bis zehn Sekunden genau — dafür lohnt keine Sonde.
var hell = Abspannkante.HellDavor(proben, stücke[0].Von, regeln) ?? ab;
var kante = await Abspannkante.VerfeinernAsync(
hell, stücke[0].Von, regeln.Mindestanteil,
wo => abtaster.DunkelanteilAsync(pfad, wo, ct));
var fein = stücke.ToList();
fein[0] = fein[0] with { Von = kante };
if (fein.Count == 1)
{
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
name, TimeSpan.FromSeconds(kante));
}
else
{
log.LogInformation(
"„{Titel}“: Abspann im Bild in {Stücke} Stücken ab {Stelle:h\\:mm\\:ss} "
+ "— dazwischen {Lücken}.",
name, fein.Count, TimeSpan.FromSeconds(kante),
string.Join(", ", fein.Zip(fein.Skip(1),
(a, b) => $"{Zeit(a.Bis)}–{Zeit(b.Von)}")));
}
return await SchreibenAsync(db, titelId, istFilm, Segmentart.Abspann, fein, ct);
}
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
///
/// <param name="Segmente">Wie viele Segmente geschrieben wurden.</param>
/// <param name="Angefasst">
/// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon
/// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht
/// erst gefragt, und beides in einen Topf zu werfen machte die
/// Zusammenfassung wertlos.
/// </param>
private readonly record struct Serienstand(int Segmente, bool Angefasst);
private async Task<Serienstand> LaufenAsync(
Guid serieId, Segmentumfang umfang, CancellationToken ct,
Action<Scanschritt>? melden = null)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var leser = raum.ServiceProvider.GetRequiredService<Fingerprintleser>();
var abtaster = raum.ServiceProvider.GetRequiredService<Frameabtaster>();
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
var serienname = await db.Series.AsNoTracking()
.Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct)
?? "Ohne Titel";
if (!await leser.VorhandenAsync(ct))
{
log.LogInformation(
"Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden.");
return new Serienstand(0, Angefasst: false);
}
// Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den
// Abgleich unbrauchbar: ohne Datei nichts zu lesen, ohne Laufzeit
// kein Fenster fürs Ende.
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == serieId)
.Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId,
(f, d) => new
{
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, d.Path, d.Duration,
})
.ToListAsync(ct);
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
// Nicht „was gefunden wurde": eine Staffel ohne Abspann sähe sonst
// bei jedem Scan aus wie eine, in der noch niemand nachgesehen hat.
var gesucht = await db.Segmentsuchen.AsNoTracking()
.Where(x => x.SeriesId == serieId)
.ToListAsync(ct);
// **Und was tatsächlich an den Folgen hängt.** Für „nur die
// Lücken" reicht der Vermerk nicht: er sagt, dass gesucht wurde,
// nicht ob etwas herauskam.
var vorhanden = await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId != null
&& db.Episodes.Any(f => f.Id == m.EpisodeId && f.SeriesId == serieId))
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art })
.ToListAsync(ct);
var staffeln = folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key).ToList();
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
serienname, staffeln.Count, folgen.Count);
// Getrennt gezählt: „14 Segmente" beantwortet nicht, ob diese Serie
// ihre Vorspänne bekommen hat — und das ist die häufigere Frage.
var gefundenIntro = 0;
var gefundenAbspann = 0;
var bildfunde = 0;
var angefasst = false;
foreach (var staffel in staffeln)
{
var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList();
string Name(int i) => Folgenname(
serienname, geordnet[i].SeasonNumber, geordnet[i].EpisodeNumber, geordnet[i].Title);
// **Einmal lesen, zweimal schneiden.** fpcalc kann nicht
// springen — es liest immer von vorn. Also die ganze Spur je
// Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte
// aus demselben Fingerprint. Das ist nicht nur richtig, sondern
// billiger als zweimal lesen.
// Eine Art fehlt dieser Staffel, sobald irgendeine ihrer Folgen
// sie nicht hat. Der Fingerprintabgleich ist Sache der ganzen Staffel,
// nicht der einzelnen Folge — für eine Lücke läuft sie ganz.
bool Luecke(Segmentart art) => geordnet.Exists(
f => !vorhanden.Exists(v => v.Folge == f.Id && v.Art == art));
var fehlt = new[] { Segmentart.Intro, Segmentart.Abspann }
.Where(art => umfang == Segmentumfang.Alles
|| gesucht.FirstOrDefault(
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|| lauf.Folgen != geordnet.Count
|| (umfang == Segmentumfang.Luecken && Luecke(art)))
.ToList();
if (fehlt.Count == 0) continue;
angefasst = true;
// **Der Fingerprintabgleich braucht etwas zum Vergleichen.** Eine
// Staffel mit einer einzigen Folge — eine laufende Serie, von
// der erst die Premiere da ist — hat das nicht. Früher wurde
// sie hier übersprungen, und zwar ganz; seit es den Bildweg
// gibt, ist das zu früh: der braucht keine zweite Folge.
var ganze = new Dictionary<Guid, IReadOnlyList<uint>>();
if (geordnet.Count >= 2)
{
for (var i = 0; i < geordnet.Count; i++)
{
melden?.Invoke(new Scanschritt(Scanphase.Segmente, Name(i)));
var fingerprint = await leser.LesenAsync(geordnet[i].Path, ct);
if (fingerprint.Count > 0) ganze[geordnet[i].Id] = fingerprint;
}
}
else
{
log.LogInformation(
"„{Serie}“, Staffel {Staffel}: nur eine Folge — Fingerprintabgleich entfällt, "
+ "es bleibt das Bild.", serienname, staffel.Key);
}
// Ohne zwei Abdrücke gibt es nichts abzugleichen; der Bildweg
// unten läuft trotzdem.
var tonarten = ganze.Count >= 2 ? fehlt : [];
foreach (var art in tonarten)
{
var fingerprints = new List<(Guid Schlüssel, IReadOnlyList<uint> Fingerprint)>();
var fensteranfang = new Dictionary<Guid, double>();
foreach (var folge in geordnet)
{
if (!ganze.TryGetValue(folge.Id, out var ganz)) continue;
var (vonIndex, anzahl) = Ausschnitt(art, ganz.Count);
if (anzahl < 2) continue;
fingerprints.Add((folge.Id, ganz.Skip(vonIndex).Take(anzahl).ToList()));
fensteranfang[folge.Id] = vonIndex * Fingerprintleser.SekundenJeFingerprint;
}
if (fingerprints.Count < 2) continue;
var treffer = Fingerprintabgleich.ReiheAbgleichen(
fingerprints, Fingerprintleser.SekundenJeFingerprint);
// **Vermerken, dass gesucht wurde — vor dem Ergebnis.**
// Auch ein Lauf ohne Fund ist ein Lauf; ihn nicht zu
// vermerken hiesse, ihn beim nächsten Scan zu wiederholen.
await VermerkenAsync(db, serieId, staffel.Key, art, geordnet.Count, ct);
var grenze = art == Segmentart.Intro ? Hoechstlaenge : Hoechstabspann;
foreach (var (folgenId, stelle) in treffer)
{
if (stelle.Bis - stelle.Von > grenze) continue;
// **Die Zeiten liegen im Fenster, nicht in der Folge.**
// Beim Abspann fängt das Fenster zehn Minuten vor dem
// Ende an; ohne diesen Aufschlag stünde der Knopf bei
// Minute zwei.
var versatz = fensteranfang.GetValueOrDefault(folgenId);
if (!await SchreibenAsync(db, folgenId, istFilm: false, art,
stelle.Von + versatz, stelle.Bis + versatz, ct)) continue;
if (art == Segmentart.Intro) gefundenIntro++; else gefundenAbspann++;
}
}
var nummern = geordnet.Select(f => f.Id).ToList();
var ueberBild = new HashSet<Guid>();
if (fehlt.Contains(Segmentart.Abspann))
{
// Auch wenn der Fingerprintabgleich gar nicht erst lief: gesucht wurde,
// und ohne Vermerk liefe der Bildweg bei jedem Scan von Neuem.
await VermerkenAsync(db, serieId, staffel.Key, Segmentart.Abspann,
geordnet.Count, ct);
// **Was der Ton nicht fand, sucht das Bild.**
//
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
// weiss davon nichts; dort sieht das Finale aus wie jede
// andere Folge.
//
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
// Minuten Keyframes; bei einer Staffel ohne Lücke läuft
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
// sonst sieht die Abfrage die eben geschriebenen Segmente nicht.
await db.SaveChangesAsync(ct);
var mitAbspann = await db.Segmente.AsNoTracking()
.Where(m => m.Art == Segmentart.Abspann
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => m.EpisodeId!.Value)
.ToListAsync(ct);
for (var i = 0; i < geordnet.Count; i++)
{
var folge = geordnet[i];
if (mitAbspann.Contains(folge.Id)) continue;
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
melden?.Invoke(new Scanschritt(Scanphase.Segmente, Name(i)));
if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id,
istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct))
{
ueberBild.Add(folge.Id);
gefundenAbspann++;
bildfunde++;
}
}
}
await db.SaveChangesAsync(ct);
await StaffelMeldenAsync(db, serienname, staffel.Key, geordnet
.Select((f, i) => (f.Id, Name: Name(i))).ToList(), ueberBild, ct);
}
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
// und genau die verhindern den nächsten vergeblichen Lauf.
await db.SaveChangesAsync(ct);
if (angefasst)
{
log.LogInformation(
"„{Serie}“ fertig: {Intro} Vorspann, {Abspann} Abspann{Bild}.",
serienname, gefundenIntro, gefundenAbspann,
bildfunde > 0 ? $", davon {bildfunde} über das Bild" : "");
}
else
{
log.LogInformation("„{Serie}“: nichts zu tun, alle Staffeln schon durchsucht.",
serienname);
}
return new Serienstand(gefundenIntro + gefundenAbspann, angefasst);
}
/// <summary>Was eine Staffel ergeben hat — erst zusammengefasst, dann je Folge.</summary>
///
/// <remarks>
/// <b>Aus der Datenbank gelesen, nicht mitgezählt.</b> Beim Zählen
/// während des Laufs stünde im Protokoll, was dieser Lauf geschrieben
/// hat; hier steht, was danach wirklich an der Folge hängt. Nur das
/// beantwortet „warum kommt bei dieser Folge kein Knopf?".
/// </remarks>
private async Task StaffelMeldenAsync(
FlaroContext db, string serienname, int staffel,
IReadOnlyList<(Guid Id, string Name)> folgen, IReadOnlySet<Guid> ueberBild,
CancellationToken ct)
{
var nummern = folgen.Select(f => f.Id).ToList();
var segmente = await db.Segmente.AsNoTracking()
.Where(m => m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
.ToListAsync(ct);
var mitIntro = segmente.Count(m => m.Art == Segmentart.Intro);
var mitAbspann = segmente.Count(m => m.Art == Segmentart.Abspann);
log.LogInformation(
// **Jeder Platzhalter verbraucht ein Argument, auch ein
// wiederholter.** Hier stand `{Folgen}` zweimal und wurde
// einmal bedient; der Logger warf eine Ausnahme, und die riss
// den ganzen Serienlauf mit — gefunden war dann alles umsonst.
// CA2017 meldet das jetzt als Fehler, siehe Directory.Build.props.
"„{Serie}“, Staffel {Staffel}: {Intro} Vorspann und {Abspann} Abspann "
+ "in {Folgen} Folge(n).",
serienname, staffel, mitIntro, mitAbspann, folgen.Count);
foreach (var (id, name) in folgen)
{
var intro = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Intro);
var abspann = segmente.FirstOrDefault(m => m.Folge == id && m.Art == Segmentart.Abspann);
log.LogInformation("{Folge}: {Vorspann}, {Abspann}.",
name,
intro is null ? "kein Vorspann"
: $"Vorspann {Zeit(intro.VonSekunden)}–{Zeit(intro.BisSekunden)}",
abspann is null ? "kein Abspann"
: $"Abspann ab {Zeit(abspann.VonSekunden)}"
+ (ueberBild.Contains(id) ? " (über das Bild)" : ""));
}
}
/// <summary>Was die Datei an Kapiteln mitbringt — in einem Halbsatz.</summary>
private static string Kapitellage(IReadOnlyList<Kapitel> kapitel)
{
if (kapitel.Count == 0) return "Keine Kapitel.";
var benannt = kapitel.Count(k => !string.IsNullOrWhiteSpace(k.Titel));
return benannt == 0
? $"{kapitel.Count} Kapitel, alle ohne Namen."
: $"{kapitel.Count} Kapitel, letztes „{kapitel[^1].Titel}“.";
}
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
private static string Zeit(double sekunden)
{
var spanne = TimeSpan.FromSeconds(Math.Max(0, sekunden));
return spanne.TotalHours >= 1 ? spanne.ToString(@"h\:mm\:ss") : spanne.ToString(@"m\:ss");
}
/// <summary>„Biohackers S02E01 · Neuanfang" — so steht eine Folge im Protokoll.</summary>
private static string Folgenname(string serie, int staffel, int folge, string? titel)
{
var kopf = $"{serie} S{staffel:00}E{folge:00}";
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
}
/// <summary>Welcher Ausschnitt des Fingerprints abgetastet wird.</summary>
///
/// <remarks>
/// In Werten, nicht in Sekunden: der Fingerprint ist eine Reihe, und
/// geschnitten wird an einer Stelle darin. Die Laufzeit steht nicht mehr
/// in der Rechnung — die Länge des Fingerprints ist sie.
/// </remarks>
private static (int Von, int Anzahl) Ausschnitt(Segmentart art, int werte)
{
var fenster = (int)Math.Round(
(art == Segmentart.Intro ? Introfenster : Abspannfenster)
/ Fingerprintleser.SekundenJeFingerprint);
if (fenster >= werte) return (0, werte);
return art == Segmentart.Intro ? (0, fenster) : (werte - fenster, fenster);
}
/// <summary>Hält fest, dass bei diesem Film gesucht wurde.</summary>
private async Task VermerkenAsync(
FlaroContext db, Guid filmId, Segmentart art, CancellationToken ct)
{
var alt = db.Segmentsuchen.Local
.FirstOrDefault(x => x.MovieId == filmId && x.Art == art)
?? await db.Segmentsuchen
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
db.Segmentsuchen.Add(new Segmentsuche
{
MovieId = filmId,
Art = art,
Folgen = 1,
GelaufenAm = clock.GetUtcNow(),
});
}
/// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary>
private async Task VermerkenAsync(
FlaroContext db, Guid serieId, int staffel, Segmentart art, int folgen, CancellationToken ct)
{
// **Erst im Änderungsspeicher nachsehen, dann in der Datenbank.**
//
// Eine Abfrage sieht nicht, was derselbe Lauf eben erst hinzugefügt
// und noch nicht gespeichert hat. Seit der Bildweg dazukam, wird
// für dieselbe Staffel zweimal vermerkt — einmal nach dem
// Fingerprintabgleich, einmal vor dem Bild —, und die zweite Abfrage fand
// nichts, legte eine zweite Zeile an und brach beim Speichern am
// eindeutigen Schlüssel ab. Die Serie verlor dabei alles, was in
// diesem Lauf gefunden worden war.
var alt = db.Segmentsuchen.Local
.FirstOrDefault(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art)
?? await db.Segmentsuchen
.FirstOrDefaultAsync(
x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct);
if (alt is not null)
{
alt.Folgen = folgen;
alt.GelaufenAm = clock.GetUtcNow();
return;
}
db.Segmentsuchen.Add(new Segmentsuche
{
SeriesId = serieId,
Staffel = staffel,
Art = art,
Folgen = folgen,
GelaufenAm = clock.GetUtcNow(),
});
}
private Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Segmentart art,
double von, double bis, CancellationToken ct) =>
SchreibenAsync(db, titelId, istFilm, art, [new Abspannstück(von, bis)], ct);
/// <summary>Setzt die Segmente einer Art auf genau diese Stücke.</summary>
///
/// <remarks>
/// <para>
/// <b>Ersetzen, nicht umschreiben.</b> Hier stand ein Abgleich auf
/// genau eine Zeile je Titel und Art — das ging, solange ein Abspann
/// eine zusammenhängende Strecke war. Seit eine Post-Credit-Szene ihn
/// teilen darf, sind es ein bis zwei Zeilen, und beim nächsten Lauf
/// vielleicht wieder eine. Die alten zu löschen und die neuen
/// anzulegen ist der einzige Weg, der in beide Richtungen stimmt.
/// </para>
/// <para>
/// <b>In derselben Transaktion</b> — gespeichert wird weiter oben, und
/// bis dahin stehen Löschung und Anlage zusammen. Dazwischen gibt es
/// keinen Augenblick, in dem der Titel gar kein Segment hätte.
/// </para>
/// </remarks>
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid titelId, bool istFilm, Segmentart art,
IReadOnlyList<Abspannstück> stücke, CancellationToken ct)
{
if (stücke.Count == 0) return false;
var alte = istFilm
? await db.Segmente.Where(m => m.MovieId == titelId && m.Art == art).ToListAsync(ct)
: await db.Segmente.Where(m => m.EpisodeId == titelId && m.Art == art).ToListAsync(ct);
db.Segmente.RemoveRange(alte);
foreach (var stück in stücke)
{
db.Segmente.Add(new Segment
{
EpisodeId = istFilm ? null : titelId,
MovieId = istFilm ? titelId : null,
Art = art,
VonSekunden = stück.Von,
BisSekunden = stück.Bis,
ErkanntAm = clock.GetUtcNow(),
});
}
return true;
}
}
+135
View File
@@ -0,0 +1,135 @@
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Bringt die gemerkten Sehstände wieder mit der Wirklichkeit in Einklang.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> Ein Film stand bei 1:32, und in „Fortsetzen" tauchte
/// er trotzdem nicht auf — bis er einmal neu gestartet wurde. Der Grund:
/// die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis,
/// die Spalte <c>InProgress</c>. Die Bruchrechnung „Stelle kleiner als
/// Anteil der Laufzeit" lässt sich nicht nach SQL übersetzen, deshalb steht
/// sie als Wahrheitswert in der Zeile.
/// </para>
/// <para>
/// <b>Und berechnet wird sie nur, wenn eine Meldung eintrifft.</b> Die
/// zerstörerische Meldung von vorgestern — zu kurze Laufzeit, Stelle
/// beschnitten, Häkchen gesetzt — hat dort <c>false</c> hinterlassen. Die
/// Korrektur am Server verhindert neuen Schaden, räumt den alten aber
/// nicht weg: dafür müsste jemand jeden betroffenen Titel einmal anspielen.
/// </para>
/// <para>
/// <b>Zwei Schritte, und die Reihenfolge zählt.</b> Erst die Laufzeit aus
/// der Datei nachtragen, dann neu bewerten — eine Neubewertung auf Basis
/// eines Stummels käme wieder zu <c>false</c>.
/// </para>
/// </remarks>
public sealed class Sehstandpflege(
IServiceScopeFactory scopes,
TimeProvider uhr,
ILogger<Sehstandpflege> log)
{
/// <summary>Unter diesem Schlüssel steht, für welchen Stand es schon lief.</summary>
///
/// <remarks>
/// <b>Einmal je Abbild.</b> Nach jedem Update einmal durchrechnen ist
/// billig — es ist ein Durchlauf über eine Tabelle mit ein paar tausend
/// Zeilen — und fängt genau diese Art Fehler ab: eine Regel ändert sich,
/// und das Festgehaltene bleibt stehen.
/// </remarks>
public const string Schluessel = "wartung.sehstaende.stand";
public async Task<int> NachUpdateAsync(CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var stand = await db.Settings.AsNoTracking().FirstOrDefaultAsync(s => s.Key == Schluessel, ct);
if (stand?.Value == Buildkennung.Build) return 0;
var geaendert = await LaufenAsync(ct);
var zeile = await db.Settings.FindAsync([Schluessel], ct);
if (zeile is null) db.Settings.Add(new Core.ServerSetting { Key = Schluessel, Value = Buildkennung.Build });
else zeile.Value = Buildkennung.Build;
await db.SaveChangesAsync(ct);
if (geaendert > 0)
{
log.LogInformation(
"Sehstände nachgerechnet: {Anzahl} Eintrag/Einträge stehen jetzt richtig.", geaendert);
}
return geaendert;
}
/// <summary>Trägt fehlende Laufzeiten nach und bewertet neu.</summary>
public async Task<int> LaufenAsync(CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Wiedergabeeinstellungen>().RegelnAsync();
var jetzt = uhr.GetUtcNow();
const int Stapel = 500;
var geaendert = 0;
for (var sprung = 0; ; sprung += Stapel)
{
var zeilen = await db.UserItemStates
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync(ct);
if (zeilen.Count == 0) break;
// **Die Laufzeiten in einem Zug, nicht je Zeile.** Fünfhundert
// Einzelabfragen für fünfhundert Zeilen wären bei achttausend
// Sehständen sechzehntausend Rundreisen.
var filme = zeilen.Where(z => z.MovieId is not null).Select(z => z.MovieId!.Value).ToList();
var folgen = zeilen.Where(z => z.EpisodeId is not null).Select(z => z.EpisodeId!.Value).ToList();
var filmlaufzeit = await db.MediaFiles.AsNoTracking()
.Where(d => d.MovieId != null && filme.Contains(d.MovieId!.Value))
.GroupBy(d => d.MovieId!.Value)
.Select(g => new { Id = g.Key, Dauer = g.Max(d => d.Duration) })
.ToDictionaryAsync(x => x.Id, x => x.Dauer, ct);
var folgenlaufzeit = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value))
.GroupBy(d => d.EpisodeId!.Value)
.Select(g => new { Id = g.Key, Dauer = g.Max(d => d.Duration) })
.ToDictionaryAsync(x => x.Id, x => x.Dauer, ct);
foreach (var z in zeilen)
{
var gemessen = z.MovieId is { } f
? filmlaufzeit.GetValueOrDefault(f)
: z.EpisodeId is { } e ? folgenlaufzeit.GetValueOrDefault(e) : null;
var vorher = (z.Runtime, z.InProgress);
// **Die gemessene Laufzeit gewinnt.** Steht in der Zeile ein
// Stummel, weil ein Client ihn einmal gemeldet hat, bleibt
// der sonst für immer stehen.
if (gemessen is { Ticks: > 0 }) z.Runtime = gemessen;
z.InProgress = ProgressRule.BelongsInContinueWatching(z, regeln);
if (vorher == (z.Runtime, z.InProgress)) continue;
// **`UpdatedAt` bleibt, wo es war.** Danach ist „Fortsetzen"
// sortiert; eine Reparatur, die alles auf heute setzt,
// mischte die Reihenfolge neu und sähe aus wie ein Fehler.
geaendert++;
}
await db.SaveChangesAsync(ct);
if (zeilen.Count < Stapel) break;
}
return geaendert;
}
}
+109 -25
View File
@@ -32,7 +32,7 @@ public sealed record Stromsitzung(
/// <remarks>
/// <b>Daran hängt das Aufräumen.</b> Ein geschlossener Browser sagt
/// niemandem Bescheid; bliebe die Sitzung stehen, liefe ffmpeg weiter und
/// schriebe den ganzen Film auf den Kratzplatz.
/// schriebe den ganzen Film auf den Transcode-Cache.
/// </remarks>
public DateTimeOffset Gesehen { get; set; } = Begonnen;
@@ -61,7 +61,7 @@ public sealed class Streamkoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
FfmpegOptions ffmpeg,
Schluesselbilder schluesselbilder,
Keyframes schluesselbilder,
TimeProvider clock,
ILogger<Streamkoordinator> log) : IDisposable
{
@@ -140,22 +140,22 @@ public sealed class Streamkoordinator(
var stufe = await bereich.ServiceProvider
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
// **Beim Kopieren wird auf das Schlüsselbild gerundet.**
// **Beim Kopieren wird auf das Keyframe gerundet.**
//
// Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
// aufschneiden; ffmpeg beginnt beim Schlüsselbild davor, während der
// aufschneiden; ffmpeg beginnt beim Keyframe davor, während der
// Ton neu gerechnet wird und genau beim Wunsch anfängt. Gemessen an
// einer Quelle mit Schlüsselbildern alle fünf Sekunden, Sprung auf
// einer Quelle mit Keyframesn alle fünf Sekunden, Sprung auf
// Sekunde 12: Bild ab 0, Ton erst ab 1,98 — zwei Sekunden stummes
// Bild, und die Uhr des Spielers zwei Sekunden daneben.
//
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
// ein Stück zurückzuspringen.
// **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.**
// **Gesprungen wird auf den Wunsch, gemeldet wird das Keyframe.**
//
// Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und
// landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton
// landet damit auf dem Keyframe vor dem Wunsch. Bild und Ton
// fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
// keiner Rechnung hier.
//
@@ -165,9 +165,11 @@ public sealed class Streamkoordinator(
// bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen
// Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt.
var gewuenscht = Math.Max(0, ab);
var uhr = System.Diagnostics.Stopwatch.StartNew();
var gemeldet = plan.BildKopieren
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
: gewuenscht;
var suche = uhr.ElapsedMilliseconds;
var anfrage = new Stromanfrage(
datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
@@ -183,10 +185,19 @@ public sealed class Streamkoordinator(
var abbruch = new CancellationTokenSource();
_sitzungen[kennung] = new Eintrag(sitzung, prozess, abbruch);
log.LogInformation(
"Strom {Kennung}: {Art} von {Datei} ab {Ab}s{Hoehe}.",
kennung, plan.Art, Path.GetFileName(datei.Path), sitzung.Ab,
plan.Hoehe is { } h ? $", auf {h}p" : "");
// **Mit Zeiten, und auch im Tagesprotokoll.**
//
// „Es dauert fünfzehn Sekunden" liess sich nicht beantworten: zwischen
// dem Antippen und dem ersten Bild liegen die Keyframesuche, der
// Start von ffmpeg und das erste Segment, und keine dieser Strecken
// war irgendwo abzulesen. Eine Zeile je Wiedergabe ist kein Lärm —
// sie ist genau das, wonach man im Protokoll sucht.
var meldung =
$"Wiedergabe: {plan.Art} von {Path.GetFileName(datei.Path)} ab {sitzung.Ab:0.#}s"
+ (plan.Hoehe is { } h ? $", auf {h}p" : "")
+ $" — Keyframesuche {suche} ms, Vorbereitung {uhr.ElapsedMilliseconds} ms.";
log.LogInformation("{Meldung}", meldung);
return sitzung;
}
@@ -220,7 +231,7 @@ public sealed class Streamkoordinator(
if (eintrag.Sitzung.Gesehen > grenze) continue;
if (!_sitzungen.TryRemove(kennung, out var weg)) continue;
log.LogInformation("Strom {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
log.LogInformation("Stream {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
kennung, Geduld);
Abraeumen(weg);
}
@@ -258,14 +269,34 @@ public sealed class Streamkoordinator(
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
public static bool PlaylistBereit(string ordner) => Playlist(ordner) is not null;
/// <summary>Das Gerät, über das VAAPI läuft.</summary>
private const string Vaapigeraet = "/dev/dri/renderD128";
/// <summary>Das Ergebnis der einmaligen Probe — <c>null</c>, solange keine lief.</summary>
private bool? _vaapiLaeuft;
/// <summary>Welche Beschleunigung benutzt wird.</summary>
///
/// <remarks>
/// <b><c>auto</c> prüft, statt zu behaupten.</b> Liegt kein
/// <c>/dev/dri/renderD128</c> vor — auf einem Mac, in einem Container ohne
/// durchgereichtes Gerät —, läuft es in Software weiter. Langsamer, aber
/// es läuft; ein ffmpeg, das mit „No VA display found" abbricht, wäre für
/// den Zuschauer dasselbe wie ein kaputter Server.
/// <para>
/// <b><c>auto</c> fragt ffmpeg, statt die Datei anzusehen.</b> Hier stand
/// <c>File.Exists("/dev/dri/renderD128")</c>, und das war keine Prüfung,
/// sondern eine Vermutung: auf dem NAS liegt das Gerät durchgereicht da,
/// aber dahinter steht keine VA-Anzeige. ffmpeg bricht dann schon an der
/// globalen Option ab — <c>No VA display found for device</c>, Rückgabe
/// 234 —, und für den Zuschauer sieht das aus wie ein kaputter Server.
/// </para>
/// <para>
/// <b>Einmal je Lauf.</b> Die Probe kostet einen ffmpeg-Start; sie vor
/// jeder Wiedergabe zu wiederholen wäre Aufwand für eine Antwort, die
/// sich nicht ändert. Wechselt die Hardware, wechselt auch der
/// Container.
/// </para>
/// <para>
/// <b><c>FLARO_HWACCEL=vaapi</c> überspringt sie</b> — wer es
/// ausdrücklich verlangt, hat einen Grund, und ein Fehlschlag steht dann
/// im Protokoll.
/// </para>
/// </remarks>
private Beschleunigung Hardware()
{
@@ -275,10 +306,62 @@ public sealed class Streamkoordinator(
{
"none" or "software" => Beschleunigung.Software,
"vaapi" => Beschleunigung.Vaapi,
_ => File.Exists("/dev/dri/renderD128") ? Beschleunigung.Vaapi : Beschleunigung.Software,
_ => (_vaapiLaeuft ??= VaapiLaeuft()) ? Beschleunigung.Vaapi : Beschleunigung.Software,
};
}
/// <summary>Lässt ffmpeg das Gerät einmal wirklich öffnen.</summary>
private bool VaapiLaeuft()
{
if (!File.Exists(Vaapigeraet)) return false;
try
{
var start = new ProcessStartInfo(ffmpeg.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// Ein Bild aus dem Nichts, nirgendwohin. Es geht allein darum, ob
// die Zeile vor dem ersten Bild durchgeht.
foreach (var a in new[]
{
"-hide_banner", "-loglevel", "error",
"-vaapi_device", Vaapigeraet,
"-f", "lavfi", "-i", "nullsrc=s=64x64:d=0.1",
"-frames:v", "1", "-f", "null", "-",
})
{
start.ArgumentList.Add(a);
}
using var prozess = Process.Start(start);
if (prozess is null) return false;
// **Mit Frist.** Ein hängender Prozess hielte sonst die erste
// Wiedergabe nach dem Start auf.
if (!prozess.WaitForExit(5000))
{
try { prozess.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
log.LogWarning("Die VAAPI-Probe antwortete nicht; es läuft in Software.");
return false;
}
if (prozess.ExitCode == 0) return true;
log.LogInformation(
"VAAPI ist nicht benutzbar ({Code}); es läuft in Software. {Grund}",
prozess.ExitCode, prozess.StandardError.ReadToEnd().Trim());
return false;
}
catch (Exception fehler) when (fehler is System.ComponentModel.Win32Exception or IOException)
{
log.LogWarning(fehler, "Die VAAPI-Probe liess sich nicht starten; es läuft in Software.");
return false;
}
}
private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung)
{
var argumente = HlsArgumente.Fuer(anfrage);
@@ -313,7 +396,7 @@ public sealed class Streamkoordinator(
// eine Spur.
if (text.Trim() is { Length: > 0 } warnungen)
{
log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
log.LogWarning("Stream {Kennung}: ffmpeg meldet {Warnungen}",
sitzung.Kennung, warnungen);
}
@@ -321,8 +404,9 @@ public sealed class Streamkoordinator(
}
sitzung.Fehler = text.Trim();
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
sitzung.Kennung, prozess.ExitCode, sitzung.Fehler);
log.LogError("Wiedergabe gescheitert: ffmpeg endete mit {Code}. {Fehler}",
prozess.ExitCode, sitzung.Fehler);
});
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
@@ -350,7 +434,7 @@ public sealed class Streamkoordinator(
}
catch (IOException fehler)
{
log.LogWarning(fehler, "Der Ordner zu Strom {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
log.LogWarning(fehler, "Der Ordner zu Stream {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
}
}
@@ -374,7 +458,7 @@ public sealed class Streamkoordinator(
try { Directory.Delete(Wurzel, recursive: true); }
catch (IOException fehler)
{
log.LogDebug(fehler, "Der Stromordner {Pfad} liess sich nicht leeren.", Wurzel);
log.LogDebug(fehler, "Der Streamordner {Pfad} liess sich nicht leeren.", Wurzel);
return 0;
}
@@ -388,7 +472,7 @@ public sealed class Streamkoordinator(
/// Löschen in <c>Abraeumen</c> an einem <c>IOException</c>, ist die
/// Sitzung aus dem Verzeichnis heraus und der Ordner bleibt für immer
/// liegen. Die Altersprüfung nimmt dabei den Fall heraus, dass ein
/// zweiter Prozess denselben Kratzplatz benutzt: ein aktiver Ordner
/// zweiter Prozess denselben Transcode-Cache benutzt: ein aktiver Ordner
/// bekommt alle paar Sekunden ein Segment geschrieben.
/// </remarks>
public long VerwaisteOrdnerWegraeumen(DateTimeOffset jetzt)
@@ -437,7 +521,7 @@ public sealed class Streamkoordinator(
// **Auch, was ein Absturz hinterlassen hat.** Nach einem harten Ende
// steht der Ordner noch da; ihn beim nächsten Herunterfahren zu
// übersehen hiesse, dass der Kratzplatz nur wächst.
// übersehen hiesse, dass der Transcode-Cache nur wächst.
WurzelLeeren();
}
}
+58
View File
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
namespace Flaro.Api;
@@ -175,3 +176,60 @@ public sealed class Tagebuch
}
}
}
/// <summary>Leitet, was der Server ohnehin meldet, ins Tagesprotokoll.</summary>
///
/// <remarks>
/// <para>
/// <b>Statt an jeder Stelle zweimal zu schreiben.</b> Das Protokoll kannte
/// nur, was jemand ausdrücklich hineinschrieb: Start, Ende, ein paar Fehler.
/// „Scan gestartet", „Strom angelegt", „Untertitel gezogen" stehen längst im
/// Quelltext — sie gingen nur an <c>ILogger</c> und damit in die
/// Container-Ausgabe, wo sie zwischen EF-Abfragen untergehen.
/// </para>
/// <para>
/// <b>Nur die eigenen Kategorien.</b> <c>Microsoft.*</c> und
/// <c>System.*</c> bleiben draussen — dort kommt je Datenbankabfrage eine
/// Zeile, und das Protokoll wäre nach einem Scan unlesbar.
/// </para>
/// </remarks>
public sealed class Tagebuchsenke(Tagebuch tagebuch) : ILoggerProvider
{
public ILogger CreateLogger(string kategorie) =>
kategorie.StartsWith("Flaro", StringComparison.Ordinal)
? new Schreiber(tagebuch, Kurzname(kategorie))
: NullLogger.Instance;
public void Dispose() { }
/// <summary>„Flaro.Api.Streamkoordinator" wird zu „Streamkoordinator".</summary>
private static string Kurzname(string kategorie) =>
kategorie[(kategorie.LastIndexOf('.') + 1)..];
private sealed class Schreiber(Tagebuch tagebuch, string woher) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
// Debug und Trace bleiben draussen: die sind fürs Entwickeln da.
public bool IsEnabled(LogLevel stufe) => stufe >= LogLevel.Information;
public void Log<TState>(
LogLevel stufe, EventId kennung, TState zustand, Exception? fehler,
Func<TState, Exception?, string> text)
{
if (!IsEnabled(stufe)) return;
var art = stufe switch
{
>= LogLevel.Error => Tagebuch.Art.Fehler,
LogLevel.Warning => Tagebuch.Art.Warnung,
_ => Tagebuch.Art.Info,
};
var satz = text(zustand, fehler);
if (fehler is not null) satz += $" — {fehler.Message}";
tagebuch.Schreiben($"{woher}: {satz}", art);
}
}
}
@@ -4,22 +4,22 @@ using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Die Regeln fürs Aufräumen des Kratzplatzes, aus den Einstellungen.</summary>
/// <summary>Die Regeln fürs Aufräumen des Transcodes, aus den Einstellungen.</summary>
///
/// <remarks>
/// Nach dem Vorbild von <see cref="Wiedergabeeinstellungen"/>: einmal je
/// Anfrage gelesen und gemerkt. Die Tabelle ist generisch, es braucht keine
/// Migration.
/// </remarks>
public sealed class Kratzplatzeinstellungen(FlaroContext db)
public sealed class Transcodeeinstellungen(FlaroContext db)
{
public const string ObergrenzeSchluessel = "wartung.kratzplatz.paket.obergrenzeGb";
public const string UnbenutztSchluessel = "wartung.kratzplatz.paket.unbenutztTage";
public const string SchonfristSchluessel = "wartung.kratzplatz.paket.schonfristMinuten";
private Kratzplatzregeln? _gemerkt;
private Transcoderegeln? _gemerkt;
public async Task<Kratzplatzregeln> RegelnAsync(CancellationToken ct = default)
public async Task<Transcoderegeln> RegelnAsync(CancellationToken ct = default)
{
if (_gemerkt is { } fertig) return fertig;
@@ -29,9 +29,9 @@ public sealed class Kratzplatzeinstellungen(FlaroContext db)
|| e.Key == SchonfristSchluessel)
.ToDictionaryAsync(e => e.Key, e => e.Value, ct);
var vorgabe = Kratzplatzregeln.Vorgabe;
var vorgabe = Transcoderegeln.Vorgabe;
_gemerkt = Kratzplatzregeln.Geprueft(
_gemerkt = Transcoderegeln.Geprueft(
Zahl(werte, ObergrenzeSchluessel, vorgabe.ObergrenzeBytes / 1024d / 1024 / 1024),
(int)Zahl(werte, UnbenutztSchluessel, vorgabe.Unbenutzt.TotalDays),
(int)Zahl(werte, SchonfristSchluessel, vorgabe.Schonfrist.TotalMinutes));
@@ -11,7 +11,7 @@ public sealed record Aufraeumbericht(
/// <param name="Name">Der Unterordner — <c>stroeme</c>, <c>packages</c>, <c>untertitel</c>.</param>
public sealed record Teilbericht(string Name, long Frei, int Dateien);
/// <summary>Räumt den Kratzplatz auf.</summary>
/// <summary>Räumt den Transcode-Cache auf.</summary>
///
/// <remarks>
/// <para>
@@ -31,13 +31,13 @@ public sealed record Teilbericht(string Name, long Frei, int Dateien);
/// nebeneinander löschten einander die Dateien unter den Händen weg.
/// </para>
/// </remarks>
public sealed class Kratzplatzpflege(
public sealed class Transcodepflege(
IServiceScopeFactory scopes,
FlaroPaths pfade,
PackageCoordinator pakete,
Streamkoordinator stroeme,
TimeProvider uhr,
ILogger<Kratzplatzpflege> log)
ILogger<Transcodepflege> log)
{
private readonly SemaphoreSlim _einerZurZeit = new(1, 1);
@@ -65,11 +65,14 @@ public sealed class Kratzplatzpflege(
var (paketfrei, paketzahl) = await PaketeAufraeumenAsync(jetzt, ct);
var (textfrei, textzahl) = await UntertitelAufraeumenAsync(ct);
var (vorschaufrei, vorschauzahl) = await VorschauAufraeumenAsync(ct);
var teile = new List<Teilbericht>
{
new("stroeme", stromfrei, 0),
new("packages", paketfrei, paketzahl),
new("untertitel", textfrei, textzahl),
new("vorschau", vorschaufrei, vorschauzahl),
};
var bericht = new Aufraeumbericht(
@@ -78,7 +81,7 @@ public sealed class Kratzplatzpflege(
if (bericht.Frei > 0)
{
log.LogInformation(
"Kratzplatz aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
"Transcode-Cache aufgeräumt ({Anlass}): {Mb} MB frei, {Dateien} Datei(en).",
anlass, bericht.Frei / (1024 * 1024), bericht.Dateien);
}
@@ -100,7 +103,7 @@ public sealed class Kratzplatzpflege(
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Kratzplatzeinstellungen>().RegelnAsync(ct);
.GetRequiredService<Transcodeeinstellungen>().RegelnAsync(ct);
// **Teilstücke gehen bedingungslos.** Sie entstehen nur beim Bauen,
// und wer gerade baut, hat die Datei noch offen — eine liegengebliebene
@@ -137,7 +140,7 @@ public sealed class Kratzplatzpflege(
.Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
foreach (var a in Kratzplatzauswahl.Wegzuwerfen(
foreach (var a in Transcodeauswahl.Wegzuwerfen(
akten, bekannt, PackageCoordinator.Bauart, regeln, jetzt, pakete.IstInBenutzung))
{
frei += Loeschen(a.Pfad);
@@ -159,6 +162,66 @@ public sealed class Kratzplatzpflege(
/// lässt den Server bei jedem Start eine vierzig Gigabyte grosse Datei
/// neu durchlesen, um wieder nichts zu finden.
/// </remarks>
/// <summary>Vorschaublätter, zu denen es keine Datei mehr gibt.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur verwaist, wie bei den Untertiteln.</b> Weder Alter noch
/// Obergrenze: Blätter entstehen beim Abspielen, und wer einen Film
/// zweimal im Jahr ansieht, soll beim zweiten Mal nicht wieder warten.
/// Sie sind klein — ein ganzer Film liegt bei wenigen Megabyte.
/// </para>
/// <para>
/// <b>Verwaist heisst: der Namensanfang passt zu nichts.</b> Er trägt
/// Kennung und Änderungszeit der Mediendatei; wird sie ersetzt oder
/// verschwindet sie, gehören die Blätter zu niemandem mehr. Dafür
/// braucht es keine Tabelle.
/// </para>
/// </remarks>
private async Task<(long Frei, int Dateien)> VorschauAufraeumenAsync(CancellationToken ct)
{
var ordner = Path.Combine(pfade.Transcode, "vorschau");
if (!Directory.Exists(ordner)) return (0, 0);
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var gefunden = new List<(string Pfad, Guid Id, long Ms)>();
foreach (var pfad in Directory.EnumerateFiles(ordner))
{
var teile = Path.GetFileNameWithoutExtension(pfad).Split('-');
if (teile.Length < 2
|| !Guid.TryParseExact(teile[0], "N", out var id)
|| !long.TryParse(teile[1], out var ms))
{
// Was hier nicht hingehört, gehört weg.
gefunden.Add((pfad, Guid.Empty, -1));
continue;
}
gefunden.Add((pfad, id, ms));
}
if (gefunden.Count == 0) return (0, 0);
var kennungen = gefunden.Where(g => g.Ms >= 0).Select(g => g.Id).Distinct().ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => kennungen.Contains(f.Id))
.Select(f => new { f.Id, f.ModifiedAt })
.ToDictionaryAsync(f => f.Id, f => f.ModifiedAt.ToUnixTimeMilliseconds(), ct);
long frei = 0;
var anzahl = 0;
foreach (var g in gefunden)
{
if (g.Ms >= 0 && dateien.TryGetValue(g.Id, out var ms) && ms == g.Ms) continue;
frei += Loeschen(g.Pfad);
anzahl++;
}
return (frei, anzahl);
}
private async Task<(long Frei, int Dateien)> UntertitelAufraeumenAsync(CancellationToken ct)
{
var ordner = Path.Combine(pfade.Transcode, "untertitel");
@@ -240,6 +303,7 @@ public sealed class Kratzplatzpflege(
("stroeme", stroeme.Wurzel),
("packages", pakete.Ordner),
("untertitel", Path.Combine(pfade.Transcode, "untertitel")),
("vorschau", Path.Combine(pfade.Transcode, "vorschau")),
};
return ordner.Select(o => new Teilbericht(o.Item1, Streamkoordinator.Belegung(o.Item2), Zaehlen(o.Item2)))
+19
View File
@@ -137,6 +137,25 @@ public sealed class Untertitelvorrat(
if (offen > 0) log.LogInformation("Untertitel: {Anzahl} Datei(en) werden im Hintergrund gelesen.", offen);
}
/// <summary>Ob diese Spur jetzt sofort ausgeliefert werden kann.</summary>
///
/// <remarks>
/// <b>Ohne zu warten und ohne anzustossen.</b> Für die HLS-Hauptliste:
/// was dort steht, muss ein Spieler in dem Augenblick abholen können, in
/// dem er es liest. AVPlayer nimmt ein „gleich" nicht an — eine Spur,
/// die beim Abruf noch nicht da ist, lässt ihn die ganze Wiedergabe
/// abbrechen, nicht bloss den Untertitel.
///
/// <b>Eine leere Datei zählt nicht.</b> Sie ist das Ergebnis „diese Spur
/// hat keinen Text"; sie anzubieten wäre ein Menüeintrag, der zu nichts
/// führt.
/// </remarks>
public bool Liegtvor(Guid dateiId, DateTimeOffset geaendert, int spur)
{
var ziel = Pfad(dateiId, geaendert, spur);
return File.Exists(ziel) && new FileInfo(ziel).Length > 0;
}
/// <summary>Holt eine Spur — und wartet dabei höchstens <paramref name="geduld"/>.</summary>
///
/// <remarks>
+149
View File
@@ -0,0 +1,149 @@
using Flaro.Core.Media;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Erzeugt Vorschaubilder im Hintergrund, eine Datei nach der anderen.</summary>
///
/// <remarks>
/// <para>
/// <b>Beim Abspielen, nicht auf Vorrat über den Bestand.</b> Bei 453
/// Filmen und 4213 Folgen wäre ein Lauf über alles ein Abend ffmpeg und
/// mehrere Gigabyte für Titel, die niemand anfasst. Wer einen Titel
/// startet, braucht die Bilder aber selten in den ersten Sekunden —
/// bis zum ersten Griff zur Leiste ist der Lauf längst durch.
/// </para>
/// <para>
/// <b>Eine zur Zeit.</b> Jeder Lauf liest eine Mediendatei von vorn bis
/// hinten durch; zwei gleichzeitig hiesse zwei Leseköpfe auf derselben
/// Platte, und nebenher läuft vielleicht noch ein Umrechnen.
/// </para>
/// </remarks>
public sealed class Vorschauvorrat(
IServiceScopeFactory scopes,
FlaroPaths pfade,
Vorschaubilder bilder,
ILogger<Vorschauvorrat> log)
{
private readonly LinkedList<Guid> _reihe = new();
private readonly Lock _schloss = new();
private bool _arbeitet;
/// <summary>Welche Datei gerade dran ist — für die Auskunft.</summary>
public Guid? Laufende { get; private set; }
private string Ordner => Path.Combine(pfade.Transcode, "vorschau");
/// <summary>Der gemeinsame Namensanfang der Blätter einer Datei.</summary>
///
/// <remarks>
/// <b>Mit der Änderungszeit, und das ist der Trick.</b> Wird die Datei
/// ersetzt, ändert sich der Anfang — die alten Blätter gehören dann
/// zu niemandem mehr, und das Aufräumen erkennt sie daran, ohne dass
/// irgendwo eine Tabelle mitgeführt werden müsste. Dasselbe Schema
/// wie beim Untertitelvorrat.
/// </remarks>
public static string Praefix(Guid dateiId, DateTimeOffset geaendert) =>
$"{dateiId:N}-{geaendert.ToUnixTimeMilliseconds()}";
/// <summary>Was schon dasteht — oder nichts.</summary>
public Vorschauplan? Liegtvor(Guid dateiId, DateTimeOffset geaendert)
{
var praefix = Praefix(dateiId, geaendert);
if (!Directory.Exists(Ordner)) return null;
var blaetter = Directory.GetFiles(Ordner, $"{praefix}-*.jpg").Length;
if (blaetter == 0) return null;
// Die Masse stehen in der Begleitdatei: sie hängen am
// Seitenverhältnis des Films und lassen sich nicht herleiten.
var masse = Path.Combine(Ordner, $"{praefix}.masse");
if (!File.Exists(masse)) return null;
var teile = File.ReadAllText(masse).Split('x');
if (teile.Length != 2
|| !int.TryParse(teile[0], out var breite)
|| !int.TryParse(teile[1], out var hoehe))
{
return null;
}
return new Vorschauplan(
Vorschaubilder.Intervall, Vorschaubilder.Spalten, Vorschaubilder.Zeilen,
breite, hoehe, blaetter);
}
/// <summary>Stellt eine Datei an, wenn ihre Bilder noch fehlen.</summary>
public void Anstellen(Guid dateiId)
{
lock (_schloss)
{
if (Laufende == dateiId || _reihe.Contains(dateiId)) return;
_reihe.AddLast(dateiId);
if (_arbeitet) return;
_arbeitet = true;
}
_ = Task.Run(AbarbeitenAsync);
}
private async Task AbarbeitenAsync()
{
while (true)
{
Guid naechste;
lock (_schloss)
{
if (_reihe.First is null) { _arbeitet = false; Laufende = null; return; }
naechste = _reihe.First.Value;
_reihe.RemoveFirst();
Laufende = naechste;
}
try { await ZiehenAsync(naechste); }
catch (Exception fehler)
{
log.LogWarning(fehler, "Vorschau für {Datei} abgebrochen: {Grund}",
naechste, Fehlertext.FuerMenschen(fehler));
}
}
}
private async Task ZiehenAsync(Guid dateiId)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var datei = await db.MediaFiles.AsNoTracking()
.Where(f => f.Id == dateiId)
.Select(f => new { f.Path, f.ModifiedAt })
.FirstOrDefaultAsync();
if (datei is null || !File.Exists(datei.Path)) return;
var praefix = Praefix(dateiId, datei.ModifiedAt);
if (Liegtvor(dateiId, datei.ModifiedAt) is not null) return;
var plan = await bilder.ErzeugenAsync(datei.Path, Ordner, praefix);
if (plan is not { } fertig) return;
await File.WriteAllTextAsync(
Path.Combine(Ordner, $"{praefix}.masse"),
$"{fertig.KachelBreite}x{fertig.KachelHoehe}");
log.LogInformation(
"Vorschau für {Datei}: {Blaetter} Blatt/Blätter, Kachel {Breite}×{Hoehe}.",
Path.GetFileName(datei.Path), fertig.Blaetter, fertig.KachelBreite, fertig.KachelHoehe);
}
/// <summary>Der Pfad eines Blattes — oder <c>null</c>, wenn es das nicht gibt.</summary>
public string? Blatt(Guid dateiId, DateTimeOffset geaendert, int nummer)
{
if (nummer is < 0 or > 999) return null;
var pfad = Path.Combine(Ordner, $"{Praefix(dateiId, geaendert)}-{nummer:000}.jpg");
return File.Exists(pfad) ? pfad : null;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ namespace Flaro.Core.Auth;
/// </para>
/// <para>
/// <b>Der Code allein holt nie ein Token ab.</b> Dafür gibt es den Abholschein,
/// den nur das anfragende Gerät kennt und der hier nur als Fingerabdruck
/// den nur das anfragende Gerät kennt und der hier nur als Fingerfingerprint
/// liegt. Ohne ihn könnte jemand sechsstellige Zahlen durchprobieren — eine
/// Million Versuche sind nichts, und am Ende stünde ein fremdes Gerät in der
/// Mediathek.
+1 -1
View File
@@ -14,7 +14,7 @@ namespace Flaro.Core.Auth;
/// </para>
/// <para>
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei
/// <see cref="DeviceToken"/> liegt hier nur der Fingerabdruck; den Link
/// <see cref="DeviceToken"/> liegt hier nur der Fingerfingerprint; den Link
/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
/// Datenbank liest, kann daraus keine Einladung bauen.
/// </para>
+2 -2
View File
@@ -6,7 +6,7 @@ namespace Flaro.Core.Auth;
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
///
/// <remarks>
/// <b>Abgelegt wird nur der Fingerabdruck, nie das Geheimnis.</b> Wer die
/// <b>Abgelegt wird nur der Fingerfingerprint, nie das Geheimnis.</b> Wer die
/// Datenbank erbeutet, hat damit Hashes und keine Zugänge. Dass das überhaupt
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
@@ -23,7 +23,7 @@ public static class Secrets
public static string Create() =>
Base64UrlEncode(RandomNumberGenerator.GetBytes(Bytes));
/// <summary>Der Fingerabdruck, wie er in der Datenbank steht.</summary>
/// <summary>Der Fingerfingerprint, wie er in der Datenbank steht.</summary>
public static string Fingerprint(string secret) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Flaro.Core.Delivery;
///
/// <remarks>
/// <para>
/// <b>Warum der Name alles trägt.</b> Im Kratzplatz liegen Dateien, zu denen
/// <b>Warum der Name alles trägt.</b> Im Transcode-Cache liegen Dateien, zu denen
/// es keine Tabelle gibt. Ein Aufräumer muss allein am Namen erkennen können,
/// wozu eine Datei gehört und ob sie noch gilt — sonst bliebe ihm nur,
/// alles zu behalten oder alles wegzuwerfen.
@@ -21,7 +21,7 @@ namespace Flaro.Core.Delivery;
/// <param name="ObergrenzeBytes">Wie viel insgesamt liegen bleiben darf; <c>0</c> heisst unbegrenzt.</param>
/// <param name="Unbenutzt">Ab wann eine unbenutzte Datei auch unterhalb der Grenze geht; <c>Zero</c> heisst nie.</param>
/// <param name="Schonfrist">Wie lange eine frisch gebrauchte Datei in jedem Fall bleibt.</param>
public sealed record Kratzplatzregeln(
public sealed record Transcoderegeln(
long ObergrenzeBytes,
TimeSpan Unbenutzt,
TimeSpan Schonfrist)
@@ -34,7 +34,7 @@ public sealed record Kratzplatzregeln(
/// Schonfrist decken die Lücke zwischen zwei Teilanfragen und einen
/// Neustart, der den Benutzungszähler verliert.
/// </remarks>
public static Kratzplatzregeln Vorgabe { get; } =
public static Transcoderegeln Vorgabe { get; } =
new(20L * 1024 * 1024 * 1024, TimeSpan.FromDays(7), TimeSpan.FromMinutes(30));
/// <summary>Nimmt entgegen, was eingestellt wurde, und biegt Unsinn zurecht.</summary>
@@ -45,13 +45,13 @@ public sealed record Kratzplatzregeln(
/// Datei weg — deshalb eine Untergrenze von fünf Minuten. Negative Werte
/// gelten als „aus".
/// </remarks>
public static Kratzplatzregeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
public static Transcoderegeln Geprueft(double obergrenzeGb, int unbenutztTage, int schonfristMinuten) =>
new(obergrenzeGb <= 0 ? 0 : (long)(obergrenzeGb * 1024 * 1024 * 1024),
unbenutztTage <= 0 ? TimeSpan.Zero : TimeSpan.FromDays(Math.Min(unbenutztTage, 3650)),
TimeSpan.FromMinutes(Math.Clamp(schonfristMinuten, 5, 1440)));
}
/// <summary>Eine Datei im Kratzplatz, so wie sie dort liegt.</summary>
/// <summary>Eine Datei im Transcode-Cache, so wie sie dort liegt.</summary>
///
/// <param name="Benutzt">
/// Wann sie zuletzt gebraucht wurde — die Änderungszeit der Datei selbst.
@@ -66,7 +66,7 @@ public readonly record struct Paketakte(
Paketname.Teile? Name);
/// <summary>Welche Dateien weggeräumt werden — reine Rechnung, ohne Platte.</summary>
public static class Kratzplatzauswahl
public static class Transcodeauswahl
{
/// <param name="bekannt">Kennung → Änderungszeitpunkt der Quelle, aus der Mediathek.</param>
/// <param name="bauart">Wie der Server heute baut.</param>
@@ -75,7 +75,7 @@ public static class Kratzplatzauswahl
IReadOnlyList<Paketakte> akten,
IReadOnlyDictionary<Guid, long> bekannt,
int bauart,
Kratzplatzregeln regeln,
Transcoderegeln regeln,
DateTimeOffset jetzt,
Func<string, bool> inBenutzung)
{
+29 -1
View File
@@ -1,6 +1,34 @@
namespace Flaro.Core.Libraries;
public enum LibraryKind { Movies, Shows }
public enum LibraryKind
{
/// <summary>Filme, mit Abgleich bei der Metadatenquelle.</summary>
Movies,
/// <summary>Serien mit Staffeln und Folgen.</summary>
Shows,
/// <summary>Einzelne Videos, ohne Abgleich.</summary>
///
/// <remarks>
/// <para>
/// <b>Für alles, was in keinem Katalog steht.</b> Urlaubsaufnahmen,
/// Mitschnitte, Vorträge. Der Dateiname ist der Titel — kein Zerlegen
/// nach „Titel (Jahr)", kein Zusammenlegen zweier Dateien zu einer
/// Fassung, keine Anfrage bei TMDB. Jedes dieser Verfahren ist bei
/// Filmen richtig und hier falsch: „Sommer 2019 Teil 2" ist kein Film
/// von 2019, und zwei Aufnahmen desselben Tages sind nicht zwei
/// Fassungen derselben Sache.
/// </para>
/// <para>
/// <b>Der Unterordner wird zur Kategorie.</b> Statt eine eigene
/// Gruppierung zu bauen, bekommt jedes Video den Namen seines Ordners
/// als Genre. Damit greifen Filter, Kategorienansicht und Suche ohne
/// eine einzige neue Zeile in der Oberfläche.
/// </para>
/// </remarks>
Videos,
}
/// <summary>Ein Verzeichnis mit Medien, wie der Betreiber es eingerichtet hat.</summary>
///
@@ -64,3 +64,157 @@ public sealed class Credit
/// </remarks>
public int Order { get; set; }
}
/// <summary>Eine Bewertung von anderswo — IMDb, Rotten Tomatoes, Metacritic.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Tabelle und kein Feld je Quelle.</b> <c>Movie.Rating</c> ist ein
/// Skalar ohne Quellenangabe, und das war richtig, solange es eine Quelle
/// gab. Für drei weitere wären es drei Spalten — und bei der vierten wieder
/// eine Migration. So ist jede Quelle eine Zeile.
/// </para>
/// <para>
/// <b>Zwei Werte, und beide gebraucht.</b> <see cref="Wert"/> ist auf 0 bis
/// 10 gebracht, damit sich Quellen vergleichen und sortieren lassen;
/// <see cref="Anzeige"/> ist, was dastehen soll. Rotten Tomatoes in „8,7 von
/// 10" umzurechnen wäre eine Lüge über die Quelle — dort steht 87 %, und
/// genau das kennt man von dort.
/// </para>
/// </remarks>
public sealed class Fremdbewertung
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
/// <summary>„imdb", „rottenTomatoes" oder „metacritic".</summary>
public required string Quelle { get; set; }
/// <summary>Auf 0 bis 10 gebracht — zum Vergleichen, nicht zum Anzeigen.</summary>
public double Wert { get; set; }
/// <summary>Was dastehen soll: „7,4", „87 %", „62".</summary>
public required string Anzeige { get; set; }
}
/// <summary>Ein Stichwort der Metadatenquelle, einmal je Name.</summary>
///
/// <remarks>
/// <b>Feiner als ein Genre, und deshalb eine eigene Tabelle.</b> „Stirb
/// langsam" ist kein Familienfilm und trägt trotzdem „christmas". Eine
/// Weihnachtsreihe nach Genres enthielte jeden Zeichentrickfilm und diesen
/// Film nicht — nach Stichwörtern stimmt es.
///
/// Kleingeschrieben abgelegt: TMDB liefert „Christmas" und „christmas" je
/// nach Eintrag, und zwei Schreibweisen wären zwei Stichwörter.
/// </remarks>
public sealed class Stichwort
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Name { get; set; }
}
/// <summary>Verbindet ein Stichwort mit einem Film oder einer Serie.</summary>
public sealed class Stichwortlink
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid StichwortId { get; init; }
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
}
/// <summary>Was für ein Abschnitt ein Segment bezeichnet.</summary>
public enum Segmentart
{
/// <summary>Der Vorspann.</summary>
Intro,
/// <summary>Der Abspann.</summary>
Abspann,
}
/// <summary>Ein überspringbarer Abschnitt einer Folge.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur bei Folgen, nicht bei Filmen.</b> Erkannt wird ein Vorspann
/// dadurch, dass er sich wiederholt — über die Folgen einer Staffel hinweg.
/// Ein Film hat nichts, womit er sich vergleichen liesse, und sein Abspann
/// ist nicht derselbe wie der eines anderen Films.
/// </para>
/// <para>
/// <b>Zeiten in Sekunden, nicht in Bildern.</b> Dieselbe Folge liegt
/// vielleicht in zwei Fassungen mit verschiedener Bildrate vor; die Stelle
/// im Film ist dieselbe.
/// </para>
/// </remarks>
public sealed class Segment
{
public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Zu welcher Folge sie gehört — oder zu welchem Film.</summary>
///
/// <remarks>
/// <b>Genau eines von beiden.</b> Gefunden werden sie auf
/// verschiedenen Wegen: bei einer Folge am Ton, weil der Vorspann sich
/// über die Staffel wiederholt, bei einem Film am Bild, weil ein Film
/// nichts hat, womit er sich vergleichen liesse. Was dabei herauskommt,
/// ist dasselbe — eine Stelle, an der man springen darf.
/// </remarks>
public Guid? EpisodeId { get; init; }
public Guid? MovieId { get; init; }
public required Segmentart Art { get; init; }
public required double VonSekunden { get; set; }
public required double BisSekunden { get; set; }
/// <summary>Wann sie gefunden wurde.</summary>
///
/// <remarks>
/// Damit ein späterer Lauf weiss, was er nicht noch einmal rechnen muss.
/// Eine Staffel abzutasten dauert Minuten; sie bei jedem Scan erneut
/// abzutasten wäre eine Dauerlast ohne Ertrag.
/// </remarks>
public DateTimeOffset ErkanntAm { get; set; }
}
/// <summary>Dass in einer Staffel schon einmal gesucht wurde.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil „nichts gefunden" auch ein Ergebnis ist.</b> Ohne diese Zeile
/// liess sich nur ablesen, was gefunden wurde. Eine Staffel ohne Abspann —
/// es gibt genug davon — sah damit bei jedem Scan aus wie eine, in der noch
/// niemand nachgesehen hat, und wurde wieder und wieder abgetastet. Das
/// heisst: bei jedem Scan jede Tonspur jeder Folge noch einmal von der
/// Platte lesen, für immer, ohne Ertrag.
/// </para>
/// <para>
/// <b>Die Folgenzahl ist der Auslöser.</b> Kommt eine Folge dazu, passt die
/// Zahl nicht mehr und es wird neu gerechnet — genau dann, wenn es etwas
/// Neues zu vergleichen gibt. Ein Zeitstempel allein täte das nicht: eine
/// Staffel, die sich nie ändert, hat nie einen Grund für einen zweiten
/// Lauf.
/// </para>
/// </remarks>
public sealed class Segmentsuche
{
public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Eine Staffel einer Serie — oder ein Film.</summary>
///
/// <remarks>
/// Genau eines von beiden, wie bei <see cref="Segment"/>. Bei einer
/// Staffel steht die Nummer daneben; ein Film hat keine.
/// </remarks>
public Guid? SeriesId { get; init; }
public int? Staffel { get; init; }
public Guid? MovieId { get; init; }
public required Segmentart Art { get; init; }
/// <summary>Wie viele Folgen die Staffel hatte, als gesucht wurde.</summary>
///
/// <remarks>Bei einem Film immer eins — es gibt nichts zu zählen.</remarks>
public required int Folgen { get; set; }
public DateTimeOffset GelaufenAm { get; set; }
}
@@ -302,6 +302,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
/// </remarks>
public string? LogoImage { get; set; }
/// <summary>Weitere Bilder für den Aufmacher, durch Komma getrennt.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum nicht eine Tabelle.</b> Es sind höchstens fünf
/// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen
/// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine
/// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für
/// eine Liste, die in eine Zeile passt.
/// </para>
/// <para>
/// <b>Getrennt vom Hintergrund.</b> Der bleibt, was er ist: das eine
/// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen
/// Aufnahmen für das Karussell, und nur die wechseln.
/// </para>
/// </remarks>
public string? Aufmacherbilder { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
@@ -497,6 +515,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
/// </remarks>
public string? LogoImage { get; set; }
/// <summary>Weitere Bilder für den Aufmacher, durch Komma getrennt.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum nicht eine Tabelle.</b> Es sind höchstens fünf
/// Zwischenspeicherschlüssel je Titel, sie werden immer zusammen
/// gelesen und immer zusammen gesetzt. Eine Tabelle dafür wäre eine
/// Verknüpfung, ein Löschverhalten und eine Reihenfolgespalte — für
/// eine Liste, die in eine Zeile passt.
/// </para>
/// <para>
/// <b>Getrennt vom Hintergrund.</b> Der bleibt, was er ist: das eine
/// Bild hinter Kacheln und Kopfzeilen. Hier stehen die grossen
/// Aufnahmen für das Karussell, und nur die wechseln.
/// </para>
/// </remarks>
public string? Aufmacherbilder { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,93 @@
using System.Globalization;
namespace Flaro.Core.Library;
/// <summary>Ein Anlass, zu dem eine eigene Reihe auf der Startseite steht.</summary>
///
/// <param name="Name">Was über der Reihe steht — „Zu Halloween".</param>
/// <param name="Von">Tag im Jahr, ab dem sie erscheint, als „MM-TT".</param>
/// <param name="Bis">Tag im Jahr, bis zu dem sie bleibt, als „MM-TT".</param>
/// <param name="Stichworte">Stichwörter der Metadatenquelle, kleingeschrieben.</param>
/// <param name="Genres">Genres, falls Stichwörter allein zu wenig hergeben.</param>
///
/// <remarks>
/// <para>
/// <b>Einstellbar und nicht eingebaut.</b> Halloween und Weihnachten kennt
/// jeder; der 1. August ist Schweizer Nationalfeiertag, der 4. Juli
/// amerikanischer, und der Geburtstag des Betreibers steht in keiner
/// Bibliothek. Eine feste Liste im Quelltext hiesse: für jeden Anlass eine
/// neue Fassung.
/// </para>
/// <para>
/// <b>Stichwörter zuerst, Genres als Zugabe.</b> „Stirb langsam" ist kein
/// Familienfilm und trägt trotzdem „christmas" — eine Weihnachtsreihe nach
/// Genres enthielte jeden Zeichentrickfilm und diesen Film nicht.
/// </para>
/// </remarks>
public sealed record Saisonanlass(
string Name,
string Von,
string Bis,
IReadOnlyList<string> Stichworte,
IReadOnlyList<string> Genres)
{
/// <summary>Was ohne eigene Einstellung dasteht.</summary>
///
/// <remarks>
/// <b>Vier, und bewusst nicht mehr.</b> Eine lange Vorgabeliste wäre
/// eine Meinung darüber, was jemand feiert. Diese vier sind weit genug
/// verbreitet, um als Beispiel zu taugen, und zeigen zugleich die drei
/// Formen: ein Zeitraum im Jahr, einer über den Jahreswechsel, und
/// einer, der sich auf Genres stützt.
/// </remarks>
public static IReadOnlyList<Saisonanlass> Vorgabe { get; } =
[
new("Zu Halloween", "10-01", "11-01",
["halloween", "haunted house", "witch", "slasher", "zombie"],
["Horror"]),
new("Zur Weihnachtszeit", "12-01", "12-27",
["christmas", "santa claus", "christmas eve"],
[]),
new("Zum Jahreswechsel", "12-28", "01-02",
["new year's eve", "new year"],
[]),
new("Für den Sommer", "06-15", "08-31",
["summer", "beach", "summer vacation", "road trip"],
[]),
];
/// <summary>Ob dieser Anlass an diesem Tag gilt.</summary>
///
/// <remarks>
/// <b>Über den Jahreswechsel hinweg.</b> „28.12. bis 02.01." ist kein
/// Fehler, sondern der Normalfall für Silvester — ein Vergleich, der nur
/// „von ≤ heute ≤ bis" kennt, fände diesen Zeitraum nie.
/// </remarks>
public bool Gilt(DateTimeOffset tag)
{
if (Tagnummer(Von) is not { } von || Tagnummer(Bis) is not { } bis) return false;
var heute = tag.Month * 100 + tag.Day;
return von <= bis
? heute >= von && heute <= bis
: heute >= von || heute <= bis;
}
/// <summary>„12-24" wird zu 1224 — oder zu <c>null</c>, wenn es keiner ist.</summary>
private static int? Tagnummer(string roh)
{
var teile = (roh ?? "").Split('-');
if (teile.Length != 2) return null;
if (!int.TryParse(teile[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var monat)) return null;
if (!int.TryParse(teile[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tag)) return null;
if (monat is < 1 or > 12 || tag is < 1 or > 31) return null;
return monat * 100 + tag;
}
/// <summary>Ob der Anlass überhaupt etwas finden kann.</summary>
public bool Brauchbar =>
!string.IsNullOrWhiteSpace(Name)
&& Tagnummer(Von) is not null && Tagnummer(Bis) is not null
&& (Stichworte.Count > 0 || Genres.Count > 0);
}
@@ -21,6 +21,15 @@ public enum Scanphase
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
Aufraeumen,
/// <summary>Vorspann und Abspann werden gesucht.</summary>
///
/// <remarks>
/// Derselbe Weg wie beim Scan, und aus demselben Grund: der Lauf geht
/// über Stunden, und ohne eine Zeile, die mitzählt, sieht die
/// Verwaltung minutenlang so aus, als täte sich nichts.
/// </remarks>
Segmente,
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
///
/// <remarks>
@@ -70,6 +79,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
Scanphase.Segmente => $"Suche Vorspann und Abspann: {Was}{zaehler}",
Scanphase.Vermerk => Was,
_ => Was,
};

Some files were not shown because too many files have changed in this diff Show More