Compare commits

...
81 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 2f7768136b Der Tonversatz ist ein Regler, abgetrennt unter den Spuren
Drei Anläufe, und du hattest jedes Mal recht.

**Er gehört ins Tonmenü** — aber sichtbar nicht zu den Spuren. Jetzt
trennt ihn ein Strich ab, darüber steht „TONVERSATZ — Ohne Versatz" als
Überschrift, darunter ein Regler. Drei Mittel, und jedes einzelne würde
schon sagen, dass das keine Sprache ist.

**Ein Regler statt Auswahlpunkten.** Feste Stufen hiessen: für jeden Wert
ein Eintrag, und die standen zwischen den Sprachen wie weitere Sprachen.
Gezogen wird von −3 bis +3 Sekunden in Hundertern; die Mitte ist als
Strich markiert, sonst trifft man die Null nur zufällig. Gerechnet wird
erst beim Loslassen, nicht beim Ziehen — jede Änderung baut den Strom neu
auf, und das tut man nicht zwanzigmal beim Hinschieben. Daneben
„Zurücksetzen", sobald etwas eingestellt ist.

**Und der Lautsprecher ist zurück.** Hier stand ein eigenes Zeichen, damit
er nicht zweimal in der Leiste auftaucht — links Stummschalten, rechts die
Tonspur. Die Wellenform erkannte aber niemand als „Ton". Zwei Lautsprecher
sind das kleinere Übel, zumal der linke beim Stummschalten
durchgestrichen wird und damit anders aussieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:42:50 +02:00
ScarriffleandClaude Opus 5 dc9d1cea85 Meine Rundung war eine Klinge, und ich bin hineingelaufen
Du hast recht, und zwar doppelt.

**Der Versatz stand im falschen Menü.** „Später (+100 ms)" unter den
Tonspuren liest sich wie der Name einer Spur. Jetzt hat er einen eigenen
Knopf in der Leiste, mit eigenem Zeichen und festen Stufen von „Ton 1 s
früher" bis „Ton 1 s später", dazwischen „Ohne Versatz" — mit Haken, wie
bei der Qualität. Im Tonmenü stehen wieder nur Sprachen.

**Und das Schlimmere: die Rundung aufs Schlüsselbild hat es verschlechtert.**
Sie musste auf die Millisekunde stimmen, sonst nimmt ffmpeg das
Schlüsselbild davor. Gemessen an einer Datei mit Schlüsselbildern alle
fünf Sekunden:

    -ss 15.015  →  Bild ab 0,000 | Ton ab 0,004
    -ss 15.014  →  Bild ab 0,000 | Ton ab 4,983

Eine Millisekunde. Und die Zeile, die den Wert an ffmpeg gibt, rundete auf
drei Nachkommastellen — bei 23,976 Bildern je Sekunde liegen
Schlüsselbilder auf Werten wie 3,336667, und die überleben das nicht
zuverlässig. Damit konnte jede Fortsetzung schlechter werden als vorher.
Das war mein Fehler, eingebaut in E8GSRE.

**Der richtige Hebel ist `-noaccurate_seek`**, und er braucht gar keine
Genauigkeit. Damit schneidet ffmpeg auch den Ton nicht auf die Sekunde,
sondern lässt ihn beim selben Schlüsselbild anfangen wie das kopierte
Bild. Gemessen, dieselbe Datei, fünf verschiedene Sprungwerte quer durch
die Bildgruppe:

    15,014 → 0,015 | 17 → 0,004 | 19,9 → 0,004 | 12,5 → 0,015 | 3,2 → 0,011

Statt bis zu fünf Sekunden also überall unter zwanzig Millisekunden, und
zwar ohne dass irgendein Wert getroffen werden muss. Fünf Testfälle quer
durch die Bildgruppe halten das jetzt fest; vorher prüfte der Sprungtest
nur, ob überhaupt Segmente entstehen.

Die Schlüsselbildsuche bleibt — aber nur noch, um dem Spieler eine
ehrliche Uhr zu geben. Vergreift sie sich, geht die Uhr falsch; Bild und
Ton bleiben zusammen. Vorher hing die Ausrichtung daran, und das war der
schlechte Tausch.

Und die Sekundenangaben gehen jetzt mit sechs Nachkommastellen hinaus
statt mit drei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:24:21 +02:00
ScarriffleandClaude Opus 5 6ad4deb634 Fünf Sekunden Bild ohne Ton — jetzt weiss ich, woher
Deine Angabe war der Schlüssel: du hast **mitten im Film die Tonspur
umgeschaltet**. Dabei baut der Spieler den Strom an der aktuellen Stelle
neu auf, und damit greift der Sprung — den ich gestern nur mit
Spielzeugdateien gemessen hatte.

Also eine Datei gebaut, die einem Film gleicht: 23,976 Bilder je Sekunde,
Schlüsselbild alle fünf Sekunden, zwei Tonspuren, Deutsch als Stereo-AAC
und Englisch als 5.1 in EAC3. Gemessen, erster Zeitstempel je Spur:

| | Bild ab | Ton ab |
|---|---|---|
| Spur 2, ab Sekunde 0 | 0,021 | 0,000 |
| Spur 2, ab 17 (mitten in der Bildgruppe) | 0,000 | **1,964** |
| Spur 2, ab 15 (knapp vor dem Schlüsselbild) | 0,000 | **4,969** |

Fast fünf Sekunden Bild ohne Ton. Zueinander sind beide dabei richtig
getaktet — aber eine solche Lücke am Anfang darf jeder Spieler deuten, wie
er will, und hls.js schiebt den Ton dann nach vorn. Genau das hast du
gehört.

Die Rundung auf das Schlüsselbild, die seit E8GSRE drin ist, nimmt das
weg: dieselbe Datei, Sprung auf 17, gerundet auf 15,015 — **Bild ab 0,000,
Ton ab 0,004**. Du läufst noch auf 5YZ63F; deshalb steht im Protokoll auch
keine ffmpeg-Warnung, die gibt es erst ab E8GSRE.

**Die Tonspur lässt sich jetzt vor dem Start wählen.** Auf der
Detailseite steht neben „Abspielen" ein Knopf „Ton: Deutsch"; das Menü
zeigt alle Spuren der Datei. Gemerkt wird dabei die **Sprache**, nicht die
Spurnummer — welche Nummer Englisch trägt, ist von Film zu Film
verschieden, dass du Englisch willst, ist es nicht. Beim nächsten Film
steht die passende Spur damit schon da, und das Umschalten mitten im Film
entfällt. Wer doch im Spieler wechselt, ändert dieselbe Merkstelle.

**Und ein Fehler aus deinem Protokoll**, der nichts damit zu tun hat:
„UNIQUE constraint failed: Sehtage.UserId, Sehtage.Tag, Sehtage.MovieId".
Das ist ein Wettlauf im Sehprotokoll von vorgestern — der Spieler meldet
alle zehn Sekunden, ein Sprung meldet sofort, und zwei Meldungen können
sich überholen. Dann fanden beide keine Zeile, beide legten eine an. Jetzt
wird in der Datenbank hochgezählt statt im Speicher, und wer dabei zu spät
kommt, zählt beim zweiten Versuch mit. Mit Test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 20:09:46 +02:00
ScarriffleandClaude Opus 5 cf272f20aa Ton und Bild: ein Versatz zum Einstellen, und zwei gemessene Fehler weniger
Du meldest Ton, der drei, vier Sekunden neben dem Bild liegt.
**Nachstellen konnte ich es nicht** — und das sage ich lieber, als eine
Vermutung als Befund zu verkaufen.

Gemessen habe ich alle vier Wege, jeweils mit ffprobe am Ergebnis, erster
Zeitstempel je Spur:

| Weg | Bild ab | Ton ab |
|---|---|---|
| Umpacken, ab 0 | 0,021 | 0,000 |
| Umpacken, ab 12 | 0,000 | 1,979 |
| Umkodieren, ab 0 | 0,021 | 0,000 |
| Umkodieren, ab 12 | 0,021 | 0,000 |

Alle vier sind **zueinander richtig**. Auch eine Quelle mit einem echten
Loch in der Tonspur blieb ausgerichtet. Die naheliegende Erklärung — `-ss`
vor `-i` mit kopiertem Bild und neu gerechnetem Ton — ist damit widerlegt.

**Zwei echte Fehler hat dieselbe Messung aber gefunden**, und beide sehen
aus wie „um Sekunden daneben":

Zeile zwei der Tabelle. Beim Umpacken fängt das Bild beim Schlüsselbild
*vor* dem Sprung an, der Ton genau beim Sprung — bei Sekunde 12 also zwei
Sekunden Bild **ohne Ton** am Anfang. Zueinander stimmen beide, aber jeder
Spieler darf diese Lücke deuten, wie er will. Und der Server meldete dem
Spieler „beginnt bei 12", obwohl er bei 10 beginnt: die Uhr, die
Zeitleiste und der gespeicherte Stand lagen um diesen Unterschied daneben.

Beides geht weg, indem der Sprung **auf das Schlüsselbild gerundet** wird
und derselbe Wert zurückgemeldet wird. Dann fangen Bild und Ton zusammen
an, es gibt keine Stille davor, und die Uhr stimmt. Beim Umkodieren
entfällt das Runden — dort schneidet das neu gerechnete Bild genau.

**Und: ffmpeg durfte bisher nichts sagen.** `-loglevel error` versteckt
genau die Meldungen, die einen Tonversatz ankündigen, und der
Fehlerkanal wurde bei Rückgabewert 0 weggeworfen. Jetzt steht er auf
`warning`, und Warnungen landen auch bei einem geglückten Lauf im
Protokoll. Wenn es bei dir wieder auftritt, steht diesmal etwas da.

**Der Versatz zum Einstellen** sitzt im Tonmenü des Spielers: „Später"
und „Früher" in Hundertmillisekundenschritten, bis ±5 s, dazu
„Zurücksetzen". Gemerkt wird er **je Datei in diesem Browser** — ein
Versatz ist meist eine Eigenschaft der Datei, kann aber auch an der
Anlage liegen, und über Bluetooth stimmt etwas anderes als am Fernseher.

Verschoben wird serverseitig: die Datei wird ein zweites Mal mit
`-itsoffset` geöffnet und von dort nur der Ton genommen. Mit einer
Eingabe verschöbe sich beides gemeinsam. Der Preis: der Strom startet neu,
und eine durchgereichte Datei muss dafür umgepackt werden — ohne ffmpeg
lässt sich nichts verschieben.

Zwei neue Läufe halten das fest: dass Bild und Ton zusammen anfangen, und
dass ein halbe-Sekunde-Versatz den Ton um eine halbe Sekunde verschiebt
und das Bild gar nicht. Der Sprungtest daneben lief bisher genau durch
diesen Fall und sah nur nach, ob überhaupt Segmente entstehen.

Ein Test musste umgeschrieben werden: er stand auf „Einstiegspunkt ist
genau 2" und hielt damit die falsche Meldung fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 19:37:21 +02:00
ScarriffleandClaude Opus 5 3fe0013be1 Die Auflösung wechselt den Text, statt einen Tooltip zu öffnen
Gestern wanderte der genaue Wert in den Tooltip. Ein Tooltip kommt
verzögert, legt sich über die Nachbarzeile und existiert am Finger gar
nicht. Jetzt steht in Ruhe „Full HD" da, und beim Überfahren tritt
„1080p · 1920 × 800" an dieselbe Stelle.

**Beide liegen übereinander, nicht nacheinander.** Das Feld ist so breit
wie der breitere der beiden Texte — sonst rückte beim Überfahren die halbe
Tabelle. Gerastert statt absolut gesetzt, damit die Zeilenhöhe von selbst
stimmt.

Ohne Zeiger gibt es kein Überfahren: dort steht das Genaue dauerhaft
daneben, statt unerreichbar zu sein.

Gemessen: in Ruhe „Full HD" bei Deckung 1 und „1080p · 1920 × 800" bei 0,
unter dem Zeiger umgekehrt — und kein `title` mehr am Element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:32:21 +02:00
ScarriffleandClaude Opus 5 cdea8e3b0a Die Höhe des Aufmachers ist jetzt einstellbar
Vier Stufen unter Einstellungen → Verwaltung → Übersicht: niedrig, mittel,
hoch, sehr hoch. Mittel ist, was gestern fest verdrahtet war.

**Keine Pixelzahl.** Eine einzustellen hiesse, sie an jedem Bildschirm neu
einzustellen — der Aufmacher misst sich an der Fensterhöhe, nicht an einer
festen Grösse. Gemessen bei 800 Bildpunkten Fensterhöhe: 496, 576, 656,
736. Oben ist Schluss, bevor von der ersten Plakatreihe nichts mehr übrig
bleibt; sie soll zeigen, dass unten noch etwas kommt. Am Telefon bleibt es
flacher, dort wäre auch das weg.

**Sie reist mit der Systemauskunft mit**, nicht in einer eigenen Abfrage.
Die holt der Client ohnehin als Erstes, noch vor der Anmeldung; käme die
Höhe später, stünde der Aufmacher einen Augenblick in der Vorgabe und
spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
Gesetzt wird sie als Marke am `<html>`, das Regelwerk hängt vier Werte
daran.

Serverweit und nur für Admins, dieselbe Linie wie bei den
Wiedergaberegeln: eine Entscheidung des Gastgebers, nicht des Gastes.
Unbekanntes in der Spalte wird zur Vorgabe statt zu einem Fehler — eine
Startseite, die an einem alten Eintrag scheitert, wäre die schlechtere
Antwort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:32:10 +02:00
ScarriffleandClaude Opus 5 a7a4419626 Adminrecht geben und nehmen — und einen, dem es niemand nimmt
Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:31:05 +02:00
ScarriffleandClaude Opus 5 d5e21d663f Einladen, statt sich für jemanden ein Passwort auszudenken
Ein Konto von Hand anzulegen heisst: sich für einen anderen ein Passwort
ausdenken und es ihm durchsagen — über denselben Kanal, über den auch ein
Link gegangen wäre. Jetzt gibt es den Link. Wer ihn öffnet, wählt Namen
und Passwort selbst und ist danach angemeldet.

Gültigkeit und Anzahl wählst du beim Erstellen: einen Tag bis einen Monat,
einmal bis unbegrenzt. **Eine Rolle wählst du nicht** — wer eingeladen
wird, ist gewöhnlicher Benutzer. Ein Feld, über das ein Link Adminrechte
verschenken könnte, gibt es gar nicht, und damit auch keinen Weg, es
versehentlich zu setzen.

**Der Link steht genau einmal da.** Gespeichert ist nur sein
Fingerabdruck, wie beim Gerätetoken; aus der Liste lässt sich keiner mehr
bauen. Eine verlorene Einladung wird deshalb nicht wiedergefunden, sondern
neu gemacht. Und gebaut wird die Adresse im Browser: der Server weiss
nicht, unter welchem Namen er von aussen erreichbar ist, und hinter einem
Reverse Proxy rät er falsch.

**Das Rennen beim Einlösen.** Zwei Leute, die gleichzeitig denselben
einmaligen Link öffnen, läsen beide „null benutzt" und schrieben beide
„eins" — und hätten beide ein Konto. Der Zähler geht deshalb mit einer
Bedingung hoch, die die Gültigkeit noch einmal prüft; wer dabei keine
Zeile trifft, war der Zweite. Das Ganze in einer Transaktion, damit ein
Tippfehler im Namen den Link nicht verbrennt. Beides steht als Test da —
zwei gleichzeitige Einlösungen ergeben genau ein Konto.

**Angelegt wird, angemeldet wird danach gewöhnlich.** Derselbe Zweischritt
wie beim Einrichten, und der Grund ist nicht Bequemlichkeit: die Anmeldung
setzt den Sitzungskeks, und ohne den lädt kein Plakat — Bilder weisen sich
über ihn aus, nicht über das Token. Gemessen beim Durchspielen: nach dem
Einlösen kommt ein Bild mit 200 zurück.

**Die Weiche liegt im Hochfahren, nicht im Router.** Der Router läuft nie,
wenn niemand angemeldet ist — ein Ausgeloggter mit Einladungslink sähe
sonst die Anmeldekarte und landete nach dem Anmelden auf der Startseite,
ohne je erfahren zu haben, wozu er eingeladen war. Nach dem Einrichten,
damit ein nagelneuer Server zuerst seinen Administrator bekommt.

Wer schon angemeldet ist, bekommt kein Formular, sondern den Hinweis und
zwei Wege. Und die Gründe einer Ablehnung sind verschieden benannt:
abgelaufen, zurückgezogen, schon benutzt. Verschwiegener wäre ein
einheitliches „gilt nicht" — und dann fragt der Eingeladene nach, und der
Gastgeber rät mit.

Nebenbei korrigiert: die Erkundung meinte, dem Benutzernamen fehle ein
eindeutiger Index. Er hat einen, seit der ersten Migration, sogar mit
`NOCASE`. Das Namensrennen war also nie offen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:23:25 +02:00
ScarriffleandClaude Opus 5 c65356c16f 4K, Full HD, HD, SD — die Zahl steht beim Überfahren
„1080p" ist eine Angabe für den, der eine Stufe einstellt. Wer sich fragt,
ob es sich lohnt, dafür den Fernseher anzumachen, liest „Full HD". Die
Zeile sagt jetzt das Wort, der Tooltip den genauen Wert und die gemessene
Grösse: `1080p · 1920 × 800`.

**Es gab zwei Leitern, und sie waren sich uneinig.** `Benennung.AusHoehe`
machte aus 2160 ein „2160p", `FileNames.FassungAusHoehe` ein „4K" — auf
derselben Detailseite stand beides übereinander, ein paar Zeilen
auseinander. Die zweite ist weg; sie hatte ausserdem nur die Höhe und
vergab sich damit beim Kinoformat.

Gemessen an zwei echten Dateien: 1920×800 heisst „Full HD" (nur an der
Höhe gemessen hiesse es „HD", und daneben stünde ein 1280×720 mit
demselben Wort), 720×576 heisst „SD".

**1440p fällt unter Full HD.** „QHD" kennt kaum jemand, und ein fünftes
Wort für etwas zwischen zwei bekannten hilft beim Entscheiden nicht.
„8K" steht nur da, damit eine 8K-Datei nicht als 4K gemeldet wird.

**Der Client zerlegt nichts mehr.** Er las die Stufe bisher mit
`display.split(' · ')[0]` heraus — richtig, solange vor der Auflösung nie
etwas anderes steht. Jetzt kommen Wort und genauer Wert als eigene Felder.

**Die Fassung wird nicht mehr gespeichert, sondern gerechnet.** Sie stand
in der Spalte und war damit das, was beim letzten Scan galt: ein neues
Wort hätte keine bestehende Zeile erreicht. Jetzt hält die Spalte nur
noch, was im Dateinamen stand („Mobil"), und die Messung kommt frisch
dazu. Eine Migration räumt die alten gerechneten Werte weg.

**Nicht angefasst:** was eine *gewählte* Stufe ist statt einer gemessenen
— die Umkodierleiter, der Qualitätswähler im Spieler, die Stufen in den
Apps. Wer eine Stufe wählt, wählt eine Zahl.

Die Apps rechnen sich das Wort ohnehin selbst aus und sagen seit jeher
„4K" und „Full HD"; der Server ist ihnen jetzt angeglichen, nicht
umgekehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:13:23 +02:00
ScarriffleandClaude Opus 5 168fcdfb4c Der Aufmacher war dir zu niedrig
`min(62vh, 560px)` → `min(72vh, 640px)`. Bei 800 Bildpunkten Fensterhöhe
gemessen: 576 statt 496, und die erste Plakatreihe lugt mit ihrem Beginn
bei 648 weiterhin über den Falz — sie soll ja zeigen, dass unten noch
etwas kommt.

Am Telefon bleibt es bei `min(52vh, 420px)`: dort ist der Aufmacher im
Verhältnis zum Bildschirm ohnehin höher.

Ein Satz musste mit, und der ist tragend: die Begründung, warum das
Aufklappen der Inhaltsangabe nicht schaukelt, nennt die feste Höhe
wörtlich. Die Zahl wird falsch, das Argument bleibt richtig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:13:06 +02:00
ScarriffleandClaude Opus 5 8e9da1355c Der Server schrieb deutsche Sätze an eine Stelle, die niemand liest
„Die Anfrage war so nicht zu gebrauchen" — das ist der Ersatzsatz des
Browsers für eine Ablehnung ohne Begründung. Dabei stand die Begründung
da: „Benutzername darf nicht leer sein, das Passwort braucht mindestens 8
Zeichen." Nur eben in `errors`, und beide Clients lesen `detail`.

Zwei Formen waren betroffen. `Results.ValidationProblem` legt den Satz
unter `errors` ab, `Results.BadRequest(new { fehler = … })` unter
`fehler` — in beiden Fällen kommt er nirgends an. Zusammen 36 Stellen,
darunter jede Zeile, die ich gestern für die Meldungen geschrieben habe.
Dass die Clients `title` und `errors` auslassen, ist dabei richtig: dort
stehen sonst die englischen Prüftexte des Rahmenwerks, und „Not Found" ist
keine Auskunft.

Jetzt eine Form, hinter `Einwand.Bitte` / `.Fehlt` / `.Belegt`. Dass es
drei benannte Türen sind statt eines Aufrufs mit Zahl, hat einen Grund:
man sieht am Wort, ob der Server um eine bessere Eingabe bittet, etwas
nicht findet oder auf etwas Bestehendes stösst.

**Auffallen konnte es nicht**, und das ist der eigentliche Fund. Die
vorhandenen Tests prüften den Rumpf als Zeichenkette — der Satz *stand* ja
drin, nur im falschen Feld. Der neue Test prüft deshalb, in welchem Feld,
und ein Wächter am Quelltext schlägt an, wenn eine der beiden alten Formen
zurückkommt. Er hat beim ersten Lauf gleich eine Stelle gefunden, die mein
Suchmuster übersehen hatte.

Dazu drei Kleinigkeiten, die beim Lesen auffielen:

**Passwörter wurden getrimmt.** Der Dialog schnitt jedem Feld die Ränder
ab, auch dem Passwort — `login.ts` sendet es ungeschoren. Ein Leerzeichen
am Ende, und das angelegte Konto war für immer unbetretbar.

**Beim Einrichten stapelten sich die Fehlerzeilen**, weil `.error`
weggeräumt wurde und `.fehler` entsteht. Drei Fehlversuche, drei rote
Kästen.

**Der Benutzerdialog versprach acht Zeichen und prüfte nichts.** Jetzt
sagt er es selbst, ohne den Umweg über die Leitung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:12:57 +02:00
ScarriffleandClaude Opus 5 7cd48f5528 Wer etwas sieht, kann es jetzt auch sagen
Wer einen Server für andere betreibt, erfährt von Fehlern zuletzt: dass
die deutsche Tonspur nach einer halben Stunde aussetzt, merkt der, der den
Film sieht, und nicht der, der ihn eingeräumt hat. Bisher gab es dafür
keinen Weg ausser dem Zuruf im Wohnzimmer.

Gemeldet wird an einem Film, einer Serie, einer einzelnen Staffel und
einer einzelnen Folge: Art (Bild, Ton, Untertitel, Angaben, Sonstiges),
eine freiwillige Stelle wie 0:34:12 und der Kommentar. Pflicht ist nur der
Kommentar — die beiden anderen machen aus einer Liste von Sätzen etwas,
das man sortieren und nachstellen kann.

**Eine Staffel gibt es als Zeile nicht**, und das prägt den Entwurf. Sie
ist Serie plus Nummer, so wie überall sonst in diesem Server; die Meldung
trägt deshalb drei Fremdschlüssel und daneben eine Staffelnummer. Dass die
Nummer auf eine Staffel zeigt, die es gibt, prüft der Endpunkt beim
Schreiben — die Datenbank kann es nicht, es gibt nichts, worauf sie zeigen
könnte.

**Die Rechteprüfung steht nirgends.** Wer einen Titel nicht sehen darf,
findet ihn beim Melden nicht — das erledigt der Freigabefilter, ohne ein
einziges `Where`. Elegant und genau deshalb leicht zu zerstören, darum
steht dafür ein Test da.

**Die eigene Liste überlebt fremde Entscheidungen.** Titel werden
nachgeschlagen, nicht verbunden: zieht ein Administrator einen Titel
zurück, nachdem jemand ihn gemeldet hat, bleibt die Meldung beim Melder
stehen — nur ohne Namen. Ein Join hätte sie verschwinden lassen, und das
wäre jemandes eigene Eingabe, weggenommen wegen einer fremden Entscheidung.
`IgnoreQueryFilters` wäre die bequeme Abkürzung und gäbe den Namen eines
unveröffentlichten Titels über einen Weg heraus, an den beim nächsten Mal
niemand denkt.

**Erledigt heisst abgehakt, nicht gelöscht**, als Zeitpunkt und nicht als
Haken — wie `PublishAt` und `ChangedSince`. Daneben steht „Wieder öffnen";
ein Fehlgriff wäre sonst endgültig. Das Archiv wird erst auf Zuruf geholt:
es wächst nur, und für den seltenen Blick dauernd zu zahlen lohnt nicht.

**Der Abschnitt steht neben der Prüfliste, nicht darin.** Die versteckt
sich, sobald nichts auf Freigabe wartet — das ist der Normalfall, und die
Meldungen verschwänden mit ihr. Umgekehrt bleibt der Meldungsabschnitt
stehen, auch wenn nichts drinsteht: ein Kanal, der sich versteckt, solange
ihn niemand benutzt hat, bringt niemandem bei, dass es ihn gibt.

Die Zahl an der Leiste holt sich nur, wer sie nicht ohnehin hat: steht „Zu
prüfen" offen, meldet der Abschnitt sie selbst und nach jedem Abhaken.

Nebenbei zwei Dinge am Dialog, die hier auffielen. Es gab **kein
mehrzeiliges Feld** — und das Einsammeln der Werte hätte ein `<textarea>`
still verschluckt: es ist kein `HTMLInputElement` und wäre ins Auffangnetz
für Auswahlfelder gelaufen, also als `undefined` hinausgegangen. Der
Server hätte „ohne Beschreibung" geantwortet, auf einem Dialog, in dem
sichtbar etwas steht. Und `dialog()` meldete sich als einziger nicht beim
Seitenwechsel an: ein Dialog von der Detailseite überlebte den
Zurück-Knopf und stand danach über der Mediathek.

Neun Testfälle auf dem Server, zwölf Läufe. Für den Web-Client gibt es in
diesem Projekt keine Tests; dort ist von Hand geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 22:18:14 +02:00
ScarriffleandClaude Opus 5 02978e482c Die Knöpfe bleiben, die Inhaltsangabe klappt auf
Ich hatte „die Beschreibung nur beim Hovern" gelesen und daraufhin
Beschreibung *und* Knöpfe ausgeblendet. Die Knöpfe gehören immer dahin: in
Ruhe steht oben, was läuft und was man damit tun kann.

Die Inhaltsangabe dagegen steht erst da, wenn man es will. Zwei Sekunden
auf dem Titel stehenbleiben, dann fährt er hoch und darunter klappt sie
auf; wer nur vorbeifährt, sieht nichts. Gibt es keine, passiert gar
nichts — der Absatz fällt dann ganz aus dem Fluss.

**Angefasst wird am Titel, losgelassen am ganzen Aufmacher.** Das ist die
ganze Vorsicht an dieser Stelle. Beim Aufklappen wächst der Block nach
oben, der Titel rückt um 77 Bildpunkte weg und steht nicht mehr unter dem
Zeiger. Hinge das Zuklappen an seinem `:hover`, wäre das die erste Hälfte
einer Schaukel. Der Aufmacher selbst kann das nicht: gemessen, offen wie
zu, 476 px hoch ab 48 — auf den Bildpunkt derselbe Kasten.

**Die Höhe wird gemessen, nicht geraten.** Nach `auto` lässt sich nicht
überblenden, und eine feste Obergrenze wäre bei einer einzeiligen Angabe
zu drei Vierteln Leerlauf. Gemessen wird bei jedem Folienwechsel und bei
jeder Fensteränderung; `scrollHeight` ginge nicht, der meldet bei einem
`-webkit-box` die ungeklammerte Höhe.

**Der Rasterabstand wandert mit.** Gemessen: zugeklappt stehen zwischen
Eckdaten und Knopfzeile 16 px — mit Inhaltsangabe wie ohne, und genau so
viele wie vorher. Aufgeklappt sind es 12 über und 16 unter dem Text, und
die Knopfzeile steht still (Unterkante 492 in beiden Zuständen).

Nebenbei kommt das Abbruchsignal endlich an: `hero()` nahm eines
entgegen, aber `main.ts` baute die Startseite ohne. Sie wird gemerkt — ihr
Neun-Sekunden-Takt lief also weiter, während man längst woanders war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 22:05:44 +02:00
ScarriffleandClaude Opus 5 ac9faacefb Im Aufmacher steht in Ruhe nur, was läuft
Inhaltsangabe und Knöpfe blenden sich beim Überfahren ein und wieder aus.
In Ruhe bleibt oben nur, *was* läuft — Schriftzug oder Name, Jahr,
Laufzeit, Freigabe. Was man damit tun kann und worum es geht, kommt auf
Zuruf. So ist das Bild in Ruhe ein Bild und nicht eine Wand aus Text
darüber.

**Langsamer herein als hinaus**, 340 gegen 220 Millisekunden: das
Erscheinen soll man sehen, das Verschwinden nicht abwarten müssen. Die
Pfeile gehen denselben Takt mit; sie standen auf 150 ms und blitzten
dagegen auf.

**Der Platz bleibt reserviert.** Mit `display: none` spränge beim ersten
Überfahren der ganze Block nach oben; geschaltet wird nur die Deckung.
Gemessen an derselben Folie: 349,2 px hoch mit Deckung 0, 349,2 px mit
Deckung 1.

**Der Terminhinweis bleibt stehen.** „Ab Di., 6. Okt., 18:00 Uhr" ist
keine Handlung, sondern die Auskunft, warum hier nichts zu drücken ist —
die darf nicht erst auf Zuruf kommen. Darum trifft die Regel `.btn` und
nicht die ganze Knopfzeile.

**Der Fokus zählt wie der Zeiger.** Ein Knopf, der unsichtbar den Fokus
bekommt, ist eine Sackgasse. Gemessen, mit abgeschalteten Übergängen:
in Ruhe alles auf 0; Fokus im Aufmacher — Text 1, beide Knöpfe 1, beide
Pfeile 0,72; nach dem Verlassen wieder 0.

`hover: hover` wie bei den Pfeilen: ohne Zeiger gäbe es kein Überfahren,
und „Abspielen" wäre nicht mehr erreichbar. Dort steht alles wie immer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 21:15:39 +02:00
ScarriffleandClaude Opus 5 3a0afa918d Die Pfeile im Aufmacher warten, bis man hinzeigt
Sie liegen über dem Bild und damit über allem, was dort steht. Bei einem
einzeiligen Titel geht das gut. „Pirates of the Caribbean — Salazars
Rache" bricht um, die erste Zeile rückt nach oben — und der linke Pfeil
sitzt auf dem ersten Buchstaben.

Ständig sichtbar ist er dort ein Fleck, auf Zuruf ein Knopf. Also
erscheinen beide erst beim Überfahren des Aufmachers, in 150 ms, und
verschwinden wieder.

`hover: hover` ist dabei die ganze Vorsicht: auf einem Tablett gibt es
kein Überfahren, und ein Knopf, der nur dabei erscheint, wäre dort
schlicht nicht erreichbar. Dort bleibt alles, wie es war — unter 860
Bildpunkten gibt es die Pfeile ohnehin nicht, da wird gewischt.

Gemessen: in Ruhe Deckung 0, kein Zeigerziel. Zeiger über dem Aufmacher:
0,72 und anklickbar. Zeiger auf dem Pfeil: 1, dunklerer Grund, und der
Klick blättert tatsächlich weiter (Tastatur-Tabulator vom Knopf „Mehr"
aus: Deckung 1, `:focus-visible` greift, Umriss 2 px) — `pointer-events:
none` hält nur den Zeiger ab, nicht den Fokus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 21:03:30 +02:00
ScarriffleandClaude Opus 5 ffc4bbe597 Ein Viertel des Hintergrundbildes lag hinter der Seitenleiste
Es stand auf `inset: 0` und lag damit auch hinter Seiten- und Kopfleiste —
beide undurchsichtig. Bei 1280×800 gemessen: 18,8 % der Breite, 7 % der
Höhe, zusammen 24,4 % der Bildfläche, die niemand je zu sehen bekam.

Solange alles weichgezeichnet war, fiel das nicht auf; ein verwischter
Rand sieht wie jeder andere aus. Scharf beginnt der Inhalt mitten im
Motiv — bei den Bergrettern mit einem angeschnittenen Kopf direkt an der
Kante der Seitenleiste.

Jetzt sitzt das Bild in dem Feld, in dem es auch zu sehen ist, und wird
darin ganz zugeschnitten: links die Seitenleistenbreite, oben die
Kopfleistenhöhe. Der Kontrast bleibt, wo er war — 3,95∶1 scharf statt
3,97∶1, derselbe Zuschnitt in einem anderen Feld.

Am Telefon gibt es keine Seitenleiste; dort nimmt die Navigationszeile
oben noch einen Streifen weg, solange man ganz oben steht, und ist nach
dem ersten Bildlauf weg.

Die beiden älteren Begründungen über der Regel standen noch da und sagten
inzwischen das Gegenteil — die eine erklärte, warum das Bild über die
volle Breite gehört. Sie sind zu einer zusammengefasst, die den ganzen Weg
erzählt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 20:16:17 +02:00
ScarriffleandClaude Opus 5 3c7c0d8799 Der Hintergrund wird erst weich, wenn etwas darüberläuft
Er war es immer: 28 Bildpunkte Weichzeichnung über die ganze Detailseite,
damit der Text darüber lesbar bleibt. Oben steht aber gar nichts darüber —
dort ist das Bild das Beste, was die Seite zu bieten hat, und es lag als
Farbschlieren da. Unscharf gehört es erst, wenn wirklich etwas darüber
hinwegläuft: die Folgen, die Besetzung, die Medienangaben.

Jetzt folgt die Weichzeichnung dem Bildlauf, über etwa siebzig Prozent
einer Bildschirmhöhe und quadratisch statt geradlinig — die ersten
Bildpunkte sollen nichts tun, sonst wäre „scharf, solange nichts
darüberliegt" nach einem Fingerbreit schon dahin. Gerundet auf ganze
Bildpunkte, weil jeder neue Wert eine bildschirmfüllende Fläche neu
zeichnen lässt; über die ganze Strecke sind das 28 Male. Gemessen über 58
Bilder beim Hinunterrollen: 8,3 ms im Mittel, kein Bild über 9,2 ms.

**Und die Deckung geht mit, das ist der unscheinbare Teil.** Eine
Weichzeichnung verrührt die hellen Stellen mit den dunklen; ein scharfes
Bild hat die weisse Stelle genau dort, wo die Zeile steht. Gemessen an der
dunklen Vorlage von „Dark": vorher 4,07∶1, scharf bei derselben Deckung
nur noch 2,44∶1. An einer hellen — Hubschrauber vor blauem Himmel —
vorher 2,75∶1. Mit 0,28 scharf und 0,45 weich stehen beide wieder bei rund
4,0∶1, die helle sogar besser als zuvor.

Der Zuhörer räumt sich selbst ab: die Detailseite wird nicht gemerkt, und
für ungemerkte Ansichten ruft `einhaengen` kein Abbruchsignal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 19:53:25 +02:00
ScarriffleandClaude Opus 5 6dc7be9822 Ein eigenes Bild bringt auch seinen Ausschnitt mit
Hochladen konnte man Plakat, Hintergrund und Schriftzug schon lange — der
Knopf stand da, der Endpunkt nahm an. Nur ankommen tat nichts: die Ablage
baute einen Schlüssel, den sie selbst nicht wiederfand (heute früh
repariert). Was blieb, waren drei Lücken, die man erst sieht, wenn der Weg
überhaupt funktioniert.

**Der Ausschnitt.** Ein Plakat steht hochkant im Verhältnis 2∶3, ein
Hintergrund liegt quer. Was man aussucht, passt selten von selbst — und
den Rest beschnitt bisher der Browser beim Anzeigen: mittig. Jetzt wählt
man selbst, zieht und vergrössert, und hochgeladen wird genau dieser
Ausschnitt: 600×900 beim Plakat, 1920×1080 beim Hintergrund. Gerechnet
wird im Browser; das spart die Leitung und dem Server eine Bildbibliothek.

**Der Schriftzug nicht.** Er ist ein PNG mit durchsichtigem Grund. Durch
dieselbe Mühle geschickt würde aus der Durchsicht ein weisses Rechteck —
ein Aufkleber über dem Bild. Er geht unverändert hinaus, Byte für Byte.

**Die Kachel merkte nichts.** Nach dem Hochladen fehlte der Anstoss nach
aussen; die Oberfläche zeigte weiter das alte Bild, bis man sie neu lud.

**Und zurück ging es gar nicht.** „Von Hand gesetzt" heisst auch: beim
Auffrischen bleibt es stehen. Ohne Rückweg wäre ein Fehlgriff ein
Dauerzustand — jetzt steht „Wieder automatisch" daneben, sobald etwas
festgesetzt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 19:41:04 +02:00
ScarriffleandClaude Opus 5 327e2e9195 Einstellungen hat jeder, Verwaltung nicht
Die Seite hiess „Verwaltung" und war für alle anderen gar nicht
erreichbar — „Meine Sprache" lag dadurch hinter einer Tür, die den meisten
verschlossen ist. Jetzt heisst sie **Einstellungen**, steht jedem offen und
hat zwei beschriftete Gruppen: oben, was einem selbst gehört (Mein Konto,
Geschaut), darunter — nur für Administratoren — **Verwaltung**. Die
Überschrift ist hier die halbe Aussage: sie sagt ohne weiteren Satz, wem
der untere Teil gehört.

In der Hauptleiste steht der Eintrag jetzt unten, ohne Gruppenüberschrift.
Eine Überschrift über einem einzigen Eintrag ist eine Überschrift über
nichts, und am Fuss sucht man Einstellungen ohnehin. `#/profil` und
`#/verwaltung/…` leiten weiter; ein Lesezeichen merkt davon nichts.

**Ein kurzes Menü am Bild.** Erst meldete der Klick ab — ohne Rückfrage.
Dann führte er auf die eigene Seite, und zum Abmelden musste man erst
dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist ehrlicher
als eine Tür, hinter der eine zweite liegt.

**Und das Profilbild lässt sich zuschneiden.** Ein Profilbild ist rund,
was hochgeladen wird selten quadratisch — den Ausschnitt dem Browser zu
überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals. Der
Dialog zeigt den runden Ausschnitt, man zieht und vergrössert, und
hochgeladen wird genau das: ein Quadrat von 512 Pixeln. Gemessen, es kommt
auch so an.

**Dazu die Spielerleiste.** Beide Knopfgruppen durften schrumpfen, ihre
Knöpfe aber nicht — wird es eng, steht der äusserste halb hinter dem
Fensterrand. Rechts sind es nur Knöpfe, da gibt es nichts nachzugeben;
links schon. Dazu seitlich `env(safe-area-inset-*)`, damit auf einem Gerät
mit runden Ecken nichts darunter liegt. Gemessen: vorher riss es ab 420
Pixeln, jetzt bis 320 nicht mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 18:24:44 +02:00
ScarriffleandClaude Opus 5 9c2e2b5f47 „Kenne ich" heisst nicht „kann ich diese Datei"
Der Spieler schickte sein Profil aus `canPlayType` — und das ist eine
Auskunft des `<video>`-Elements. Ein Strom läuft aber nicht durch das
Element, sondern durch den MSE-Puffer, und die beiden sind sich nicht
einig.

Gemessen in Chromium:

| | canPlayType | MediaSource.isTypeSupported |
|---|---|---|
| `audio/flac` | probably | **false** |
| `audio/mp4; codecs="mp3"` | probably | **false** |

Der Server glaubte dem Profil, packte die Datei pflichtschuldig um und
liess die Spur unberührt — und der Browser stand vor etwas, das er nach
eigener Auskunft können müsste. Heraus kam „Dieser Browser kann die
Spuren dieser Datei nicht öffnen".

Jetzt zählt ein Codec nur, wenn **beide** Auskünfte ihn bestätigen; welcher
der beiden Wege es wird, entscheidet der Server erst danach. Und geprüft
wird jeder Typ in der Form, in der er wirklich ankommt — `audio/mp4` und
nicht `audio/flac`: genau dieser Unterschied entschied bei FLAC zwischen
„geht" und „geht nicht". Gemessen: MP3 fällt dadurch aus dem Profil, der
Server rechnet es um, statt es durchzureichen.

**Und wenn doch etwas durchrutscht, wird gerechnet statt gemeldet.**
Scheitert die Wiedergabe mit einem Codec- oder Dekodierfehler, baut der
Spieler den Strom genau einmal neu auf — mit `transcode: true`, an
derselben Stelle. Der Satz „über das Zahnrad lässt sich eine Fassung
wählen, die er versteht" ist weg: wer das liest, weiss weder welche, noch
warum der Server sie nicht gleich gegeben hat.

Lief schon ein gerechneter Strom, unterbleibt der zweite Versuch — dann
liegt es nicht am Codec, und dreissig Sekunden Warten auf dasselbe sind
keine Hilfe.

**Nicht geprüft:** dass der Rückfall im Betrieb auslöst. Jede Datei im
Prüfbestand spielt; ein echtes Scheitern liess sich hier nicht
herbeiführen. Gemessen ist der Serverteil (`remux` wird zu `transcode`)
und das neue Profil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:44:11 +02:00
ScarriffleandClaude Opus 5 e30158b84c Eine Sekunde heisst nicht „Weiterschauen"
Die Schwelle aus den Einstellungen wirkt serverseitig, also sofort überall:
`/home/continue` liest die gespeicherte Spalte. Eine Stelle blieb trotzdem
widersprüchlich — die **Beschriftung**.

Web und Apps entschieden „Weiterschauen" gegen „Abspielen" selbst, allein
daran, ob eine Stelle über null steht. Nach einer Sekunde stand der Titel
damit nicht mehr in der Reihe, hiess aber weiter „Weiterschauen", trug
einen Fortschrittsbalken unter der Kachel und bot „Aus Weiterschauen
entfernen" an.

Der Server hatte diese Frage längst beantwortet. `ItemStateResponse` bringt
sie jetzt mit; `MediaItem.isResumable` und `.resumeProgress` sind die
einzigen Stellen, die sie noch stellen. Im Simulator nachgeprüft: nach
einer Sekunde heisst der Knopf „Abspielen", „Von vorn" fehlt, und der
Balken unter der Kachel ist weg.

Kennt ein Server das Feld nicht — Jellyfin, oder ein älterer Flaro —, gilt
wie früher die blosse Stelle.

**Und das Profilbild steht in den Apps.** `FlaroAccount` führt es mit,
`/auth/me` bringt es bei jedem Start frisch, und der Buchstabenkreis tritt
zurück, sobald eines da ist. Jellyfin bleibt beim Buchstaben — dort gibt es
kein Profilbild, und das ist keine Lücke, sondern der Stand der Dinge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:30:38 +02:00
ScarriffleandClaude Opus 5 579a49b73b Der Server wusste, was du zuletzt gesehen hast — und sonst nichts
Für „wie viel habe ich letzte Woche geschaut" gab es keine Daten.
`UserItemState` ist eine Zeile je Titel: der letzte Stand. Wer einen Film
zweimal sieht, überschreibt den ersten Durchgang; wann etwas lief, wusste
der Server nur fürs jeweils letzte Mal. Nachträglich erfinden lässt sich
das nicht.

Jetzt gibt es ein Sehprotokoll: **eine Zeile je Nutzer, Tag und Titel.**
Keine Sitzungen — die brauchen ein Ende, und das kommt oft nie an, wenn
der Browser zugemacht oder das Telefon zugeklappt wird. Tagesbündel haben
diesen Lebenslauf nicht, es wird nur addiert.

**Gezählt wird, was wirklich lief.** Der Zuwachs zwischen zwei Meldungen,
gedeckelt auf die Zeit, die dazwischen verstrichen ist (mal zwei als
Toleranz für doppelte Geschwindigkeit und ausgefallene Meldungen). Sonst
ergäbe ein Sprung von Minute 5 auf Minute 90 anderthalb Stunden Sehzeit in
zehn Sekunden. Gemessen an fünf Meldungen im Abstand von elf Sekunden:
88 Sekunden statt 190.

Dabei fiel ein zweiter Fall auf: war **gar keine** Zeit verstrichen, griff
kein Deckel statt eines engen — eine frisch angelegte Zeile hätte ihre
Anfangsstelle voll mitgezählt. Wer einen Film öffnet und sofort auf Minute
fünf springt, hat keine fünf Minuten gesehen.

Im Profil stehen drei Zahlen, und jede sagt, woher sie kommt: die
aufgezeichnete Zeit fängt heute an, die Zahl gesehener Titel steht seit
jeher fest. Dazu eine Wochenkurve, die häufigsten Genres und was zuletzt
lief. **Jeder sieht nur sich selbst, auch ein Administrator** — es gibt
keinen Weg, ein fremdes Konto zu meinen.

Zwei Dinge auf dem Weg dorthin:

**Eine stellbare Uhr für die Tests.** Zwei Minuten Film lassen sich nicht
in fünf Millisekunden sehen, und das ist richtig so — prüfen liess es sich
nur, indem die Zeit vergeht, ohne sie zu verbringen.

**Ein Namenskonflikt im CSS.** Die leeren Wochenbalken trugen die Klasse
`leer`, und die gehört längst der Fläche „hier ist nichts" — mitsamt
24 Pixeln Polster. Jeder leere Balken war dadurch 48 Pixel hoch, egal
welche Höhe dastand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:20:01 +02:00
ScarriffleandClaude Opus 5 d8a1633cf4 Das eigene Bild führte aus dem Server heraus
Ein Klick auf den Buchstabenkreis meldete ab. Keine Rückfrage, kein Menü,
kein Weg zurück — hinter einer Geste, die überall sonst „mein Konto"
heisst. Jetzt führt er auf `#/profil`; Abmelden steht dort als Knopf, mit
Nachfrage.

Auf der Seite: Bild wählen, tauschen, entfernen. Dazu „Meine Sprache" —
**umgezogen, nicht kopiert.** Sie stand mitten in der Verwaltung, und die
zeigt der Client nur Administratoren; der Endpunkt erlaubte sie längst
jedem. Wer kein Admin war, konnte seine eigene Sprache nirgends ändern.
Der Warnhinweis „weicht ab" bleibt in der Verwaltung, denn dort entsteht
die Verwirrung — er verweist jetzt aufs Profil.

**Vorher musste ein Fehler weg, der seit Monaten dasteht.**
`ImageCache.StoreStreamAsync` baute den Schlüssel aus 32 Hexzeichen plus
Dateiendung; `PfadFür` verlangt genau 64 Hexzeichen und nichts sonst. Es
gab keinen Wert, den beide gelten liessen: **jeder Bildupload gab `null`
zurück und endete in einem 502.** Kein Test fragte je danach — jetzt vier.
Damit geht nebenbei auch „eigenes Bild hochladen" für Filme und Serien
wieder.

Weil der Schlüssel keine Endung mehr trägt, kommt der Medientyp beim
Ausliefern aus den ersten Bytes der Datei. Vorher ging alles als
`image/jpeg` hinaus — bei TMDB stimmte das, bei einem hochgeladenen PNG
nicht.

**Und die gemerkte Sitzung war eine Kopie, die nie nachzog.** Sie wurde
bei der Anmeldung geschrieben und danach nie wieder. Wer sein Bild am
Telefon setzt und den Browser am Schreibtisch öffnet, fand dort den alten
Buchstaben — bis zum nächsten Anmelden. Gemessen und behoben: beim
Hochfahren wird das Konto abgeglichen, und das Profil meldet Änderungen
an die Kopfleiste, damit sie ohne Neuladen nachzieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 17:07:40 +02:00
ScarriffleandClaude Opus 5 9622da0284 Eine Sekunde ist kein Anfang
Drei Zahlen standen als Konstante im Quelltext und bestimmten, wie sich
der Server anfühlt. Jetzt stehen sie in der Verwaltung, unter
„Wiedergabe".

**Weiterschauen ab.** Gemessen: `BelongsInContinueWatching` verlangte nur
`Position.Ticks > 0` — ein Tick genügte. Wer im Aufmacher eine Sekunde
hineinschaute, fand den Titel danach in „Fortsetzen". Jetzt gibt es eine
Untergrenze, voreingestellt auf 1½ Minuten.

Was sich dabei **nicht** ändert: die Stelle wird weiter gemerkt. Das ist
der Unterschied zu Jellyfin, um dessentwillen es diesen Server gibt — die
Schwelle entscheidet, ob der Titel in der Reihe steht, nicht ob man ihn
wiederfindet. Nachgemessen: nach dem Umstellen ist „Heat" aus der Reihe
verschwunden und steht weiterhin bei Sekunde 1.

**Gesehen ab.** War 90 % als `const`, ist jetzt 85/90/95.

**Umkodieren.** `-preset veryfast -crf 21` und VAAPI `-qp 23` waren fest
verdrahtet, ohne Einstellung und ohne Umgebungsvariable. Drei Stufen:
Qualität (medium/19, qp 21), Ausgewogen (wie bisher), Tempo
(ultrafast/23, qp 26). Mit dem Warnsatz daneben, und der ist ernst
gemeint: was in Echtzeit laufen muss, darf nicht langsamer rechnen als es
abspielt.

**Und das Vorhandene wird neu bewertet.** `InProgress` ist eine
gespeicherte Spalte — die Bruchrechnung lässt sich nicht nach SQL
übersetzen. Eine neue Schwelle wirkte sonst nur auf künftige Meldungen.
Der Endpunkt läuft deshalb einmal in Stapeln durch und meldet zurück, wie
viele Zeilen das umgeworfen hat: „Übernommen — 2 Einträge wurden neu
bewertet." Ohne diesen Satz änderte sich die Startseite scheinbar von
selbst.

`ProgressRule` bleibt ein reiner Typ: die Regeln kommen als Wert herein.
Nebenbei zieht der doppelte `zeile()`-Bauer nach `teile.ts` — er stand
zweimal wortgleich da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:52:53 +02:00
ScarriffleandClaude Opus 5 1e4a231d1b Elf Abschnitte in einer Spalte waren zehn zu viel
Die Verwaltung war eine Seite mit 1786 Zeilen. Wer das Protokoll sehen
wollte, scrollte an Mediatheken, Prüfliste, Benutzern und Speicher
vorbei — und jede neue Einstellung hätte sie länger gemacht.

Jetzt hat sie eine eigene Leiste mit sieben Bereichen, jeder mit seiner
Adresse: `#/verwaltung/protokolle` lässt sich ablegen, und ein Neuladen
bleibt dort stehen. Die Leiste sieht aus wie die Hauptleiste daneben,
damit sie als Navigation gelesen wird und nicht als Knopfreihe; am
Telefon wird aus der Spalte ein Streifen zum Schieben.

**Gebaut wird nur, was offen ist.** Das ist nicht nur sparsamer: das
Abspielfenster fragt im Fünf-Sekunden-Takt nach, die Mediathekenliste
beobachtet laufende Scans. Gemessen: nach dem Wechsel in einen anderen
Bereich kommt zwölf Sekunden lang keine Anfrage mehr.

`admin.ts` ist in acht Dateien zerlegt — eine je Bereich, dazu `teile.ts`
für die gemeinsame Überschrift und `index.ts` für Leiste und Weiche.

Zwei Dinge mussten dabei nachziehen:

**Die Markierung in der Hauptleiste.** Sie verglich stur zwei Segmente;
bei `#/verwaltung/metadaten` traf das auf keinen Eintrag, und
„Verwaltung" verlor seinen Balken. Jetzt gilt auch ein Segment.

**„5 Titel warten — ansehen".** Der Knopf sprang mit `scrollIntoView` zur
Prüfliste. Die steht nicht mehr im selben Dokument — er wechselt jetzt
den Bereich. Die Hervorhebung danach entfällt damit, und mit ihr ihr CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:39:50 +02:00
ScarriffleandClaude Opus 5 94c6604e8b Pfeile am Bildrand, und „Mehr" bleibt, wo es ist
**Pfeile.** Der Aufmacher hatte nur die Punkte; die Pfeile, die du gedrückt
hast, gehörten zur Reihe darunter. Jetzt stehen links und rechts über dem
Bild zwei grosse Knöpfe. Am Telefon gibt es sie nicht — dort nähme ein
Knopf über dem Bild ein Drittel der Breite —, dafür lässt sich wischen.
Nur waagerecht und erst ab 48 Pixeln, sonst käme man am Aufmacher nicht
mehr vorbeigescrollt.

**Und die Knöpfe springen nicht mehr.** „Abspielen" ist 128 Pixel breit,
„Fortsetzen ab 1:23:45" 207, der Terminhinweis 257 — alles in derselben
Flexreihe, also wanderte „Mehr" bei jedem Umblättern mit, und das
Karussell blättert von allein. Der vordere Platz hat jetzt eine feste
Breite. Gemessen: „Mehr" steht auf allen sieben Folien an derselben Stelle.

Dabei fielen zwei Dinge auf, die nicht am Umbau lagen:

**Der Textblock war so breit wie sein längster Satz.** Als blosses Flexkind
richtete er sich nach dem Inhalt — bei einer kurzen Inhaltsangabe schmaler
als bei einer langen. Auf dem Telefon rückte „Mehr" dadurch um fünfzig
Pixel, und mit dem festen Platz lief die Zeile sogar über den Bildschirm
hinaus; `overflow: hidden` schnitt die rechte Hälfte des Textes ab.

**Ein fehlender Schriftzug liess den Aufmacher namenlos.** Steht ein Logo
in den Metadaten, wurde der Titel daneben versteckt — auch wenn das Bild
gar nicht lud. Zu sehen war ein Platzhalter und sonst nichts. Jetzt tritt
der Name an seine Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:32:55 +02:00
ScarriffleandClaude Opus 5 10a5070e58 Eine Folge ist weder ein Film noch eine Serie
Die App meldete auf der Startseite „Dieser Titel ist auf dem Server nicht
mehr vorhanden" — über eine Folge, die sie selbst gerade anzeigte.

Gemessen im Quelltext: `item(id:)` probiert `/movies/{id}`, dann
`/series/{id}`, und wirft sonst `.notFound`. In „Weiterschauen" steht
eine Folge mit ihrer eigenen Kennung, und einen Endpunkt für eine
einzelne Folge gab es gar nicht. Jetzt gibt es `GET /api/v1/episodes/{id}`
— mit `SeriesId`, denn ohne die Serie fehlen der Folge Titel und Bild.
Im Simulator nachgestellt und nachgeprüft: die Seite steht.

**Zuletzt hinzugefügt.** Die App zeigte „Filme" und „Serien" — den Anfang
der Mediathek in der Reihenfolge der Datenbank, also immer denselben.
Jetzt je Mediathek eine Reihe „Neu in …", sortiert nach Zugang. Die
Kennungen beginnen nun mit `latest`, und daran hängen zwei Einstellungen,
die an einem Flaro-Server deshalb nichts taten: „Gesehenes in Neuzugängen
ausblenden" und „Aufmacher nur aus Neuzugängen".

**Geplante Titel.** `MediaItem` kennt jetzt `availableFrom`. Aufmacher und
Detailseite zeigen statt des Knopfes „Bald ist es soweit" mit dem Termin.
Die Kachel spielte trotzdem los — im Simulator kam der Spieler bis „Wird
vorbereitet …" —, also zählt ein geplanter Titel jetzt wie eine Sammlung:
nichts zu starten, der Tipp führt auf die Seite.

**Und das Bild im Aufmacher.** Bei einer angefangenen Folge stand dort ihr
Standbild statt des Bildes der Serie; wer der Serie eines ausgesucht
hatte, fand ein anderes vor. Die Kachel behält das Standbild — sie zeigt,
wo man steht —, der Aufmacher bekommt `SeriesBackdropImage`.

**Die Punkte im Karussell waren 26 × 3 Pixel.** Von drei Klicks auf
denselben Punkt traf nur der auf der Mittellinie; fünf Pixel daneben
geschah nichts. Der Knopf ist jetzt 27 Pixel hoch, sichtbar bleiben 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 16:06:58 +02:00
ScarriffleandClaude Opus 5 f10fb4c1bc Abspielen spielt ab
Im Aufmacher der Startseite stand „Abspielen", und wer draufdrückte,
landete auf der Detailseite. Gemessen am laufenden Server: beide Knöpfe
riefen dieselbe Funktion — `oeffnen` —, der eine hiess nur anders.

Drei Dinge waren falsch, alle an derselben Stelle:

**Der Knopf log.** Jetzt startet er den Spieler, mit Fortsetzen ab der
Stelle, an der man war. Ohne Datei — eine ganze Serie — heisst er
„Ansehen" und führt dorthin, wo man wählen kann.

**Eine angefangene Folge war kein Film.** Sie lief durch `alsHero` mit
`art: 'filme'` und ihrer eigenen Kennung; der Klick ging auf
`#/filme/<Folgenkennung>` und endete in „Diesen Film gibt es nicht
mehr". Nachgemessen im Browser, dann `angefangenAlsHero` dafür gebaut:
die Detailseite gehört der Serie, der Fortschritt der Folge.

**Ein geplanter Titel sah aus wie jeder andere.** Jetzt steht statt des
Knopfes „Bald ist es soweit" mit dem Termin, wie auf der Detailseite.

Dafür nennt `/home/continue` und `/home/next` nun Datei, Länge und
Termin, und die Vorschläge bringen ihre Dateien mit — ohne die konnte
der Aufmacher gar nicht abspielen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 15:42:42 +02:00
ScarriffleandClaude Opus 5 343b0cdcfb Ein Knopf, der nichts tut, ist schlimmer als keiner
Der geplante Film zeigte weiter „Abspielen" und „Herunterladen", und wer
drückte, bekam „Dieser Titel ist ab dem 6.10. um 18 Uhr verfügbar". Die
Sperre im Server war richtig; die Oberfläche wusste nur nichts davon.

Gemessen: die Liste führte `PublishAt` seit jeher mit, die Einzelabfrage
`GET /api/v1/movies/{id}` nicht — und die Detailseite entscheidet genau
daran. `publishAt` kam also immer als `null` an, die Prüfung lief ins
Leere. Dasselbe fehlte bei `GET /api/v1/series/{id}` und bei den Folgen.

Jetzt steht dort, wo sonst der Knopf stünde: „Bald ist es soweit", mit
dem Termin darunter. Kein Versprechen, das beim Klicken zurückgenommen
wird.

Der Test prüfte bisher nur die Liste — darum fiel es nicht auf. Er fragt
jetzt die Detailseite und schlägt ohne die Korrektur fehl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 15:10:30 +02:00
ScarriffleandClaude Opus 5 255eb7c2f0 Spider-Man ist nicht 720p, er ist breit
Die Auflösung wurde allein aus der Höhe gebildet. Ein Kinoformat von 2.39:1
liegt als 1920×800 vor — nach der Höhe gerechnet kam „720p" heraus, und das
stand so in der Oberfläche. Jetzt zählt die grössere von Breite und Höhe;
Hochformat kippt damit nicht, und 4K-Breitbild heisst nicht mehr 1440p.

Die Detailansicht hat zwei Leserschaften, und bisher bekamen beide dasselbe.
Wer schauen will, sieht jetzt Laufzeit, Auflösung und die Sprachen — jede
einmal, statt „Englisch · Englisch · Englisch · Englisch · Deutsch". Wer die
Mediathek betreut, sieht darunter Dateiformat, Grösse und den Ordner. Der
Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über der
Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und Nanni", ist
die Sache klar. Er kommt nur für Administratoren überhaupt vom Server —
versteckt im Client wäre er weiterhin auslesbar.

Ein geplanter Titel war unsichtbar. Das ist die Hälfte, die nichts bringt:
dass er kommt, ist der Sinn des Planens. Er steht jetzt mit „Verfügbar ab …"
in der Mediathek, und die Datei geht trotzdem nicht heraus — Download wie
Strom lehnen bis zum Termin ab, auch für Administratoren. Wer vorziehen will,
hat „Jetzt veröffentlichen"; eine Hintertür für eine Rolle wäre eine Regel,
die manchmal gilt.

Was ein Auffrischen geändert hat, steht jetzt unter „Zu prüfen" — in einer
eigenen Gruppe mit „Zur Kenntnis" statt „Veröffentlichen". Diese Titel sind
sichtbar; es gibt nichts freizugeben, nur etwas abzuhaken.

Und das Protokoll sagt endlich, wo es knallt. „InvalidOperationException:
Nullable object must have a value." ohne eine einzige Zeile Herkunft ist für
niemanden eine Diagnose — weder für den Betreiber noch für mich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 14:51:00 +02:00
ScarriffleandClaude Opus 5 f44779c899 Eine Zahl ohne das, worauf sie sich bezieht
„4 warten auf Freigabe" stand in der Mediathekenzeile, und die Liste darüber
war leer — sogar versteckt. Zwei Gründe, beide behoben.

Die Prüfliste wurde einmal beim Öffnen geholt und nie wieder. Findet ein
laufender Scan danach etwas Neues, zählt die Zeile es mit, die Liste aber
nicht. Sie hört jetzt auf dieselbe Meldung wie die Seitenleiste: Scan durch,
etwas freigegeben, Mediathek geändert — nachladen.

Und der Knopf gab blind frei. Auf die Frage „wo sehe ich die vier Filme?"
war das keine Antwort: er veröffentlichte sie, ohne sie zu zeigen. Jetzt
führt er dorthin, wo sie mit Plakat, Jahr und Dateiname stehen und sich
einzeln anhaken lassen — und hebt die Stelle kurz hervor, damit man nicht
weitersucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 13:56:12 +02:00
ScarriffleandClaude Opus 5 58e06d0539 Hanni und Nanni ist nicht Hanni und Nanni 3
Deine drei Fälle haben eines gemeinsam, und es ist nicht das, was ich zuerst
vermutet hatte: keiner der Dateinamen nennt ein Jahr. Der Riegel von gestern
greift deshalb nicht.

Gemessen, woran es wirklich liegt. Bei TMDB heisst der Film „Hanni & Nanni",
auf der Platte „Hanni und Nanni". Das Kaufmanns-Und fällt im Vergleichs-
schlüssel als Sonderzeichen heraus, das Wort „und" bleibt stehen — die
Namensgleichheit griff also nie. Gewählt wurde stattdessen der erstbeste
Treffer, und das war „Hanni & Nanni 3". Der richtige stand auf Platz zwei,
und alle vier Treffer hatten dieselbe Beliebtheit: die Reihenfolge war
Zufall.

Bindewörter zwischen zwei Namen zählen jetzt nicht mehr mit — gestrichen,
nicht übersetzt, sonst bliebe „Batman and Robin" aussen vor. Am Wortanfang
bleiben sie stehen: „Und dann kam Polly" ist kein „dann kam Polly". Und die
Suche vergleicht über den Schlüssel statt Zeichen für Zeichen.

Das verhindert aber nur Neues. Was schon zusammengelegt ist, bleibt es —
deshalb gibt es jetzt „Eigener Film": eine Datei löst sich aus ihrem Titel
und wird ein eigener Eintrag, mit dem Titel aus dem Dateinamen und ohne
Kennung. Eine zu raten hiesse, denselben Fehler zu wiederholen; so steht sie
unter „Ohne Zuordnung" und ist in zwei Klicks richtig.

Der Super-Mario-Fall liegt anders: beide Titel treffen heute richtig. Der
steckt noch aus einem früheren Lauf fest und braucht das Trennen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 13:40:21 +02:00
ScarriffleandClaude Opus 5 c3124ba27c Zwei Teile, ein Treffer — und einer verschwand
Du hast drei Filme vermisst, und die Zahlen sagten schon, wo sie sind: 448
Filme plus 13 zweite Fassungen sind genau die 461 Dateien. Es fehlte keine
Datei; drei Filme steckten als Fassung in einem anderen.

Gemessen, warum. Der Dateinamenleser ist unschuldig — „Teil 2" bleibt im
Titel. TMDB aber liefert für „Heiligtümer des Todes Teil 1" wie für „Teil 2"
denselben Treffer zuerst, sobald ohne Jahr gesucht werden muss. Und weil
zwei Einträge dieselbe Kennung nicht teilen dürfen, wurde der zweite
geschluckt.

Zwei Dateien, deren Namen verschiedene Jahre nennen, sind jetzt verschiedene
Filme. Der erste Anlauf hatte ein Jahr Abstand als Toleranz erlaubt — für
Dezember-Erstaufführungen — und verschluckte damit genau den Fall, um den es
geht: Teil 1 ist von 2010, Teil 2 von 2011.

Dazu sieht man endlich, was nicht zugeordnet werden konnte. „Nicht
zugeordnet" listete bisher nur Dateien ganz ohne Titel, und die gibt es bei
Filmen nie — deshalb standen dort nur Serien. Daneben eine Liste der Titel
mit mehreren Dateien: zwei Fassungen sind die Regel, zwei verschiedene Filme
der Fehler, und an den Dateinamen sieht man es sofort.

Der Bearbeiten-Dialog hat jetzt Reiter statt Knöpfe, die weitere Fenster
öffnen — ein Fenster über einem Fenster, und man wusste nicht mehr, was man
zumacht. „Titel ändern" und „Bilder bearbeiten" sind aus dem Kontextmenü
raus; über „Bearbeiten" kommt man an beides.

Beim Prüfen gefunden: ein Dialog blieb nach einem Seitenwechsel über der
neuen Seite stehen. Er hängt an body, nicht am Inhaltsbereich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 13:21:42 +02:00
ScarriffleandClaude Opus 5 fcf40e4394 Der Orden des Phönix stand vor dem Stein der Weisen
Alphabetisch stimmt das, nur sieht man sie andersherum. Es gibt jetzt einen
Sortiertitel: „Harry Potter 1", „Harry Potter 2" — und die Reihe steht in
der Ordnung, in der man sie schaut. Leer gelassen bleibt alles wie bisher,
beim Titel ohne Artikel davor.

Er hat einen eigenen Merker, nicht den des Titels. Wer die Reihenfolge
festlegt, soll den Titel trotzdem weiter automatisch bekommen; beides an
einem Schalter hiesse, für das eine auf das andere zu verzichten. Eine
Korrektur der Zuordnung lässt ihn ebenfalls stehen.

Und WALL·E bleibt nicht mehr unter E. Die Kachel wurde nach einer Korrektur
zwar neu beschriftet, blieb aber an ihrem Platz — jetzt rutscht sie dorthin,
wo sie hingehört. Liegt der Platz im noch nicht geladenen Teil der Liste,
geht sie weg und sagt wohin, statt falsch stehenzubleiben.

Dazu ein Dialog, in dem alles zu einem Titel beieinander steht: Titel,
Sortiertitel, Bilder, Zuordnung. Die vier Menüpunkte gab es schon, jeder für
sich richtig — zusammen aber kein Ort. Wer einen Film geraderückt, macht
selten nur eines davon. Die Abkürzungen bleiben.

Beim Bauen gefunden: das Zurücksetzen des Sortiertitels endete in einer 500.
SortTitle.For ist eine C#-Methode, und EF Core übersetzt sie nicht nach SQL.
Es geht um eine Zeile — die wird jetzt geladen und zurückgeschrieben, statt
die Regel ein zweites Mal als SQL zu schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 22:25:31 +02:00
ScarriffleandClaude Opus 5 e70c33f7e2 Not Found war weder deutsch noch wahr
Beim Korrigieren einer Zuordnung geht der bearbeitete Eintrag im vorhandenen
auf — er wird gelöscht. Die Antwort nannte aber nur die Daten des
überlebenden, nicht seine Kennung, und so zeigte die Kachel weiter auf etwas,
das es nicht mehr gab. Wer sie dann bearbeiten wollte, bekam „Not Found".

Zwei Fehler in einer Meldung. Der Server sagt jetzt, welcher Eintrag
überlebt hat und ob zusammengelegt wurde; die Kachel nimmt sich weg statt
auf ein Loch zu zeigen, und sagt wohin.

Und das Englische: in `detail` steht der Satz, den der Server selbst
geschrieben hat — deutsch und mit einem Hinweis, was zu tun ist. `title` ist
dagegen der Statusname aus dem Rahmenwerk, und genau der wurde angezeigt, wo
`detail` fehlte. Er wird nicht mehr gelesen; wo der Server nichts zu sagen
hat, schreibt der Client selbst einen deutschen Satz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 22:05:36 +02:00
ScarriffleandClaude Opus 5 c46063637f Die Kachel zeigte auf die Folge und wollte zur Serie
„Nächste Folge" führte ins Leere, und es lag weder am Scan noch am
Auffrischen: die Kachel trug die Folgenkennung und öffnete damit eine
Serienseite — die es unter dieser Kennung nie gibt. Heraus kam ausgerechnet
dort „Diese Serie gibt es nicht mehr", wo man gerade weiterschauen wollte.
Der Server kannte die Serie an beiden Stellen und schickte sie nur nicht mit.

Wohin die Kachel führt und was sie misst, ist jetzt getrennt. Beides auf
dieselbe Kennung zu legen wäre der nächste Fehler gewesen: „Als gesehen
markieren" hätte die ganze Serie abgehakt statt der einen Folge, die man zur
Hälfte gesehen hat.

Dazu Streichen wie im Dateimanager: gedrückt halten und darüberfahren wählt
aus, rückwärts über Gewähltes nimmt wieder weg. Ein kurzer Klick bleibt, was
er war. Abgetastet wird die Strecke zwischen zwei Zeigerereignissen, nicht
nur deren Endpunkte — gemessen: ein Zug über vier Kacheln erwischte sonst die
erste und die letzte. Plakate lassen sich nicht mehr durch die Gegend
schleppen.

„Abwählen" ist ein Kreuz geworden; als vierter Knopf neben drei Aktionen sah
es aus, als täte es etwas mit den Titeln.

In der kleinen Leiste lag das Vorschaubild über dem Fortschrittsstrich und
verdeckte ihn rechts. Und ein Klick genau darauf tat nichts, obwohl das die
Stelle ist, auf die man zeigt — jetzt holt auch das Bild den Film zurück.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 21:48:00 +02:00
ScarriffleandClaude Opus 5 7df9fa6c01 Zwei Dinge in derselben Ecke, und ein Modus, den niemand bestellt hat
Der Gesehen-Haken und die drei Punkte sassen beide oben rechts — auf dem
Plakat sah das aus wie ein Fehler. Jetzt hat jede Ecke eine Aufgabe: oben
links die Auswahl, oben rechts der Stand, unten links Abspielen, unten rechts
das Menü.

Der Knopf „Auswählen" ist weg. Gemessen, warum er stören musste: der längere
Text „Auswahl beenden" brach die Filterzeile um und schob das Raster um
zwanzig Bildpunkte nach unten. Und grundsätzlicher ist ein Modus ein Zustand,
den man kennen muss, bevor man etwas tun kann. Jetzt trägt jede Kachel oben
links ein Kästchen, das beim Überfahren erscheint — wie die drei Punkte auch.
Der erste Haken fängt die Auswahl an, der letzte beendet sie.

Der Spieler lässt sich verkleinern. Er rutscht in eine Leiste am unteren Rand
und läuft weiter, während man die Mediathek aufräumt; ein Klick darauf holt
ihn zurück. Dasselbe Videoelement, nur anders eingerahmt — ein zweiter
Spieler unten hiesse, das Video auszuhängen, und dabei hört es auf zu
spielen.

Das Zahnrad mit acht Strahlen sah aus wie eine Sonne. Jetzt: Wellenform für
den Ton, CC-Kasten für die Untertitel, Schieberegler für die Qualität. Alle
drei Menüs stehen immer da — vorher verschwanden sie bei nur einer Tonspur
oder ohne Untertitel, und wer sie suchte, fand nichts und wusste nicht warum.

Dabei gefunden und behoben: die Wiedergabe blieb nach zwanzig Sekunden
stehen, obwohl längst der ganze Rest auf der Platte lag. Results.File setzte
Last-Modified, der Browser legte die Playlist mit zwei Einträgen ab, und
hls.js bekam bei jedem Nachfragen dieselben zwei zurück. Eine wachsende Liste
ist das Gegenteil von zwischenspeicherbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 20:31:12 +02:00
ScarriffleandClaude Opus 5 eda9e67802 Wo die Grafikeinheit eingetragen wird, und was passiert wenn sie fehlt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:53:00 +02:00
ScarriffleandClaude Opus 5 4853200d5c Er packte um, was gar nicht am Umpacken lag
Der Browser bekam stillschweigend Apples Geräteprofil — und mit ihm HEVC, AC3
und EAC3, von denen er keines öffnet. Der Server packte die Datei
pflichtschuldig nach MP4 um, liess Bild und Ton dabei unberührt, und heraus
kam eine tadellose Datei, mit der Chrome nichts anfangen konnte. Umpacken
fasst das Bild nun einmal nicht an.

Jetzt sagt das Gerät selbst, was es kann, und es gibt einen zweiten Weg neben
der fertigen Datei: HLS, das nach Sekunden anfängt statt nach Minuten.
Dasselbe Rohr erledigt beides — mkv nach fMP4 mit „-c:v copy", 4K nach 480p
mit libx264 — und eine MP4, die der Browser ohnehin öffnet, geht ganz ohne
ffmpeg hinaus.

Dazu eine eigene Steuerleiste: Tonspur und Untertitel während der Wiedergabe
umschaltbar, vier Auflösungsstufen, Tastenkürzel, und bei einem Fehler ein
deutscher Satz statt eines schwarzen Bildes.

Gemessen statt vermutet: Chromium antwortet auf canPlayType für HLS mit
"maybe" und kann es dann doch nicht — deshalb entscheidet Hls.isSupported().
hls.js wiegt 372 KB und wird erst beim Abspielen geholt; die Oberfläche
bleibt bei 109.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:52:27 +02:00
ScarriffleandClaude Opus 5 557708b769 Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren"
lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine
Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als
Meldung statt in der Browserkonsole.

Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten
neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues
Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt,
endet nicht mehr in einer Sackgasse.

Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit
404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die
Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in
einer Prüfung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 18:28:33 +02:00
ScarriffleandClaude Opus 5 c854c4a09a 451 gegen 461, und ein Band für das, was noch wartet
Drei Zahlen, die alle stimmen und Verschiedenes meinen: Plex zählte 451
Filme, Flaro 449, und der Scan lief über 461 Dateien. Die
Mediathekszeile nennt sie jetzt getrennt — Titel, Folgen, Dateien,
zweite Fassungen und nicht Zugeordnetes. Damit beantwortet sich die
Frage selbst, statt offen zu bleiben.

Die Rechnung dahinter war beim ersten Versuch falsch: sie verglich
gegen alle Folgen und kam auf 1223 Titel bei 324 Dateien. Gezählt wird
jetzt, zu wie vielen Titeln überhaupt noch eine Datei daliegt — dann
geht Dateien = Titel + Fassungen + ohne auf, auch wenn etwas vermisst
wird. Nachgemessen mit einer echten Plex-Kopie: 3 Titel, 4 Dateien, 1
zweite Fassung.

Dazu trägt eine Kachel, die noch auf Freigabe wartet, jetzt ein Band —
„Wartet" oder „Geplant". Für einen Administrator standen wartende Titel
bisher mitten zwischen den freigegebenen, ohne dass etwas sie
unterschied; wer einen suchte, fand ihn nur über die Prüfliste. Dafür
reicht `PublishAt` jetzt bis in die Kachel durch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 22:04:17 +02:00
ScarriffleandClaude Opus 5 5a5cf3d006 Dieselbe Kennung zweimal zu vergeben war kein Fehler, sah aber so aus
Im Zuordnungsdialog stand „An error occurred while processing your
request." — englisch, ohne Grund, ohne nächsten Schritt. Dahinter steckte
der Eindeutigkeitsindex: der gewählte Film war in derselben Mediathek
schon vergeben, und die Kennung wurde trotzdem blind gesetzt.

Es ist aber gar kein Fehler. Zwei Dateien, die denselben Film meinen,
gehören zusammengelegt — genau das tut der Scan am Ende jedes Laufs, und
der Sehfortschritt wird dabei gefaltet. Die Korrektur von Hand tut es
jetzt auch, für Filme wie für Serien.

Dazu die zweite Hälfte: auch das Letzte, was durchrutscht, spricht
Deutsch. Der Fehlerbehandler am Ende der Kette gab bisher die Vorgabe von
ASP.NET aus. Jetzt bekommt jede Ausnahme einen deutschen Satz, und die
Einzelheiten gehen ins Tagesprotokoll — dorthin, wo man sie sucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:53:20 +02:00
ScarriffleandClaude Opus 5 0c07d4efc7 Ein falscher Treffer verhinderte seine eigene Korrektur
„Doctor Strange" war fälschlich auf ein Making-of von 2022 gelaufen. Beim
Korrigieren hängte die Vorschlagssuche `year=2022` an — aus genau der
Zuordnung, die weg sollte — und lieferte nur noch Titel von 2022. Der
richtige Film von 2016 konnte gar nicht erscheinen, egal wie oft man
suchte.

An TMDB nachgemessen:

  query='Doctor Strange' ohne Jahr     → 2016 Doctor Strange, 2022 Multiverse …
  query='Doctor Strange' mit year=2022 → genau die drei Treffer aus der Meldung

Gesucht wird jetzt mit dem Jahr aus dem Dateinamen, nie mit dem aus dem
Treffer. Die Oberfläche trägt ebenfalls keines mehr vor: das angezeigte
stammt aus derselben falschen Zuordnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:14:55 +02:00
ScarriffleandClaude Opus 5 74c625b829 Das Dreipunktemenü in der Prüfliste ging gar nicht auf
Der öffnende Klick blubbert nach dem eigenen Zuhörer weiter zum Dokument,
und der dortige schliesst alles, was nicht im Menü liegt — also auch
dieses, im selben Augenblick. In der Mediathek stand deshalb ein
`stopPropagation()` am Griff; in der Prüfliste fehlte es.

Eine Regel, an die sich jeder Aufrufer erinnern muss, ist keine Regel.
Deshalb nicht die Kopie nachgezogen, sondern das Menü merkt sich jetzt
selbst, woran es hängt, und der globale Zuhörer lässt den Griff in Ruhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 21:07:36 +02:00
ScarriffleandClaude Opus 5 d2fbaad871 Jeder Titel war seine eigene Dublette, und das seit Monaten
„Metadaten auffrischen" hat an allem, was schon zugeordnet war, schlicht
nichts getan. Die Dublettenprüfung schlägt jeden Titel nach, der eine
TMDB-Kennung hat — beim Auffrischen also auch den gerade bearbeiteten.
Ohne Vergleich auf Gleichheit fand sich jeder als "Dublette seiner
selbst", wurde übersprungen und nie fortgeschrieben. Titel, Beschreibung
und Bilder blieben für immer so, wie sie beim allerersten Lauf
hereinkamen.

In der Oberfläche sah das aus, als wirke die Spracheinstellung nicht —
und genau so wurde es gemeldet. Gemessen, Ende zu Ende:

  Serversprache en-US:  101 Dalmatians  ·  The Lion King
  Serversprache de-DE:  101 Dalmatiner  ·  Der König der Löwen

Damit das nie wieder unbemerkt bleibt, weiss der Server jetzt, in welcher
Sprache er die Titel hat: eine Spalte MetadataLanguage, gesetzt beim
Anreichern, und in der Verwaltung eine Zeile mit der Verteilung. Weicht
etwas ab, frischt ein Knopf genau diese Titel auf — nicht den ganzen
Bestand, und ohne das Dateisystem noch einmal abzulaufen.

Dazu zwei Dinge, die ohne das nicht gingen: ein Titel lässt sich von Hand
setzen und wird dabei festgeschrieben wie ein Plakat, mit einem Weg
zurück. Und ein Titel, der auf Freigabe wartet, trägt jetzt dasselbe
Dreipunktemenü wie eine Kachel — Bilder, Zuordnung, Titel. Ausgerechnet
dort fehlte es, obwohl das der Eintrag ist, den man vor der Freigabe
geradeziehen will.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 20:49:06 +02:00
ScarriffleandClaude Opus 5 ce38da1376 Die Auswahlliste klappte hinter dem Dialog auf
Ein `<dialog>` mit `showModal()` liegt in der Top-Layer des Browsers,
über allem — unabhängig von `z-index`. Die Liste hing am `body` und
verschwand deshalb hinter genau dem Dialog, in dem ihr Knopf steht.
Jetzt wird sie in den Dialog gehängt, wenn es einen gibt. Mit
`elementFromPoint` nachgeprüft: an ihrer Stelle liegt wirklich ein
Listeneintrag obenauf.

Dazu zwei Stellen, die zu der Vermutung geführt haben, die
Spracheinstellung wirke nicht:

„Übersetzungen holen" stand stur auf en-US. Neben einem Server, der auf
Deutsch steht, liest sich das wie eine Aussage über den Server.
Vorgewählt wird jetzt die eigene Sprache, sonst die erste, die nicht
schon die des Servers ist — und der Text nennt die Serversprache.

Und „Meine Sprache" sagt jetzt deutlich, wenn sie abweicht. Sie ändert
nicht, was der Server holt, nur was man selbst sieht: steht sie auf
Englisch, während der Server auf Deutsch holt, sieht die ganze
Mediathek englisch aus und jedes Auffrischen scheint wirkungslos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 20:03:01 +02:00
ScarriffleandClaude Opus 5 e87d50e470 Wer die Genreliste durchblättern wollte, schloss sie dabei
Der Zuhörer hängt mit `capture` am Fenster, damit auch ein Bildlauf in
einem inneren Behälter die Liste schliesst — sie steht `position: fixed`
und bliebe sonst neben einem Knopf stehen, der längst weggescrollt ist.
Nur scrollt eine Liste mit zwanzig Genres selbst, und dieses Ereignis
kam dort ebenfalls an. Jetzt wird geprüft, woher es stammt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:22:42 +02:00
ScarriffleandClaude Opus 5 aeaf6eb578 „highschool musical 3" fand TMDB nicht, „high school musical 3" schon
Drei gemeldete Fälle, alle an TMDB nachgemessen statt vermutet:

  highschool musical 3              0 Treffer
  high school musical 3             2 Treffer, der richtige zuerst
  Harry Potter im Orden des Phoenix 0 Treffer
  Harry Potter                     20 Treffer, der richtige darunter
  dr dolittle                      13 Treffer, der richtige zuerst

Der fehlende Punkt bei Dr. Dolittle war nie das Problem — diese Vermutung
ist in Suchvarianten.cs festgehalten, damit sie nicht wiederkommt.

Die Suche versucht jetzt der Reihe nach: den Titel wie er dasteht, dann
das längste Wort an jeder Stelle getrennt (von der Mitte nach aussen,
ohne Wörterbuch — „highschool" trifft nach 2 Versuchen, „jurassicpark"
nach 5), dann von hinten gekürzt, bis zwei Worte übrig sind. Höchstens
zehn Anfragen, und nur dann, wenn die erste gar nichts brachte.

Und die Treffer werden nach Ähnlichkeit zum gesuchten Titel sortiert,
nicht nach Beliebtheit. Über gemeinsame Worte ohne Akzente: so trennt
„Harry Potter" den „Orden des Phönix" von „Harry Potter: Fireplace", und
„Phoenix" findet „Phönix".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:13:24 +02:00
ScarriffleandClaude Opus 5 4b0323f955 Drei Auswahlfelder liessen sich gleichzeitig aufklappen
Jedes merkte sich nur seine eigene Liste. Nebeneinander geöffnet lagen
sie übereinander, und keine wusste von den anderen. Ein Auswahlfeld ist
ein Menü, und von denen ist höchstens eines offen — jetzt modulweit
vermerkt, genau wie beim Kontextmenü. Escape schliesst zusätzlich von
überall, nicht nur mit dem Fokus in der Liste.

Dazu stand unter jeder Mediathek „0 Titel freigeben". Der Knopf wurde
über `hidden` versteckt — das Attribut wirkt über `display: none` mit der
Spezifität von Null, und `.btn` setzt `display: inline-flex`. Die Klasse
`.hidden` trägt ein `!important` und setzt sich durch.

Und das Favicon kommt jetzt aus `icon.png`, dem gerenderten App-Icon
selbst. Meine nachgezeichnete Fassung war falsch: die Grafik füllt die
Fläche, statt klein in der Mitte zu sitzen. Ein Icon, das dem echten nur
ähnelt, driftet beim nächsten Entwurf wieder auseinander.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 19:01:58 +02:00
ScarriffleandClaude Opus 5 8809b9f46f Die Mediathek hörte bei I auf, und jede Folge stand doppelt
Zwei Befunde aus dem laufenden Betrieb.

Die Liste holte genau eine Seite. Der Server gibt höchstens 200 Einträge
je Zug — das ist richtig so —, nur fragte die Oberfläche nie nach der
zweiten. Bei 455 Filmen endete die Mediathek mitten im Alphabet, ohne
Hinweis, dass da noch etwas kommt. Jetzt wird beim Scrollen nachgeladen,
und die Zahl sagt „200 von 455" statt einer Behauptung, die man beim
Scrollen widerlegt.

Dabei kam heraus, dass ein IntersectionObserver eine Seite braucht, die
auch gezeichnet wird: im Fenster im Hintergrund löst er nicht aus, und
beim Prüfen blieb die Liste genau deshalb bei 200 stehen. Daneben steht
jetzt eine Prüfung am Scrollereignis, die überall greift.

Der zweite Befund: jede Folge zeigte zwei gleich aussehende Knopfreihen.
Es sind zwei Dateien — Plex' „Optimized for Mobile"-Kopien. Flaro kannte
„optimized", aber nicht „Optimized for Mobile", und sah ohnehin nur den
unmittelbaren Elternordner; Plex legt die Fassung aber als
`Plex Versions/Optimized for Mobile/<Serie>/<Staffel>/…` ab. Jetzt wird
drei Ebenen hoch gesucht, die Schublade `Plex Versions` benennt keine
Serie mehr, und an der Folge steht „Mobil" statt einer zweiten,
unerklärten Reihe.

Nicht um Plex zu bedienen — die Dateien liegen nun einmal so auf der
Platte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 18:45:07 +02:00
ScarriffleandClaude Opus 5 960cca3dae Der Abschnitt verschwand alle drei Sekunden und kam wieder
`laden()` setzte `innerHTML = ''` und baute die Mediathekenliste neu —
während eines Scans alle drei Sekunden. Der ganze Abschnitt flackerte
sichtbar, und wer gerade auf einen Knopf zielte, klickte ins Leere.
Schlimmer: jeder Neuaufbau startete über `verfolgen(false)` einen
weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander.

Jetzt wird fortgeschrieben statt neu gezeichnet: eine Zeile je
Mediathek, die bleibt, und nur Kopfzeile, Prüfungsknopf und
Freigabeknopf ändern sich. Belegt im Browser — nach einem Auffrischen
sind es dieselben Knoten, nur die Beschriftung ist eine andere.

Dieselbe Behandlung für die Seitenleiste. Und weil Seitenleiste und
Verwaltung getrennt nachfragten, zeigten sie zwei Momente — 95 hier, 84
dort, während im Hintergrund Doppelte zusammengelegt wurden. Die
Verwaltung reicht ihren Stand jetzt weiter: ein Abruf, zwei Anzeigen.

Dazu zwei Dinge, die beim Prüfen auffielen. Zurücksetzen und Entfernen
prallen jetzt serverseitig mit 409 ab, solange ein Lauf läuft — die
Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht
verlassen; ein Aufruf von Hand während eines Scans liess den Lauf an
einer DbUpdateConcurrencyException sterben. Und in der
Fortschrittszeile steht beim Holen der Folgenmetadaten jetzt der
Serienname: „Folge 2×13" allein sagt bei 3815 Folgen nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 18:21:43 +02:00
ScarriffleandClaude Opus 5 a6e9a6e425 Ein Klick auf einen Vorschlag sah aus wie gar kein Klick
Hinter der Auswahl steckt ein Abruf bei TMDB, der Titel, Besetzung und
Bilder nachlädt — mehrere Sekunden, in denen die Oberfläche aussah wie
vorher. Wer nichts sieht, klickt noch einmal, und dann laufen zwei.
Jetzt tritt die Liste zurück, die gewählte Zeile bekommt einen
Akzentrahmen und daneben steht „Wird zugeordnet…" mit laufendem Kreis.

Dazu das Plakat je Vorschlag. „Dark" von 2017 und „Dark Shadows" von
1966 sind an einer Textzeile kaum zu unterscheiden, am Bild in einer
Zehntelsekunde — und der Server lieferte die Adresse ohnehin schon mit,
sie wurde nur weggeworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:58:06 +02:00
ScarriffleandClaude Opus 5 193a23a904 Ein Unterstrich ist ein Wortzeichen, und das kostete 3445 Dateien
In `The Good Doctor_S02E18_Trampolin.mkv` steht vor dem S ein
Unterstrich. Das Muster verlangte dort `\b` — eine Wortgrenze sitzt aber
zwischen Wortzeichen und Nicht-Wortzeichen, und der Unterstrich ist
selbst eines. Zwischen `r` und `S` gab es damit keine Grenze, und der
gesamte Bestand aus einem Mediathek-Download fiel durch die Erkennung.
Die Rückschau verlangt jetzt ausdrücklich: kein Buchstabe, keine Ziffer
davor — ein Unterstrich schon.

Zwei weitere Muster kamen dabei ans Licht. Mediatheken nummerieren nach
Jahr (`Die Anstalt_S2024E04`); drei Ziffern für die Staffel waren zu
wenig. Und `# Folge 12 #` ist eine Folgennummer, nur auf Deutsch — die
Staffel kommt dann vom Ordner darüber, und nur wenn auch der keine
nennt, gilt die Übereinkunft Staffel 1.

Gemessen an den 3559 Pfaden aus dem Protokoll: 3554 werden jetzt
erkannt. Die fünf übrigen tragen wirklich keine Nummer — ein Titel in
einem Staffelordner, sonst nichts. Die Zahl der Serien steigt damit von
58 auf 95, und 95 ist die Zahl, die von Anfang an richtig war.

Die neuen Tests arbeiten mit genau diesen Namen. Die alten hatten den
Fehler nicht gefunden, weil in jedem ausgedachten Namen ein Leerzeichen
vor der Folgennummer stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:50:29 +02:00
ScarriffleandClaude Opus 5 cfb1e71762 3559 von 5884 Dateien verschwanden lautlos
Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname
eine nennen — richtig so. Nur wurde die Datei dann übersprungen, ohne
Zeile, ohne Spur. In der Mediathek standen 58 Serien und niemand konnte
sagen, wo die anderen geblieben sind.

Jetzt bekommt jede solche Datei eine Zeile ohne Zuordnung. Damit ist sie
zählbar, auflistbar und nach einer besseren Regel nachträglich
zuzuordnen. Untersucht wird sie nicht: ffprobe auf 3559 Dateien kostet
eine halbe Stunde für Angaben, die niemand zu sehen bekommt, und sobald
die Zuordnung steht, holt der nächste Lauf es nach.

Dazu ein Abschnitt "Nicht zugeordnet" in der Verwaltung, nach Grund
gebündelt und mit den Pfaden zum Aufklappen. Zwei Gründe gibt es, und
sie verlangen Verschiedenes: fehlt die Folgennummer, muss umbenannt
werden; fehlt der Serienname, genügt ein Ordner darum herum. Die Liste
lässt sich als Datei herunterladen — eine, die man markieren und
kopieren muss, schickt niemand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:23:36 +02:00
ScarriffleandClaude Opus 5 a8bb98c44c Umsortiert wird gezogen, nicht geklickt
Die Pfeile sind weg; eine Mediathek wird in der Seitenleiste an ihren
Platz gezogen. Mit Pointer Events statt HTML5-Drag: letzteres kennt kein
Telefon, zeichnet ein Geisterbild, das sich nicht gestalten lässt, und
liefert während des Ziehens keine brauchbaren Koordinaten.

Mit Schwelle von sechs Bildpunkten — sonst verschiebt jeder leicht
wackelige Klick die Reihenfolge, und ein Klick ohne Bewegung führt
weiterhin zur Mediathek, auch im Bearbeitungsmodus. Die weichenden
Zeilen gleiten, die gezogene folgt dem Zeiger ohne Verzögerung; bei
„Bewegung reduzieren" gleitet nichts.

Die Pfeiltasten bleiben auf der fokussierten Zeile, nur ohne sichtbare
Knöpfe. Ziehen ist mit der Tastatur nicht bedienbar, und eine Funktion,
die nur mit der Maus erreichbar ist, ist für den, der keine benutzt, gar
keine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:09:28 +02:00
ScarriffleandClaude Opus 5 a22fa9b58e Drei Zähler für dieselbe Sache, und keiner davon offensichtlich falsch
Während eines Scans stand in der Mediathek 20, in der Verwaltung 14 und
in der Seitenleiste 0. Die Verwaltung frischte alle fünf Runden auf, die
Seitenleiste erst am Ende des Laufs. Jetzt laufen beide auf demselben
Takt, alle drei Runden.

Dazu: während ein Lauf läuft, sind Scannen, Metadaten auffrischen,
Zurücksetzen und Entfernen gesperrt — die ersten beiden prallen am
Server ab und sind nur verwirrend, die anderen beiden löschen Zeilen,
während der Lauf welche schreibt. Umbenennen und Prüfung bleiben
bedienbar. Die Sperre richtet sich nach dem Zustand, den der Server
meldet, nicht nach dem Klick: wer die Seite neu lädt, während ein Lauf
läuft, findet sie ebenfalls gesperrt vor.

Die Reihenfolge der Mediatheken wird jetzt in der Seitenleiste
umsortiert, über „Seitenleiste bearbeiten" neben der Überschrift. Sie
wirkt dort, also gehört der Griff dorthin — vorher schob man in den
Einstellungen und musste danach nachsehen, ob es das war, was man
wollte.

Der Zuordnungsdialog zeigt die Herkunft: Ordner, Zahl der Dateien und
ein paar Namen. Liegt eine Zuordnung völlig daneben, sagt der angezeigte
Titel nichts mehr darüber, welche Datei gemeint ist.

Und SQLite läuft jetzt im WAL-Modus. In der Vorgabe sperrt ein Schreiber
die ganze Datei: solange der Scan schrieb, kam keine Leseanfrage durch.
Genau so sah es aus — Bilddialog und Protokoll liefen während eines
Laufs sporadisch ins Leere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 16:31:50 +02:00
ScarriffleandClaude Opus 5 e1b3625809 Das Würfeln der Build-Kennung brach den ganzen Lauf ab
`tr -dc … < /dev/urandom | head -c 6`: head schliesst die Pipe nach sechs
Zeichen, tr bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
Fehlschlag. Das Skript endete mit 141, bevor es irgendetwas gebaut hatte
— dieselbe Falle, vor der dreissig Zeilen weiter oben eine Anmerkung
wegen `grep -q` steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 15:58:18 +02:00
ScarriffleandClaude Opus 5 ac04901ccc Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft
Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare
Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne
Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand
läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im
Quelltext stehen.

Version und Build. Die Version steht fest; daneben eine Kennung aus
sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der
Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist
"reden wir vom selben Build?" in zwei Sekunden beantwortet statt in
einem halben Tag.

Serien. Lag eine Datei direkt im Serienordner und trug vor der
Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95
Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief
der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab
zwei Serien. Und weder neue noch umgezogene Dateien standen in der
Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite
Zeile mit gleichem (LibraryId, Path) anlegte und der
Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede
Serienanlage wird jetzt im Tagesprotokoll begründet.

Fehlermeldungen. "An error occurred while saving the entity changes"
stand englisch in einer deutschen Oberfläche und nannte weder Ursache
noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll
und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für
"Failed to fetch", das aus dem Browser kam und nie übersetzt wurde.

Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen
davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt
serverseitig rückwärts, mit einem Knopf für mehr.

Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein
helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein
eigenes Listenfeld mit Tastaturbedienung.

Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess
zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp.

Sprache. Die Serversprache stand in der Serverliste zwischen Name,
Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen
eigenen Abschnitt ganz oben.

Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne
Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden,
ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer
Zuordnung frischt nur noch die Kachel auf statt die ganze Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 15:55:56 +02:00
ScarriffleandClaude Opus 5 ab8a66d898 Die Veröffentlichung meldete Erfolg, obwohl nichts ankam
docker buildx build --push beendete mit Rückgabewert 0, nachdem es
"ERROR: failed to build" gedruckt hatte — ein TLS-Zeitlimit mitten im
Hochladen. Das Skript meldete "Fertig", in der Registry lag das alte Image,
und auf dem NAS wäre eine Fassung angekommen, die niemand gebaut hat.

Das ist die schlimmste Sorte Fehler: einer, der wie Erfolg aussieht.

Jetzt wird der Digest vor und nach dem Bau abgefragt. Antwortet die
Registry nicht, oder liegt dort noch derselbe Stand wie vorher, bricht das
Skript mit Rückgabewert 1 ab und sagt es.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:41:39 +02:00
ScarriffleandClaude Opus 5 fe44d45323 Verschobene Dateien hinterliessen eine Leiche
Seit verschwundene Dateien markiert statt gelöscht werden, blieb nach jeder
Umbenennung die alte Zeile als "vermisst" stehen. Damit galt die alte Folge
nicht als verwaist, die alte Serie nicht als leer — und nach dem Umbenennen
eines Serienordners stand alles doppelt da.

Jetzt erkennt der Scan eine Verschiebung an Grösse und Änderungszeit und
hängt den vorhandenen Eintrag um, statt einen zweiten anzulegen.

Der Zeitstempel musste dafür auf Sekunden gerundet werden: er geht durch
die Datenbank und kommt nicht auf den Tick genau zurück. Mit Vergleich auf
Gleichheit schlug die Erkennung ausnahmslos fehl — der Test zeigte es,
nachdem er zweimal dasselbe falsche Ergebnis lieferte.

Grösse und Zeit müssen beide stimmen, und das Merkmal muss eindeutig sein.
Grösse allein reichte nicht: zwei gleich grosse Dateien gibt es, und die zu
vertauschen wäre schlimmer als eine Leiche.

Dazu: die Zahl der Titel läuft während eines Scans mit, statt erst am Ende
zu springen. Alle fünf Runden, nicht jede — ein Abruf je Sekunde für eine
Zahl wäre Verschwendung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:34:49 +02:00
ScarriffleandClaude Opus 5 b64497acd5 Nicht jede erfolgreiche Antwort hat einen Körper
Bei jedem Scanstart stand in der Verwaltung "JSON.parse: unexpected end of
data" — eine Fehlermeldung für etwas, das tadellos funktioniert hatte.

Gemessen: der Scan-Endpunkt antwortet mit 202 Accepted, Content-Length 0
und ohne Content-Type. Er läuft im Hintergrund weiter, da gibt es noch
nichts zu melden. Der Abruf-Helfer behandelte aber nur 204 als leer und
liess JSON.parse auf eine leere Zeichenkette los.

Jetzt entscheidet der Inhalt, nicht der Status. Eine Liste von
Status-Codes wäre wieder eine, die beim nächsten Endpunkt unvollständig
ist.

Dazu: die Zeile "Metadaten" nannte die Sprache ein zweites Mal, obwohl sie
seit kurzem eine eigene Zeile hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:22:47 +02:00
ScarriffleandClaude Opus 5 98565e14b5 Der Ordner bestimmt die Serie, nicht der Dateiname
Aus einer Serie wurden siebenhundert. Dateien wie "S01E001_Aang.mkv"
tragen vor der Folgennummer nichts, also wurde der ganze Dateiname zum
Serientitel — jede Folge eine eigene Serie. Und umgekehrt ergab
"Doctor Who (2005) - S12E04.mkv" im Ordner "Doctor Who" zwei Serien, weil
die Jahresangabe nur im Dateinamen stand.

Jetzt gilt: was unter Serien/Pingu/ liegt, gehört zu Pingu. Egal wie tief,
egal wie die Datei heisst. Der Ordner ist die verlässlichere Angabe, weil
ihn ein Mensch angelegt hat; so macht es Plex seit je und aus demselben
Grund. Staffel und Folge liest weiterhin der Dateiname.

Ohne bekannte Wurzel bleibt es beim Dateinamen — dort könnte über dem
Staffelordner irgendetwas stehen. Diesen Weg gehen nur die Tests.

Dazu vier Dinge aus der Benutzung:

Die Mediatheken standen scheinbar in zufälliger Reihenfolge. SQLite
vergleicht Text zeichengenau, und dort stehen alle Grossbuchstaben vor
allen kleinen: "Serien" landete über "film". Jetzt ohne Rücksicht auf
Gross- und Kleinschreibung — und umsortierbar über zwei Pfeile. Nicht über
Ziehen: das ist schöner, aber mit der Tastatur unbedienbar und auf dem
Telefon ein Glücksspiel.

Die Sprache des Servers steckte im Dialog "Metadaten", zusammen mit dem
TMDB-Schlüssel — also dort, wo niemand nach einer Sprache sucht. Wer die
Mediathek auf Englisch vorfand, sah nur "Meine Sprache" und hielt das für
die einzige Möglichkeit. Jetzt eine eigene Zeile.

Das Kontextmenü gab es nur per Rechtsklick, und den findet nur, wer ihn
vermutet — auf dem Telefon gibt es ihn gar nicht. Jetzt drei Punkte auf
jeder Kachel, beim Überfahren und bei Tastaturfokus, auf Geräten ohne
Zeiger dauerhaft. Und "Zuordnung korrigieren" steht endlich darin: wer
zwanzig falsch erkannte Titel vor sich hat, soll sie dort geradeziehen
können statt über vierzig Seitenwechsel.

Folgen ohne eigenes Bild nehmen das der Serie. TMDB liefert Folgenbilder
nur für einen Teil der Folgen und bei kleineren Serien für gar keine; eine
graue Fläche sagt nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 14:20:00 +02:00
ScarriffleandClaude Opus 5 fa18c0d7fa Zwei Namen für denselben Titel brachen den ganzen Lauf ab
Der Eindeutigkeitsindex aus Punkt 3 stellte die Regel auf, dichtete aber
den einen Weg nicht ab, auf dem sie verletzt wird: der Enricher setzt die
TMDB-Kennung, und sobald zwei Einträge derselben Mediathek dieselbe
bekamen, brach EF die gesamte Speicherung ab — mit "An error occurred
while saving the entity changes", einer Meldung, die den Grund nicht nennt
und den ganzen Scan mitnimmt.

Jetzt wird die Kennung nicht blind gesetzt, sondern vorgemerkt und nach
der Schleife zusammengelegt. Zwei Tests halten den Fall fest: der Anbieter
antwortet auf jede Anfrage mit demselben Film beziehungsweise derselben
Serie, also genau die Lage, die vorher den Lauf zerriss.

Dazu der wahrscheinliche Grund für die siebenhundert Serien: der
Serientitel kam ausschliesslich aus dem Dateinamen. Heisst eine Datei nur
"S01E01.mkv", steht vor der Folgennummer nichts, und der ganze Dateiname
wurde zum Serientitel — zwanzig Folgen ergaben zwanzig Serien namens
"S01E01", "S01E02", und so weiter. Jetzt sagt der Ordner den Namen, wenn
die Datei ihn nicht nennt; der Staffelordner wird dabei übersprungen, sonst
hiesse die Serie "Staffel 1". Plex und Jellyfin lesen ihn seit je so.

Dazu die Prüfliste: Folgen werden nach Serie gebündelt, weil zwanzig neue
Folgen eine Entscheidung sind und nicht zwanzig. Mit Mehrfachauswahl —
anhaken und gemeinsam freigeben oder auf einen Zeitpunkt legen. Und der
Zeitpunkt ist ein datetime-local statt eines Textfelds mit Formvorschrift,
bei dem jeder Tippfehler erst nach dem Absenden auffiel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:58:33 +02:00
ScarriffleandClaude Opus 5 228c318ad2 Die Medientabelle spricht durchgehend dieselbe Sprache
Die Tonzeile nahm bereits die Formulierung vom Server, die Videozeile baute
ihren Text weiter selbst — "240p (H264)" neben "AAC · Mono", zwei
Schreibweisen in derselben Tabelle untereinander. Die gemessene Auflösung
wandert in den Tooltip, wo sie hingehört: 1040 ist keine Stufe, sondern ein
beschnittenes Bild.

Dazu: ein Spurtitel, der nur die Sprache wiederholt, fällt weg. "Deutsch ·
AAC · Mono · ‚Deutsch'" sagt dasselbe zweimal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:19:21 +02:00
ScarriffleandClaude Opus 5 632d3efa35 Neue Funde erst prüfen, dann veröffentlichen
Je Mediathek einstellbar, Vorgabe aus: wer eine Mediathek anlegt, will sie
sehen. Angeschaltet warten neue Funde unter "Zu prüfen", bis jemand sie
freigibt — oder ihnen einen Termin gibt.

Ein Zeitpunkt, kein Zustandsfeld. PublishAt null heisst wartet, in der
Vergangenheit heisst veröffentlicht, in der Zukunft heisst geplant. Die
Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
umgeschaltet; damit stimmt sie nach einem Neustart, nach einer
Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar
nicht lief. Ein Ticker hätte in jedem dieser Fälle das Falsche getan.

Das eigentliche Risiko waren die zwanzig Abfragestellen in vier Dateien.
Schriebe jede ihren eigenen Filter, reichte eine vergessene — und
auffallen würde es nicht beim Programmieren, sondern wenn jemand im
Haushalt einen Titel sieht, den er nicht sehen sollte. Deshalb ein
globaler Abfragefilter in FlaroContext, der sich nicht vergessen lässt,
und eine Middleware, die je Anfrage setzt, wer Unveröffentlichtes sehen
darf. Vorgabe am Kontext ist "alles sehen", damit Scanner und Metadaten
ohne Benutzer arbeiten können; für jede HTTP-Anfrage entscheidet die
Middleware.

Der Test zählt die Inhaltsrouten auf und prüft jede einzeln auf die
Abwesenheit eines wartenden Titels. Eine neue Route, die den Filter
umgeht, fällt dort durch.

Die Migration trägt für alle vorhandenen Titel das Hinzufügedatum als
Veröffentlichung nach. Ohne diese zwei Zeilen stünde jeder auf null — und
der gesamte Bestand wäre für alle ausser Administratoren verschwunden,
sofort beim Start im Container und ohne Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 13:03:24 +02:00
ScarriffleandClaude Opus 5 eb63271e92 Ein Kontextmenü auf jeder Kachel — mitsamt Bildauswahl
Gesehen, Favorit und Weglegen gab es ausschliesslich auf der Detailseite.
In einer Übersicht mit zwanzig Titeln hiess "die hab ich alle gesehen"
vierzig Seitenwechsel. Im ganzen Quelltext stand kein einziger
contextmenu-Zuhörer.

Drei Wege hinein, weil es drei Arten gibt zu bedienen: Rechtsklick für die
Maus, Shift+F10 und die Kontextmenü-Taste für die Tastatur, langer Druck
für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf dem
Telefon zu verstecken. Der lange Druck bricht bei jeder Bewegung ab, sonst
löst Scrollen ihn aus.

Die Aktionen wirken auf der Kachel selbst. Die Liste neu zu laden verlöre
Bildlauf und Filter — für einen Haken.

Dazu die Bildauswahl. Es gab genau einen Bild-Endpunkt, und der lieferte
nur aus: wählte TMDB ein schlechtes Plakat, blieb es. Jetzt stehen Plakat,
Hintergrund und Schriftzug zur Wahl, dazu eigene Bilder bis 10 MB. Die
Kandidaten kosten nichts — TMDB liefert sie beim Einzelabruf ohnehin mit,
und bisher wurde alles ausser dem Schriftzug weggeworfen.

Drei Merker je Titel, nicht einer: man kann ein eigenes Plakat wollen und
trotzdem automatische Hintergründe. Ohne sie überschriebe das nächste
Auffrischen jede Handauswahl, und wer einmal ein besseres Plakat gesucht
hat, suchte es jedes Mal neu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:43:52 +02:00
ScarriffleandClaude Opus 5 e1399c808f Die Detailseite trägt wieder
Drei Dinge, eins davon war mein Fehlbefund: Regie und Drehbuch werden sehr
wohl angezeigt — nur standen sie irgendwo zwischen dreissig Gesichtern.
Jetzt kommt der Stab zuerst, weil man beim Suchen eher die Regie im Kopf
hat als den zwölften Darsteller.

Bei Zeichentrick hängt TMDB "(voice)" an den Rollennamen, und so stand es
auch da — englisch, mitten im deutschen Text. Daraus wird "Stimme: Gru".

Ähnliche Titel als eigene Reihe, nachgeladen, damit die Seite sofort
steht. Aus dem eigenen Bestand gerechnet, nicht aus TMDBs
Empfehlungsliste: ein Vorschlag, den man nicht abspielen kann, ist
Werbung. Gemeinsame Menschen wiegen dreifach gegenüber gemeinsamen
Genres — dass zwei Filme "Drama" sind, sagt wenig; dieselbe Regie sagt
viel. Kostet keine einzige fremde Anfrage, der Katalog steht ohnehin in
der Datenbank. Findet sich nichts, erscheint die Reihe gar nicht.

Und der Hintergrund: er begann bei left: 240px, also hinter der
Seitenleiste, und war mit opacity .28 und blur(2px) weder Bild noch
Fläche. Jetzt über die volle Breite, kräftiger unscharf — er taucht die
Seite in die Farbe des Films, so wie bei Plex.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:37:55 +02:00
ScarriffleandClaude Opus 5 11a44f6a90 Ein Aufmacher über die volle Breite
Die Startseite begann bei einer Reihe aus 150 px breiten Plakaten — das
grösste Element über dem Falz war 225 px hoch. Plex und Netflix öffnen mit
einem Bild über die ganze Breite, und das ist der grösste Einzelunterschied
im Eindruck. Ein Layout-, kein Farbproblem.

Bis zu sieben Titel, aus dem, was die Startseite ohnehin holt: erst
Angefangenes, dann Neuzugänge, dann Vorschläge. Kein zusätzlicher Abruf.

Nur mit Hintergrundbild. Ein Hero ohne Bild ist schlechter als keiner —
eine graue Fläche über den halben Bildschirm sagt nichts und nimmt den
Reihen den Platz. Gibt es keinen einzigen Titel mit Backdrop, entfällt er
ersatzlos.

Zwei Verläufe statt einem: der von unten lässt die erste Reihe sich
darüberschieben, der von links sorgt dafür, dass der Text trägt, auch wenn
das Bild dort hell ist.

Pause bei Mauszeiger und bei Tastaturfokus — wer gerade liest, soll nicht
nach neun Sekunden woanders landen. Bei "Bewegung reduzieren" wechselt er
gar nicht von selbst. Die Punkte sind echte Knöpfe mit Beschriftung; ein
div ist für Tastatur und Vorlesesoftware kein Bedienelement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:34:57 +02:00
ScarriffleandClaude Opus 5 6bd9cdb12d Eine Formensprache statt vier Radien und sechs Schriftgrössen
Die Oberfläche wirkte zusammengesetzt, nicht entworfen. Vier Gründe, alle
am Quelltext belegbar.

Die Farben waren von der App abgedriftet: elevated, placeholder, text,
text-dim, text-muted und die Trennlinie wichen in der letzten Stelle ab,
und der Standardakzent war in der App Lilac, im Browser Orchid. Einzeln
unsichtbar, zusammen genau der Grund, warum App und Browser nicht wie
dasselbe Programm aussahen. Alles auf FlaroDesign zurückgeführt.

Vier konkurrierende Radien — 6 auf Plakaten, 8 auf Karten, 999 auf jedem
Knopf, zweimal nacktes 3 — sind jetzt drei, und die ersten beiden stimmen
wieder mit FlaroDesign überein. Pillen bleiben, wo sie hingehören: Suche
und Filter.

Sechs Überschriftengrössen ohne Verhältnis zueinander sind eine Skala.
Der Kacheltitel war mit 13 px/500 leiser als die Metazeile daneben; das
erzeugt keine Hierarchie, sondern Rauschen.

Und der Akzent sass auf neun Dingen gleichzeitig. Wenn alles Akzent ist,
markiert er nichts. Der Primärknopf ist jetzt hell mit dunkler Schrift —
genau das macht Netflix, dessen Rot nur im Schriftzug vorkommt.

Dazu drei Dinge, die vorher ganz fehlten: ein sichtbarer Fokusring auf
allem Bedienbaren, ohne den die Oberfläche mit der Tastatur praktisch
unbedienbar war; Rücksicht auf "Bewegung reduzieren", das im Projekt
nirgends vorkam; und ein Hover, der mehr tut als zwei Bildpunkte zu
heben — mitsamt einem Abspielknopf, der nicht länger das halbe Plakat
verdeckt.

Tools/tokens-pruefen.py vergleicht beide Quellen und läuft im
Veröffentlichungsskript mit. Kein Generator: erzeugter Swift liest sich
schlechter als geschriebener, und die Prüfung fängt dasselbe ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:31:00 +02:00
ScarriffleandClaude Opus 5 0cd88acf70 Aus "DEU EAC3 6.0" wird "Deutsch · Dolby Digital Plus · 5.1"
Die Werte gingen roh von ffprobe in die Anzeige. Jedes Stück war richtig
und keines verständlich: EAC3 heisst Dolby Digital Plus, MOV_TEXT heisst
schlicht Text, und sechs Kanäle sind 5.1 — "6.0" ist eine Schreibweise,
die es nicht gibt. Die Auflösung stand als gemessene Höhe da, also "1040p"
für ein beschnittenes Breitbild, was aussieht wie ein Fehler.

Benennung macht daraus Sätze, und zwar im Kern: die Apps brauchen
dieselben, und zweimal formuliert heisst dieselbe Tonspur am Fernseher
anders als im Browser.

CultureInfo taugte dafür nicht, zweimal gemessen: sein NativeName liefert
den Namen in der Sprache selbst — "English", "français" —, und für ein
erfundenes Kürzel wie "xyz" erfindet es stillschweigend eine Kultur, statt
zu scheitern. Also eine eigene Liste, über das zwei- und das
dreibuchstabige Kürzel, weil beide in Mediendateien vorkommen und ISO
639-2 für manche Sprachen zwei kennt.

Unbekanntes bleibt stehen statt zu "Unbekannt" zu werden — die rohe
Angabe ist immer noch eine Auskunft. Und "und" verschwindet ganz: ffmpeg
schreibt das für unbekannt, und als Sprache angezeigt sieht es aus wie
eine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:28:17 +02:00
ScarriffleandClaude Opus 5 921533050b Zwei Titel je Film, und die Sprache lässt sich wählen
Die Mediathek sah zweisprachig aus — "Despicable Me" neben "Ich einfach
unverbesserlich". Es gab nur ein Titelfeld, und welche Sprache darin
landete, entschied die Einstellung zum Zeitpunkt des Scans.

Movie und Series tragen jetzt zusätzlich OriginalTitle. Gesucht wird über
beide: wer "Despicable Me" tippt, findet "Ich einfach unverbesserlich".
Auf der Detailseite steht der Originaltitel unter dem übersetzten, wenn er
abweicht.

Die Sprache der Metadaten ist in der Verwaltung eine Auswahl statt eines
Textfelds. Vorher musste man "de-DE" wissen, und ein Tippfehler darin nahm
TMDB stillschweigend als Englisch an.

Dazu eine eigene Sprache je Benutzer. Das Ehrliche daran steht im Dialog:
die Daten liegen in einer Sprache vor, und wer eine andere wählt, bekommt,
was übersetzt vorliegt — für den Rest die Serversprache, nicht ein leeres
Feld. ItemTranslation hält die zweite Sprache, gefüllt über eine
Sammelaktion. Nicht nebenbei beim Öffnen: das verteilte die Wartezeit über
Wochen und träfe immer den, der gerade etwas sucht.

Alle Sprachen für alle Titel vorzuhalten wäre der Bestand mal Sprachen an
Abrufen und Speicher, für einen Haushalt mit zweien. Deshalb auf Ansage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:23:47 +02:00
ScarriffleandClaude Opus 5 f359807561 Der Fortschritt sagt, woran er gerade ist — und schreibt es mit
Vorher stand in der Verwaltung minutenlang "Wird durchsucht…", ohne
Hinweis, ob überhaupt etwas passiert. Bei vierzigtausend Dateien ist das
nicht zu unterscheiden von einem hängenden Lauf.

Scanschritt trägt Phase, Gegenstand und Zählstand und bildet daraus den
Satz selbst — im Kern und nicht in der Oberfläche, weil die Apps denselben
brauchen und zweimal formuliert er auseinanderdriftet. Sechs Phasen von
"Suche Medien in /media/Filme" über "Suche ‚Ich einfach unverbesserlich'
bei der Metadatenquelle — 44 von 1204" bis "Räume auf und lege Doppeltes
zusammen". Pfade werden auf die letzten zwei Bestandteile gekürzt.

ScanAsync und EnrichAsync bekommen je einen optionalen IProgress; optional
heisst, dass kein vorhandener Aufrufer bricht. Der Koordinator hält nur
den letzten Schritt — eine Liste über vierzigtausend Titel wäre ein Leck
im Arbeitsspeicher.

Dazu das Tagebuch: eine Datei je Tag unter config/logs, nach lokaler Zeit
und nicht nach UTC, weil ein Scan um 23 Uhr am heutigen Datum gesucht
wird. Dreissig Tage Aufbewahrung, einstellbar über FLARO_LOG_TAGE — ohne
Grenze läuft die config-Partition eines NAS lautlos voll. Es wirft nie:
ein volles Dateisystem darf keinen Scan abbrechen.

In der Verwaltung lesbar, nach Fehlern filterbar und herunterladbar. Das
Containerprotokoll ist nach einem Neustart weg und nur über SSH
erreichbar — die Frage "warum wurde der Film gestern Nacht nicht erkannt?"
liess sich damit nicht beantworten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:15:18 +02:00
ScarriffleandClaude Opus 5 637e95fbb1 Die Verwaltung zeichnete sich endlos neu
Gemessen am laufenden Server: nach dem Scan lief eine Schleife mit zwei
Abrufen je Runde, sichtbar als vier dauernd drehende Spinner.

Der Weg dahin: verfolgen() lief bei jedem Aufbau der Mediathekenzeile, sah
den Zustand finished, meldete das über geaendert(), und geaendert() war
mediathekenNeuLaden — also neu zeichnen. Der Neuaufbau startete verfolgen()
erneut, das wieder finished sah. Dass der Lauf längst vorbei war, machte
keinen Unterschied; der Zustand bleibt im Speicher stehen.

Zwei Dinge dagegen. verfolgen() meldet nur noch, wenn es selbst einen Lauf
angestossen hat — beim Aufbau zeigt es den Stand an und schweigt. Und die
Verwaltung frischt über zaehlerAuffrischen() nur noch die Seitenleiste auf
statt die ganze Seite neu zu zeichnen.

Dazu zwei Befunde aus dem Plan, die sich beim Nachsehen als falsch
erwiesen: die Takte werden sehr wohl abbestellt, dafür gibt es seit je
einen MutationObserver, und das Dashboard tauscht seinen Inhalt erst nach
dem Abruf. Geblieben ist der Spinner in den Listen, der bei jedem
Auffrischen erschien — der kommt jetzt nur noch beim ersten Aufbau.

Dazu der Zwischenspeicher für Ansichten. Der Router hängt sie aus statt
sie zu zerstören; beim Zurückkommen stehen sie sofort wieder da, mitsamt
Bildlauf, offenem Reiter und gesetzten Filtern, und laden leise nach.
Gemessen: null Abrufe und 4 von 4 Kacheln beim Zurückkommen aus der
Verwaltung. Höchstens fünf Ansichten, Verfall nach fünf Minuten,
weggeworfen bei allem, was den Bestand ändert. Detailseite und Verwaltung
sind ausgenommen — beide zeigen Laufendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 12:11:06 +02:00
ScarriffleandClaude Opus 5 8b67a7ec88 Verschwundene Dateien werden gemeldet, nicht stillschweigend gelöscht
Vorher räumte der Scan jede Datei weg, die er nicht mehr sah, und mit der
letzten Datei fiel der Titel samt Sehfortschritt aller Benutzer. Für eine
wirklich gelöschte Datei ist das richtig. Für eine Platte im Ruhezustand,
ein noch nicht eingehängtes Volume oder einen umbenannten Ordner ist es
Datenverlust — und für den Scan sehen alle vier Fälle gleich aus.

Jetzt trägt MediaFile ein MissingSince. Der Eintrag bleibt stehen, steht
in der Verwaltung unter "Nicht mehr gefunden", und gelöscht wird auf
Ansage. Taucht die Datei wieder auf, verschwindet die Markierung von
selbst.

Dazu die Notbremse, und sie ist der eigentliche Grund für den ganzen
Punkt: Fehlt der Wurzelpfad, oder liegt dort keine einzige Mediendatei
mehr, obwohl vorher welche da waren, bricht der Lauf ab und sagt warum.
Ohne sie erklärte ein nicht eingehängtes Volume beim Start des NAS den
gesamten Bestand für verschwunden — bei 458 Titeln.

Der Abschnitt in der Verwaltung erscheint nur, wenn es etwas zu melden
gibt. Ein dauerhaft leerer wäre Rauschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:46:55 +02:00
ScarriffleandClaude Opus 5 01ff41fd71 Der Testhelfer wartet dreissig Sekunden statt fünf
Die alte Grenze war für zwei, drei Dateien bemessen. Der neue Test über
120 Dateien schaffte sie allein, fiel aber durch, sobald die ganze Suite
nebenher lief — kein Fehler im Scan, sondern zu knappe Geduld.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:42:09 +02:00
ScarriffleandClaude Opus 5 63c7ebb6d5 Ein Film kommt je Mediathek genau einmal vor
Der Scan verglich den Titel aus dem Dateinamen zeichengenau. Ein Punkt
Unterschied — Dr. Dolittle 2 gegen Dr Dolittle 2 —, oder eine Datei mit
Jahresangabe neben einer ohne, und es entstanden zwei Einträge. Die
Metadaten ordneten beide demselben TMDB-Eintrag zu, beide bekamen
denselben Titel, dasselbe Jahr, dasselbe Plakat, und nichts führte sie je
wieder zusammen.

Drei Ebenen, weil eine nicht trägt.

Titelschluessel vergleicht ohne Satzzeichen, ohne Akzente und ohne
Grossschreibung; ein fehlendes Jahr passt auf jedes, zwei bekannte
verschiedene trennen. Bewusst nicht aggressiver: Spider-Man und
Spiderman bleiben getrennt, und die beiden Dune von 1984 und 2021
sowieso. Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne
zu wissen, welche Datei wohin gehörte.

Ein gefilterter Eindeutigkeitsindex auf (LibraryId, TmdbId) stellt die
Regel in die Datenbank statt in Code, der sie einhalten soll. Gefiltert,
weil ohne Metadaten alle Einträge null tragen.

Zusammenlegung räumt auf, was kein Vergleich erkennen könnte: Ich
einfach unverbesserlich und Despicable Me sind derselbe Film, und das
verrät allein die TMDB-Kennung. Läuft nach jedem Scan, und von Hand über
einen Knopf in der Verwaltung. Der Sehfortschritt wird gefaltet, nicht
umgehängt — die weitere Stelle gewinnt, gesehen gewinnt, weggelegt
gewinnt nicht.

Die Migration räumt zuerst auf und legt dann erst den Index an.
Andersherum scheiterte sie auf jeder Datenbank mit Dubletten, und zwar
beim Start im Container, wo die Meldung niemand sieht. Gemessen an einer
Datenbank mit zwei Dubletten und zwei Sehständen: 3 Filme auf 2, beide
Dateien erhalten, Position 1800 statt 300 behalten.

Dazu Fassungen. MediaFile.VersionLabel liest Mobil, 4K oder Director's
Cut aus dem Namen, sonst aus der gemessenen Bildhöhe. Die Liste ist
geschlossen, damit eine Fassung nicht YIFY heisst. Und der Fassungsname
beendet jetzt den Titel wie jedes andere Beiwerk — vorher wurde aus
"The Matrix (1999) - Mobil.mp4" der Film "The Matrix - Mobil", also ein
zweiter Eintrag neben dem Original. Genau die Dublette, um die es ging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:39:10 +02:00
ScarriffleandClaude Opus 5 d01c5d43f4 Der Scan schreibt auch dann zwischendurch, wenn ffprobe scheitert
Gemessen an achtzig Dateien: der Zählstand steht nach einer Sekunde bei
38, nach zwei bei 75, nach drei bei 80. Der Scan liefert also längst
laufend — anders als ich behauptet hatte. Der Grund steht in
UntersuchenAsync, das je Datei speichert; ich hatte nur das
SaveChangesAsync nach der Schleife gesehen.

Eine Lücke bleibt: scheitert die Untersuchung, kehrt sie vor dem
Speichern um. Ein Bestand aus Dateien, die ffprobe nicht lesen kann,
bliebe damit bis zum Schluss unsichtbar, und wer zusieht, hält den Scan
für hängend. Deshalb jetzt zusätzlich alle fünfzig Dateien ein Schreiben
aus der Schleife heraus — fünfzig und nicht jede, weil ein Schreibvorgang
je Datei bei vierzigtausend mehr kostet, als die Sichtbarkeit wert ist.

Für das Aufräumen danach ist das unbedenklich, aber nicht offensichtlich:
es löscht alles mit älterer SeenAt-Zeit als der Lauf, und würde die Zeit
irgendwo neu genommen statt vor der Schleife festgehalten, risse es den
halben Bestand mit. Ein Test über 120 Nulldateien — an denen ffprobe
scheitert, also genau der neue Weg — hält das fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:27:41 +02:00
ScarriffleandClaude Opus 5 b25c7a4712 Ordner durchklicken, statt den Pfad zu wissen
Beim Anlegen einer Mediathek verlangte das Feld den Pfad im Container —
nicht den auf dem NAS. Diese Unterscheidung kennt nur, wer die
Compose-Datei selbst geschrieben hat, und ein Tippfehler darin ergibt
eine leere Mediathek ohne erkennbaren Grund.

Neben dem Feld steht jetzt ein Knopf, der die Ordner des Servers im
selben Dialog aufklappt: Pfadzeile in anklickbaren Stücken, Unterordner
darunter, unten der Knopf mit dem gewählten Pfad darauf.

Im selben Dialog und nicht in einem zweiten darüber: zwei gestapelte
Dialoge sind beim Schliessen immer eine Frage zu viel.

Das Feld bleibt tippbar. Wer den Pfad kennt, ist damit schneller, und
bei einem tiefen Baum ist es die einzige geduldschonende Möglichkeit.

An der Wurzel bleiben /proc, /sys, /usr und Verwandte aussen vor. Das
ist keine Absperrung — wer den Pfad kennt, tippt ihn weiterhin ein —,
sondern eine Liste aus zwanzig Einträgen, von denen achtzehn nie eine
Mediathek enthalten, ist unbrauchbar.

Ein Ordner ohne Leserecht wird gemeldet, nicht geworfen. Ohne das flöge
eine UnauthorizedAccessException bis in die Antwort und würde zur 500,
während die ehrliche Auskunft lautet: den darfst du nicht lesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 11:22:16 +02:00
ScarriffleandClaude Opus 5 64fb61f2dc Ein typografisches Anführungszeichen zerriss die Zeichenkette
Beim Einhängen des Ordner-Endpunkts stand „{path}" in einer
interpolierten Zeichenkette. Das schliessende Zeichen beendete sie
mitten im Satz, und der Rest las sich für den Übersetzer als Folge
unbekannter Bezeichner — zehn Fehler aus einer Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 10:45:05 +02:00
ScarriffleandClaude Opus 5 8a191d0faf Die Einrichtung läuft auch ohne HTTPS
crypto.randomUUID gibt es nur im secure context — HTTPS oder localhost,
sonst nichts. Wer Flaro auf einem NAS betreibt und ihn unter
http://192.168.x.x:8097 aufruft, hat die Funktion nicht, und die
Geräteerkennung warf mitten in die Einrichtung „crypto.randomUUID is not
a function". Das Konto liess sich nicht anlegen.

Das ist nicht der Sonderfall. Ein Heimserver im eigenen Netz hat kein
Zertifikat, und genau dort wird die Einrichtung durchlaufen.

crypto.getRandomValues ist nicht so beschränkt und liefert denselben
Zufall; daraus wird die UUID von Hand zusammengesetzt. Fällt auch das
aus, bleibt Math.random — kein Zufall für Geheimnisse, aber hier geht es
nur darum, zwei Browser auseinanderzuhalten, und gar keine Kennung wäre
schlechter als eine schwache.

Geprüft mit 20000 Durchläufen: alle entsprechen der Form von Fassung 4,
keine Dublette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 09:04:30 +02:00
ScarriffleandClaude Opus 5 3df873c8cd Der Arbeitsrechner baut, das NAS lädt nur
Der Runner auf dem NAS scheitert an der Docker-App des UGREEN: sie lehnt
den Einhängepunkt /var/run/docker.sock ab. Nachgewiesen durch Halbieren
der Compose-Datei — ohne diese eine Zeile wird das Projekt angelegt, mit
ihr nicht, und die Meldung nennt keinen Grund.

Übrig blieben Docker-in-Docker mit privileged oder der Weg über SSH an
der Oberfläche vorbei. Beide geben dem Runner faktisch Root auf dem NAS,
und das für eine Aufgabe, die der Arbeitsrechner ohnehin erledigen kann:
er ist für die Apps nötig, weil Xcode nirgendwo sonst läuft.

Der Workflow löst deshalb nicht mehr bei jedem Push aus. Sonst sammelt
Gitea Läufe an, die niemand abarbeitet, und die Actions-Seite füllt sich
mit Leichen. Er bleibt als workflow_dispatch stehen, der Auslöser ist mit
drei Zeilen wieder da.

Die Anleitung stellt das Bauen vom Arbeitsrechner nach vorn und hält zwei
Dinge fest, die beim Einrichten Zeit gekostet haben: die Docker-App zieht
Images nicht selbst, und die Anmeldung an der Registry will ein
Zugriffstoken statt des Kontopassworts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 08:47:42 +02:00
196 changed files with 42692 additions and 1189 deletions
+19 -10
View File
@@ -1,20 +1,29 @@
# Baut das Flaro-Image und legt es in die Registry dieses Gitea. # Baut das Flaro-Image und legt es in die Registry dieses Gitea.
# #
# **Warum hier und nicht auf dem Arbeitsrechner.** Ein Bau von Hand heisst: # **Zur Zeit schläft diese Datei.** Gebaut wird auf dem Arbeitsrechner mit
# jemand muss daran denken, muss die richtige Architektur angeben und muss # `Tools/image-veroeffentlichen.sh` — der Rechner ist für die Apps ohnehin
# angemeldet sein. Hier passiert es bei jedem Push von selbst, immer für # nötig, weil Xcode nirgendwo sonst läuft.
# beide Architekturen, und die Anmeldung besorgt Gitea.
# #
# **Voraussetzung ist ein Runner.** Ohne einen registrierten Act-Runner # **Weil Gitea nicht selbst baut.** Es verteilt Arbeit an einen Act-Runner,
# läuft diese Datei nie an — dann bleibt der Weg von Hand aus # und der müsste auf dem NAS laufen. Dort scheitert er: sein Zweck ist das
# `Tools/image-veroeffentlichen.sh`. # Bauen von Images, also braucht er Zugriff auf Docker, und die Docker-App
# des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab. Übrig
# blieben `privileged: true` oder der Weg über SSH an der Oberfläche vorbei
# — beides gibt dem Runner faktisch Root auf dem NAS.
#
# **Deshalb nur `workflow_dispatch`.** Mit einem Push-Auslöser legt Gitea
# bei jedem Push einen Lauf an, den niemand abarbeitet; nach einer Woche
# steht die Actions-Seite voll mit Leichen. Kommt irgendwann ein Runner
# dazu, lässt sich der Lauf von Hand starten — und der Auslöser unten ist
# mit drei Zeilen wieder da.
name: Image bauen und veröffentlichen name: Image bauen und veröffentlichen
on: on:
push: # Wieder automatisch, sobald ein Runner existiert:
branches: [main] # push:
tags: ['v*'] # branches: [main]
# tags: ['v*']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+1
View File
@@ -0,0 +1 @@
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 673 B

-1
View File
@@ -1 +0,0 @@
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>

Before

Width:  |  Height:  |  Size: 675 B

+7 -7
View File
@@ -2,7 +2,7 @@
"fill-specializations" : [ "fill-specializations" : [
{ {
"value" : { "value" : {
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000" "automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
} }
}, },
{ {
@@ -17,15 +17,15 @@
"layers" : [ "layers" : [
{ {
"fill" : { "fill" : {
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000" "automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
}, },
"image-name" : "play.svg", "image-name" : "flare (1).svg",
"name" : "play", "name" : "flare (1)",
"position" : { "position" : {
"scale" : 1.18, "scale" : 100,
"translation-in-points" : [ "translation-in-points" : [
49.7265625, 0,
1.468530916781674e-05 0
] ]
} }
} }
+34
View File
@@ -7837,6 +7837,40 @@
} }
} }
} }
},
"upcoming.from %@": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ab %@ Uhr"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "From %@"
}
}
}
},
"upcoming.title": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bald ist es soweit"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Coming soon"
}
}
}
} }
}, },
"version": "1.1" "version": "1.1"
+10
View File
@@ -130,6 +130,16 @@ final class FlaroSession {
/// Wer angemeldet ist — sofern der Server einen Namen dafür kennt. /// Wer angemeldet ist — sofern der Server einen Namen dafür kennt.
var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName } var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName }
/// Das Profilbild des angemeldeten Kontos, als Adresse.
///
/// **Nur Flaro hat eines.** Jellyfin gibt kein Profilbild heraus; dort
/// bleibt es beim Buchstaben, und das ist keine Lücke, sondern der Stand
/// der Dinge.
var accountAvatarURL: URL? {
guard let schluessel = flaroAccount?.avatarImage else { return nil }
return backend?.rawImageURL(path: schluessel)
}
/// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos /// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst. /// erscheinen gar nicht erst.
private(set) var libraries: [MediaLibrary] = [] private(set) var libraries: [MediaLibrary] = []
+2 -2
View File
@@ -155,8 +155,8 @@ final class HomeFeed {
case .continueFirst: case .continueFirst:
// Innerhalb der Gruppen ziehen, nicht über sie hinweg: // Innerhalb der Gruppen ziehen, nicht über sie hinweg:
// Angefangenes bleibt vorn, wechselt aber untereinander. // Angefangenes bleibt vorn, wechselt aber untereinander.
let ordered = HeroSelection.ordered(unique.filter { $0.watchProgress != nil }) let ordered = HeroSelection.ordered(unique.filter(\.isResumable))
+ HeroSelection.ordered(unique.filter { $0.watchProgress == nil }) + HeroSelection.ordered(unique.filter { !$0.isResumable })
return Array(ordered.prefix(layout.heroCount)) return Array(ordered.prefix(layout.heroCount))
case .latestOnly: case .latestOnly:
return Array(HeroSelection.ordered(unique).prefix(layout.heroCount)) return Array(HeroSelection.ordered(unique).prefix(layout.heroCount))
+1 -1
View File
@@ -61,7 +61,7 @@ enum TopShelfWriter {
width: posterWidth, width: posterWidth,
height: posterHeight height: posterHeight
), ),
progress: item.watchProgress progress: item.resumeProgress
) )
} }
) )
@@ -44,7 +44,7 @@ struct ItemActionEntries: View {
// **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle // **Nur, wenn es etwas zu entfernen gibt.** Ohne gemerkte Stelle
// stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den // stünde hier ein Eintrag, der nichts tut — und der Nutzer suchte den
// Fehler bei sich. // Fehler bei sich.
if item.watchProgress != nil { if item.isResumable {
Button { Button {
run { try await $0.removeFromContinueWatching(item) } run { try await $0.removeFromContinueWatching(item) }
} label: { } label: {
+5 -1
View File
@@ -57,7 +57,11 @@ struct QuickPlayTile<Card: View>: View {
/// Ob es überhaupt etwas zu starten gibt. /// Ob es überhaupt etwas zu starten gibt.
/// ///
/// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung. /// Eine Sammlung ist kein Titel — dort wäre der Knopf eine Behauptung.
private var isPlayable: Bool { item.kind != .collection } /// Ein geplanter Titel ebenso: der Server lehnt ihn bis zum Termin ab,
/// und ein Tipp auf die Kachel startete trotzdem — gemessen am Simulator,
/// der Spieler kam bis „Wird vorbereitet …". Dort führt der Tipp jetzt
/// auf die Seite, wo der Termin steht.
private var isPlayable: Bool { item.kind != .collection && !item.isUpcoming }
private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) } private var route: MediaRoute { MediaRoute(ratingKey: item.id, title: item.title) }
@@ -0,0 +1,44 @@
import SwiftUI
import FlaroDesign
import MediaKit
/// Steht dort, wo sonst „Abspielen" stünde.
///
/// **Ein Knopf, der nichts tut, ist schlimmer als keiner.** Ein geplanter
/// Titel steht in der Mediathek, damit man weiss, dass er kommt — das ist der
/// Sinn des Planens. Der Server lehnt ihn bis zum Termin ab; stünde der Knopf
/// trotzdem da, wäre die Reihenfolge: draufdrücken, freuen, Absage lesen.
///
/// Ein Baustein für Aufmacher und Detailseite, weil sonst an der einen Stelle
/// der Termin stünde und an der anderen weiter der Knopf.
struct UpcomingNotice: View {
@Environment(\.flaroAccent) private var accent
let date: Date
var body: some View {
HStack(spacing: FlaroSpacing.regular) {
Image(systemName: "clock")
.font(.flaroCardTitle)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 2) {
Text("upcoming.title")
.font(.flaroCardTitle)
Text("upcoming.from \(Self.formatted(date))")
.font(.flaroCaption)
.foregroundStyle(Color.flaroTextSecondary)
}
}
.padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, FlaroSpacing.snug)
.background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control))
.accessibilityElement(children: .combine)
}
/// „Dienstag, 6. Oktober 2026 um 18:00" — in der Sprache des Geräts.
static func formatted(_ date: Date) -> String {
date.formatted(
.dateTime.weekday(.wide).day().month(.wide).year().hour().minute())
}
}
+33 -25
View File
@@ -97,35 +97,43 @@ struct PlayableDetailView: View {
// Karussell auf dem Telefon quer ist. // Karussell auf dem Telefon quer ist.
aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact) aspectRatio: HeroArtwork.aspectRatio(.detailHeader, compact: sizeClass == .compact)
) { ) {
Button { // Geplant heisst sichtbar, nicht abspielbar. Weder Abspielen
playback.play(item, mediaIndex: selectedVersion) // noch Herunterladen — der Server lehnt beides bis zum Termin ab.
} label: { if item.isUpcoming, let ab = item.availableFrom {
Label( UpcomingNotice(date: ab)
item.watchProgress == nil ? "hero.play" : "hero.resume", } else {
systemImage: "play.fill"
)
.font(.flaroCardTitle)
.padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, FlaroSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
.disabled(currentMedia?.files.first == nil)
if let progress = item.watchProgress, progress > 0 {
Button { Button {
playback.play(item, mediaIndex: selectedVersion, resuming: false) playback.play(item, mediaIndex: selectedVersion)
} label: { } label: {
Label("detail.playFromStart", systemImage: "gobackward") Label(
.labelStyle(.iconOnly) item.isResumable ? "hero.resume" : "hero.play",
.font(.flaroCardTitle) systemImage: "play.fill"
.padding(FlaroSpacing.snug) )
.font(.flaroCardTitle)
.padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, FlaroSpacing.snug)
} }
.buttonStyle(.bordered) .buttonStyle(.borderedProminent)
.tint(Color.flaroTextSecondary) .tint(accent)
} .disabled(currentMedia?.files.first == nil)
DownloadButton(item: item, mediaIndex: selectedVersion) // „Von vorn" nur, wenn es ein „weiter" gibt — dieselbe
// Entscheidung wie beim Knopf daneben.
if item.isResumable {
Button {
playback.play(item, mediaIndex: selectedVersion, resuming: false)
} label: {
Label("detail.playFromStart", systemImage: "gobackward")
.labelStyle(.iconOnly)
.font(.flaroCardTitle)
.padding(FlaroSpacing.snug)
}
.buttonStyle(.bordered)
.tint(Color.flaroTextSecondary)
}
DownloadButton(item: item, mediaIndex: selectedVersion)
}
} }
} }
+1 -1
View File
@@ -403,7 +403,7 @@ private struct ChildRow: View {
.frame(width: item.kind == .season ? 48 : 96) .frame(width: item.kind == .season ? 48 : 96)
.clipShape(.rect(cornerRadius: 4)) .clipShape(.rect(cornerRadius: 4))
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let progress = item.watchProgress { if let progress = item.resumeProgress {
WatchProgressBar(fraction: progress) WatchProgressBar(fraction: progress)
} }
} }
+22 -15
View File
@@ -47,21 +47,28 @@ struct HeroSection: View {
// Bildschirm leer. // Bildschirm leer.
fillsAvailableSpace: sizeClass != .compact fillsAvailableSpace: sizeClass != .compact
) { ) {
Button { // Geplant heisst sichtbar, nicht abspielbar — statt des Knopfes
// **Über QuickPlay, nicht unmittelbar.** Steht im Aufmacher // der Termin.
// eine Serie — und Jellyfins „Zuletzt hinzugefügt" liefert if item.isUpcoming, let ab = item.availableFrom {
// neue Folgen als ihre Serie —, schickte der direkte Weg UpcomingNotice(date: ab)
// deren eigene Kennung an den Player, und der scheiterte mit } else {
// „keine abspielbare Datei". Eine Serie hat keine Fassungen. Button {
quickPlay.start(item, backend: session.backend, coordinator: playback) // **Über QuickPlay, nicht unmittelbar.** Steht im
} label: { // Aufmacher eine Serie — und Jellyfins „Zuletzt
Label(playLabel, systemImage: "play.fill") // hinzugefügt" liefert neue Folgen als ihre Serie —,
.font(.flaroCardTitle) // schickte der direkte Weg deren eigene Kennung an den
.padding(.horizontal, FlaroSpacing.loose) // Player, und der scheiterte mit „keine abspielbare
.padding(.vertical, FlaroSpacing.snug) // Datei". Eine Serie hat keine Fassungen.
quickPlay.start(item, backend: session.backend, coordinator: playback)
} label: {
Label(playLabel, systemImage: "play.fill")
.font(.flaroCardTitle)
.padding(.horizontal, FlaroSpacing.loose)
.padding(.vertical, FlaroSpacing.snug)
}
.buttonStyle(.borderedProminent)
.tint(accent)
} }
.buttonStyle(.borderedProminent)
.tint(accent)
NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) {
Label("hero.details", systemImage: "info.circle") Label("hero.details", systemImage: "info.circle")
@@ -77,7 +84,7 @@ struct HeroSection: View {
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde — /// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde —
/// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht. /// sonst wäre nicht erkennbar, dass der Film an der alten Stelle weitergeht.
private var playLabel: LocalizedStringKey { private var playLabel: LocalizedStringKey {
item.watchProgress == nil ? "hero.play" : "hero.resume" item.isResumable ? "hero.resume" : "hero.play"
} }
private var metadata: String? { Self.metadata(for: item) } private var metadata: String? { Self.metadata(for: item) }
+2 -2
View File
@@ -61,7 +61,7 @@ struct MediaRow: View {
width: 440, width: 440,
height: 248 height: 248
), ),
progress: item.watchProgress progress: item.resumeProgress
) )
.frame(width: landscapeWidth) .frame(width: landscapeWidth)
} else { } else {
@@ -73,7 +73,7 @@ struct MediaRow: View {
width: 300, width: 300,
height: 450 height: 450
), ),
progress: item.watchProgress progress: item.resumeProgress
) )
.frame(width: posterWidth) .frame(width: posterWidth)
} }
+1 -1
View File
@@ -106,7 +106,7 @@ struct LibraryView: View {
title: item.title, title: item.title,
subtitle: item.cardSubtitle, subtitle: item.cardSubtitle,
artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.watchProgress progress: item.resumeProgress
) )
} }
.task { await loadMoreIfNeeded(reaching: item) } .task { await loadMoreIfNeeded(reaching: item) }
+33 -12
View File
@@ -1,24 +1,46 @@
import SwiftUI import SwiftUI
import FlaroDesign import FlaroDesign
/// Der erste Buchstabe auf einer Scheibe. /// Das eigene Bild auf einer Scheibe — oder der erste Buchstabe.
/// ///
/// **Kein Platzhalterbild.** Jellyfin gibt kein Profilbild heraus, ohne dass /// **Der Buchstabe ist keine Notlösung.** Jellyfin gibt kein Profilbild
/// eines hinterlegt ist, und am Konto führt die App heute keines mit. Ein /// heraus, und auch an einem Flaro-Konto muss keines hängen. Ein Buchstabe
/// Buchstabe ist ehrlicher als eine graue Silhouette, die bei jedem gleich /// ist ehrlicher als eine graue Silhouette, die bei jedem gleich aussieht —
/// aussieht — und man erkennt an ihm wenigstens, mit welchem Konto man /// und man erkennt an ihm wenigstens, mit welchem Konto man angemeldet ist.
/// angemeldet ist.
/// ///
/// Stand bis eben in `AccountBar` und wird jetzt zweimal gebraucht: dort in /// Steht ein Bild im Konto, tritt es an seine Stelle. Lädt es nicht, kommt
/// der Seitenleiste und auf dem iPhone oben rechts, wo seit dem Wegfall des /// der Buchstabe zurück: eine leere Scheibe sagt weniger als ein P.
/// Einstellungen-Tabs der Weg dorthin liegt. ///
/// Stand einmal in `AccountBar` und wird inzwischen dreimal gebraucht: dort in
/// der Seitenleiste, auf dem iPhone oben rechts und in der Werkzeugleiste.
struct AccountAvatar: View { struct AccountAvatar: View {
let name: String let name: String
var diameter: CGFloat = 26 var diameter: CGFloat = 26
/// Das Bild des Kontos, falls eines hinterlegt ist.
var image: URL?
@Environment(\.flaroAccent) private var accent @Environment(\.flaroAccent) private var accent
var body: some View { var body: some View {
buchstabe
.frame(width: diameter, height: diameter)
.background(accent, in: .circle)
.overlay {
if let image {
AsyncImage(url: image) { bild in
bild.resizable().scaledToFill()
} placeholder: {
// Nichts: darunter liegt der Buchstabe und füllt die
// Lücke, bis das Bild da ist.
Color.clear
}
.frame(width: diameter, height: diameter)
.clipShape(.circle)
}
}
}
private var buchstabe: some View {
Text(name.prefix(1).uppercased()) Text(name.prefix(1).uppercased())
// Mit der Scheibe wachsen, nicht fest: oben rechts auf dem // Mit der Scheibe wachsen, nicht fest: oben rechts auf dem
// Telefon ist er grösser als in der Leiste, und ein Buchstabe in // Telefon ist er grösser als in der Leiste, und ein Buchstabe in
@@ -26,8 +48,6 @@ struct AccountAvatar: View {
// Fehler. // Fehler.
.font(.system(size: diameter * 0.46, weight: .semibold)) .font(.system(size: diameter * 0.46, weight: .semibold))
.foregroundStyle(Color.flaroCanvas) .foregroundStyle(Color.flaroCanvas)
.frame(width: diameter, height: diameter)
.background(accent, in: .circle)
} }
} }
@@ -53,7 +73,8 @@ struct AccountToolbarButton: View {
var body: some View { var body: some View {
Button(action: openSettings) { Button(action: openSettings) {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34) AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
image: session.accountAvatarURL)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text("sidebar.settings")) .accessibilityLabel(Text("sidebar.settings"))
+1 -1
View File
@@ -78,7 +78,7 @@ struct AccountBar: View {
isExpanded.toggle() isExpanded.toggle()
} label: { } label: {
HStack(spacing: FlaroSpacing.snug) { HStack(spacing: FlaroSpacing.snug) {
AccountAvatar(name: title) AccountAvatar(name: title, image: session.accountAvatarURL)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Text(title) Text(title)
+2 -1
View File
@@ -514,7 +514,8 @@ struct RootView: View {
Button { Button {
showsSettings = true showsSettings = true
} label: { } label: {
AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34) AccountAvatar(name: session.accountName ?? session.serverName, diameter: 34,
image: session.accountAvatarURL)
.padding(FlaroSpacing.snug) .padding(FlaroSpacing.snug)
// Damit er auch über einem hellen Aufmacher steht. // Damit er auch über einem hellen Aufmacher steht.
.background(.black.opacity(0.28), in: .circle) .background(.black.opacity(0.28), in: .circle)
+1 -1
View File
@@ -44,7 +44,7 @@ struct TVHeroSection: View {
/// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde. /// „Weiterschauen" statt „Abspielen", wenn schon etwas gesehen wurde.
private var abspielTitel: LocalizedStringKey { private var abspielTitel: LocalizedStringKey {
item.watchProgress == nil ? "hero.play" : "hero.resume" item.isResumable ? "hero.resume" : "hero.play"
} }
private var metadaten: String? { private var metadaten: String? {
+2 -2
View File
@@ -55,7 +55,7 @@ struct TVMediaRow: View {
artworkURL: session.backend?.artworkURL( artworkURL: session.backend?.artworkURL(
path: item.rowArtworkPath, width: 880, height: 496 path: item.rowArtworkPath, width: 880, height: 496
), ),
progress: item.watchProgress, progress: item.resumeProgress,
onFocusChange: { if $0 { onFocus(item) } } onFocusChange: { if $0 { onFocus(item) } }
) { onSelect(item) } ) { onSelect(item) }
} else { } else {
@@ -65,7 +65,7 @@ struct TVMediaRow: View {
artworkURL: session.backend?.artworkURL( artworkURL: session.backend?.artworkURL(
path: item.thumb, width: 560, height: 840 path: item.thumb, width: 560, height: 840
), ),
progress: item.watchProgress, progress: item.resumeProgress,
onFocusChange: { if $0 { onFocus(item) } } onFocusChange: { if $0 { onFocus(item) } }
) { onSelect(item) } ) { onSelect(item) }
} }
+1 -1
View File
@@ -78,7 +78,7 @@ struct TVLibraryView: View {
artworkURL: session.backend?.artworkURL( artworkURL: session.backend?.artworkURL(
path: item.posterArtworkPath, width: 560, height: 840 path: item.posterArtworkPath, width: 560, height: 840
), ),
progress: item.watchProgress progress: item.resumeProgress
) { ) {
// **Über QuickPlay, nicht unmittelbar.** Steht im // **Über QuickPlay, nicht unmittelbar.** Steht im
// Raster eine Serie, hat die keine Fassungen; der // Raster eine Serie, hat die keine Fassungen; der
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -54,13 +54,12 @@ public actor FlaroBackend: MediaBackend {
} }
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine // **Mehrere Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der
// fertige Startseite liefert, und das ist Absicht: welche Reihen eine // eine fertige Startseite liefert, und das ist Absicht: welche Reihen
// App zeigt, entscheidet die App. Der Server liefert die Zutaten. // eine App zeigt, entscheidet die App. Der Server liefert die Zutaten.
async let weiter = reihe(pfad: "/api/v1/home/continue") async let weiter = reihe(pfad: "/api/v1/home/continue")
async let naechste = reihe(pfad: "/api/v1/home/next") async let naechste = reihe(pfad: "/api/v1/home/next")
async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self) let mediatheken = (try? await libraries()) ?? []
async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self)
var reihen: [MediaRowGroup] = [] var reihen: [MediaRowGroup] = []
@@ -76,18 +75,46 @@ public actor FlaroBackend: MediaBackend {
title: String(localized: "flaro.row.nextUp", bundle: .module), title: String(localized: "flaro.row.nextUp", bundle: .module),
items: eintraege)) items: eintraege))
} }
if let gefunden = try? await filme, !gefunden.items.isEmpty {
// **Je Mediathek eine Reihe mit dem zuletzt Hinzugefügten.**
//
// Vorher standen hier zwei Reihen, „Filme" und „Serien", mit dem
// Anfang der Mediathek in der Reihenfolge der Datenbank — also immer
// demselben. Drei Dinge waren damit falsch: es war kein Neuzugang,
// es warf getrennte Mediatheken wieder zusammen, und die Kennungen
// begannen nicht mit `latest`. An dieser Vorsilbe hängen zwei
// 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() }
}
guard !titel.isEmpty else { continue }
reihen.append(MediaRowGroup( reihen.append(MediaRowGroup(
id: "flaro.movies", id: "latest.\(mediathek.id)",
title: String(localized: "flaro.row.movies", bundle: .module), title: String(
items: gefunden.items.map { $0.neutral() })) localized: "flaro.row.latestIn \(mediathek.title)",
} bundle: .module),
if let gefunden = try? await serien, !gefunden.items.isEmpty { libraryID: mediathek.id,
reihen.append(MediaRowGroup( items: titel))
id: "flaro.series",
title: String(localized: "flaro.row.series", bundle: .module),
items: gefunden.items.map { $0.neutral() }))
} }
return reihen return reihen
} }
@@ -135,6 +162,21 @@ public actor FlaroBackend: MediaBackend {
FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) { FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) {
return serie.neutral() return serie.neutral()
} }
// **Und zuletzt eine Folge.**
//
// Sie fehlte, und das war der Grund für „Dieser Titel ist auf dem
// Server nicht mehr vorhanden" direkt von der Startseite: in
// „Weiterschauen" steht eine Folge mit ihrer eigenen Kennung, und die
// ist weder ein Film noch eine Serie.
if let folge = try? await client.send(
FlaroRequest(path: "/api/v1/episodes/\(id)"), as: Wire.Episode.self) {
var serie: Wire.Series?
if let kennung = folge.seriesId {
serie = try? await client.send(
FlaroRequest(path: "/api/v1/series/\(kennung)"), as: Wire.Series.self)
}
return folge.neutral(series: serie)
}
throw .notFound throw .notFound
} }
@@ -401,17 +443,26 @@ public struct FlaroAccount: Sendable, Equatable {
public let userName: String public let userName: String
public let isAdministrator: Bool public let isAdministrator: Bool
/// Das eigene Bild, als Schlüssel in die Bildablage — `nil`: der Buchstabe.
///
/// **Mit Vorgabewert, damit Jellyfin unberührt bleibt.** Dort gibt es
/// kein Profilbild, und ein Pflichtfeld hier zwänge jeden Aufrufer, sich
/// dazu zu äussern.
public let avatarImage: String?
public init( public init(
baseURL: URL, baseURL: URL,
serverName: String, serverName: String,
userID: String, userID: String,
userName: String, userName: String,
isAdministrator: Bool isAdministrator: Bool,
avatarImage: String? = nil
) { ) {
self.baseURL = baseURL self.baseURL = baseURL
self.serverName = serverName self.serverName = serverName
self.userID = userID self.userID = userID
self.userName = userName self.userName = userName
self.isAdministrator = isAdministrator self.isAdministrator = isAdministrator
self.avatarImage = avatarImage
} }
} }
@@ -38,6 +38,8 @@ enum Wire {
let id: String let id: String
let userName: String let userName: String
let isAdministrator: Bool let isAdministrator: Bool
/// Das Profilbild, als Schlüssel in die Bildablage des Servers.
let avatarImage: String?
} }
struct Library: Decodable, Sendable { struct Library: Decodable, Sendable {
@@ -78,6 +80,12 @@ enum Wire {
let rating: Int? let rating: Int?
let dismissed: Bool let dismissed: Bool
let lastPlayedAt: Date? let lastPlayedAt: Date?
/// Ob der Server den Titel in „Weiterschauen" führt.
///
/// **Nicht dasselbe wie „Stelle über null".** Seit es eine
/// einstellbare Untergrenze gibt, sind das zwei Fragen: eine Sekunde
/// wird gemerkt, zählt aber nicht als angefangen.
let inProgress: Bool?
} }
struct Movie: Decodable, Sendable { struct Movie: Decodable, Sendable {
@@ -89,6 +97,8 @@ enum Wire {
let backdropImage: String? let backdropImage: String?
let files: [File] let files: [File]
let state: ItemState? let state: ItemState?
/// Ab wann abspielbar. In der Zukunft heisst: noch nicht.
let publishAt: Date?
} }
struct Series: Decodable, Sendable { struct Series: Decodable, Sendable {
@@ -101,6 +111,7 @@ enum Wire {
let episodeCount: Int let episodeCount: Int
let seasons: [Int] let seasons: [Int]
let unwatchedCount: Int let unwatchedCount: Int
let publishAt: Date?
} }
struct Episode: Decodable, Sendable { struct Episode: Decodable, Sendable {
@@ -112,6 +123,10 @@ enum Wire {
let stillImage: String? let stillImage: String?
let files: [File] let files: [File]
let state: ItemState? let state: ItemState?
let publishAt: Date?
/// 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?
} }
/// Eine Kachel aus „Weiterschauen" oder „Nächste Folge". /// Eine Kachel aus „Weiterschauen" oder „Nächste Folge".
@@ -126,6 +141,12 @@ enum Wire {
let posterImage: String? let posterImage: String?
let backdropImage: String? let backdropImage: String?
let state: ItemState let state: ItemState
/// Zu welcher Serie die Folge gehört.
let seriesId: String?
/// Das breite Bild der Serie — für den Aufmacher, nicht für die
/// Kachel: die will das Standbild der Folge.
let seriesBackdropImage: String?
let publishAt: Date?
} }
struct CodeResponse: Decodable, Sendable { struct CodeResponse: Decodable, Sendable {
@@ -20,6 +20,8 @@ extension Wire.Movie {
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt, lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false, isFavourite: state?.favourite ?? false,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)] versions: [Wire.fassung(id: id, files: files)]
) )
} }
@@ -39,6 +41,7 @@ extension Wire.Series {
// offen ist — die App zeigt daraus die Plakette, rechnet aber // offen ist — die App zeigt daraus die Plakette, rechnet aber
// intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege // intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege
// zu derselben Aussage zu haben. // zu derselben Aussage zu haben.
availableFrom: publishAt,
episodeCount: episodeCount, episodeCount: episodeCount,
playedEpisodeCount: episodeCount - unwatchedCount, playedEpisodeCount: episodeCount - unwatchedCount,
childCount: seasons.count childCount: seasons.count
@@ -60,11 +63,15 @@ extension Wire.Episode {
index: episode, index: episode,
parentIndex: season, parentIndex: season,
seriesTitle: series?.title, seriesTitle: series?.title,
seriesID: series?.id, // Die Serie aus dem Vertrag, wenn sie nicht mitgegeben wurde:
// ohne sie führte der Weg zurück zur Serie ins Leere.
seriesID: series?.id ?? seriesId,
playCount: state?.playCount, playCount: state?.playCount,
resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) },
lastPlayedAt: state?.lastPlayedAt, lastPlayedAt: state?.lastPlayedAt,
isFavourite: state?.favourite ?? false, isFavourite: state?.favourite ?? false,
isInProgress: state?.inProgress,
availableFrom: publishAt,
versions: [Wire.fassung(id: id, files: files)] versions: [Wire.fassung(id: id, files: files)]
) )
} }
@@ -85,15 +92,22 @@ extension Wire.Started {
year: year, year: year,
duration: state.runtimeSeconds.map { Int($0 * 1000) }, duration: state.runtimeSeconds.map { Int($0 * 1000) },
thumb: folge ? backdropImage : posterImage, thumb: folge ? backdropImage : posterImage,
art: backdropImage, // **Für die Fläche über den halben Bildschirm das Bild der
// Serie.** `backdropImage` ist bei einer Folge ihr Standbild —
// richtig für die Kachel, falsch für den Aufmacher: wer der
// Serie ein Bild ausgesucht hatte, fand dort ein anderes vor.
art: folge ? (seriesBackdropImage ?? backdropImage) : backdropImage,
seriesThumb: folge ? posterImage : nil, seriesThumb: folge ? posterImage : nil,
index: episode, index: episode,
parentIndex: season, parentIndex: season,
seriesTitle: folge ? title : nil, seriesTitle: folge ? title : nil,
seriesID: folge ? seriesId : nil,
playCount: state.playCount, playCount: state.playCount,
resumeMilliseconds: Int(state.positionSeconds * 1000), resumeMilliseconds: Int(state.positionSeconds * 1000),
lastPlayedAt: state.lastPlayedAt, lastPlayedAt: state.lastPlayedAt,
isFavourite: state.favourite isFavourite: state.favourite,
isInProgress: state.inProgress,
availableFrom: publishAt
) )
} }
} }
@@ -84,7 +84,8 @@ public enum FlaroSignIn {
serverName: gruss.serverName, serverName: gruss.serverName,
userID: antwort.user.id, userID: antwort.user.id,
userName: antwort.user.userName, userName: antwort.user.userName,
isAdministrator: antwort.user.isAdministrator), client) isAdministrator: antwort.user.isAdministrator,
avatarImage: antwort.user.avatarImage), client)
} }
/// Nimmt ein gespeichertes Token wieder auf. /// Nimmt ein gespeichertes Token wieder auf.
@@ -111,7 +112,8 @@ public enum FlaroSignIn {
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro", serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: wer.id, userID: wer.id,
userName: wer.userName, userName: wer.userName,
isAdministrator: wer.isAdministrator), client) isAdministrator: wer.isAdministrator,
avatarImage: wer.avatarImage), client)
} }
// MARK: - Der Weg über einen Code // MARK: - Der Weg über einen Code
@@ -179,7 +181,8 @@ public enum FlaroSignIn {
serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro", serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro",
userID: anmeldung.user.id, userID: anmeldung.user.id,
userName: anmeldung.user.userName, userName: anmeldung.user.userName,
isAdministrator: anmeldung.user.isAdministrator)), client) isAdministrator: anmeldung.user.isAdministrator,
avatarImage: anmeldung.user.avatarImage)), client)
case "expired": case "expired":
return (.expired, nil) return (.expired, nil)
default: default:
@@ -35,6 +35,23 @@
} }
} }
}, },
"flaro.row.latestIn %@": {
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Neu in %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "New in %@"
}
}
}
},
"flaro.row.movies": { "flaro.row.movies": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@@ -71,6 +71,26 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
/// eine Fähigkeit, die es nur auf einem gibt. /// eine Fähigkeit, die es nur auf einem gibt.
public let isFavourite: Bool public let isFavourite: Bool
/// Ob der Server den Titel als angefangen führt.
///
/// **Die Entscheidung gehört dem Server, nicht der Beschriftung.** „Hat
/// eine Stelle über null" und „ist angefangen" waren dasselbe, bis es eine
/// einstellbare Untergrenze gab. Seitdem heisst ein Titel, in den jemand
/// eine Sekunde hineingeschaut hat, nicht mehr „Weiterschauen" — er steht
/// ja auch nicht mehr in der Reihe.
///
/// `nil` heisst: der Server sagt nichts dazu (Jellyfin, oder ein älterer
/// Flaro). Dann gilt wie früher die blosse Stelle.
public let isInProgress: Bool?
/// Ab wann es den Titel gibt — `nil` heisst: jetzt.
///
/// **Ein geplanter Titel steht in der Mediathek, läuft aber nicht.** Das
/// ist der Sinn des Planens: man soll sehen, dass er kommt. Ohne dieses
/// Feld bot die App „Abspielen" an und bekam vom Server eine Absage —
/// ein Knopf, der ein Versprechen gibt und es zurücknimmt.
public let availableFrom: Date?
// Zählwerte bei Serien und Staffeln. // Zählwerte bei Serien und Staffeln.
public let episodeCount: Int? public let episodeCount: Int?
public let playedEpisodeCount: Int? public let playedEpisodeCount: Int?
@@ -117,6 +137,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
lastPlayedAt: Date? = nil, lastPlayedAt: Date? = nil,
addedAt: Date? = nil, addedAt: Date? = nil,
isFavourite: Bool = false, isFavourite: Bool = false,
isInProgress: Bool? = nil,
availableFrom: Date? = nil,
episodeCount: Int? = nil, episodeCount: Int? = nil,
playedEpisodeCount: Int? = nil, playedEpisodeCount: Int? = nil,
childCount: Int? = nil, childCount: Int? = nil,
@@ -155,6 +177,8 @@ public struct MediaItem: Sendable, Equatable, Identifiable, Codable {
self.lastPlayedAt = lastPlayedAt self.lastPlayedAt = lastPlayedAt
self.addedAt = addedAt self.addedAt = addedAt
self.isFavourite = isFavourite self.isFavourite = isFavourite
self.isInProgress = isInProgress
self.availableFrom = availableFrom
self.episodeCount = episodeCount self.episodeCount = episodeCount
self.playedEpisodeCount = playedEpisodeCount self.playedEpisodeCount = playedEpisodeCount
self.childCount = childCount self.childCount = childCount
@@ -225,6 +249,29 @@ extension MediaItem {
public var isWatched: Bool { (playCount ?? 0) > 0 } public var isWatched: Bool { (playCount ?? 0) > 0 }
/// Ob die Beschriftung „Weiterschauen" heissen soll.
///
/// Eine Stelle, die beide Fälle kennt: was der Server sagt, und was ohne
/// seine Auskunft gilt. Stünde der Zweifel an jedem Knopf einzeln, hiesse
/// derselbe Titel auf der Startseite anders als auf seiner Seite.
public var isResumable: Bool { isInProgress ?? (watchProgress != nil) }
/// Der Balken unter der Kachel — nur, wenn der Titel wirklich angefangen ist.
///
/// **Sonst sagt die Kachel etwas anderes als der Knopf.** Ein Strich unter
/// dem Plakat heisst „du bist mittendrin". Für eine Sekunde, in die jemand
/// hineingeschaut hat, ist das keine Auskunft, sondern ein Fussel.
public var resumeProgress: Double? { isResumable ? watchProgress : nil }
/// Ob der Titel erst noch kommt.
///
/// Gefragt wird an einer Stelle, beantwortet an einer: sonst steht an
/// einem Bildschirm „Abspielen" und am nächsten der Termin.
public var isUpcoming: Bool {
guard let availableFrom else { return false }
return availableFrom > Date()
}
/// Ab wo fortgesetzt wird. /// Ab wo fortgesetzt wird.
public var resumeTime: Duration { public var resumeTime: Duration {
.milliseconds(resumeMilliseconds ?? 0) .milliseconds(resumeMilliseconds ?? 0)
+87 -3
View File
@@ -43,22 +43,106 @@ if ! grep -q "$REGISTRY" ~/.docker/config.json 2>/dev/null; then
exit 1 exit 1
fi fi
# **Vor dem Bauen, nicht danach.** Laufen App und Browser farblich
# auseinander, soll das auffallen, bevor ein Image daraus wird.
if command -v python3 >/dev/null && [ -f Tools/tokens-pruefen.py ]; then
python3 Tools/tokens-pruefen.py || {
echo
echo "Abgebrochen. Mit FLARO_DRIFT_EGAL=1 trotzdem bauen."
[ "${FLARO_DRIFT_EGAL:-}" = "1" ] || exit 1
}
fi
# Fragt die Registry, welcher Stand dort unter `latest` liegt.
#
# Anonym — das Paket ist öffentlich lesbar. Schlägt es fehl, ist die Antwort
# leer, und der Aufrufer entscheidet, was das bedeutet.
registry_digest() {
local token
token=$(curl -fsS --max-time 20 \
"https://$REGISTRY/v2/token?scope=repository:$PFAD:pull&service=container_registry" \
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("token",""))' 2>/dev/null) || return 1
[ -n "$token" ] || return 1
curl -fsS --max-time 20 -D- -o /dev/null \
-H "Authorization: Bearer $token" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
"https://$REGISTRY/v2/$PFAD/manifests/latest" 2>/dev/null \
| awk 'BEGIN{IGNORECASE=1} /^docker-content-digest:/ {print $2}' | tr -d '\r'
}
MARKEN=(-t "$BILD:latest") MARKEN=(-t "$BILD:latest")
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG") [ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
# **Eine Kennung je Bau.**
#
# Sechs Zeichen, jedes Mal neu — auch bei unverändertem Quelltext. Sie landet
# im Abbild und steht danach in der Oberfläche unter „Einstellungen" sowie
# unter `/health`. Damit ist die Frage „läuft auf dem NAS wirklich der Bau von
# eben?" in zwei Sekunden beantwortet statt in einem halben Tag.
#
# Ohne I, O, 0 und 1: diese Kennung wird vorgelesen und abgetippt.
#
# **Nicht `tr < /dev/urandom | head -c 6`.** `head` schliesst die Pipe nach
# sechs Zeichen, `tr` bekommt SIGPIPE, und mit `set -o pipefail` gilt das als
# Fehlschlag — das Skript brach mit 141 ab, bevor es irgendetwas gebaut hatte.
# Dieselbe Falle wie oben bei `grep -q`, und sie hat hier zweimal zugeschlagen.
BUILD=$(python3 -c "import secrets; print(''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(6)))")
echo "Build-Kennung dieses Laufs: $BUILD"
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein # **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon. # Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
docker buildx inspect flaro-bauer >/dev/null 2>&1 \ docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|| docker buildx create --name flaro-bauer --use >/dev/null || docker buildx create --name flaro-bauer --use >/dev/null
# **Den Digest festhalten, bevor gebaut wird.** Nur so lässt sich hinterher
# sagen, ob wirklich etwas Neues angekommen ist.
vorher=$(registry_digest || true)
echo "Baue $BILD für $PLATTFORMEN." echo "Baue $BILD für $PLATTFORMEN."
docker buildx build \ if ! docker buildx build \
--builder flaro-bauer \ --builder flaro-bauer \
--platform "$PLATTFORMEN" \ --platform "$PLATTFORMEN" \
-f server/Dockerfile \ -f server/Dockerfile \
--build-arg "FLARO_BUILD=$BUILD" \
"${MARKEN[@]}" \ "${MARKEN[@]}" \
--push . --push . ; then
echo
echo "Der Bau oder das Hochladen ist gescheitert. In der Registry liegt weiter:"
echo " ${vorher:-nichts}"
exit 1
fi
# **Nachsehen, statt zu glauben.**
#
# `docker buildx build --push` hat bei einem abgebrochenen Upload schon mit
# Rückgabewert 0 beendet, nachdem es „ERROR: failed to build" gedruckt hatte —
# ein TLS-Zeitlimit mitten im Hochladen. Das Skript meldete Erfolg, in der
# Registry lag das alte Image, und auf dem NAS kam eine Fassung an, die
# niemand gebaut hatte. Seitdem wird gefragt, nicht vertraut.
nachher=$(registry_digest || true)
if [ -z "$nachher" ]; then
echo
echo "Die Registry antwortet nicht auf $BILD:latest — hochgeladen wurde offenbar nichts."
exit 1
fi
if [ -n "$vorher" ] && [ "$vorher" = "$nachher" ]; then
echo
echo "In der Registry liegt noch derselbe Stand wie vorher:"
echo " $nachher"
echo "Das Hochladen ist also nicht durchgekommen. Noch einmal versuchen."
exit 1
fi
echo echo
echo "Fertig. Auf dem NAS:" echo "Fertig. In der Registry liegt jetzt:"
echo " $nachher"
echo
echo "Build-Kennung: $BUILD"
echo " Dieselbe muss nach dem Ziehen unter Einstellungen stehen."
echo " Steht dort eine andere, läuft auf dem NAS ein älteres Abbild."
echo
echo "Auf dem NAS:"
echo " image: $BILD:${FASSUNG:-latest}" echo " image: $BILD:${FASSUNG:-latest}"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Prüft, ob App und Weboberfläche dieselben Farben und Formen benutzen.
**Warum es das braucht.** Die Werte stehen an mehreren Stellen von Hand
abgeschrieben — in Swift für die Apps, in CSS für den Browser. Sie sind
auseinandergelaufen, und zwar unsichtbar: `#1D1D21` gegen `#1D1D22`,
`#A0A0A8` gegen `#B4B4BE`, Radius 8 gegen 6. Einzeln merkt das niemand.
Zusammen sorgen sie dafür, dass App und Browser nicht wie dasselbe Programm
aussehen.
**Warum kein Generator.** Einer müsste Swift erzeugen, und erzeugter Swift
lässt sich schlechter lesen und kommentieren als geschriebener. Diese Prüfung
kostet weniger und fängt dasselbe ab: sie sagt, dass etwas auseinanderläuft,
und überlässt die Entscheidung dem Menschen.
Aufruf: python3 Tools/tokens-pruefen.py
Rückgabe: 0, wenn alles übereinstimmt; sonst 1.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
WURZEL = Path(__file__).resolve().parent.parent
SWIFT_FARBEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/FlaroColor.swift"
SWIFT_AKZENT = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/Color/AccentOption.swift"
SWIFT_FORMEN = WURZEL / "Packages/FlaroKit/Sources/FlaroDesign/FlaroFont.swift"
CSS = WURZEL / "web/app/src/style.css"
# Was womit zusammengehört. Links der Swift-Name, rechts das CSS-Token.
FARBEN = {
"canvas": "--canvas",
"surface": "--surface",
"elevated": "--elevated",
"placeholder": "--placeholder",
"textPrimary": "--text",
"textSecondary": "--text-dim",
"textMuted": "--text-muted",
"destructive": "--destructive",
}
# Radien: Swift-Name → CSS-Token. `sheet` und `panel` haben im Browser kein
# Gegenstück — dort gibt es keine Blätter und keine schwebende Seitenleiste.
RADIEN = {
"card": "--r-karte",
"control": "--r-flaeche",
}
def hex_aus_swift(text: str, name: str) -> str | None:
"""Liest `static let name = RGB(hex: 0xAABBCC)` oder `RGB(0xAABBCC)`."""
m = re.search(rf"\b{name}\b[^\n=]*=\s*[^\n]*?0x([0-9A-Fa-f]{{6}})", text)
return "#" + m.group(1).upper() if m else None
def ohne_tvos(text: str) -> str:
"""Entfernt die tvOS-Zweige.
**Sonst vergleicht man den falschen Wert.** `FlaroRadius.card` ist 8 —
und auf tvOS 12, weil aus drei Metern eine stärkere Rundung nötig ist.
Der Browser läuft nie auf einem Fernseher; massgeblich ist der andere Zweig.
"""
return re.sub(r"#if os\(tvOS\).*?(?:#else(.*?))?#endif", lambda m: m.group(1) or "",
text, flags=re.S)
def zahl_aus_swift(text: str, name: str) -> float | None:
"""Liest den ersten Zahlenwert hinter `static let name`."""
m = re.search(rf"\b{name}\b\s*:\s*CGFloat\s*(?:\{{[^}}]*?return\s*)?([0-9]+(?:\.[0-9]+)?)", text, re.S)
if m:
return float(m.group(1))
m = re.search(rf"\b{name}\b[^\n=]*=\s*([0-9]+(?:\.[0-9]+)?)", text)
return float(m.group(1)) if m else None
def token_aus_css(text: str, name: str) -> str | None:
m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
return m.group(1).strip() if m else None
def main() -> int:
fehlend = [p for p in (SWIFT_FARBEN, SWIFT_AKZENT, SWIFT_FORMEN, CSS) if not p.exists()]
if fehlend:
for p in fehlend:
print(f"Nicht gefunden: {p.relative_to(WURZEL)}")
return 1
farben_swift = SWIFT_FARBEN.read_text(encoding="utf-8")
akzent_swift = SWIFT_AKZENT.read_text(encoding="utf-8")
formen_swift = ohne_tvos(SWIFT_FORMEN.read_text(encoding="utf-8"))
css = CSS.read_text(encoding="utf-8")
abweichungen: list[str] = []
geprueft = 0
for swift_name, css_name in FARBEN.items():
a = hex_aus_swift(farben_swift, swift_name)
b = token_aus_css(css, css_name)
if a is None or b is None:
abweichungen.append(f" {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
continue
geprueft += 1
if a.upper() != b.upper():
abweichungen.append(f" {swift_name}: Swift {a}, CSS {css_name} {b}")
# Die Vorgabefarbe der App gegen den Akzent im Browser.
vorgabe = re.search(r'identifier\s*==\s*"([a-z]+)"', akzent_swift)
if not vorgabe:
vorgabe = re.search(r'\.first\s*\{\s*\$0\.id\s*==\s*"([a-z]+)"', akzent_swift)
if vorgabe:
hex_vorgabe = hex_aus_swift(akzent_swift, vorgabe.group(1))
akzent_css = token_aus_css(css, "--accent")
if hex_vorgabe and akzent_css:
geprueft += 1
if hex_vorgabe.upper() != akzent_css.upper():
abweichungen.append(
f" Vorgabe-Akzent: Swift {vorgabe.group(1)} {hex_vorgabe}, CSS {akzent_css}")
for swift_name, css_name in RADIEN.items():
a = zahl_aus_swift(formen_swift, swift_name)
b = token_aus_css(css, css_name)
if a is None or b is None:
abweichungen.append(f" Radius {swift_name} / {css_name}: nicht gefunden ({a} / {b})")
continue
geprueft += 1
b_zahl = float(re.sub(r"[^0-9.]", "", b) or 0)
if abs(a - b_zahl) > 0.01:
abweichungen.append(f" Radius {swift_name}: Swift {a:g}, CSS {css_name} {b}")
if abweichungen:
print(f"App und Weboberfläche laufen auseinander ({len(abweichungen)} Stelle(n)):")
print("\n".join(abweichungen))
print("\nFlaroDesign ist massgeblich — die CSS zieht nach.")
return 1
print(f"App und Weboberfläche stimmen überein ({geprueft} Werte geprüft).")
return 0
if __name__ == "__main__":
sys.exit(main())
+59 -31
View File
@@ -42,41 +42,36 @@ Ein Mac baut ohne Angabe **arm64** — also gehört die Angabe dazu.
## Schritt 2: Das Image auf das NAS bringen ## Schritt 2: Das Image auf das NAS bringen
Drei Wege. Der erste ist der bequemste, der zweite braucht keine Registry. **Das NAS baut nicht, es lädt nur.** Genau wie bei Jellyfin oder jedem
anderen Container: dort baut jemand anderes und veröffentlicht, du ziehst
das Ergebnis. Bei Flaro ist dieser Jemand dein Arbeitsrechner — er ist für
die Apps ohnehin nötig, weil Xcode nirgendwo sonst läuft.
Drei Wege. Der erste ist der vorgesehene; der zweite braucht keine Registry.
### Weg A — über die Registry deines Gitea (empfohlen) ### Weg A — über die Registry deines Gitea (empfohlen)
Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit. Dein Gitea unter `git.scarriffle.com` bringt eine Container-Registry mit.
Einmal einrichten, danach ist jedes Update ein `docker compose pull`. Einmal einrichten, danach ist jedes Update ein `docker compose pull`.
#### A1 · Gitea baut selbst #### A1 · Anmelden, einmalig
`.gitea/workflows/image.yml` liegt im Projekt. Bei jedem Push auf `main` Nicht mit dem Kontopasswort, sondern mit einem Zugriffstoken: *Gitea →
baut Gitea das Image für **beide** Architekturen und legt es ab — ohne dass Einstellungen → Anwendungen → Zugriffstoken*, Berechtigungen
jemand daran denken muss, und ohne Anmeldung am Arbeitsrechner. `write:package` und `read:package`. Ein Token lässt sich einzeln widerrufen,
und bei eingeschalteter Zwei-Faktor-Anmeldung funktioniert das Passwort hier
**Voraussetzung ist ein registrierter Act-Runner.** Gitea verteilt Arbeit gar nicht.
nur, es baut nicht selbst: ohne Runner legt es bei jedem Push einen Lauf an,
der dann liegenbleibt. Nachsehen unter
*Repo → Einstellungen → Actions → Runner*.
Der Runner gehört **auf das NAS** — dort läuft Docker ohnehin, und damit
braucht es für ein neues Image nie wieder einen Arbeitsrechner. Fertig
vorbereitet liegt er in `server/docker-compose.runner.yml`; hineingehört nur
das Registrierungstoken aus derselben Gitea-Seite.
Danach erscheint der Runner in der Liste, und der wartende Lauf startet —
oder spätestens der nächste Push.
#### A2 · Von Hand vom Arbeitsrechner
Einmalig anmelden — das Passwort tippst du selbst ein:
```bash ```bash
docker login git.scarriffle.com docker login git.scarriffle.com
``` ```
Danach genügt ein Befehl; er baut amd64 **und** arm64 und lädt hoch: Benutzername ist dein Gitea-Name, als Passwort das Token.
#### A2 · Bauen und hochladen
Ein Befehl; er baut amd64 **und** arm64 und lädt beides als ein Manifest
hoch:
```bash ```bash
Tools/image-veroeffentlichen.sh Tools/image-veroeffentlichen.sh
@@ -88,6 +83,13 @@ Mit einer Fassungsnummer, wenn du sie festnageln willst:
Tools/image-veroeffentlichen.sh 0.2.0 Tools/image-veroeffentlichen.sh 0.2.0
``` ```
Nur die Architektur des NAS — deutlich schneller, weil die fremde sonst
unter Emulation gebaut wird:
```bash
FLARO_PLATTFORMEN=linux/amd64 Tools/image-veroeffentlichen.sh
```
#### A3 · Das Paket öffentlich stellen #### A3 · Das Paket öffentlich stellen
Sonst muss sich auch das NAS anmelden. In Gitea unter Sonst muss sich auch das NAS anmelden. In Gitea unter
@@ -108,9 +110,15 @@ kommt:
**Bestätigen** — danach findet die Suche in der Docker-App auch **Bestätigen** — danach findet die Suche in der Docker-App auch
`scarriffle/flaro`. Ist das Paket privat, trägst du unter `scarriffle/flaro`. Ist das Paket privat, trägst du unter
*Registierungseinstellungen* Benutzername und ein Gitea-Zugriffstoken ein *Registierungseinstellungen* Benutzername und dasselbe Zugriffstoken ein.
(nicht dein Kontopasswort: ein Token lässt sich einzeln widerrufen, unter
*Einstellungen → Anwendungen → Zugriffstoken*, Berechtigung `read:package`). #### A5 · Das Image herunterladen, bevor du das Projekt anlegst
**Die Docker-App zieht Images nicht selbst.** Steht in einer Compose-Datei
ein Image, das lokal fehlt, bricht sie das Anlegen mit „Bitte überprüfen Sie
die Konfigurationsinformationen" ab — ohne zu sagen, woran es liegt. Also
vorher **Image → Herunterladen**, Registry `gitea`, Quellname
`scarriffle/flaro:latest`.
In der Compose-Datei steht dann: In der Compose-Datei steht dann:
@@ -141,11 +149,23 @@ docker load < /volume1/docker/flaro.tar.gz
In der Compose-Datei steht dann schlicht `image: flaro:latest`. In der Compose-Datei steht dann schlicht `image: flaro:latest`.
### Weg C — auf dem NAS bauen ### Weg C — Gitea baut selbst
Geht, lohnt sich aber selten: der Bau braucht den Quelltext, Node, das `.gitea/workflows/image.yml` liegt im Projekt und kann denselben Bau auf
.NET-SDK als Zwischenschritt und ordentlich Arbeitsspeicher. Die Docker-App einem Act-Runner ausführen. **Gitea verteilt Arbeit nur, es baut nicht
von UGREEN kann `build:` in einer Compose-Datei ausserdem nicht immer. selbst** — ohne registrierten Runner passiert nichts.
Der Runner müsste auf dem NAS laufen, und daran scheitert es dort: er
braucht Zugriff auf Docker, denn sein Zweck ist das Bauen von Images. Die
Docker-App des UGREEN lehnt den Einhängepunkt `/var/run/docker.sock` ab —
geprüft, indem die Compose-Datei Zeile für Zeile aufgebaut wurde: ohne diese
eine Zeile wird das Projekt angelegt, mit ihr nicht. Übrig bleiben
`privileged: true` mit einem Docker-in-Docker-Image oder der Weg über SSH an
der Oberfläche vorbei. Beide geben dem Runner faktisch Root auf dem NAS.
Deshalb löst der Workflow **nicht** mehr bei jedem Push aus — sonst sammeln
sich Läufe an, die nie jemand abarbeitet. Er steht auf `workflow_dispatch`
und lässt sich in Gitea von Hand starten, sobald einmal ein Runner da ist.
--- ---
@@ -253,7 +273,15 @@ keine Stelle, die das könnte.
## Aktualisieren ## Aktualisieren
Mit Weg A: **Zwei Schritte, zwei Maschinen.** Erst baut der Arbeitsrechner die neue
Fassung und legt sie in die Registry:
```bash
Tools/image-veroeffentlichen.sh
```
Dann holt das NAS sie. In der Docker-App genügt das Aktualisieren des
Projekts; über die Kommandozeile:
```bash ```bash
docker compose pull && docker compose up -d docker compose pull && docker compose up -d
+35 -2
View File
@@ -48,7 +48,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
ARG TARGETARCH ARG TARGETARCH
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends \ && apt-get install -y --no-install-recommends \
ffmpeg libva2 libva-drm2 mesa-va-drivers \ ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
&& if [ "$TARGETARCH" = "amd64" ]; then \ && if [ "$TARGETARCH" = "amd64" ]; then \
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \ apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
fi \ fi \
@@ -67,17 +67,50 @@ RUN mkdir -p /config /cache /transcode && chown -R 1000:1000 /config /cache /tra
# braucht weder ein Startskript noch root im Container. # braucht weder ein Startskript noch root im Container.
USER 1000:1000 USER 1000:1000
# **Kein eigenes ASPNETCORE_URLS.** Das Basisimage setzt seit .NET 8 selbst
# ASPNETCORE_HTTP_PORTS=8080; beides zusammen ergab bei jedem Start die
# Warnung „Overriding HTTP_PORTS '8080'". Zwei Wege zum selben Port sind
# einer zu viel.
ENV FLARO_DATA=/config \ ENV FLARO_DATA=/config \
FLARO_CACHE=/cache \ FLARO_CACHE=/cache \
FLARO_TRANSCODE=/transcode \ FLARO_TRANSCODE=/transcode \
ASPNETCORE_URLS=http://+:8080 \
DOTNET_gcServer=0 DOTNET_gcServer=0
# **Welches Abbild das hier ist.**
#
# Sechs Zeichen, bei jedem Bau neu gewürfelt — auch dann, wenn am Quelltext
# nichts anders ist. Die Version sagt, welcher Stand gemeint ist; diese
# Kennung sagt, welches Abbild wirklich läuft.
#
# Das ist keine Spielerei: zwei Funktionen standen nachweislich im Quelltext
# und waren auf dem Server trotzdem nicht zu sehen. Ob dort ein älteres
# Abbild lief, war nicht feststellbar, und die Fehlersuche lief einen halben
# Tag ins Leere. Seitdem beantwortet ein Blick die erste aller Fragen.
#
# Ganz unten, weil jede Änderung daran alle folgenden Schichten neu baut —
# und das sind hier nur noch die beiden COPY-Zeilen.
ARG FLARO_BUILD=DEV
ENV FLARO_BUILD=${FLARO_BUILD}
WORKDIR /app WORKDIR /app
COPY --from=build /app . COPY --from=build /app .
COPY --from=web /web/dist ./wwwroot COPY --from=web /web/dist ./wwwroot
EXPOSE 8080 EXPOSE 8080
# **Eine Gesundheitsprüfung, die den Server fragt — nicht die Laufzeit.**
#
# Ohne sie weiss Docker nur, dass ein Prozess läuft. Die Oberfläche eines NAS
# zeigt dann minutenlang „wird bereitgestellt", während der Server längst
# antwortet, und niemand kann unterscheiden, ob er noch wandert oder hängt.
#
# `/health` und nicht `dotnet --info`: letzteres sagt nur, dass die Laufzeit
# da ist, und das war sie auch, als der Server noch migrierte.
#
# 60 Sekunden Anlaufzeit, weil die Migrationen beim ersten Start einer
# gefüllten Datenbank länger brauchen als ein Startvorgang sonst.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS http://localhost:8080/health || exit 1
VOLUME ["/config", "/cache", "/transcode"] VOLUME ["/config", "/cache", "/transcode"]
ENTRYPOINT ["dotnet", "Flaro.Api.dll"] ENTRYPOINT ["dotnet", "Flaro.Api.dll"]
+18 -6
View File
@@ -31,6 +31,20 @@ services:
# Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder. # Datenbank oder ein zweiter Server finden Zuordnungen und Bilder wieder.
- FLARO_WRITE_SIDECARS=true - FLARO_WRITE_SIDECARS=true
# Womit das Bild gerechnet wird, wenn eine Wiedergabe umkodiert werden
# muss — weil der Browser den Codec nicht kennt oder eine kleinere
# Auflösung gewählt wurde.
#
# auto prüft, ob /dev/dri/renderD128 da ist, und nimmt sonst
# den Hauptprozessor. Das ist die Voreinstellung.
# vaapi erzwingt die Grafikeinheit. Fehlt sie, scheitert die
# Wiedergabe — nützlich, um zu prüfen, ob sie wirklich greift.
# none immer in Software. Langsam, aber überall gleich.
#
# **Zum blossen Umpacken wird nichts davon gebraucht.** Eine MKV mit
# H.264 nach MP4 zu schachteln kostet den Server fast nichts.
# - FLARO_HWACCEL=auto
# Wem die Dateien auf dem Wirt gehören. 1000:1000 ist die Voreinstellung # Wem die Dateien auf dem Wirt gehören. 1000:1000 ist die Voreinstellung
# im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und # im Image; wer andere Kennungen hat, trägt sie hier ein — `id -u` und
# `id -g` auf dem Wirt sagen, welche. # `id -g` auf dem Wirt sagen, welche.
@@ -67,9 +81,7 @@ services:
devices: devices:
- /dev/dri:/dev/dri - /dev/dri:/dev/dri
healthcheck: # Die Gesundheitsprüfung steckt im Image und fragt /health ab — hier
test: ["CMD", "dotnet", "--info"] # braucht es sie nicht noch einmal. Die alte Fassung prüfte `dotnet
interval: 30s # --info` und sagte damit nur, dass die Laufzeit da ist; das war sie auch,
timeout: 5s # während der Server noch migrierte.
retries: 3
start_period: 20s
+16
View File
@@ -1,5 +1,21 @@
# Ein Gitea-Runner auf dem NAS. # Ein Gitea-Runner auf dem NAS.
# #
# **Nicht in Betrieb — und auf einem UGREEN-NAS nicht über die Docker-App
# anzulegen.** Deren Compose-Prüfung lehnt den Einhängepunkt
# `/var/run/docker.sock` ab. Nachgewiesen durch Halbieren: dieselbe Datei
# ohne diese eine Zeile wird angelegt, mit ihr kommt nur „Bitte überprüfen
# Sie die Konfigurationsinformationen", ohne Angabe eines Grundes.
#
# Was bliebe: `gitea/act_runner:latest-dind-rootless` mit `privileged: true`
# statt des Socket, oder die Datei über SSH ablegen und dort von Hand
# starten. Beides gibt dem Runner faktisch Root auf dem NAS, und bei einem
# öffentlichen Repo reicht dann unter Umständen ein fremder Pull Request,
# um Code darauf auszuführen. Deshalb baut bis auf Weiteres der
# Arbeitsrechner — siehe `Tools/image-veroeffentlichen.sh`.
#
# Die Datei bleibt liegen, damit der Weg beim nächsten Mal nicht noch
# einmal von vorn untersucht werden muss.
#
# **Wozu.** Gitea verteilt Arbeit nur, es baut nicht selbst. Ohne einen # **Wozu.** Gitea verteilt Arbeit nur, es baut nicht selbst. Ohne einen
# registrierten Runner legt es bei jedem Push einen Lauf an, der dann # registrierten Runner legt es bei jedem Push einen Lauf an, der dann
# liegenbleibt. Mit diesem hier baut das NAS das Flaro-Image selbst und legt # liegenbleibt. Mit diesem hier baut das NAS das Flaro-Image selbst und legt
+47
View File
@@ -0,0 +1,47 @@
namespace Flaro.Api;
/// <summary>Wie hoch der Aufmacher auf der Startseite steht.</summary>
///
/// <remarks>
/// <para>
/// <b>Vier Stufen, keine Zahl.</b> Eine Pixelzahl einzustellen hiesse, sie
/// für jeden Bildschirm neu einzustellen — der Aufmacher misst sich an der
/// Fensterhöhe, nicht an einer festen Grösse. Vier Wörter sind das, was man
/// dazu zu sagen hat.
/// </para>
/// <para>
/// <b>Was die Stufen bedeuten, steht im Regelwerk</b>, nicht hier: der Server
/// weiss nichts von Bildpunkten, er merkt sich nur die Wahl. Nachzulesen bei
/// <c>.hero</c> in <c>style.css</c>.
/// </para>
/// <para>
/// <b>Serverweit.</b> Dieselbe Linie wie bei den Wiedergaberegeln: eine
/// Entscheidung des Gastgebers, nicht des Gastes.
/// </para>
/// </remarks>
public static class Aufmacherhoehe
{
public const string Schlüssel = "darstellung.aufmacher.hoehe";
/// <summary>Die Mitte — das, was vorher fest verdrahtet war.</summary>
public const string Vorgabe = "mittel";
private static readonly string[] Stufen = ["niedrig", "mittel", "hoch", "sehrhoch"];
/// <summary>Macht aus dem Gespeicherten eine der vier Stufen.</summary>
///
/// <remarks>
/// Unbekanntes wird zur Vorgabe statt zu einem Fehler: in der Spalte
/// steht, was irgendwann einmal hineingeschrieben wurde, und eine
/// Startseite, die daran scheitert, wäre die schlechtere Antwort.
/// </remarks>
public static string Lesen(string? wert)
{
var blank = wert?.Trim().ToLowerInvariant() ?? "";
return Stufen.Contains(blank) ? blank : Vorgabe;
}
/// <summary>Ob das eine Stufe ist, die es gibt.</summary>
public static bool Kennt(string? wert) =>
wert is not null && Stufen.Contains(wert.Trim().ToLowerInvariant());
}
+46
View File
@@ -0,0 +1,46 @@
namespace Flaro.Api;
/// <summary>Welche Version läuft — und welcher Build genau.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum zwei Angaben.</b> Die Version sagt, welcher Stand gemeint ist;
/// sie steht fest und ändert sich nur von Hand. Der Build sagt, welches
/// Abbild tatsächlich läuft, und ändert sich bei <i>jedem</i> Bau — auch
/// dann, wenn am Quelltext nichts anders ist.
/// </para>
/// <para>
/// <b>Und warum das nötig wurde.</b> Es hat einen halben Tag gekostet: zwei
/// Funktionen waren nachweislich im Quelltext und auf dem Server trotzdem
/// nicht zu sehen. Ob dort ein älteres Abbild lief, liess sich nicht
/// feststellen — der Server sagte es nirgends. Jede Fehlersuche lief damit
/// ins Leere, weil die erste Frage unbeantwortbar war: reden wir vom selben
/// Build?
/// </para>
/// <para>
/// <b>Sechs Zeichen, Grossbuchstaben und Ziffern.</b> Kurz genug, um sie
/// vorzulesen und abzutippen, lang genug, um nicht zufällig zweimal
/// vorzukommen. Ohne <c>I</c>, <c>O</c>, <c>0</c> und <c>1</c>, weil die
/// beim Vorlesen verwechselt werden.
/// </para>
/// </remarks>
public static class Buildkennung
{
/// <summary>Die feste Version. Steigt nur, wenn jemand sie hier ändert.</summary>
public const string Version = "1.0.0";
/// <summary>Die Kennung dieses Abbilds — oder <c>DEV</c> ausserhalb eines Containers.</summary>
///
/// <remarks>
/// Gesetzt wird sie beim Bau des Abbilds über <c>FLARO_BUILD</c>. Fehlt
/// sie, läuft der Server aus dem Quellbaum: dann ist <c>DEV</c> die
/// ehrlichere Angabe als eine erfundene Kennung, die nichts bezeichnet.
/// </remarks>
public static string Build { get; } =
Environment.GetEnvironmentVariable("FLARO_BUILD") is { Length: > 0 } wert
? wert.Trim()
: "DEV";
/// <summary>„1.0.0 · K7F2QX" — für Protokoll und Anzeige.</summary>
public static string Beides => $"{Version} · {Build}";
}
+48
View File
@@ -0,0 +1,48 @@
namespace Flaro.Api;
/// <summary>Ein abgelehnter Versuch — mit einem Satz, den die Clients auch lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Es gab drei Formen, und zwei davon kamen nie an.</b> Gemessen: die
/// Oberfläche las bei jedem abgelehnten Versuch „Die Anfrage war so nicht zu
/// gebrauchen" — ihren eigenen Ersatzsatz für eine 400 ohne Begründung. Dabei
/// hatte der Server sehr wohl einen deutschen Satz geschrieben, nur an eine
/// Stelle, an der niemand nachsieht.
/// </para>
/// <para>
/// <c>Results.ValidationProblem</c> legt ihn unter <c>errors</c> ab,
/// <c>Results.BadRequest(new { fehler = … })</c> unter <c>fehler</c>. Beide
/// Clients lesen <c>detail</c> — der Browser in <c>api.ts</c>, die Apps in
/// <c>FlaroClient.swift</c> —, und zwar aus gutem Grund: in <c>title</c> und in
/// <c>errors</c> stehen sonst die englischen Prüftexte des Rahmenwerks, und
/// „Not Found" ist keine Auskunft. Nur stand in <c>detail</c> an 28 Stellen
/// eben gar nichts.
/// </para>
/// <para>
/// <b>Also eine Form.</b> <c>Results.Problem(detail, …)</c> füllt genau das
/// Feld, das gelesen wird. Dass es hier durch drei benannte Türen geht statt
/// durch einen Aufruf mit Zahl, hat einen Grund: so lässt sich nachsehen, wo
/// ein Server etwas ablehnt, und man sieht am Wort, ob es eine Bitte um eine
/// bessere Eingabe war, etwas Fehlendes oder ein Zusammenstoss.
/// </para>
/// <para>
/// <b>Nicht für Ausnahmen.</b> Was unerwartet schiefgeht, übersetzt
/// <see cref="Fehlertext"/>. Hier geht es um Absichten, die der Server
/// zurückweist — nicht um Unfälle.
/// </para>
/// </remarks>
public static class Einwand
{
/// <summary>400 — so war die Eingabe nicht zu gebrauchen.</summary>
public static IResult Bitte(string satz) =>
Results.Problem(satz, statusCode: StatusCodes.Status400BadRequest);
/// <summary>404 — das, worauf es sich bezieht, gibt es nicht.</summary>
public static IResult Fehlt(string satz) =>
Results.Problem(satz, statusCode: StatusCodes.Status404NotFound);
/// <summary>409 — es gibt schon etwas, das im Weg steht.</summary>
public static IResult Belegt(string satz) =>
Results.Problem(satz, statusCode: StatusCodes.Status409Conflict);
}
@@ -0,0 +1,133 @@
using Flaro.Core.Metadata;
using Flaro.Data;
using Flaro.Metadata;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Plakat, Hintergrund und Schriftzug von Hand wählen.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür.</b> Es gab genau einen Bild-Endpunkt, und der lieferte nur aus.
/// Wählte die Metadatenquelle ein schlechtes Plakat, blieb es — und die
/// einzige Abhilfe war, den ganzen Titel einem anderen Eintrag zuzuordnen.
/// </para>
/// <para>
/// <b>Die Kandidaten kosten nichts.</b> TMDB liefert sie beim Einzelabruf
/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
/// </para>
/// </remarks>
public static class ArtworkEndpoints
{
/// <summary>Höchstens so gross darf ein eigenes Bild sein.</summary>
///
/// <remarks>
/// Zehn Megabyte reichen für jedes Plakat. Ohne Grenze lädt irgendwann
/// jemand ein Foto hoch, und der Cache eines NAS ist nicht dafür da.
/// </remarks>
private const long HoechstensBytes = 10 * 1024 * 1024;
public static void MapArtwork(this IEndpointRouteBuilder app)
{
var gruppe = app.MapGroup("/api/v1")
.RequireAuthorization(FlaroPolicies.Administrator);
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
string art, Guid id, FlaroContext db, TmdbClient tmdb, 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
? 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)
: 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);
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)
: [];
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
});
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
string art, Guid id, BildWahlRequest anfrage,
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
{
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
var schluessel = string.IsNullOrWhiteSpace(anfrage.Url)
? null
: await ablage.StoreAsync(new RemoteImage(anfrage.Url), ct);
if (anfrage.Url is { Length: > 0 } && schluessel is null)
return Results.Problem("Das Bild liess sich nicht laden.", statusCode: 502);
var gesetzt = art == "series"
? await Setzen(db.Series.Where(s => s.Id == id), anfrage.Kind, schluessel, ct)
: await Setzen(db.Movies.Where(m => m.Id == id), anfrage.Kind, schluessel, ct);
return gesetzt ? 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) =>
{
if (kind is not ("poster" or "backdrop" or "logo"))
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular.");
var form = await anfrage.ReadFormAsync(ct);
var datei = form.Files.FirstOrDefault();
if (datei is null || datei.Length == 0)
return Einwand.Bitte("Keine Datei dabei.");
if (datei.Length > HoechstensBytes)
return Einwand.Bitte("Höchstens 10 MB.");
await using var strom = datei.OpenReadStream();
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
var gesetzt = art == "series"
? await Setzen(db.Series.Where(s => s.Id == id), kind, schluessel, ct)
: await Setzen(db.Movies.Where(m => m.Id == id), kind, schluessel, ct);
return gesetzt ? Results.Ok(new { image = schluessel }) : Results.NotFound();
}).DisableAntiforgery();
}
/// <summary>Setzt ein Bild und merkt sich, dass es von Hand kam.</summary>
private static async Task<bool> Setzen<T>(
IQueryable<T> abfrage, string art, string? schluessel, CancellationToken ct)
where T : class, IBebildert
{
var betroffen = art switch
{
"poster" => await abfrage.ExecuteUpdateAsync(s => s
.SetProperty(x => x.PosterImage, schluessel)
.SetProperty(x => x.PosterPinned, schluessel != null), ct),
"backdrop" => await abfrage.ExecuteUpdateAsync(s => s
.SetProperty(x => x.BackdropImage, schluessel)
.SetProperty(x => x.BackdropPinned, schluessel != null), ct),
_ => await abfrage.ExecuteUpdateAsync(s => s
.SetProperty(x => x.LogoImage, schluessel)
.SetProperty(x => x.LogoPinned, schluessel != null), ct),
};
return betroffen > 0;
}
}
+20 -10
View File
@@ -77,14 +77,22 @@ public static class AuthEndpoints
return Results.NoContent(); return Results.NoContent();
}).AllowAnonymous(); }).AllowAnonymous();
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) => // **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) // in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues
? Results.Ok(new AuthenticatedUser( // Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt,
id, // sähe sonst das alte, bis er sich neu anmeldet.
wer.FindFirstValue(ClaimTypes.Name) ?? "", app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) =>
wer.IsInRole(FlaroRoles.Administrator))) {
: Results.Unauthorized()) if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id))
.RequireAuthorization(); return Results.Unauthorized();
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id);
if (benutzer is null) return Results.Unauthorized();
return Results.Ok(new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
}).RequireAuthorization();
MapCodeFlow(app); MapCodeFlow(app);
} }
@@ -152,7 +160,7 @@ public static class AuthEndpoints
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt); c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
if (offen is null) if (offen is null)
return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound); return Einwand.Fehlt("Dieser Code gilt nicht mehr.");
offen.ApprovedByUserId = benutzerId; offen.ApprovedByUserId = benutzerId;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -190,7 +198,9 @@ public static class AuthEndpoints
LastSeenAt = jetzt, LastSeenAt = jetzt,
}); });
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator)); return new LoginResponse(token, new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
} }
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName; private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
+638 -14
View File
@@ -5,8 +5,70 @@ namespace Flaro.Api.Endpoints;
// und bricht Clients, die niemand mehr aktualisiert. // und bricht Clients, die niemand mehr aktualisiert.
public sealed record SystemInfo( public sealed record SystemInfo(
string Version, string ServerName, bool NeedsSetup, string Version, string Build, string ServerName, bool NeedsSetup,
IReadOnlyList<AttributionResponse> Attributions); IReadOnlyList<AttributionResponse> Attributions,
/// <summary>Wie hoch der Aufmacher steht: „niedrig", „mittel", „hoch", „sehrhoch".</summary>
///
/// <remarks>
/// <b>Hier und nicht in einer eigenen Abfrage.</b> Diese Auskunft holt
/// der Client ohnehin als Erstes, noch vor der Anmeldung. Käme die Höhe
/// später, stünde der Aufmacher einen Augenblick in der Vorgabe und
/// spränge dann — genau das, was eine einstellbare Höhe vermeiden soll.
/// </remarks>
string HeroHeight = "mittel");
/// <summary>Ein Ablageort des Servers, mitsamt Belegung.</summary>
///
/// <param name="Variable">
/// Die Umgebungsvariable, über die er gesetzt wird. <b>Sie wird mit
/// angezeigt</b>, weil sie die Antwort auf die nächste Frage ist: „und wie
/// ändere ich das?" Im Betrieb umbiegen lässt sich ein Pfad nicht — dann
/// 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>
public sealed record Speicherort(
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
/// <summary>Dateien, die zu keinem Titel gehören — mit Begründung.</summary>
public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei> Dateien);
/// <summary>Ein Titel, zu dem die Metadatenquelle nichts hergab.</summary>
///
/// <param name="Art">„movie" oder „series" — damit der Client weiss, wohin.</param>
/// <param name="Dateien">Wie viele Dateien daran hängen.</param>
/// <param name="Beispiel">Ein Dateiname, damit man den Titel wiedererkennt.</param>
///
/// <remarks>
/// <b>Weil die Zahl allein nichts erklärt.</b> In der Übersicht standen 448
/// Filme, in Plex 451 — und nirgends stand, welche drei fehlen oder warum.
/// Eine Mediathek, die etwas nicht zuordnen konnte, muss sagen was.
/// </remarks>
public sealed record OhneZuordnung(
Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel);
/// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param>
public sealed record Mehrfachbericht(
int Gesamt, IReadOnlyList<Mehrfachtitel> Titel);
/// <param name="Dateien">Die Dateien, die an diesem einen Titel hängen.</param>
public sealed record Mehrfachtitel(
Guid Id, string Art, string Titel, int? Jahr, IReadOnlyList<Fassungsdatei> Dateien);
/// <summary>Eine Datei an einem Titel — mit Kennung, damit sie sich herauslösen lässt.</summary>
public sealed record Fassungsdatei(Guid Id, string Name);
public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund);
/// <summary>Wie viele Titel in welcher Sprache vorliegen.</summary>
///
/// <param name="Sprache"><c>null</c> heisst: vor der Einführung dieser Spalte geholt.</param>
public sealed record Sprachanteil(string? Sprache, int Anzahl);
/// <param name="Abweichend">Wie viele nicht in der Serversprache vorliegen.</param>
public sealed record Sprachverteilung(
string Serversprache, IReadOnlyList<Sprachanteil> Anteile, int Abweichend);
public sealed record TitelRequest(string? Title);
public sealed record ServerNameRequest(string Name); public sealed record ServerNameRequest(string Name);
@@ -26,7 +88,28 @@ public sealed record LoginRequest(
string UserName, string Password, string DeviceName, string DeviceId, string UserName, string Password, string DeviceName, string DeviceId,
bool StaySignedIn = true); bool StaySignedIn = true);
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator); public sealed record AuthenticatedUser(
Guid Id, string UserName, bool IsAdministrator,
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
string? Language = null,
/// <summary>Das eigene Bild, als Schlüssel in die Bildablage.</summary>
///
/// <remarks>
/// <b>Hier und nicht in einer eigenen Abfrage.</b> Die Kopfleiste zeigt es
/// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen
/// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt.
/// </remarks>
string? AvatarImage = null,
/// <summary>Der Besitzer — ihm nimmt niemand das Adminrecht.</summary>
///
/// <remarks>
/// Hinten angehängt, wie jedes neue Feld in dieser Datei: in der Mitte
/// eingeschoben bricht es jede Aufrufstelle, die die Werte der Reihe nach
/// übergibt — und davon gibt es hier drei.
/// </remarks>
bool IsOwner = false);
public sealed record LoginResponse(string Token, AuthenticatedUser User); public sealed record LoginResponse(string Token, AuthenticatedUser User);
@@ -44,6 +127,12 @@ public sealed record CodeState(string State, LoginResponse? Login);
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator); public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
/// <summary>Adminrecht geben oder nehmen.</summary>
public sealed record RechtRequest(bool IsAdministrator);
/// <summary>Eine der vier Stufen für den Aufmacher.</summary>
public sealed record HoeheRequest(string Height);
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary> /// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -52,11 +141,126 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte /// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
/// Bibliothek als leer auszugeben wäre die falsche. /// Bibliothek als leer auszugeben wäre die falsche.
/// </remarks> /// </remarks>
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path, int? ItemCount); public sealed record LibraryResponse(
Guid Id, string Name, string Kind, string Path, int? ItemCount,
/// <summary>Ob neue Funde erst geprüft werden.</summary>
bool ReviewNewItems = false,
/// <summary>Wie viele Titel dieser Mediathek noch auf Freigabe warten.</summary>
///
/// <remarks>
/// <b>Hier und nicht nur in der Prüfliste.</b> Wer eine Mediathek
/// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders
/// warten, erfährt er sonst nur, wenn er weiterscrollt.
/// </remarks>
int PendingCount = 0,
/// <summary>Wie viele Mediendateien in dieser Mediathek liegen.</summary>
///
/// <remarks>
/// <b>Weil „455 Filme" und „461 Dateien" beide stimmen können.</b> Plex
/// zählte 451, Flaro 449 — und der Scan lief über 461 Dateien. Drei
/// Zahlen für drei verschiedene Dinge, und keine davon falsch. Nebeneinander
/// zu sehen, wie sie sich aufteilen, beendet die Frage; eine davon allein
/// zu zeigen hält sie offen.
/// </remarks>
int FileCount = 0,
/// <summary>Wie viele Folgen — nur bei Serien, sonst <c>null</c>.</summary>
int? EpisodeCount = null,
/// <summary>Dateien, die an keinem Titel hängen.</summary>
int UnassignedCount = 0,
/// <summary>Dateien über die erste je Titel hinaus — zweite Fassungen.</summary>
int ExtraVersionCount = 0);
public sealed record RenameLibraryRequest(string Name); public sealed record RenameLibraryRequest(string Name);
public sealed record CreateLibraryRequest(string Name, string Kind, string Path); public sealed record CreateLibraryRequest(
string Name, string Kind, string Path,
/// <summary>Ob neue Funde erst geprüft werden. Vorgabe: nein.</summary>
bool ReviewNewItems = false);
public sealed record PruefungRequest(bool ReviewNewItems);
/// <summary>Die Reihenfolge der Mediatheken, von oben nach unten.</summary>
public sealed record ReihenfolgeRequest(IReadOnlyList<Guid>? Ids);
public sealed record SprachRequest(string? Language);
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
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);
public sealed record Bildauswahl(
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
public sealed record BildWahlRequest(string Kind, string? Url);
/// <summary>Ein Titel, der noch wartet oder geplant ist.</summary>
///
/// <remarks>
/// <b>Eine Zeile je Serie, nicht je Folge.</b> Bei zwanzig neuen Folgen
/// derselben Serie wären zwanzig Zeilen zwanzig Entscheidungen für eine
/// Sache. Die Folgen stecken in <see cref="Ids"/>; freigegeben werden sie
/// gemeinsam.
/// </remarks>
public sealed record Pruefeintrag(
/// <summary><c>movie</c> oder <c>series</c>.</summary>
string Kind,
Guid Id, string Title, int? Year, string? PosterImage,
/// <summary><c>null</c> heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant.</summary>
///
/// <remarks>
/// Bei einer Serie nur gesetzt, wenn alle Folgen denselben Termin tragen —
/// sonst wäre es eine Behauptung über zwanzig Folgen, von denen eine
/// anders liegt.
/// </remarks>
DateTimeOffset? PublishAt,
string? Path,
/// <summary>Ob eine Zuordnung besteht — ohne sie fehlen Titel und Bilder.</summary>
bool Matched,
/// <summary>Wie viele Titel dahinterstecken — bei einer Serie die Zahl der Folgen.</summary>
int Count,
/// <summary>Worauf eine Freigabe wirkt.</summary>
IReadOnlyList<Guid> Ids,
/// <summary>Warum der Eintrag hier steht: <c>neu</c> oder <c>geaendert</c>.</summary>
///
/// <remarks>
/// <b>Zwei verschiedene Dinge in einer Liste, und das mit Absicht.</b> Ein
/// neuer Fund wartet auf Freigabe und ist bis dahin unsichtbar. Ein
/// geänderter Titel steht längst in der Mediathek — er soll nur nicht
/// unbemerkt anders heissen oder ein anderes Plakat tragen. Beides gehört
/// vor dieselben Augen, aber nicht unter denselben Knopf: das eine wird
/// veröffentlicht, das andere nur zur Kenntnis genommen.
/// </remarks>
string Grund = "neu");
public sealed record FreigabeRequest(
IReadOnlyList<Guid>? Ids,
DateTimeOffset? PublishAt,
/// <summary>Statt einzelner Kennungen: alles Wartende dieser Mediathek.</summary>
Guid? LibraryId = null);
/// <summary>Ein Protokolltag.</summary>
public sealed record Protokolltag(string Date, long Bytes);
/// <summary>Eine Datei, die der letzte Scan nicht mehr gefunden hat.</summary>
public sealed record VermissteDatei(
Guid FileId,
string Path,
string? VersionLabel,
DateTimeOffset MissingSince,
string LibraryName,
/// <summary>Zu welchem Titel sie gehört — damit klar ist, was fehlen würde.</summary>
string? Title,
Guid? MovieId,
Guid? EpisodeId);
// --- Stufe 2: Bibliothek ------------------------------------------------- // --- Stufe 2: Bibliothek -------------------------------------------------
@@ -74,7 +278,11 @@ public sealed record ScanStatus(
int? Probed, int? Probed,
IReadOnlyList<ScanProblem>? Problems, IReadOnlyList<ScanProblem>? Problems,
string? Error, string? Error,
MetadataProgress? Metadata); MetadataProgress? Metadata,
/// <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);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary> /// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress( public sealed record MetadataProgress(
@@ -83,7 +291,38 @@ public sealed record MetadataProgress(
/// <summary>Eine Spur in einer Datei.</summary> /// <summary>Eine Spur in einer Datei.</summary>
public sealed record MediaStreamResponse( public sealed record MediaStreamResponse(
int Index, string Kind, string? Codec, string? Language, string? Title, int Index, string Kind, string? Codec, string? Language, string? Title,
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height); bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height,
/// <summary>Die Zeile, wie sie angezeigt werden soll.</summary>
///
/// <remarks>
/// <b>Vom Server formuliert, nicht vom Client.</b> „DEU EAC3 6.0" wird
/// „Deutsch · Dolby Digital Plus · 5.1" — und die Apps brauchen denselben
/// Satz. Zweimal formuliert driftet er auseinander, und dann heisst
/// dieselbe Tonspur am Fernseher anders als im Browser. Die Rohwerte
/// stehen weiterhin daneben; wer sie braucht, hat sie.
/// </remarks>
string? Display = null,
/// <summary>Die Auflösung als Wort — „4K", „Full HD", „HD", „SD".</summary>
///
/// <remarks>
/// <b>Eigenes Feld, damit der Client nichts zerlegen muss.</b> Die
/// Oberfläche las die Stufe bisher mit <c>display.split(' · ')[0]</c>
/// heraus — richtig, solange vor der Auflösung nie etwas anderes steht,
/// und falsch an dem Tag, an dem doch.
/// </remarks>
string? ResolutionLabel = null,
/// <summary>Dieselbe Auflösung genau — „1080p". Gehört in den Tooltip.</summary>
string? ResolutionExact = null,
/// <summary>Die Sprache als Wort — „Deutsch" statt „deu".</summary>
///
/// <remarks>
/// <b>Damit eine Liste von Sprachen ohne Rätselraten entsteht.</b> Die
/// Detailansicht nennt, in welchen Sprachen ein Film vorliegt; sie dafür
/// aus <see cref="Display"/> herauszuschneiden hiesse, sich auf dessen
/// Satzbau zu verlassen.
/// </remarks>
string? LanguageName = null);
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary> /// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
/// ///
@@ -93,9 +332,32 @@ public sealed record MediaStreamResponse(
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier /// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
/// Fassungen genügt der Name. /// Fassungen genügt der Name.
/// </remarks> /// </remarks>
/// <param name="VersionLabel">
/// <b>Wie diese Fassung heisst</b> — „Mobil", „4K", „Director's Cut" — oder
/// <c>null</c>, wenn es nur eine gibt. Ohne diese Angabe standen zwei Dateien
/// derselben Folge als zwei gleich aussehende Knopfreihen untereinander, und
/// die Folge wirkte doppelt.
/// </param>
/// <param name="Folder">
/// <b>Der Ordner unterhalb der Mediathek</b> — <c>null</c> für alle, die nicht
/// verwalten dürfen.
///
/// <para>
/// <b>Weil man einer falschen Zuordnung am Ordner sofort ansieht, dass sie
/// falsch ist.</b> Steht über der Beschreibung „Hanni &amp; Nanni 3" und der
/// Ordner heisst <c>Hanni und Nanni</c>, ist die Sache in einer Sekunde klar
/// — ohne Umweg über den Zuordnungsdialog.
/// </para>
/// <para>
/// <b>Nur der Teil unterhalb der Mediathek, und nur für Administratoren.</b>
/// Der volle Pfad verriete jedem Angemeldeten den Aufbau des Servers; der
/// relative sagt genau das, was zum Beurteilen nötig ist.
/// </para>
/// </param>
public sealed record MediaFileResponse( public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds, Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
IReadOnlyList<MediaStreamResponse> Streams); IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
string? Folder = null);
public sealed record MovieResponse( public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview, Guid Id, string Title, int? Year, string? Overview,
@@ -116,7 +378,35 @@ public sealed record MovieResponse(
/// von denen keiner angezeigt wird. /// von denen keiner angezeigt wird.
/// </remarks> /// </remarks>
IReadOnlyList<string>? Genres = null, IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null); IReadOnlyList<CreditResponse>? Credits = null,
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
string? OriginalTitle = null,
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
///
/// <remarks>
/// <b>Damit die Mediathek es zeigen kann.</b> Für einen Administrator
/// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
/// über die Prüfliste.
/// </remarks>
DateTimeOffset? PublishAt = null,
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
bool TitlePinned = false,
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
///
/// <remarks>
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
/// </remarks>
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
public sealed record CreditResponse( public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image); Guid Id, string Name, string Role, string? Character, string? Image);
@@ -139,12 +429,63 @@ public sealed record SeriesResponse(
string? Tagline = null, string? Tagline = null,
string? ContentRating = null, string? ContentRating = null,
IReadOnlyList<string>? Genres = null, IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null); IReadOnlyList<CreditResponse>? Credits = null,
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
string? OriginalTitle = null,
/// <summary>Ab wann sichtbar — <c>null</c> heisst: wartet auf Freigabe.</summary>
///
/// <remarks>
/// <b>Damit die Mediathek es zeigen kann.</b> Für einen Administrator
/// stehen wartende Titel mitten zwischen den freigegebenen, ohne dass
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
/// über die Prüfliste.
/// </remarks>
DateTimeOffset? PublishAt = null,
/// <summary>Favorit, Bewertung, Häkchen — zur Serie als Ganzem.</summary>
///
/// <remarks>
/// <b>Neben <see cref="UnwatchedCount"/>, nicht an dessen Stelle.</b> Die
/// Zahl sagt, wie viel noch aussteht; dieser Zustand sagt, was der
/// Benutzer über die Serie selbst festgehalten hat. Ohne ihn konnte eine
/// Kachel nicht wissen, ob „Als gesehen markieren" oder „Als ungesehen
/// markieren" darauf gehört — sie bot immer das erste an.
/// </remarks>
ItemStateResponse? State = null,
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
bool TitlePinned = false,
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
///
/// <remarks>
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
/// </remarks>
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
public sealed record EpisodeResponse( public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files, IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State); ItemStateResponse? State,
DateTimeOffset? PublishAt = null,
/// <summary>Zu welcher Serie sie gehört.</summary>
///
/// <remarks>
/// <b>Damit eine Folge für sich geöffnet werden kann.</b> Die Apps
/// reichen eine Kennung durch, ohne zu wissen, was dahintersteckt. War es
/// eine Folge, fand sich weder ein Film noch eine Serie, und die
/// Startseite meldete „Dieser Titel ist auf dem Server nicht mehr
/// vorhanden" — über einen Titel, den sie selbst gerade angezeigt hatte.
/// </remarks>
Guid? SeriesId = null);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total); public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
@@ -163,6 +504,30 @@ 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);
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
///
/// <param name="ResumeAfterSeconds">
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
/// </param>
/// <param name="WatchedFraction">Ab welchem Anteil er als gesehen gilt, 0 bis 1.</param>
/// <param name="Encoder">„qualitaet", „ausgewogen" oder „tempo".</param>
public sealed record PlaybackSettingsResponse(
double ResumeAfterSeconds, double WatchedFraction, string Encoder);
/// <summary>Was geändert werden soll — <c>null</c> heisst: unverändert.</summary>
public sealed record PlaybackSettingsRequest(
double? ResumeAfterSeconds, double? WatchedFraction, string? Encoder);
/// <summary>Was danach gilt, und wie viele Zeilen das umgeworfen hat.</summary>
///
/// <remarks>
/// <b>Die Zahl gehört in die Antwort.</b> Eine neue Schwelle wirkt rückwirkend
/// auf alles, was schon in „Weiterschauen" steht; ohne diese Rückmeldung
/// änderte sich die Startseite scheinbar von selbst.
/// </remarks>
public sealed record PlaybackSettingsApplied(
double ResumeAfterSeconds, double WatchedFraction, string Encoder, int Reevaluated);
// --- Stufe 3: Ausliefern ------------------------------------------------- // --- Stufe 3: Ausliefern -------------------------------------------------
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary> /// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
@@ -190,8 +555,26 @@ public sealed record MatchCandidate(
public sealed record ApplyMatchRequest(int TmdbId); public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary> /// <summary>Wie die Zuordnung jetzt aussieht.</summary>
///
/// <param name="Id">
/// <b>Welcher Eintrag das jetzt ist.</b> Meist der, den man korrigiert hat —
/// nach einem Zusammenlegen aber der überlebende, und der hat eine andere
/// Kennung.
/// </param>
/// <param name="Merged">
/// <b>Ob dabei zwei Einträge zu einem wurden.</b>
///
/// <para>
/// Ohne diese Auskunft stand der korrigierte Titel weiter in der Liste,
/// obwohl es ihn nicht mehr gab: wer ihn dann bearbeiten wollte, bekam eine
/// 404 — und zwar als englisches „Not Found", weil auch das nicht stimmte.
/// Der Client kann die Kachel jetzt wegnehmen, statt auf einen gelöschten
/// Eintrag zu zeigen.
/// </para>
/// </param>
public sealed record MatchState( public sealed record MatchState(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year); int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year,
Guid Id = default, bool Merged = false);
// --- Stufe 4: Fortschritt und Zustand ------------------------------------- // --- Stufe 4: Fortschritt und Zustand -------------------------------------
@@ -220,13 +603,109 @@ public sealed record ProgressRequest(
public sealed record WatchedRequest(bool Watched); public sealed record WatchedRequest(bool Watched);
public sealed record FavouriteRequest(bool Favourite); public sealed record FavouriteRequest(bool Favourite);
/// <summary>Mehrere Titel auf einmal abhaken — oder das Häkchen wegnehmen.</summary>
///
/// <remarks>
/// <b>Eine Anfrage statt zweihundert.</b> „Alle als gesehen markieren" über
/// eine Mediathek hinweg wäre sonst ein Anfragensturm, bei dem mittendrin
/// etwas scheitert und niemand weiss, was durchging.
/// </remarks>
public sealed record BulkWatchedRequest(IReadOnlyList<Guid> Ids, bool Watched);
/// <param name="Changed">Wie viele Titel sich dadurch geändert haben.</param>
/// <param name="Unknown">
/// Kennungen, zu denen es nichts gibt — stillschweigend zu übergehen hiesse,
/// einen Teilerfolg als vollen auszugeben.
/// </param>
public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknown);
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
/// <summary>Was ein Spieler kann und was er sehen will.</summary>
///
/// <param name="Containers">Schachteln, die das Gerät öffnet — „mp4", „webm".</param>
/// <param name="Height">
/// Die gewünschte Stufe: 1080, 720 oder 480. <c>null</c> heisst Originalauflösung.
/// </param>
/// <param name="AudioStream">
/// Welche Tonspur — die Nummer aus der Datei. <c>null</c> nimmt die erste.
/// </param>
/// <param name="StartSeconds">Ab welcher Sekunde des Films.</param>
///
/// <remarks>
/// <b>Das Gerät sagt, was es kann, statt dass der Server es rät.</b> Vorher
/// bekam jeder Browser Apples Profil — und mit ihm HEVC, AC3 und EAC3, von
/// denen er keines öffnen kann.
/// </remarks>
public sealed record StreamRequest(
IReadOnlyList<string>? Containers,
IReadOnlyList<string>? VideoCodecs,
IReadOnlyList<string>? AudioCodecs,
int? Height = null,
int? AudioStream = null,
double? StartSeconds = null,
/// <summary>Umkodieren, egal was das Profil sagt.</summary>
///
/// <remarks>
/// <b>Für den zweiten Versuch.</b> Der Spieler schickt sein Profil aus
/// <c>canPlayType</c> — und das ist eine Behauptung: „kenne ich" heisst
/// nicht „kann ich diese Datei". Scheitert die Wiedergabe trotzdem, weiss
/// der Browser mehr als jede Abfrage vorher, und sein Urteil schlägt
/// seine eigene Auskunft.
/// </remarks>
bool Transcode = false,
/// <summary>Um wie viele Millisekunden der Ton verschoben wird.</summary>
///
/// <remarks>
/// <b>Für Dateien, bei denen es ab Werk nicht stimmt.</b> Positiv heisst
/// später, negativ früher. Der Server schiebt dabei nicht am Bild, sondern
/// liest die Datei ein zweites Mal mit <c>-itsoffset</c> und nimmt von dort
/// nur den Ton — mit einer Eingabe verschöbe sich beides gemeinsam und
/// nichts wäre gewonnen.
/// </remarks>
int AudioOffsetMs = 0);
/// <summary>Eine wählbare Spur, so wie sie im Menü steht.</summary>
public sealed record SpurAuskunft(int Index, string Label, string? Language, bool IsDefault);
/// <param name="StartSeconds">
/// Ab welcher Sekunde des Films dieser Strom beginnt. <b>Der Spieler zählt ab
/// null</b> — ohne diesen Wert ginge jeder gemeldete Stand um den Vorlauf daneben.
/// </param>
public sealed record StreamResponse(
Guid SessionId,
string Url,
string Method,
double StartSeconds,
int? Height,
int? AudioStream,
IReadOnlyList<SpurAuskunft> AudioStreams,
IReadOnlyList<SpurAuskunft> SubtitleStreams);
public sealed record RatingRequest(int? Rating); public sealed record RatingRequest(int? Rating);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary> /// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
public sealed record ItemStateResponse( public sealed record ItemStateResponse(
double PositionSeconds, double? RuntimeSeconds, double PositionSeconds, double? RuntimeSeconds,
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed, bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
DateTimeOffset? LastPlayedAt); DateTimeOffset? LastPlayedAt,
/// <summary>Ob der Titel in „Weiterschauen" steht.</summary>
///
/// <remarks>
/// <b>Damit die Beschriftung dasselbe sagt wie die Reihe.</b> Web und Apps
/// entschieden „Weiterschauen" gegen „Abspielen" selbst, allein daran, ob
/// eine Stelle über null steht. Seit es eine einstellbare Untergrenze gibt,
/// lief das auseinander: ein Titel, in den man eine Sekunde hineingeschaut
/// hat, stand nicht mehr in der Reihe — hiess aber weiter „Weiterschauen".
///
/// Der Server hat diese Frage ohnehin beantwortet; sie noch einmal zu
/// stellen hiesse, dieselbe Regel an vier Stellen zu pflegen.
/// </remarks>
bool InProgress = false);
/// <summary>Eine Zeile in „Weiterschauen".</summary> /// <summary>Eine Zeile in „Weiterschauen".</summary>
/// ///
@@ -235,11 +714,77 @@ public sealed record ItemStateResponse(
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest /// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist. /// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
/// </remarks> /// </remarks>
/// <param name="Id">
/// Die Kennung des Titels selbst — bei einer Folge also die der Folge. Dorthin
/// geht der Fortschritt.
/// </param>
/// <param name="SeriesId">
/// <b>Zu welcher Serie die Folge gehört</b> — <c>null</c> bei einem Film.
///
/// <para>
/// <b>Ohne diese Angabe führte die Kachel ins Leere.</b> Sie trug die
/// Folgenkennung und sollte damit eine Serienseite öffnen; die gibt es unter
/// dieser Kennung nie, und heraus kam „Diese Serie gibt es nicht mehr" —
/// ausgerechnet unter „Nächste Folge", wo man gerade weiterschauen wollte.
/// </para>
/// </param>
public sealed record ContinueEntry( public sealed record ContinueEntry(
Guid Id, string Kind, string Title, string? SubTitle, Guid Id, string Kind, string Title, string? SubTitle,
int? Season, int? Episode, int? Year, int? Season, int? Episode, int? Year,
string? PosterImage, string? BackdropImage, string? PosterImage, string? BackdropImage,
ItemStateResponse State); ItemStateResponse State,
Guid? SeriesId = null,
/// <summary>Die Datei, die abgespielt würde.</summary>
///
/// <remarks>
/// <b>Damit „Abspielen" abspielt.</b> Der Aufmacher der Startseite hatte
/// keine Datei zur Hand und konnte deshalb nur die Detailseite öffnen —
/// ein Knopf, auf dem „Abspielen" steht und der etwas anderes tut.
/// </remarks>
Guid? FileId = null,
double? DurationSeconds = null,
/// <summary>Ab wann abspielbar. In der Zukunft heisst: noch nicht.</summary>
DateTimeOffset? PublishAt = null,
/// <summary>Das breite Bild der Serie, zu der die Folge gehört.</summary>
///
/// <remarks>
/// <b>Neben dem Folgenbild, nicht statt seiner.</b> Die Kachel will das
/// Bild der Folge — es zeigt, wo man steht. Der Aufmacher füllt die halbe
/// 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);
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
///
/// <remarks>
/// <b>Zwei Arten Zahl, und der Unterschied gehört dazu.</b>
/// <c>LoggedSeconds</c> kommt aus dem Sehprotokoll und fängt an dem Tag an,
/// an dem es eingebaut wurde. <c>WatchedTitles</c> und
/// <c>WatchedRuntimeSeconds</c> kommen aus den Sehständen und sind für die
/// ganze Vergangenheit vollständig. Beides als eine Zahl auszugeben hiesse,
/// eine davon zu behaupten.
/// </remarks>
public sealed record StatistikResponse(
double LoggedSeconds,
int WatchedTitles,
double WatchedRuntimeSeconds,
DateTimeOffset? Since,
IReadOnlyList<Wochenwert> Weeks,
IReadOnlyList<Zuletztgesehen> Recent,
IReadOnlyList<Genrewert> Genres);
/// <param name="Start">Der Montag dieser Woche.</param>
public sealed record Wochenwert(DateOnly Start, double Seconds);
public sealed record Zuletztgesehen(
Guid Id, string Kind, string Title, string? SubTitle,
string? PosterImage, DateTimeOffset At, bool Watched);
public sealed record Genrewert(string Name, double Seconds);
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary> /// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
public sealed record SessionResponse( public sealed record SessionResponse(
@@ -250,3 +795,82 @@ public sealed record SessionResponse(
/// <summary>Was der Server gerade tut.</summary> /// <summary>Was der Server gerade tut.</summary>
public sealed record ServerStatusResponse( public sealed record ServerStatusResponse(
int Playing, int Packaging, int Scanning, double Uptime); int Playing, int Packaging, int Scanning, double Uptime);
/* ---------- Meldungen ---------- */
/// <summary>Was jemand an einem Titel gemeldet hat.</summary>
public sealed record MeldungRequest(
/// <summary><c>bild</c>, <c>ton</c>, <c>untertitel</c>, <c>daten</c> oder <c>sonstiges</c>.</summary>
string Kind,
string Comment,
/// <summary>Eine Stelle wie <c>0:34:12</c> — oder nichts.</summary>
string? Position = null,
/// <summary>Nur bei einer Serie: eine einzelne Staffel statt der ganzen.</summary>
int? SeasonNumber = null);
/// <summary>Eine Meldung, wie sie gelesen wird.</summary>
///
/// <remarks>
/// <b><c>ItemId</c> ist die Kennung der Detailseite, nicht die des
/// Gemeldeten.</b> Bei einer Folge und bei einer Staffel steht hier die Serie —
/// die Detailseite gehört ihr, wie bei „Weiterschauen" auch. Was genau gemeint
/// ist, steht in <c>Target</c> und <c>SubTitle</c>.
///
/// <b><c>Title</c> ist leer, wenn der Titel nicht mehr sichtbar ist.</b> Ein
/// Administrator kann einen Titel zurückziehen, nachdem jemand ihn gemeldet
/// hat; die Meldung bleibt dann in der eigenen Liste des Melders stehen, nur
/// ohne Namen. Sie verschwinden zu lassen hiesse, jemandem seine eigene Eingabe
/// wegen einer fremden Entscheidung wegzunehmen.
/// </remarks>
public sealed record Meldungseintrag(
Guid Id,
/// <summary><c>movie</c>, <c>series</c>, <c>season</c> oder <c>episode</c>.</summary>
string Target,
Guid ItemId,
string Title,
/// <summary>„Staffel 2" oder „S2 · F5 — Der Pakt" — sonst <c>null</c>.</summary>
string? SubTitle,
string? PosterImage,
string Kind,
double? PositionSeconds,
string Comment,
DateTimeOffset ReportedAt,
string ReportedBy,
/// <summary><c>null</c> heisst: offen.</summary>
DateTimeOffset? ResolvedAt);
/* ---------- Einladungen ---------- */
/// <summary>Was beim Erstellen eines Einladungslinks gewählt wird.</summary>
public sealed record EinladungRequest(
/// <summary>Wie viele Tage er gilt.</summary>
int Days = 7,
/// <summary>Wie oft er sich einlösen lässt. <c>null</c> heisst: unbegrenzt.</summary>
int? MaxUses = 1);
/// <summary>Der frisch erstellte Link — der Token steht hier zum einzigen Mal.</summary>
///
/// <remarks>
/// <b>Die Adresse baut der Client.</b> Der Server weiss nicht, unter welchem
/// Namen er von aussen erreichbar ist; hinter einem Reverse Proxy rät er
/// falsch, und ein falscher Einladungslink ist schlimmer als keiner.
/// </remarks>
public sealed record EinladungErstellt(Guid Id, string Token, DateTimeOffset ExpiresAt, int? MaxUses);
/// <summary>Eine Einladung in der Verwaltungsliste — ohne Token.</summary>
public sealed record Einladungseintrag(
Guid Id,
string CreatedBy,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
int? MaxUses,
int UsedCount,
DateTimeOffset? RevokedAt,
/// <summary>Ob sich damit jetzt noch ein Konto anlegen lässt.</summary>
bool Valid);
/// <summary>Was die Einladungsseite zeigt, bevor jemand etwas eintippt.</summary>
public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTimeOffset ExpiresAt);
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
public sealed record EinloesenRequest(string UserName, string Password);
@@ -25,11 +25,13 @@ public static class DeliveryEndpoints
// durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200. // durch bis zum SPA-Rückfall und bekäme die Webseite mit Status 200.
// Genau das ist passiert: der Spieler bekam HTML statt Video. // Genau das ist passiert: der Spieler bekam HTML statt Video.
app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async ( app.MapMethods("/api/v1/files/{id:guid}/download", ["GET", "HEAD"], async (
Guid id, FlaroContext db, PackageCoordinator packer) => Guid id, FlaroContext db, PackageCoordinator packer, TimeProvider uhr) =>
{ {
var gefunden = await Laden(db, id); var gefunden = await Laden(db, id);
if (gefunden is not var (datei, plan)) return Results.NotFound(); if (gefunden is not var (datei, plan)) return Results.NotFound();
if (await FreiAb(db, datei, uhr.GetUtcNow()) is { } nochNicht) return NochNicht(nochNicht);
// Passt schon alles, geht die Datei unverändert hinaus — mit // Passt schon alles, geht die Datei unverändert hinaus — mit
// Längenangabe und Bereichsanfragen, also mit Fortschritt und // Längenangabe und Bereichsanfragen, also mit Fortschritt und
// Fortsetzen. // Fortsetzen.
@@ -70,6 +72,41 @@ public static class DeliveryEndpoints
}).RequireAuthorization(); }).RequireAuthorization();
} }
/// <summary>Ab wann diese Datei herausgehen darf — <c>null</c> heisst: sofort.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier und nicht im Sichtbarkeitsfilter.</b> Ein geplanter Titel steht
/// mit „Verfügbar ab …" in der Mediathek — das ist der Sinn des Planens.
/// Sichtbar heisst aber nicht abspielbar: ohne diese Prüfung liesse sich
/// die Datei trotzdem herunterladen, und die Angabe wäre eine Zierde.
/// </para>
/// <para>
/// <b>Für alle, auch für Administratoren.</b> Wer die Freigabe vorziehen
/// will, hat dafür den Knopf „Jetzt veröffentlichen" — eine Hintertür für
/// eine Rolle wäre eine Regel, die manchmal gilt.
/// </para>
/// </remarks>
internal static async Task<DateTimeOffset?> FreiAb(
FlaroContext db, MediaFile datei, DateTimeOffset jetzt)
{
DateTimeOffset? wann = datei.MovieId is { } film
? await db.Movies.AsNoTracking().Where(m => m.Id == film)
.Select(m => m.PublishAt).FirstOrDefaultAsync()
: datei.EpisodeId is { } folge
? await db.Episodes.AsNoTracking().Where(f => f.Id == folge)
.Select(f => f.PublishAt).FirstOrDefaultAsync()
: null;
return wann is { } termin && termin > jetzt ? termin : null;
}
/// <summary>Der Satz, der statt der Datei kommt.</summary>
internal static IResult NochNicht(DateTimeOffset wann) =>
Results.Problem(
$"Dieser Titel ist ab {wann.ToLocalTime():dd.MM.yyyy, HH:mm} Uhr verfügbar.",
statusCode: StatusCodes.Status403Forbidden);
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id) private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
{ {
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id); var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
@@ -0,0 +1,195 @@
using System.Security.Claims;
using Flaro.Core.Auth;
using Flaro.Core.Users;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum.</b> Ein Konto von Hand anzulegen heisst, sich ein Passwort für
/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal,
/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen
/// und Passwort selbst geben.
/// </para>
/// <para>
/// <b>Zwei Welten in einer Datei.</b> Erstellen, auflisten und zurückziehen
/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein
/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale
/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre
/// ungebremst.
/// </para>
/// </remarks>
public static class EinladungEndpoints
{
private const int HoechstensTage = 365;
public static void MapEinladungen(this IEndpointRouteBuilder app)
{
var verwalten = app.MapGroup("/api/v1/invites")
.RequireAuthorization(FlaroPolicies.Administrator);
verwalten.MapPost("", async (
EinladungRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage);
if (anfrage.MaxUses is { } anzahl && anzahl < 1)
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
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
// wiedergefunden, sondern neu gemacht.
var token = Secrets.Create();
var jetzt = uhr.GetUtcNow();
var einladung = new Einladung
{
TokenFingerprint = Secrets.Fingerprint(token),
CreatedByUserId = ich,
CreatedAt = jetzt,
ExpiresAt = jetzt.AddDays(tage),
MaxUses = anfrage.MaxUses,
};
db.Einladungen.Add(einladung);
await db.SaveChangesAsync(ct);
return Results.Ok(new EinladungErstellt(
einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses));
});
verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var jetzt = uhr.GetUtcNow();
var roh = await db.Einladungen.AsNoTracking()
.OrderByDescending(e => e.CreatedAt)
.Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id,
(e, u) => new { E = e, u.UserName })
.ToListAsync(ct);
return Results.Ok(roh.Select(z => new Einladungseintrag(
z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt,
z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList());
});
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
verwalten.MapDelete("/{id:guid}", async (
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
einladung.RevokedAt ??= uhr.GetUtcNow();
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
/* ---------- Was ohne Konto erreichbar sein muss ---------- */
app.MapGet("/api/v1/invites/{token}", async (
string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var gefunden = await Suchen(db, token, ct);
if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand;
var name = await db.Users.AsNoTracking()
.Where(u => u.Id == gefunden!.CreatedByUserId)
.Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? "";
var servername = await db.Settings.AsNoTracking()
.Where(s => s.Key == SystemEndpoints.NameSchlüssel)
.Select(s => s.Value).FirstOrDefaultAsync(ct);
return Results.Ok(new Einladungsschau(
string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername,
name, gefunden!.ExpiresAt));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapPost("/api/v1/invites/{token}/redeem", async (
string token, EinloesenRequest anfrage,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var name = anfrage.UserName?.Trim() ?? "";
if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8)
{
return Einwand.Bitte(
"Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
}
var jetzt = uhr.GetUtcNow();
var gefunden = await Suchen(db, token, ct);
if (Pruefen(gefunden, jetzt) is { } einwand) return einwand;
// **Verbrauchen und anlegen gehören zusammen.**
//
// Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen,
// läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten
// beide ein Konto. Der Zähler wird deshalb mit einer Bedingung
// hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei
// keine Zeile trifft, war der Zweite.
//
// Und das Ganze in einer Transaktion: scheitert das Anlegen am
// Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger
// Link an einem Tippfehler verbrannt.
await using var geschaeft = await db.Database.BeginTransactionAsync(ct);
var verbraucht = await db.Einladungen
.Where(e => e.Id == gefunden!.Id
&& e.RevokedAt == null
&& e.ExpiresAt > jetzt
&& (e.MaxUses == null || e.UsedCount < e.MaxUses))
.ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct);
if (verbraucht != 1)
return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt.");
if (await db.Users.AnyAsync(u => u.UserName == name, ct))
return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen.");
db.Users.Add(new User
{
UserName = name,
PasswordHash = PasswordHasher.Hash(anfrage.Password!),
IsAdministrator = false,
});
await db.SaveChangesAsync(ct);
await geschaeft.CommitAsync(ct);
return Results.NoContent();
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
}
private static Task<Einladung?> Suchen(FlaroContext db, string token, CancellationToken ct) =>
db.Einladungen.FirstOrDefaultAsync(
e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct);
/// <summary>Der Grund, warum ein Link nicht gilt — oder <c>null</c>.</summary>
///
/// <remarks>
/// <b>Unterschiedliche Sätze, mit Absicht.</b> Alles in ein „gilt nicht"
/// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit
/// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird,
/// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat —
/// sonst fragt er nach, und der Gastgeber rät mit.
/// </remarks>
private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch
{
null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."),
{ RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."),
var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."),
var e when e.MaxUses is { } h && e.UsedCount >= h =>
Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."),
_ => null,
};
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
}
@@ -0,0 +1,197 @@
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Neue Funde prüfen, veröffentlichen oder auf einen Zeitpunkt legen.</summary>
public static class FreigabeEndpoints
{
public static void MapFreigabe(this IEndpointRouteBuilder app)
{
var gruppe = app.MapGroup("/api/v1/review")
.RequireAuthorization(FlaroPolicies.Administrator);
// **Was noch wartet, und was schon geplant ist.**
//
// Beides in einer Liste: ein geplanter Titel ist nicht erledigt, er
// hat nur schon einen Termin. Ihn verschwinden zu lassen hiesse, dass
// man ihn nur findet, wenn man sich den Termin gemerkt hat.
gruppe.MapGet("/", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var jetzt = uhr.GetUtcNow();
var filme = await db.Movies.AsNoTracking()
.Where(m => m.PublishAt == null || m.PublishAt > jetzt || m.ChangedSince != null)
.OrderBy(m => m.SortTitleValue)
.Select(m => new Pruefeintrag(
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
m.TmdbId != null, 1, new List<Guid> { m.Id },
// **Neu sticht geändert.** Ein Titel, der noch gar nicht
// sichtbar ist, braucht eine Freigabe — dass sich dabei
// auch etwas geändert hat, ist die kleinere Nachricht.
m.PublishAt == null || m.PublishAt > jetzt ? "neu" : "geaendert"))
.ToListAsync(ct);
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
// derselben Serie sind eine Entscheidung, nicht zwanzig.
var offeneFolgen = await db.Episodes.AsNoTracking()
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
.Select(f => new
{
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.PublishAt,
Pfad = db.MediaFiles.Where(d => d.EpisodeId == f.Id)
.Select(d => d.Path).FirstOrDefault(),
})
.ToListAsync(ct);
var serienIds = offeneFolgen.Select(f => f.SeriesId).Distinct().ToList();
var serien = await db.Series.AsNoTracking()
.Where(s => serienIds.Contains(s.Id))
.Select(s => new { s.Id, s.Title, s.Year, s.PosterImage, Zugeordnet = s.TmdbId != null })
.ToDictionaryAsync(s => s.Id, ct);
var gebuendelt = offeneFolgen
.GroupBy(f => f.SeriesId)
.Select(g =>
{
var serie = serien.GetValueOrDefault(g.Key);
var termine = g.Select(f => f.PublishAt).Distinct().ToList();
return new Pruefeintrag(
"series", g.Key,
serie?.Title ?? "Unbekannte Serie",
serie?.Year,
serie?.PosterImage,
// Nur wenn alle denselben Termin tragen — sonst wäre es
// eine Behauptung über Folgen, die anders liegen.
termine.Count == 1 ? termine[0] : null,
Pfad(g.Select(f => f.Pfad).FirstOrDefault()),
serie?.Zugeordnet ?? false,
g.Count(),
g.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => f.Id).ToList());
})
.OrderBy(e => e.Title)
.ToList();
// **Geänderte Serien stehen für sich.** Die Bündelung oben geht
// über wartende Folgen; eine Serie, deren Titel oder Plakat sich
// geändert hat, hat damit nichts zu tun — ihre Folgen sind längst
// sichtbar.
var schonDa = gebuendelt.Select(e => e.Id).ToHashSet();
var geaenderteSerien = await db.Series.AsNoTracking()
.Where(x => x.ChangedSince != null && !schonDa.Contains(x.Id))
.OrderBy(x => x.SortTitleValue)
.Select(x => new Pruefeintrag(
"series", x.Id, x.Title, x.Year, x.PosterImage, null,
null, x.TmdbId != null,
db.Episodes.Count(f => f.SeriesId == x.Id),
new List<Guid> { x.Id },
"geaendert"))
.ToListAsync(ct);
return Results.Ok(filme.Concat(gebuendelt).Concat(geaenderteSerien).ToList());
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
static string? Pfad(string? datei) =>
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
});
// **Zur Kenntnis genommen.**
//
// Für Titel, die längst sichtbar sind und sich nur geändert haben.
// Veröffentlichen wäre dort das falsche Wort — es gibt nichts
// freizugeben, nur etwas abzuhaken.
gruppe.MapPost("/acknowledge", async (
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
var ids = anfrage.Ids ?? [];
var filme = ids.Count == 0
? await db.Movies.Where(m => m.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct)
: await db.Movies.Where(m => ids.Contains(m.Id) && m.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.ChangedSince, (DateTimeOffset?)null), ct);
var serien = ids.Count == 0
? await db.Series.Where(x => x.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct)
: await db.Series.Where(x => ids.Contains(x.Id) && x.ChangedSince != null)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ChangedSince, (DateTimeOffset?)null), ct);
return Results.Ok(new { Acknowledged = filme + serien });
});
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
gruppe.MapPost("/publish", async (
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var wann = anfrage.PublishAt ?? uhr.GetUtcNow();
// Alles Wartende einer einzelnen Mediathek — der Knopf, der dort
// steht, wo man gerade gescannt hat.
if (anfrage.LibraryId is { } mediathek)
{
var jetztDort = uhr.GetUtcNow();
var dortFilme = await db.Movies
.Where(m => m.LibraryId == mediathek
&& (m.PublishAt == null || m.PublishAt > jetztDort))
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
var dortFolgen = await db.Episodes
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek)
&& (f.PublishAt == null || f.PublishAt > jetztDort))
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
return Results.Ok(new { published = dortFilme + dortFolgen });
}
// **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
if (anfrage.Ids is null or { Count: 0 })
{
// **Alles, was noch nicht sichtbar ist** — Wartendes und
// Geplantes. Nur das Wartende zu fassen hiesse: ein auf
// nächste Woche gelegter Titel bliebe liegen, obwohl er in
// derselben Liste stand und „Alle veröffentlichen" gedrückt
// wurde.
var jetzt = uhr.GetUtcNow();
var alleFilme = await db.Movies
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
var alleFolgen = await db.Episodes
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
return Results.Ok(new { published = alleFilme + alleFolgen });
}
var ids = anfrage.Ids;
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
return Results.Ok(new { published = filme + folgen });
});
// Zurückziehen — wieder in die Prüfung.
gruppe.MapPost("/withhold", async (
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
return Einwand.Bitte("Ohne Kennungen gibt es nichts zurückzuziehen.");
var ids = anfrage.Ids;
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, (DateTimeOffset?)null), ct);
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, (DateTimeOffset?)null), ct);
return Results.Ok(new { withheld = filme + folgen });
});
}
}
@@ -18,7 +18,7 @@ public static class ImageEndpoints
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild // Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei // unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
// einer Mediathek mit tausend Plakaten das meiste an Verkehr. // einer Mediathek mit tausend Plakaten das meiste an Verkehr.
return Results.File(pfad, "image/jpeg", enableRangeProcessing: true); return Results.File(pfad, ImageCache.MedientypVon(pfad), enableRangeProcessing: true);
}) })
.RequireAuthorization() .RequireAuthorization()
.AddEndpointFilter(async (zusammenhang, weiter) => .AddEndpointFilter(async (zusammenhang, weiter) =>
@@ -1,3 +1,6 @@
using Flaro.Api.Auth;
using Flaro.Core.Library;
using Flaro.Core.Media;
using System.Security.Claims; using System.Security.Claims;
using Flaro.Core.Playback; using Flaro.Core.Playback;
using Flaro.Data; using Flaro.Data;
@@ -16,6 +19,34 @@ public static class LibraryContentEndpoints
/// </remarks> /// </remarks>
private const int HöchstensJeSeite = 200; private const int HöchstensJeSeite = 200;
/// <summary>Wie viele Titel eine Auswahl höchstens umfasst.</summary>
private const int HöchstensJeZug = 1000;
/// <summary>Welche Titel im Rumpf stehen — oder <c>null</c>, wenn keiner da ist.</summary>
///
/// <remarks>
/// <b>Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.</b>
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
/// Knopf dafür gibt es länger als die Auswahl.
/// </remarks>
private static async Task<IReadOnlyList<Guid>?> Auswahl(HttpRequest anfrage)
{
if (anfrage.ContentLength is null or 0) return null;
if (!anfrage.HasJsonContentType()) return null;
try
{
var gelesen = await anfrage.ReadFromJsonAsync<RefreshRequest>();
return gelesen?.Ids;
}
catch (System.Text.Json.JsonException)
{
// Unlesbarer Rumpf heisst: keine Auswahl. Alles aufzufrischen ist
// hier die harmlosere Auslegung — und sie kostet nur Zeit.
return null;
}
}
/// <summary>Wonach eine Liste geordnet wird.</summary> /// <summary>Wonach eine Liste geordnet wird.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -57,13 +88,84 @@ public static class LibraryContentEndpoints
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse( return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title, s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)); s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height,
Display: s.Kind switch
{
MediaStreamKind.Video => Benennung.Bildspur(s.Codec, s.Width, s.Height),
MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title),
_ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false),
},
LanguageName: Benennung.Sprache(s.Language),
ResolutionLabel: s.Kind == MediaStreamKind.Video
? Benennung.Aufloesungswort(s.Width, s.Height) : null,
ResolutionExact: s.Kind == MediaStreamKind.Video
? Benennung.Aufloesung(s.Width, s.Height) : null));
} }
private static MediaFileResponse Datei( private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer, Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
ILookup<Guid, MediaStreamResponse> spuren) => ILookup<Guid, MediaStreamResponse> spuren, string? fassung = null,
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList()); string? mediathekspfad = null) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds,
spuren[id].ToList(),
// **Drei Quellen, in dieser Reihenfolge.** Was in der Datenbank
// steht, kam aus dem Dateinamen und ist das Genaueste. Sonst wird
// der Pfad gelesen — ein Bestand von vor dieser Änderung soll
// nicht erst neu gescannt werden müssen, um „Mobil" zu zeigen.
// Und sonst die Messung: hier gerechnet und nicht gespeichert,
// damit ein neues Wort nicht erst einen Scan braucht.
fassung ?? FileNames.Fassung(pfad) ?? AusDenSpuren(spuren[id]),
Ordner(pfad, mediathekspfad));
/// <summary>Die Fassung aus der gemessenen Bildgrösse — „Full HD", „SD".</summary>
private static string? AusDenSpuren(IEnumerable<MediaStreamResponse> spuren) =>
spuren.FirstOrDefault(s => s.Kind == nameof(MediaStreamKind.Video))?.ResolutionLabel;
/// <summary>Der Pfad der Mediathek — nur für Administratoren.</summary>
///
/// <remarks>
/// <b>Hier entscheidet sich, wer den Ordner sieht.</b> Ohne Wurzel bleibt
/// <see cref="Ordner"/> bei <c>null</c>, und das Feld kommt erst gar nicht
/// in der Antwort an — statt es im Client zu verstecken, wo es jeder
/// auslesen könnte.
/// </remarks>
private static async Task<string?> MediathekspfadFuer(
FlaroContext db, ClaimsPrincipal wer, Guid bibliothek)
{
if (!wer.IsInRole(FlaroRoles.Administrator)) return null;
return await db.Libraries.AsNoTracking()
.Where(l => l.Id == bibliothek)
.Select(l => l.Path)
.FirstOrDefaultAsync();
}
/// <summary>Der Ordner unterhalb der Mediathek — oder <c>null</c>.</summary>
///
/// <remarks>
/// <b>Nur der Teil unterhalb, nie der volle Pfad.</b> Der verriete jedem
/// Angemeldeten den Aufbau des Servers; was zum Beurteilen einer Zuordnung
/// nötig ist, ist der Ordnername — „Hanni und Nanni" neben dem Titel
/// „Hanni &amp; Nanni 3" sagt in einer Sekunde, dass etwas nicht stimmt.
///
/// <c>null</c> für alle, die nicht verwalten dürfen: dort wird der
/// Mediathekspfad gar nicht erst hereingereicht.
/// </remarks>
private static string? Ordner(string pfad, string? mediathekspfad)
{
if (string.IsNullOrEmpty(mediathekspfad)) return null;
var ordner = System.IO.Path.GetDirectoryName(pfad);
if (string.IsNullOrEmpty(ordner)) return null;
var wurzel = mediathekspfad.TrimEnd('/', '\\');
if (!ordner.StartsWith(wurzel, StringComparison.Ordinal)) return null;
var darunter = ordner[wurzel.Length..].Trim('/', '\\');
// Liegt die Datei unmittelbar in der Mediathek, gibt es keinen Ordner
// — und „" wäre eine Zeile, die nichts sagt.
return darunter.Length > 0 ? darunter : null;
}
/// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary> /// <summary>Die Genres eines Titels, in der Reihenfolge der Quelle.</summary>
private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) => private static async Task<IReadOnlyList<string>> GenresZu(FlaroContext db, Guid? film, Guid? serie) =>
@@ -91,22 +193,25 @@ public static class LibraryContentEndpoints
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>. /// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
/// </remarks> /// </remarks>
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu( private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null) FlaroContext db, ClaimsPrincipal wer,
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
{ {
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return []; if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
var gesucht = (filme ?? folgen)!; var gesucht = (filme ?? folgen ?? serien)!;
if (gesucht.Count == 0) return []; if (gesucht.Count == 0) return [];
var zeilen = await db.UserItemStates.AsNoTracking() var zeilen = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == benutzer) .Where(z => z.UserId == benutzer)
.Where(z => filme != null .Where(z => filme != null
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value) ? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
: z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)) : folgen != null
? z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)
: z.SeriesId != null && gesucht.Contains(z.SeriesId!.Value))
.ToListAsync(); .ToListAsync();
return zeilen.ToDictionary( return zeilen.ToDictionary(
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value, z => (filme != null ? z.MovieId : folgen != null ? z.EpisodeId : z.SeriesId)!.Value,
PlaybackEndpoints.Abbild); PlaybackEndpoints.Abbild);
} }
@@ -147,12 +252,33 @@ public static class LibraryContentEndpoints
// erst beim nächsten neuen Film. Geholt wird über die bekannte // erst beim nächsten neuen Film. Geholt wird über die bekannte
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand // Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
// bleibt damit stehen. // bleibt damit stehen.
//
// **Mit einer Auswahl im Rumpf nur diese Titel.** Zehn ausgewählte
// Filme kosten zehn Anfragen bei TMDB; der ganze Bestand viertausend
// und eine Viertelstunde. Ohne Rumpf bleibt es beim ganzen Bestand.
//
// **Der Rumpf wird von Hand gelesen, und das hat einen Grund.** Als
// `RefreshRequest?` im Kopf der Methode lehnte der Server jede Anfrage
// ohne Rumpf mit 404 ab — also genau den Aufruf „die ganze Mediathek
// auffrischen", den es vorher schon gab. Gemessen, nicht vermutet: ohne
// Rumpf 404, mit `{}` 202.
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async ( app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) => Guid id, HttpRequest anfrage, FlaroContext db, ScanCoordinator koordinator) =>
{ {
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound(); if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
return koordinator.TryStart(id, erneut: true) IReadOnlySet<Guid>? nurDiese = null;
if (await Auswahl(anfrage) is { Count: > 0 } gewaehlt)
{
if (gewaehlt.Count > HöchstensJeZug)
return Results.Problem(
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
+ "Nimm weniger und wiederhole es.",
statusCode: StatusCodes.Status400BadRequest);
nurDiese = gewaehlt.ToHashSet();
}
return koordinator.TryStart(id, erneut: true, nurDiese: nurDiese)
? Results.AcceptedAtRoute("Scanstand", new { id }) ? Results.AcceptedAtRoute("Scanstand", new { id })
: Results.Problem( : Results.Problem(
"Für diese Bibliothek läuft bereits ein Lauf.", "Für diese Bibliothek läuft bereits ein Lauf.",
@@ -175,20 +301,90 @@ public static class LibraryContentEndpoints
zustand.Error, zustand.Error,
metadaten is null ? null : new MetadataProgress( metadaten is null ? null : new MetadataProgress(
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched, metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
metadaten.Skipped, metadaten.Error))); metadaten.Skipped, metadaten.Error),
bericht?.Missing,
zustand.Schritt?.Text()));
}).WithName("Scanstand").RequireAuthorization(); }).WithName("Scanstand").RequireAuthorization();
} }
/// <summary>Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur, was man besitzt.</b> Ein Vorschlag, den man nicht abspielen
/// kann, ist Werbung. TMDB hätte eine Empfehlungsliste, aber die besteht
/// zur Hälfte aus Filmen, die hier nicht liegen.
/// </para>
/// <para>
/// <b>Aus Genres und Menschen, ohne eine einzige fremde Anfrage.</b> Der
/// Katalog steht ohnehin in der Datenbank. Ein gemeinsamer Mensch wiegt
/// schwerer als ein gemeinsames Genre: dass zwei Filme „Drama" sind, sagt
/// wenig; dass beide von derselben Regie stammen, sagt viel.
/// </para>
/// </remarks>
private static async Task<List<Guid>> AehnlicheAsync(
FlaroContext db, Guid filmId, int wieViele)
{
var genres = await db.GenreLinks.Where(g => g.MovieId == filmId)
.Select(g => g.GenreId).ToListAsync();
var leute = await db.Credits.Where(c => c.MovieId == filmId)
.Select(c => c.PersonId).ToListAsync();
if (genres.Count == 0 && leute.Count == 0) return [];
var bibliothek = await db.Movies.Where(m => m.Id == filmId)
.Select(m => m.LibraryId).FirstOrDefaultAsync();
var punkte = await db.Movies
.Where(m => m.Id != filmId && m.LibraryId == bibliothek)
.Select(m => new
{
m.Id,
Punkte =
db.GenreLinks.Count(g => g.MovieId == m.Id && genres.Contains(g.GenreId))
+ db.Credits.Count(c => c.MovieId == m.Id && leute.Contains(c.PersonId)) * 3,
})
.Where(x => x.Punkte > 0)
.OrderByDescending(x => x.Punkte)
.Take(wieViele)
.Select(x => x.Id)
.ToListAsync();
return punkte;
}
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
///
/// <remarks>
/// <b>Einmal je Anfrage, nicht je Zeile.</b> Ein Abruf für fünfzig Titel
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
/// </remarks>
private static async Task<string?> SpracheVon(ClaimsPrincipal wer, FlaroContext db)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return null;
return await db.Users.Where(u => u.Id == kennung).Select(u => u.Language).FirstOrDefaultAsync();
}
private static void MapBrowse(IEndpointRouteBuilder app) private static void MapBrowse(IEndpointRouteBuilder app)
{ {
app.MapGet("/api/v1/movies", async ( app.MapGet("/api/v1/movies", async (
ClaimsPrincipal wer, FlaroContext db, ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) => Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{ {
var sprache = await SpracheVon(wer, db);
var abfrage = db.Movies.AsNoTracking(); var abfrage = db.Movies.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung); if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
// **Über beide Titel.** Wer „Despicable Me" tippt, soll „Ich einfach
// unverbesserlich" finden — und umgekehrt. Nur über den
// angezeigten zu suchen hiesse, die halbe Mediathek unauffindbar
// zu machen, sobald jemand den anderen Namen im Kopf hat.
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%")); {
var muster = $"%{search.Trim()}%";
abfrage = abfrage.Where(m =>
EF.Functions.Like(m.Title, muster)
|| (m.OriginalTitle != null && EF.Functions.Like(m.OriginalTitle, muster)));
}
if (!string.IsNullOrWhiteSpace(genre)) if (!string.IsNullOrWhiteSpace(genre))
{ {
var gesucht = genre.Trim(); var gesucht = genre.Trim();
@@ -211,8 +407,28 @@ public static class LibraryContentEndpoints
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(m => new .Select(m => new
{ {
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.Id,
// **Die Übersetzung sticht, wenn es eine gibt.** Fehlt sie,
// steht der Titel in der Sprache des Servers da — nicht
// leer. Ein Film ohne englische Übersetzung heisst dann
// eben weiter „Das Boot", und das ist richtiger als eine
// Lücke.
Title = sprache == null ? m.Title
: db.Translations
.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
m.OriginalTitle, m.Year,
Overview = sprache == null ? m.Overview
: db.Translations
.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
m.PosterImage, m.BackdropImage,
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating, m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
m.PublishAt,
// **Damit die Kachel weiss, wohin sie gehört.** Nach einer
// Korrektur stand „WALL·E" weiter unter E, bis man die
// Seite neu lud.
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
}) })
.ToListAsync(); .ToListAsync();
@@ -222,7 +438,7 @@ public static class LibraryContentEndpoints
var ids = kennungen.Select(m => m.Id).ToList(); var ids = kennungen.Select(m => m.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value)) .Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId }) .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId, f.VersionLabel })
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: ids); var stände = await ZuständeZu(db, wer, filme: ids);
@@ -230,9 +446,14 @@ public static class LibraryContentEndpoints
var items = kennungen.Select(m => new MovieResponse( var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id) dateien.Where(f => f.MovieId == m.Id)
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel)).ToList(),
stände.GetValueOrDefault(m.Id), stände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList(); m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
OriginalTitle: m.OriginalTitle,
PublishAt: m.PublishAt,
TitlePinned: m.TitlePinned,
SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned)).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt)); return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -241,10 +462,16 @@ public static class LibraryContentEndpoints
ClaimsPrincipal wer, FlaroContext db, ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) => Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{ {
var sprache = await SpracheVon(wer, db);
var abfrage = db.Series.AsNoTracking(); var abfrage = db.Series.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung); if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%")); {
var muster = $"%{search.Trim()}%";
abfrage = abfrage.Where(x =>
EF.Functions.Like(x.Title, muster)
|| (x.OriginalTitle != null && EF.Functions.Like(x.OriginalTitle, muster)));
}
if (!string.IsNullOrWhiteSpace(genre)) if (!string.IsNullOrWhiteSpace(genre))
{ {
var gesucht = genre.Trim(); var gesucht = genre.Trim();
@@ -264,8 +491,24 @@ public static class LibraryContentEndpoints
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(s => new .Select(s => new
{ {
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, s.Id,
Title = sprache == null ? s.Title
: db.Translations
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? s.Title,
s.OriginalTitle, s.Year,
Overview = sprache == null ? s.Overview
: db.Translations
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
s.PosterImage, s.BackdropImage,
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating, s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
// **Eine Serie wartet, solange keine Folge sichtbar ist.**
// Der früheste Zeitpunkt sagt, ab wann überhaupt etwas da
// ist; steht dort nichts, wartet alles.
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
.Min(f => f.PublishAt),
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
}) })
.ToListAsync(); .ToListAsync();
@@ -279,6 +522,7 @@ public static class LibraryContentEndpoints
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien // zusammen. Je Serie zu fragen wäre bei fünfzig Serien
// einundfünfzig Abfragen. // einundfünfzig Abfragen.
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList()); var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, serien: ids);
var items = serien.Select(s => var items = serien.Select(s =>
{ {
@@ -287,7 +531,13 @@ public static class LibraryContentEndpoints
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count, s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(), meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
meine.Count(f => !gesehen.Contains(f.Id)), meine.Count(f => !gesehen.Contains(f.Id)),
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating); s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
OriginalTitle: s.OriginalTitle,
PublishAt: s.PublishAt,
State: stände.GetValueOrDefault(s.Id),
TitlePinned: s.TitlePinned,
SortTitle: s.SortTitleValue,
SortTitlePinned: s.SortTitlePinned);
}).ToList(); }).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt)); return Results.Ok(new Page<SeriesResponse>(items, gesamt));
@@ -321,6 +571,41 @@ public static class LibraryContentEndpoints
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.** // **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus // Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung. // „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
// Ähnliche Titel als eigener Abruf: die Detailseite soll stehen,
// bevor diese Rechnung durch ist.
app.MapGet("/api/v1/movies/{id:guid}/similar", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, int take = 12) =>
{
var kennungen = await AehnlicheAsync(db, id, Math.Clamp(take, 1, 24));
if (kennungen.Count == 0) return Results.Ok(new Page<MovieResponse>([], 0));
var sprache = await SpracheVon(wer, db);
var filme = await db.Movies.AsNoTracking()
.Where(m => kennungen.Contains(m.Id))
.Select(m => new
{
m.Id,
Title = sprache == null ? m.Title
: db.Translations.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
m.OriginalTitle, m.Year, m.PosterImage, m.BackdropImage, m.LogoImage,
m.RuntimeMinutes, m.Rating, m.ContentRating,
})
.ToListAsync();
// 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,
OriginalTitle: f.OriginalTitle))
.ToList();
return Results.Ok(new Page<MovieResponse>(items, items.Count));
}).RequireAuthorization();
app.MapGet("/api/v1/movies/{id:guid}", async ( app.MapGet("/api/v1/movies/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) => Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{ {
@@ -331,18 +616,30 @@ public static class LibraryContentEndpoints
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId == id) .Where(f => f.MovieId == id)
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration }) .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.VersionLabel })
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]); var stände = await ZuständeZu(db, wer, filme: [id]);
var wurzel = await MediathekspfadFuer(db, wer, film.LibraryId);
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
.FirstOrDefaultAsync(t => t.MovieId == id && t.Language == sprache);
return Results.Ok(new MovieResponse( return Results.Ok(new MovieResponse(
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage, film.Id, uebersetzt?.Title ?? film.Title, film.Year,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren, f.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(id), stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating, film.LogoImage, film.RuntimeMinutes, film.Rating,
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
await GenresZu(db, film: id, serie: null), await GenresZu(db, film: id, serie: null),
await MitwirkendeZu(db, film: id, serie: null))); await MitwirkendeZu(db, film: id, serie: null),
OriginalTitle: film.OriginalTitle,
TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned,
PublishAt: film.PublishAt));
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async ( app.MapGet("/api/v1/series/{id:guid}", async (
@@ -355,17 +652,70 @@ public static class LibraryContentEndpoints
var folgen = await db.Episodes.AsNoTracking() var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == id) .Where(f => f.SeriesId == id)
.Select(f => new { f.Id, f.SeasonNumber }) .Select(f => new { f.Id, f.SeasonNumber, f.PublishAt })
.ToListAsync(); .ToListAsync();
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList()); var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
.FirstOrDefaultAsync(t => t.SeriesId == id && t.Language == sprache);
return Results.Ok(new SeriesResponse( return Results.Ok(new SeriesResponse(
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage, serie.Id, uebersetzt?.Title ?? serie.Title, serie.Year,
uebersetzt?.Overview ?? serie.Overview, serie.PosterImage, serie.BackdropImage,
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(), folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
folgen.Count(f => !gesehen.Contains(f.Id)), folgen.Count(f => !gesehen.Contains(f.Id)),
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating, serie.LogoImage, serie.RuntimeMinutes, serie.Rating,
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
await GenresZu(db, film: null, serie: id), await GenresZu(db, film: null, serie: id),
await MitwirkendeZu(db, film: null, serie: id))); await MitwirkendeZu(db, film: null, serie: id),
OriginalTitle: serie.OriginalTitle,
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id),
TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned,
PublishAt: folgen.Min(f => f.PublishAt)));
}).RequireAuthorization();
// **Eine Folge für sich.**
//
// Es gab sie nur im Rudel — als Liste einer Serie. Die Apps reichen
// aber eine blanke Kennung durch: sie haben sie aus „Weiterschauen"
// und wissen nicht, ob ein Film, eine Serie oder eine Folge
// dahintersteckt. Sie fragten der Reihe nach Film und Serie, fanden
// beides nicht und meldeten „Dieser Titel ist auf dem Server nicht
// mehr vorhanden" — über eine Folge, die sie selbst gerade zeigten.
app.MapGet("/api/v1/episodes/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
{
var folge = await db.Episodes.AsNoTracking()
.Where(f => f.Id == id)
.Select(f => new
{
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber,
f.Title, f.Overview, f.StillImage, f.PublishAt,
})
.FirstOrDefaultAsync();
if (folge is null) return Results.NotFound();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == 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: [id]);
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == folge.SeriesId)
.Select(x => x.LibraryId).FirstAsync());
return Results.Ok(new EpisodeResponse(
folge.Id, folge.SeasonNumber, folge.EpisodeNumber,
folge.Title, folge.Overview, folge.StillImage,
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));
}).RequireAuthorization(); }).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann // **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
@@ -385,7 +735,7 @@ public static class LibraryContentEndpoints
.Where(f => f.SeasonNumber > diese.SeasonNumber .Where(f => f.SeasonNumber > diese.SeasonNumber
|| (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber)) || (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber))
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage }) .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
// Keine weitere Folge ist kein Fehler — es war die letzte. // Keine weitere Folge ist kein Fehler — es war die letzte.
@@ -393,16 +743,26 @@ public static class LibraryContentEndpoints
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId == naechste.Id) .Where(d => d.EpisodeId == naechste.Id)
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration }) .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.VersionLabel })
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]); 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();
return Results.Ok(new EpisodeResponse( return Results.Ok(new EpisodeResponse(
naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber, naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber,
naechste.Title, naechste.Overview, naechste.StillImage, naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild,
dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel)).ToList(),
stände.GetValueOrDefault(naechste.Id))); stände.GetValueOrDefault(naechste.Id),
naechste.PublishAt,
SeriesId: diese.SeriesId));
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async ( app.MapGet("/api/v1/series/{id:guid}/episodes", async (
@@ -415,22 +775,33 @@ public static class LibraryContentEndpoints
var folgen = await abfrage var folgen = await abfrage
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage }) .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.PublishAt })
.ToListAsync(); .ToListAsync();
var wurzel = await MediathekspfadFuer(db, wer,
await db.Series.AsNoTracking().Where(x => x.Id == id)
.Select(x => x.LibraryId).FirstAsync());
var ids = folgen.Select(f => f.Id).ToList(); var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking() var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value)) .Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId }) .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId, d.VersionLabel })
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var stände = await ZuständeZu(db, wer, folgen: ids); var stände = await ZuständeZu(db, wer, folgen: ids);
var serienBild = await db.Series.AsNoTracking()
.Where(x => x.Id == id)
.Select(x => x.PosterImage).FirstOrDefaultAsync();
var items = folgen.Select(f => new EpisodeResponse( var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview,
f.StillImage ?? serienBild,
dateien.Where(d => d.EpisodeId == f.Id) dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren, d.VersionLabel, wurzel)).ToList(),
stände.GetValueOrDefault(f.Id))).ToList(); stände.GetValueOrDefault(f.Id),
f.PublishAt,
SeriesId: id)).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count)); return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -1,5 +1,7 @@
using System.Security.Claims;
using Flaro.Api.Auth; using Flaro.Api.Auth;
using Flaro.Core.Libraries; using Flaro.Core.Libraries;
using Flaro.Core.Library;
using Flaro.Core.Users; using Flaro.Core.Users;
using Flaro.Data; using Flaro.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -8,6 +10,9 @@ namespace Flaro.Api.Endpoints;
public static class ManagementEndpoints public static class ManagementEndpoints
{ {
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
public static void MapManagement(this IEndpointRouteBuilder app) public static void MapManagement(this IEndpointRouteBuilder app)
{ {
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator); var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
@@ -15,20 +20,87 @@ public static class ManagementEndpoints
benutzer.MapGet("/", async (FlaroContext db) => benutzer.MapGet("/", async (FlaroContext db) =>
Results.Ok(await db.Users Results.Ok(await db.Users
.OrderBy(u => u.UserName) .OrderBy(u => u.UserName)
.Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator)) .Select(u => new AuthenticatedUser(
u.Id, u.UserName, u.IsAdministrator, u.Language, u.AvatarImage, u.IsOwner))
.ToListAsync())); .ToListAsync()));
/* ---------- Rechte, Besitz, Löschen ---------- */
// **Warum es einen Besitzer gibt.** Sobald Administratoren einander
// das Recht entziehen können, gibt es einen Zustand ohne jeden
// Administrator — und aus dem führt kein Weg zurück ausser dem an der
// Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus.
benutzer.MapPut("/{id:guid}/administrator", async (
Guid id, RechtRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, CancellationToken ct) =>
{
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
if (ziel.IsOwner && !anfrage.IsAdministrator)
return Einwand.Bitte("Dem Besitzer lässt sich das Adminrecht nicht nehmen.");
// **Nicht an sich selbst.** Wer sich im Eifer das eigene Recht
// nimmt, steht vor einer Verwaltung, die er nicht mehr öffnen
// kann — und das Zurücknehmen braucht genau die.
if (Kennung(wer) == id && !anfrage.IsAdministrator)
return Einwand.Bitte("Das eigene Adminrecht kannst du dir nicht selbst nehmen.");
ziel.IsAdministrator = anfrage.IsAdministrator;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
// **Nur der Besitzer gibt den Besitz weiter**, und nur an jemanden,
// der schon Administrator ist: sonst wäre der Server einen Klick von
// einem Besitzer entfernt, der die Verwaltung gar nicht sieht.
benutzer.MapPost("/{id:guid}/owner", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
var bisher = await db.Users.FirstOrDefaultAsync(u => u.Id == ich, ct);
if (bisher is not { IsOwner: true })
return Einwand.Bitte("Nur der Besitzer kann den Besitz weitergeben.");
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
if (!ziel.IsAdministrator)
return Einwand.Bitte("Der Besitz geht nur an einen Administrator.");
bisher.IsOwner = false;
ziel.IsOwner = true;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
benutzer.MapDelete("/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Results.NoContent();
if (ziel.IsOwner) return Einwand.Bitte("Den Besitzer kann man nicht löschen.");
if (Kennung(wer) == id)
return Einwand.Bitte("Das eigene Konto kannst du hier nicht löschen.");
// Geräte, Sehstände, Sehprotokoll, Meldungen und offene
// Einladungen hängen mit `Cascade` daran — siehe `FlaroContext`.
// Ein Konto zu löschen und seine Spuren zu lassen hiesse, Zahlen
// über jemanden zu führen, den es nicht mehr gibt.
db.Users.Remove(ziel);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) => benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
{ {
var name = anfrage.UserName.Trim(); var name = anfrage.UserName.Trim();
if (name.Length == 0 || anfrage.Password.Length < 8) if (name.Length == 0 || anfrage.Password.Length < 8)
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
{
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
});
if (await db.Users.AnyAsync(u => u.UserName == name)) if (await db.Users.AnyAsync(u => u.UserName == name))
return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict); return Einwand.Belegt($"„{name}“ gibt es schon.");
var neu = new User var neu = new User
{ {
@@ -51,9 +123,44 @@ public static class ManagementEndpoints
// dem sich nach dem Klick nichts findet — da ist der Unterschied // dem sich nach dem Klick nichts findet — da ist der Unterschied
// zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens // zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens
// sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile. // sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile.
bibliotheken.MapGet("/", async (FlaroContext db) => bibliotheken.MapGet("/", async (FlaroContext db, TimeProvider uhr) =>
{ {
var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync(); // **Ohne Rücksicht auf Gross- und Kleinschreibung.**
//
// SQLite vergleicht Text sonst zeichengenau, und dort stehen alle
// Grossbuchstaben vor allen kleinen: „Serien" landete über „film",
// obwohl f vor s kommt. Wer eine Mediathek umbenennt, sieht sie
// dann scheinbar grundlos springen — es sah aus, als stünde die
// zuletzt bearbeitete oben.
// **Erst die eigene Reihenfolge, dann der Name.** Wer nie
// umsortiert hat, bekommt das Alphabet; wer es getan hat, seine
// Reihenfolge. Ohne den zweiten Schlüssel stünden alle
// Nie-Sortierten in zufälliger Folge.
var liste = await db.Libraries.AsNoTracking()
.OrderBy(l => l.SortOrder)
.ThenBy(l => EF.Functions.Collate(l.Name, "NOCASE"))
.ToListAsync();
var jetzt = uhr.GetUtcNow();
// **Was noch wartet, je Mediathek.** Wer scannt, sieht das Ergebnis
// an der Mediathek — dass zwanzig Titel irgendwo weiter unten
// warten, erführe er sonst nur durch Weiterscrollen.
//
// Für einen gewöhnlichen Benutzer bleibt die Zahl null: der
// Freigabefilter lässt ihn Unveröffentlichtes gar nicht erst
// zählen, und das ist richtig so.
var wartendeFilme = await db.Movies.AsNoTracking()
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
.GroupBy(m => m.LibraryId)
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
var wartendeFolgen = await db.Episodes.AsNoTracking()
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
.GroupBy(f => db.Series.Where(s => s.Id == f.SeriesId)
.Select(s => s.LibraryId).First())
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
var filme = await db.Movies.AsNoTracking() var filme = await db.Movies.AsNoTracking()
.GroupBy(m => m.LibraryId) .GroupBy(m => m.LibraryId)
@@ -65,36 +172,286 @@ public static class ManagementEndpoints
.Select(g => new { Id = g.Key, Anzahl = g.Count() }) .Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl); .ToDictionaryAsync(x => x.Id, x => x.Anzahl);
return Results.Ok(liste.Select(l => new LibraryResponse( // **Drei Zahlen, die alle stimmen und Verschiedenes meinen.**
l.Id, l.Name, l.Kind.ToString(), l.Path, //
l.Kind == LibraryKind.Shows // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461
? serien.GetValueOrDefault(l.Id) // Dateien. Keine davon ist falsch — es sind Titel, Dateien und
: filme.GetValueOrDefault(l.Id))).ToList()); // zweite Fassungen. Nebeneinander beantwortet sich die Frage von
// selbst; einzeln bleibt sie offen.
var folgen = await db.Episodes.AsNoTracking()
.GroupBy(f => db.Series.Where(x => x.Id == f.SeriesId)
.Select(x => x.LibraryId).First())
.Select(g => new { Id = g.Key, Anzahl = g.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Anzahl);
// Vermisste zählen nicht mit: sie liegen nicht mehr auf der Platte,
// und der Scan hat sie deshalb auch nicht gesehen.
//
// **`MitTitel` zählt Titel, nicht Dateien** — und zwar nur solche,
// zu denen wirklich noch eine Datei daliegt. Ohne das ginge die
// Rechnung schief, sobald etwas vermisst wird: ein erster Versuch
// verglich gegen alle Folgen und kam auf 1223 Titel bei 324
// Dateien.
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MissingSince == null)
.GroupBy(f => f.LibraryId)
.Select(g => new
{
Id = g.Key,
Alle = g.Count(),
Ohne = g.Count(f => f.MovieId == null && f.EpisodeId == null),
MitTitel = g.Where(f => f.MovieId != null).Select(f => f.MovieId).Distinct().Count()
+ g.Where(f => f.EpisodeId != null).Select(f => f.EpisodeId).Distinct().Count(),
})
.ToDictionaryAsync(x => x.Id, x => x);
return Results.Ok(liste.Select(l =>
{
var zaehler = dateien.GetValueOrDefault(l.Id);
var alleDateien = zaehler?.Alle ?? 0;
var ohneTitel = zaehler?.Ohne ?? 0;
// Jeder Titel, zu dem noch eine Datei daliegt, hat mindestens
// eine. Was darüber liegt, sind zweite Fassungen — Plex'
// „Optimized for Mobile" etwa.
var mitTitel = zaehler?.MitTitel ?? 0;
return new LibraryResponse(
l.Id, l.Name, l.Kind.ToString(), l.Path,
l.Kind == LibraryKind.Shows
? serien.GetValueOrDefault(l.Id)
: filme.GetValueOrDefault(l.Id),
l.ReviewNewItems,
l.Kind == LibraryKind.Shows
? wartendeFolgen.GetValueOrDefault(l.Id)
: wartendeFilme.GetValueOrDefault(l.Id),
FileCount: alleDateien,
EpisodeCount: l.Kind == LibraryKind.Shows ? folgen.GetValueOrDefault(l.Id) : null,
UnassignedCount: ohneTitel,
ExtraVersionCount: Math.Max(0, alleDateien - ohneTitel - mitTitel));
}).ToList());
}).RequireAuthorization(); }).RequireAuthorization();
// **Was nicht mehr da ist, aber noch in der Mediathek steht.**
//
// Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte
// Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen
// steht es hier, und das Löschen ist eine Entscheidung.
// **Die Reihenfolge der Seitenleiste.**
//
// Als ganze Liste und nicht als „eins hoch": zwei Benutzer, die
// gleichzeitig schieben, kämen sonst in einen Zustand, den keiner von
// beiden wollte. Die Liste beschreibt, wie es danach aussehen soll.
bibliotheken.MapPut("/order", async (
ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
return Einwand.Bitte("Ohne Reihenfolge gibt es nichts zu ordnen.");
// Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden.
for (var i = 0; i < anfrage.Ids.Count; i++)
{
var platz = i + 1;
var kennung = anfrage.Ids[i];
await db.Libraries.Where(l => l.Id == kennung)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.SortOrder, platz), ct);
}
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
// Die Prüfung je Mediathek umstellen.
bibliotheken.MapPut("/{id:guid}/review", async (
Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
var betroffen = await db.Libraries.Where(l => l.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ReviewNewItems, anfrage.ReviewNewItems), ct);
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Was zu keinem Titel gehört.**
//
// Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname
// eine nennen — nur verschwanden die Dateien damit lautlos. Bei 3559
// von 5884 war das die halbe Mediathek, und in der Übersicht stand
// eine Zahl, die niemand erklären konnte. Hier stehen sie, mit dem
// Grund dazu.
bibliotheken.MapGet("/{id:guid}/unassigned", async (
Guid id, FlaroContext db, int take = 300) =>
{
var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id);
if (bibliothek is null) return Results.NotFound();
var offen = db.MediaFiles.AsNoTracking()
.Where(f => f.LibraryId == id && f.MovieId == null && f.EpisodeId == null
&& f.MissingSince == null);
var gesamt = await offen.CountAsync();
var pfade = await offen.OrderBy(f => f.Path).Take(Math.Clamp(take, 1, 2000)).ToListAsync();
// Der Grund wird hier gerechnet, nicht gespeichert: er hängt allein
// am Pfad und an der Benennungsregel. Eine Spalte dafür wäre beim
// nächsten Regelwechsel veraltet, ohne dass es jemand merkt.
return Results.Ok(new Unzugeordnet(
gesamt,
pfade.Select(f => new UnzugeordneteDatei(
f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList()));
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Was die Metadatenquelle nicht hergab.**
//
// `/unassigned` listet Dateien ganz ohne Titel — bei einer
// Filmbibliothek ist das immer leer, weil der Scan für jede Datei
// einen Eintrag anlegt. Sichtbar war damit nur die Hälfte: dass ein
// Film zwar dasteht, aber unter seinem Dateinamen und ohne Plakat,
// erfuhr niemand. In der Übersicht standen 448 Filme, in Plex 451 —
// und nirgends, welche drei fehlen.
bibliotheken.MapGet("/{id:guid}/unmatched", async (
Guid id, FlaroContext db, int take = 300) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
var grenze = Math.Clamp(take, 1, 2000);
var filme = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == id && m.TmdbId == null)
.OrderBy(m => m.SortTitleValue)
.Take(grenze)
.Select(m => new
{
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
Dateien = db.MediaFiles.Count(f => f.MovieId == m.Id),
Beispiel = db.MediaFiles.Where(f => f.MovieId == m.Id)
.Select(f => f.Path).FirstOrDefault(),
})
.ToListAsync();
var serien = await db.Series.AsNoTracking()
.Where(x => x.LibraryId == id && x.TmdbId == null)
.OrderBy(x => x.SortTitleValue)
.Take(grenze)
.Select(x => new
{
x.Id, x.Title, Jahr = x.ParsedYear ?? x.Year,
Dateien = db.Episodes.Count(f => f.SeriesId == x.Id),
Beispiel = db.Episodes.Where(f => f.SeriesId == x.Id)
.Join(db.MediaFiles, f => f.Id, d => d.EpisodeId, (_, d) => d.Path)
.FirstOrDefault(),
})
.ToListAsync();
var liste = filme
.Select(m => new OhneZuordnung(
m.Id, "movie", m.Title, m.Jahr, m.Dateien, Dateiname(m.Beispiel)))
.Concat(serien.Select(x => new OhneZuordnung(
x.Id, "series", x.Title, x.Jahr, x.Dateien, Dateiname(x.Beispiel))))
.OrderBy(x => x.Titel, StringComparer.CurrentCultureIgnoreCase)
.ToList();
return Results.Ok(liste);
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Titel mit mehr als einer Datei — dort versteckt sich Falsches.**
//
// Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in
// Ordnung. Manchmal sind es aber zwei verschiedene Filme, die bei der
// Metadatenquelle auf denselben Treffer liefen — und dann ist einer
// davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das
// an den Dateinamen in zwei Sekunden.
bibliotheken.MapGet("/{id:guid}/versions", async (
Guid id, FlaroContext db, int take = 300) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
var mehrfach = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1)
.OrderBy(m => m.SortTitleValue)
.Take(Math.Clamp(take, 1, 2000))
.Select(m => new
{
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
Dateien = db.MediaFiles.Where(f => f.MovieId == m.Id)
.Select(f => new { f.Id, f.Path }).ToList(),
})
.ToListAsync();
var liste = mehrfach
.Select(m => new Mehrfachtitel(
m.Id, "movie", m.Title, m.Jahr,
m.Dateien.Select(f => new Fassungsdatei(f.Id, Dateiname(f.Path) ?? f.Path)).ToList()))
.ToList();
return Results.Ok(new Mehrfachbericht(liste.Count, liste));
}).RequireAuthorization(FlaroPolicies.Administrator);
bibliotheken.MapGet("/missing", async (FlaroContext db) =>
{
var liste = await db.MediaFiles.AsNoTracking()
.Where(f => f.MissingSince != null)
.OrderBy(f => f.Path)
.Select(f => new VermissteDatei(
f.Id, f.Path, f.VersionLabel, f.MissingSince!.Value,
db.Libraries.Where(l => l.Id == f.LibraryId).Select(l => l.Name).First(),
f.MovieId != null
? db.Movies.Where(m => m.Id == f.MovieId).Select(m => m.Title).FirstOrDefault()
: db.Episodes.Where(e => e.Id == f.EpisodeId)
.Select(e => e.Title ?? ("Folge " + e.EpisodeNumber)).FirstOrDefault(),
f.MovieId, f.EpisodeId))
.ToListAsync();
return Results.Ok(liste);
}).RequireAuthorization(FlaroPolicies.Administrator);
// Endgültig weg — mitsamt dem Titel, wenn es seine letzte Datei war.
bibliotheken.MapDelete("/missing", async (Guid? fileId, FlaroContext db) =>
{
var betroffen = db.MediaFiles.Where(f => f.MissingSince != null);
if (fileId is { } eine) betroffen = betroffen.Where(f => f.Id == eine);
// Die Titel merken, bevor die Dateien weg sind — danach lässt sich
// nicht mehr feststellen, welche verwaist zurückbleiben.
var filme = await betroffen.Where(f => f.MovieId != null)
.Select(f => f.MovieId!.Value).Distinct().ToListAsync();
var folgen = await betroffen.Where(f => f.EpisodeId != null)
.Select(f => f.EpisodeId!.Value).Distinct().ToListAsync();
var weg = await betroffen.ExecuteDeleteAsync();
await db.Movies
.Where(m => filme.Contains(m.Id) && !db.MediaFiles.Any(f => f.MovieId == m.Id))
.ExecuteDeleteAsync();
await db.Episodes
.Where(e => folgen.Contains(e.Id) && !db.MediaFiles.Any(f => f.EpisodeId == e.Id))
.ExecuteDeleteAsync();
await db.Series
.Where(x => !db.Episodes.Any(e => e.SeriesId == x.Id))
.ExecuteDeleteAsync();
return Results.Ok(new { removed = weg });
}).RequireAuthorization(FlaroPolicies.Administrator);
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) => bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{ {
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art)) if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows.");
{
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
});
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad // **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und // führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
// die Ursache stünde allenfalls im Protokoll. // die Ursache stünde allenfalls im Protokoll.
if (!Directory.Exists(anfrage.Path)) if (!Directory.Exists(anfrage.Path))
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte($"„{anfrage.Path}“ gibt es im Container nicht. " +
{ "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt.");
["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
});
var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path }; var neu = new Library
{
Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path,
ReviewNewItems = anfrage.ReviewNewItems,
};
db.Libraries.Add(neu); db.Libraries.Add(neu);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Created($"/api/v1/libraries/{neu.Id}", return Results.Created($"/api/v1/libraries/{neu.Id}",
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0)); new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0,
neu.ReviewNewItems));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in // **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in
@@ -107,10 +464,7 @@ public static class ManagementEndpoints
{ {
var name = anfrage.Name.Trim(); var name = anfrage.Name.Trim();
if (name.Length == 0) if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte("Ein Name darf nicht leer sein.");
{
["name"] = ["Ein Name darf nicht leer sein."],
});
var vorhanden = await db.Libraries.FindAsync(id); var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound(); if (vorhanden is null) return Results.NotFound();
@@ -125,16 +479,74 @@ public static class ManagementEndpoints
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim // sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server, // Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
// den man nicht noch einmal benutzt. // den man nicht noch einmal benutzt.
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) => bibliotheken.MapDelete("/{id:guid}", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{ {
var vorhanden = await db.Libraries.FindAsync(id); var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound(); if (vorhanden is null) return Results.NotFound();
// Aus demselben Grund wie beim Zurücksetzen — nur dass hier die
// ganze Mediathek unter dem laufenden Scan verschwände.
if (koordinator.IsRunning(id))
{
return Results.Problem(
"Für diese Mediathek läuft gerade ein Scan. "
+ "Entfernen geht erst, wenn er durch ist.",
statusCode: StatusCodes.Status409Conflict);
}
db.Libraries.Remove(vorhanden); db.Libraries.Remove(vorhanden);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Inhalt wegwerfen, die Mediathek behalten.**
//
// Wenn eine Zuordnungsregel falsch war, steht hinterher ein Bestand da,
// den kein Scan mehr geradezieht: die falschen Einträge haben ihre
// Dateien, gelten also nicht als verwaist, und bleiben. Die Mediathek
// zu löschen und neu anzulegen tut dasselbe — nur verliert man dabei
// Namen, Pfad und Einstellungen und tippt sie neu.
//
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
// was Flaro über sie weiss.
bibliotheken.MapPost("/{id:guid}/reset", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{
var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound();
// **Nicht während eines Laufs.**
//
// Die Oberfläche sperrt den Knopf, aber darauf darf sich der
// Server nicht verlassen: ein zweiter Administrator oder ein
// Aufruf von Hand genügt. Wer Zeilen löscht, während der Scan
// welche schreibt, bekommt einen Lauf, der mitten im Schreiben
// abbricht — und einen Bestand, den niemand mehr erklären kann.
if (koordinator.IsRunning(id))
{
return Results.Problem(
"Für diese Mediathek läuft gerade ein Scan. "
+ "Zurücksetzen geht erst, wenn er durch ist.",
statusCode: StatusCodes.Status409Conflict);
}
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
await db.Movies.Where(m => m.LibraryId == id).ExecuteDeleteAsync();
await db.Episodes
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == id))
.ExecuteDeleteAsync();
await db.Series.Where(s => s.LibraryId == id).ExecuteDeleteAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
} }
/// <summary>Nur der Dateiname — der volle Pfad geht niemanden etwas an.</summary>
private static string? Dateiname(string? pfad) =>
pfad is null ? null : Path.GetFileName(pfad);
} }
public static class FlaroPolicies public static class FlaroPolicies
+317 -10
View File
@@ -25,6 +25,224 @@ public static class MatchEndpoints
{ {
public static void MapMatching(this IEndpointRouteBuilder app) public static void MapMatching(this IEndpointRouteBuilder app)
{ {
// **Den Titel von Hand setzen — und festschreiben.**
//
// TMDB kennt nicht jeden Titel so, wie er im Haushalt heisst, und bei
// Mitschnitten gibt es gar keinen. Ohne diesen Weg bleibt nur der
// Dateiname oder ein falscher Treffer.
//
// Festgeschrieben wie Plakat und Hintergrund: sonst überschreibt das
// nächste Auffrischen die Korrektur, und man tippt sie nach jedem Lauf
// neu.
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async (
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
{
var titel = anfrage.Title?.Trim();
if (string.IsNullOrEmpty(titel))
{
return Einwand.Bitte("Ein Titel darf nicht leer sein.");
}
if (titel.Length > 512)
return Einwand.Bitte("Höchstens 512 Zeichen.");
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(m => m.Title, titel)
.SetProperty(m => m.SortTitleValue,
m => m.SortTitlePinned ? m.SortTitleValue : SortTitle.For(titel))
.SetProperty(m => m.TitlePinned, true))
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(x => x.Title, titel)
.SetProperty(x => x.SortTitleValue,
x => x.SortTitlePinned ? x.SortTitleValue : SortTitle.For(titel))
.SetProperty(x => x.TitlePinned, true));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Der Sortiertitel — wonach einsortiert wird, wenn nicht nach dem Titel.**
//
// Eine Reihe steht alphabetisch durcheinander: „…Orden des Phönix" vor
// „…Stein der Weisen", weil O vor S liegt. Mit „Harry Potter 1",
// „Harry Potter 2" stehen sie so, wie man sie sieht.
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
{
var sortiert = anfrage.Title?.Trim();
if (string.IsNullOrEmpty(sortiert))
{
return Results.Problem(
"Ein Sortiertitel darf nicht leer sein. Zum Zurücksetzen gibt es „Wieder automatisch“.",
statusCode: StatusCodes.Status400BadRequest);
}
if (sortiert.Length > 512)
return Einwand.Bitte("Höchstens 512 Zeichen.");
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(m => m.SortTitleValue, sortiert)
.SetProperty(m => m.SortTitlePinned, true))
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(x => x.SortTitleValue, sortiert)
.SetProperty(x => x.SortTitlePinned, true));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Zurück zum errechneten Sortiertitel.**
//
// Anders als beim Titel wird er sofort neu gebildet: er steht nirgends
// sichtbar, und ihn bis zum nächsten Auffrischen falsch stehen zu
// lassen hiesse, dass die Liste solange in der falschen Reihenfolge
// steht.
//
// **Geladen und zurückgeschrieben, nicht in einem Rutsch.** Die Regel
// steht in `SortTitle.For` — einer C#-Methode, die EF Core nicht nach
// SQL übersetzen kann. Der Versuch endete in einer 500; dieselbe Regel
// ein zweites Mal als SQL zu schreiben wäre die Variante, bei der
// beide irgendwann auseinanderlaufen. Es geht um eine Zeile.
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
string art, Guid id, FlaroContext db) =>
{
if (art == "movies")
{
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
film.SortTitleValue = SortTitle.For(film.Title);
film.SortTitlePinned = false;
}
else
{
var serie = await db.Series.FirstOrDefaultAsync(x => x.Id == id);
if (serie is null) return Results.NotFound();
serie.SortTitleValue = SortTitle.For(serie.Title);
serie.SortTitlePinned = false;
}
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Zurück zum automatischen Titel.**
//
// Ohne diesen Weg gäbe es keinen zurück, ausser den alten Namen zu
// erraten. Der Titel selbst bleibt stehen, bis das nächste Auffrischen
// ihn holt — sonst stünde dort bis dahin gar nichts.
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async (
string art, Guid id, FlaroContext db) =>
{
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.TitlePinned, false))
: await db.Series.Where(x => x.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.TitlePinned, false));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Eine Datei aus ihrem Titel herauslösen.**
//
// Zwei Dateien hängen an einem Eintrag, gehören aber zu zwei Filmen:
// „Hanni und Nanni" und „Hanni und Nanni 3" liefen bei der
// Metadatenquelle auf denselben Treffer, und der zweite Film war
// damit nicht mehr zu sehen. Die Riegel dagegen greifen erst beim
// nächsten Lauf — was schon zusammengelegt ist, bleibt es.
//
// Der neue Eintrag bekommt den Titel aus dem Dateinamen und keine
// Kennung: er steht danach unter „Ohne Zuordnung" und lässt sich dort
// in zwei Klicks richtigstellen. Eine Kennung zu raten hiesse, denselben
// Fehler noch einmal zu machen.
app.MapPost("/api/v1/files/{id:guid}/split", async (
Guid id, FlaroContext db, TimeProvider clock) =>
{
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.Id == id);
if (datei is null) return Results.NotFound();
if (datei.MovieId is not { } alt)
{
return Results.Problem(
"Nur eine Filmdatei lässt sich herauslösen. Bei Serien entscheidet "
+ "die Folgennummer, zu welcher Folge eine Datei gehört.",
statusCode: StatusCodes.Status400BadRequest);
}
var geschwister = await db.MediaFiles.CountAsync(f => f.MovieId == alt);
if (geschwister < 2)
{
return Results.Problem(
"An diesem Titel hängt nur diese eine Datei — da gibt es nichts zu trennen.",
statusCode: StatusCodes.Status409Conflict);
}
var name = FileNames.ParseMovie(datei.Path);
var bisher = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == alt);
var neu = new Movie
{
LibraryId = datei.LibraryId,
ParsedTitle = name.Title,
ParsedYear = name.Year,
Title = name.Title,
SortTitleValue = SortTitle.For(name.Title),
Year = name.Year,
// **Sofort sichtbar.** Wer etwas herauslöst, will es sehen —
// nicht erst freigeben müssen.
PublishAt = bisher.PublishAt ?? clock.GetUtcNow(),
};
db.Movies.Add(neu);
datei.MovieId = neu.Id;
await db.SaveChangesAsync();
return Results.Ok(new MatchState(null, null, false, neu.Title, neu.Year, Id: neu.Id));
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Woher dieser Titel kommt.**
//
// Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben
// liegt — „Dark Shadows" von 1966 statt „Dark" —, sagt der angezeigte
// Titel nichts mehr darüber, welche Datei da eigentlich gemeint ist.
// Der Ordner sagt es, und zwar sofort.
app.MapGet("/api/v1/{art:regex(^movies|series$)}/{id:guid}/herkunft", async (
string art, Guid id, FlaroContext db) =>
{
var dateien = art == "movies"
? db.MediaFiles.AsNoTracking().Where(f => f.MovieId == id)
: db.MediaFiles.AsNoTracking().Where(f => f.EpisodeId != null
&& db.Episodes.Any(e => e.Id == f.EpisodeId && e.SeriesId == id));
// **Gezählt wird vollständig, gezeigt nur eine Handvoll.** Hier
// stand erst ein `Take(50)` vor dem Zählen — bei 1200 Folgen
// behauptete der Dialog dann „50 Dateien". Eine Zahl, die bei
// genau dem Titel falsch wird, bei dem man hinsieht.
//
// **Ein Zug, dann im Speicher.** Der Ordner aus einem Pfad zu
// schneiden lässt sich nicht nach SQL übersetzen — der Versuch
// endete in einem 500er. Die Pfade eines einzelnen Titels sind
// auch bei 1200 Folgen ein paar hundert Kilobyte; das ist
// billiger als eine Abfrage, die nicht läuft.
var pfade = await dateien.Select(f => f.Path).ToListAsync();
if (pfade.Count == 0) return Results.Ok(new Herkunft(null, 0, []));
var ordner = pfade
.Select(p => Path.GetDirectoryName(p) ?? "")
.Distinct(StringComparer.Ordinal)
.ToList();
return Results.Ok(new Herkunft(
ordner.Count == 1 ? ordner[0] : GemeinsamerAnfang(ordner),
pfade.Count,
pfade.Order(StringComparer.Ordinal)
.Take(6)
.Select(Path.GetFileName)
.Where(n => n is not null)
.ToList()!));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/movies/{id:guid}/matches", async ( app.MapGet("/api/v1/movies/{id:guid}/matches", async (
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) => Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
{ {
@@ -34,7 +252,16 @@ public static class MatchEndpoints
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde // Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
// — nicht der angezeigte Titel, der ja gerade der falsche ist. // — nicht der angezeigte Titel, der ja gerade der falsche ist.
var vorschläge = await anbieter.SearchMoviesAsync( var vorschläge = await anbieter.SearchMoviesAsync(
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year); string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(),
// **Das Jahr aus dem Dateinamen, nicht das aus der Zuordnung.**
//
// 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);
return Results.Ok(vorschläge.Select(v => return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList()); new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
@@ -42,18 +269,46 @@ public static class MatchEndpoints
app.MapPut("/api/v1/movies/{id:guid}/match", async ( app.MapPut("/api/v1/movies/{id:guid}/match", async (
Guid id, ApplyMatchRequest anfrage, FlaroContext db, Guid id, ApplyMatchRequest anfrage, FlaroContext db,
IMetadataProvider anbieter, MatchApplier anwender) => IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) =>
{ {
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id); var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound(); if (film is null) return Results.NotFound();
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId); var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
if (gefunden is null) if (gefunden is null)
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.", return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
statusCode: StatusCodes.Status404NotFound);
// **Hat die Kennung schon jemand, ist es derselbe Film.**
//
// Ein Film kommt je Mediathek genau einmal vor — das erzwingt ein
// Eindeutigkeitsindex. Die Kennung blind zu setzen liess ihn
// zuschlagen, und der Benutzer bekam „An error occurred while
// processing your request." zu sehen: eine englische
// Allgemeinmeldung für einen Fall, der gar kein Fehler ist.
//
// Zwei Dateien, die denselben Film meinen, gehören zusammengelegt
// — genau das tut der Scan am Ende jedes Laufs auch. Der
// Sehfortschritt wird dabei gefaltet, nichts geht verloren.
var schonDa = await db.Movies
.Where(m => m.LibraryId == film.LibraryId && m.TmdbId == anfrage.TmdbId && m.Id != id)
.Select(m => m.Id)
.FirstOrDefaultAsync();
if (schonDa != Guid.Empty)
{
await anwender.ApplyAsync(
(await db.Movies.FirstAsync(m => m.Id == schonDa)), gefunden);
await zusammen.FilmVerschmelzenAsync(sieger: schonDa, verlierer: id);
var sieger = await db.Movies.AsNoTracking().FirstAsync(m => m.Id == schonDa);
return Results.Ok(new MatchState(
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
Id: sieger.Id, Merged: true));
}
await anwender.ApplyAsync(film, gefunden); await anwender.ApplyAsync(film, gefunden);
return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year)); return Results.Ok(new MatchState(
film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year, Id: film.Id));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/series/{id:guid}/matches", async ( app.MapGet("/api/v1/series/{id:guid}/matches", async (
@@ -63,7 +318,9 @@ public static class MatchEndpoints
if (serie is null) return Results.NotFound(); if (serie is null) return Results.NotFound();
var vorschläge = await anbieter.SearchSeriesAsync( var vorschläge = await anbieter.SearchSeriesAsync(
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year); string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(),
// Siehe oben: aus dem Dateinamen, nicht aus der Zuordnung.
year ?? serie.ParsedYear);
return Results.Ok(vorschläge.Select(v => return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList()); new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
@@ -71,18 +328,68 @@ public static class MatchEndpoints
app.MapPut("/api/v1/series/{id:guid}/match", async ( app.MapPut("/api/v1/series/{id:guid}/match", async (
Guid id, ApplyMatchRequest anfrage, FlaroContext db, Guid id, ApplyMatchRequest anfrage, FlaroContext db,
IMetadataProvider anbieter, MatchApplier anwender) => IMetadataProvider anbieter, MatchApplier anwender, Zusammenlegung zusammen) =>
{ {
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id); var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
if (serie is null) return Results.NotFound(); if (serie is null) return Results.NotFound();
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId); var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
if (gefunden is null) if (gefunden is null)
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.", return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
statusCode: StatusCodes.Status404NotFound);
// Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie,
// und zwei Ordner derselben Serie gehören zusammengelegt.
var schonDa = await db.Series
.Where(x => x.LibraryId == serie.LibraryId && x.TmdbId == anfrage.TmdbId && x.Id != id)
.Select(x => x.Id)
.FirstOrDefaultAsync();
if (schonDa != Guid.Empty)
{
await anwender.ApplyAsync(await db.Series.FirstAsync(x => x.Id == schonDa), gefunden);
await zusammen.SerieVerschmelzenAsync(sieger: schonDa, verlierer: id);
var sieger = await db.Series.AsNoTracking().FirstAsync(x => x.Id == schonDa);
return Results.Ok(new MatchState(
sieger.TmdbId, sieger.ImdbId, sieger.MatchLocked, sieger.Title, sieger.Year,
Id: sieger.Id, Merged: true));
}
await anwender.ApplyAsync(serie, gefunden); await anwender.ApplyAsync(serie, gefunden);
return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year)); return Results.Ok(new MatchState(
serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year, Id: serie.Id));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
} }
/// <summary>Der längste Pfad, mit dem alle anfangen — oder <c>null</c>.</summary>
///
/// <remarks>
/// Verglichen wird nach Ordnerbestandteilen, nicht nach Zeichen: sonst
/// ergäben <c>/media/Serien/Dark</c> und <c>/media/Serien/Darkwing</c> den
/// gemeinsamen Anfang <c>/media/Serien/Dark</c>, und der bezeichnet einen
/// Ordner, in dem nur eines von beidem liegt.
/// </remarks>
private static string? GemeinsamerAnfang(IReadOnlyList<string> pfade)
{
if (pfade.Count == 0) return null;
var teile = pfade.Select(p => p.Split('/', StringSplitOptions.RemoveEmptyEntries)).ToList();
var gemeinsam = new List<string>();
for (var i = 0; i < teile[0].Length; i++)
{
var stueck = teile[0][i];
if (teile.Any(t => i >= t.Length || t[i] != stueck)) break;
gemeinsam.Add(stueck);
}
return gemeinsam.Count == 0 ? null : "/" + string.Join('/', gemeinsam);
}
} }
/// <summary>Woher ein Titel im Dateisystem stammt.</summary>
///
/// <param name="Ordner">Der gemeinsame Ordner aller Dateien; <c>null</c>, wenn es keinen gibt.</param>
/// <param name="Dateien">Wie viele Dateien an diesem Titel hängen.</param>
/// <param name="Beispiele">Ein paar Dateinamen — mehr braucht niemand zum Wiedererkennen.</param>
public sealed record Herkunft(string? Ordner, int Dateien, IReadOnlyList<string> Beispiele);
@@ -0,0 +1,305 @@
using System.Security.Claims;
using Flaro.Core.Library;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Melden, was an einem Titel nicht stimmt — und es abarbeiten.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es das gibt.</b> Wer einen Server für andere betreibt, erfährt von
/// Fehlern zuletzt: dass die deutsche Tonspur nach einer halben Stunde
/// aussetzt, merkt der, der den Film sieht, und nicht der, der ihn eingeräumt
/// hat. Bisher gab es dafür keinen Weg ausser dem Zuruf im Wohnzimmer.
/// </para>
/// <para>
/// <b>Melden darf jeder, lesen nur die Verwaltung.</b> Die eine Ausnahme sind
/// die eigenen Meldungen: wer etwas gemeldet hat, soll nachsehen können, ob
/// etwas daraus geworden ist, ohne zu fragen. Das ist dieselbe Linie wie beim
/// Sehprotokoll — eigene Daten ja, fremde nein, auch als Administrator nicht
/// über diesen Weg.
/// </para>
/// </remarks>
public static class MeldungEndpoints
{
private const int HoechstensZeichen = 2000;
/// <summary>Wie viele Meldungen eine Liste höchstens zurückgibt.</summary>
///
/// <remarks>
/// Die offene Liste ist eine Warteschlange. Wer fünfhundert offene
/// Meldungen hat, hat ein anderes Problem als das, das dieser Bildschirm
/// löst.
/// </remarks>
private const int HoechstensZeilen = 500;
public static void MapMeldungen(this IEndpointRouteBuilder app)
{
// **Am selben Präfix wie alles, was eine undurchsichtige Titelkennung
// annimmt.** Gesehen, Favorit, Weglegen und Fortschritt liegen auch
// dort; der Client muss dadurch nicht wissen, ob er einen Film, eine
// Serie oder eine Folge in der Hand hält.
var melden = app.MapGroup("/api/v1/items").RequireAuthorization();
melden.MapPost("/{id:guid}/reports", async (
Guid id, MeldungRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
var kommentar = anfrage.Comment?.Trim() ?? "";
if (kommentar.Length == 0)
return Einwand.Bitte("Ohne Beschreibung lässt sich nichts nachsehen.");
if (kommentar.Length > HoechstensZeichen)
return Einwand.Bitte($"Höchstens {HoechstensZeichen} Zeichen.");
if (AlsArt(anfrage.Kind) is not { } art)
{
return Einwand.Bitte("Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.");
}
if (!AlsStelle(anfrage.Position, out var stelle))
return Einwand.Bitte("Die Stelle muss wie 0:34:12 aussehen.");
// **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen
// Titel nicht sehen darf, findet ihn hier nicht — ohne ein
// einziges `Where` und ohne eine Stelle, die man zu pflegen
// vergessen kann.
Guid? film = null, serie = null, folge = null;
if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id;
else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id;
else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id;
else return Einwand.Bitte("Diesen Titel gibt es nicht.");
int? staffel = null;
if (anfrage.SeasonNumber is { } nummer)
{
if (serie is null)
return Einwand.Bitte("Eine Staffel gibt es nur zu einer Serie.");
if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct))
return Einwand.Bitte("Diese Staffel gibt es in der Serie nicht.");
staffel = nummer;
}
var meldung = new Meldung
{
UserId = benutzer,
MovieId = film,
SeriesId = serie,
EpisodeId = folge,
SeasonNumber = staffel,
Art = art,
Stelle = stelle,
Kommentar = kommentar,
GemeldetAm = uhr.GetUtcNow(),
};
db.Meldungen.Add(meldung);
await db.SaveChangesAsync(ct);
return Results.Ok(new { id = meldung.Id });
});
// **Die eigenen — jeder nur sich selbst, auch ein Administrator.**
// Steht vor der Verwaltungsgruppe, damit `mine` nicht als Kennung
// gelesen wird; die Verwaltungsrouten tragen zusätzlich `:guid`.
app.MapGroup("/api/v1/reports/mine").RequireAuthorization()
.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } benutzer) return Results.Unauthorized();
var roh = await db.Meldungen.AsNoTracking()
.Where(m => m.UserId == benutzer)
.OrderByDescending(m => m.GemeldetAm)
.Take(HoechstensZeilen)
.ToListAsync(ct);
return Results.Ok(await Anreichern(db, roh, ct));
});
var verwalten = app.MapGroup("/api/v1/reports")
.RequireAuthorization(FlaroPolicies.Administrator);
verwalten.MapGet("", async (
FlaroContext db, CancellationToken ct, bool erledigt = false) =>
{
// **Offene aufsteigend, erledigte absteigend.** Das eine ist eine
// Warteschlange — dort gehört das Älteste nach oben. Das andere
// ist ein Archiv, und darin sucht man das Letzte.
var abfrage = db.Meldungen.AsNoTracking();
var roh = erledigt
? await abfrage.Where(m => m.ErledigtAm != null)
.OrderByDescending(m => m.ErledigtAm).Take(HoechstensZeilen).ToListAsync(ct)
: await abfrage.Where(m => m.ErledigtAm == null)
.OrderBy(m => m.GemeldetAm).Take(HoechstensZeilen).ToListAsync(ct);
return Results.Ok(await Anreichern(db, roh, ct));
});
// **Abhaken, nicht löschen.** Wiederholt sich ein Problem, ist die
// Vorgeschichte die halbe Antwort. Zweimal abhaken ist kein Fehler,
// sondern zweimal dasselbe Ergebnis.
verwalten.MapPost("/{id:guid}/resolve", (Guid id, ClaimsPrincipal wer,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
Umschalten(id, wer, db, ct, uhr.GetUtcNow()));
verwalten.MapPost("/{id:guid}/reopen", (Guid id, ClaimsPrincipal wer,
FlaroContext db, CancellationToken ct) =>
Umschalten(id, wer, db, ct, null));
}
private static async Task<IResult> Umschalten(
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct, DateTimeOffset? wann)
{
var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct);
if (meldung is null)
return Einwand.Fehlt("Diese Meldung gibt es nicht mehr.");
meldung.ErledigtAm = wann;
meldung.ErledigtVon = wann is null ? null : Kennung(wer);
await db.SaveChangesAsync(ct);
return Results.NoContent();
}
/// <summary>Hängt Titel, Plakat und Melder an die nackten Zeilen.</summary>
///
/// <remarks>
/// <b>Nachgeschlagen, nicht verbunden.</b> Ein Join würde eine Meldung
/// verschwinden lassen, sobald ihr Titel für den Fragenden unsichtbar wird
/// — und das trifft ausgerechnet die eigene Liste des Melders, wenn ein
/// Administrator den Titel zurückzieht. Hier bleibt die Zeile stehen und
/// der Name fehlt.
///
/// <b>Und ohne <c>IgnoreQueryFilters</c>.</b> Das wäre die bequeme Lösung
/// und gäbe den Namen eines unveröffentlichten Titels über einen Weg
/// heraus, an den beim nächsten Mal niemand denkt.
/// </remarks>
private static async Task<List<Meldungseintrag>> Anreichern(
FlaroContext db, List<Meldung> roh, CancellationToken ct)
{
var filmIds = roh.Where(m => m.MovieId != null).Select(m => m.MovieId!.Value).Distinct().ToList();
var serienIds = roh.Where(m => m.SeriesId != null).Select(m => m.SeriesId!.Value).Distinct().ToList();
var folgenIds = roh.Where(m => m.EpisodeId != null).Select(m => m.EpisodeId!.Value).Distinct().ToList();
var leuteIds = roh.Select(m => m.UserId).Distinct().ToList();
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
var serien = await db.Series.AsNoTracking().Where(s => serienIds.Contains(s.Id))
.Select(s => new { s.Id, s.Title, s.PosterImage }).ToDictionaryAsync(s => s.Id, ct);
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
{
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
})
.ToDictionaryAsync(f => f.Id, ct);
var leute = await db.Users.AsNoTracking().Where(u => leuteIds.Contains(u.Id))
.Select(u => new { u.Id, u.UserName }).ToDictionaryAsync(u => u.Id, ct);
var fertig = new List<Meldungseintrag>(roh.Count);
foreach (var m in roh)
{
var melder = leute.TryGetValue(m.UserId, out var u) ? u.UserName : "";
var gemeinsam = (
Kind: AlsWort(m.Art),
Stelle: m.Stelle?.TotalSeconds,
m.Kommentar, m.GemeldetAm, m.ErledigtAm);
if (m.MovieId is { } film)
{
filme.TryGetValue(film, out var f);
fertig.Add(new Meldungseintrag(
m.Id, "movie", film, f?.Title ?? "", null, f?.PosterImage,
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
}
else if (m.EpisodeId is { } folge)
{
folgen.TryGetValue(folge, out var f);
fertig.Add(new Meldungseintrag(
m.Id, "episode", f?.SerienId ?? folge, f?.Serientitel ?? "",
f is null ? null
: $"S{f.SeasonNumber} · F{f.EpisodeNumber}"
+ (f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
f?.PosterImage,
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
}
else if (m.SeriesId is { } serie)
{
serien.TryGetValue(serie, out var s);
fertig.Add(new Meldungseintrag(
m.Id, m.SeasonNumber is null ? "series" : "season", serie,
s?.Title ?? "",
m.SeasonNumber is { } nummer ? $"Staffel {nummer}" : null,
s?.PosterImage,
gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar,
gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm));
}
}
return fertig;
}
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
private static Meldungsart? AlsArt(string? wert) => wert?.Trim().ToLowerInvariant() switch
{
"bild" => Meldungsart.Bild,
"ton" => Meldungsart.Ton,
"untertitel" => Meldungsart.Untertitel,
"daten" => Meldungsart.Daten,
"sonstiges" => Meldungsart.Sonstiges,
_ => null,
};
/// <summary>Das Wort, das über die Leitung geht — nicht der Name im Code.</summary>
///
/// <remarks>
/// Von Hand und nicht über <c>ToString()</c>: sonst entschiede ein
/// Umbenennen im C# über das Format, das der Client liest.
/// </remarks>
private static string AlsWort(Meldungsart art) => art switch
{
Meldungsart.Bild => "bild",
Meldungsart.Ton => "ton",
Meldungsart.Untertitel => "untertitel",
Meldungsart.Daten => "daten",
_ => "sonstiges",
};
/// <summary>„1:02:03", „2:03" oder „123" — genau die Formen, die der Client schreibt.</summary>
///
/// <remarks>
/// Nicht <c>TimeSpan.Parse</c>: der liest „2:03" als zwei Stunden und drei
/// Minuten. Wer in ein Feld neben einem Film „2:03" schreibt, meint zwei
/// Minuten — so steht es auch unter jedem Fortschrittsbalken.
/// </remarks>
private static bool AlsStelle(string? text, out TimeSpan? wert)
{
wert = null;
var roh = text?.Trim() ?? "";
if (roh.Length == 0) return true;
var teile = roh.Split(':');
if (teile.Length > 3) return false;
var zahlen = new List<int>();
foreach (var teil in teile)
{
if (!int.TryParse(teil, out var zahl) || zahl < 0) return false;
zahlen.Add(zahl);
}
// Alles ausser dem vordersten Glied zählt in Sechzigern.
if (zahlen.Skip(1).Any(z => z > 59)) return false;
var sekunden = zahlen.Aggregate(0L, (summe, zahl) => summe * 60 + zahl);
wert = TimeSpan.FromSeconds(sekunden);
return true;
}
}
@@ -22,6 +22,15 @@ public static class PlaybackEndpoints
{ {
private const int HöchstensJeSeite = 200; private const int HöchstensJeSeite = 200;
/// <summary>Wie viele Titel eine Sammelanfrage höchstens trägt.</summary>
///
/// <remarks>
/// <b>Grosszügig, aber nicht offen.</b> Eine Mediathek mit 500 Filmen
/// lässt sich damit in einem Zug abhaken; eine Anfrage mit 50 000
/// Kennungen ist keine Bedienung mehr, sondern ein Angriff.
/// </remarks>
private const int HöchstensJeZug = 1000;
public static void MapPlayback(this IEndpointRouteBuilder app) public static void MapPlayback(this IEndpointRouteBuilder app)
{ {
MapZustand(app); MapZustand(app);
@@ -39,9 +48,10 @@ public static class PlaybackEndpoints
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre. // Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
titel.MapPost("/progress", async ( titel.MapPost("/progress", async (
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db, Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
TimeProvider clock, PlaybackSessions laufende) => TimeProvider clock, PlaybackSessions laufende, Wiedergabeeinstellungen einstellungen) =>
{ {
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
var zustand = await Zustand(db, benutzer, id); var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound(); if (zustand is null) return Results.NotFound();
@@ -53,7 +63,12 @@ public static class PlaybackEndpoints
? TimeSpan.FromSeconds(sekunden) ? TimeSpan.FromSeconds(sekunden)
: zustand.Runtime ?? await Dateilaufzeit(db, zustand); : zustand.Runtime ?? await Dateilaufzeit(db, zustand);
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge); var ergebnis = ProgressRule.Apply(
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
await Mitzaehlen(db, benutzer, zustand, ergebnis.Position, clock);
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen // **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und // gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
@@ -72,7 +87,7 @@ public static class PlaybackEndpoints
// Wer weitersieht, hat es sich offenbar anders überlegt. // Wer weitersieht, hat es sich offenbar anders überlegt.
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false; if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
Nachfuehren(zustand); Nachfuehren(zustand, regeln);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client // **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
@@ -90,9 +105,11 @@ public static class PlaybackEndpoints
}); });
titel.MapPut("/watched", async ( titel.MapPut("/watched", async (
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
Wiedergabeeinstellungen einstellungen) =>
{ {
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
var zustand = await Zustand(db, benutzer, id); var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound(); if (zustand is null) return Results.NotFound();
@@ -114,57 +131,118 @@ public static class PlaybackEndpoints
zustand.Dismissed = false; zustand.Dismissed = false;
} }
// **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an
// der Serie und daneben „noch 42 offen".
if (zustand.SeriesId is { } serie)
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
zustand.UpdatedAt = clock.GetUtcNow(); zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand); Nachfuehren(zustand, regeln);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand)); return Results.Ok(Abbild(zustand));
}); });
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. // **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung
titel.MapPost("/dismiss", async ( // im Pfad trägt und eine Liste keine hat.
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => app.MapPut("/api/v1/items/watched", async (
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
Wiedergabeeinstellungen einstellungen) =>
{ {
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
if (anfrage.Ids.Count == 0)
return Results.Ok(new BulkWatchedResponse(0, []));
if (anfrage.Ids.Count > HöchstensJeZug)
return Results.Problem(
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
+ "Nimm weniger und wiederhole es.",
statusCode: StatusCodes.Status400BadRequest);
var jetzt = clock.GetUtcNow();
var unbekannt = new List<Guid>();
var geaendert = 0;
// **Nacheinander, nicht in einem Rutsch.** Eine Serie zieht ihre
// Folgen mit, und dafür gibt es keine Mengenoperation, die das
// ebenso richtig täte. Gespeichert wird einmal am Ende.
foreach (var id in anfrage.Ids.Distinct())
{
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) { unbekannt.Add(id); continue; }
if (zustand.Watched == anfrage.Watched && zustand.Position == TimeSpan.Zero) continue;
if (anfrage.Watched && !zustand.Watched)
{
zustand.PlayCount++;
zustand.LastPlayedAt = jetzt;
}
zustand.Watched = anfrage.Watched;
zustand.Position = TimeSpan.Zero;
zustand.Dismissed = false;
zustand.UpdatedAt = jetzt;
Nachfuehren(zustand, regeln);
if (zustand.SeriesId is { } serie)
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
geaendert++;
}
await db.SaveChangesAsync();
return Results.Ok(new BulkWatchedResponse(geaendert, unbekannt));
}).RequireAuthorization();
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
titel.MapPost("/dismiss", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
Wiedergabeeinstellungen einstellungen) =>
{
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
var zustand = await Zustand(db, benutzer, id); var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound(); if (zustand is null) return Results.NotFound();
zustand.Dismissed = true; zustand.Dismissed = true;
zustand.UpdatedAt = clock.GetUtcNow(); zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand); Nachfuehren(zustand, regeln);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand)); return Results.Ok(Abbild(zustand));
}); });
titel.MapPut("/favourite", async ( titel.MapPut("/favourite", async (
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
Wiedergabeeinstellungen einstellungen) =>
{ {
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
var zustand = await Zustand(db, benutzer, id); var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound(); if (zustand is null) return Results.NotFound();
zustand.Favourite = anfrage.Favourite; zustand.Favourite = anfrage.Favourite;
zustand.UpdatedAt = clock.GetUtcNow(); zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand); Nachfuehren(zustand, regeln);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand)); return Results.Ok(Abbild(zustand));
}); });
titel.MapPut("/rating", async ( titel.MapPut("/rating", async (
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
Wiedergabeeinstellungen einstellungen) =>
{ {
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
if (anfrage.Rating is { } wert && wert is < 1 or > 10) if (anfrage.Rating is { } wert && wert is < 1 or > 10)
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.", return Einwand.Bitte("Eine Bewertung liegt zwischen 1 und 10.");
statusCode: StatusCodes.Status400BadRequest);
var zustand = await Zustand(db, benutzer, id); var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound(); if (zustand is null) return Results.NotFound();
zustand.Rating = anfrage.Rating; zustand.Rating = anfrage.Rating;
zustand.UpdatedAt = clock.GetUtcNow(); zustand.UpdatedAt = clock.GetUtcNow();
Nachfuehren(zustand); Nachfuehren(zustand, regeln);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(Abbild(zustand)); return Results.Ok(Abbild(zustand));
}); });
@@ -223,7 +301,7 @@ public static class PlaybackEndpoints
var folgen = await db.Episodes.AsNoTracking() var folgen = await db.Episodes.AsNoTracking()
.Where(f => serienIds.Contains(f.SeriesId)) .Where(f => serienIds.Contains(f.SeriesId))
.Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage }) .Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage, f.PublishAt })
.ToListAsync(); .ToListAsync();
var folgenIds = folgen.Select(f => f.Id).ToList(); var folgenIds = folgen.Select(f => f.Id).ToList();
@@ -233,7 +311,7 @@ public static class PlaybackEndpoints
var serien = await db.Series.AsNoTracking() var serien = await db.Series.AsNoTracking()
.Where(x => serienIds.Contains(x.Id)) .Where(x => serienIds.Contains(x.Id))
.Select(x => new { x.Id, x.Title, x.PosterImage }) .Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
.ToDictionaryAsync(x => x.Id); .ToDictionaryAsync(x => x.Id);
var liste = new List<ContinueEntry>(); var liste = new List<ContinueEntry>();
@@ -255,9 +333,21 @@ public static class PlaybackEndpoints
naechste.Id, "episode", serie.Title, naechste.Title, naechste.Id, "episode", serie.Title, naechste.Title,
naechste.SeasonNumber, naechste.EpisodeNumber, null, naechste.SeasonNumber, naechste.EpisodeNumber, null,
serie.PosterImage, naechste.StillImage ?? serie.PosterImage, serie.PosterImage, naechste.StillImage ?? serie.PosterImage,
zustand is null ? Leer() : Abbild(zustand))); zustand is null ? Leer() : Abbild(zustand),
SeriesId: x.SeriesId,
PublishAt: naechste.PublishAt,
SeriesBackdropImage: serie.BackdropImage));
} }
// Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für
// alle Folgen aller angefassten Serien.
var erste = await Erstdateien(db, [], liste.Select(e => e.Id).ToList());
liste = liste.Select(e => e with
{
FileId = erste.GetValueOrDefault(e.Id).Id,
DurationSeconds = erste.GetValueOrDefault(e.Id).Sekunden,
}).ToList();
return Results.Ok(new Page<ContinueEntry>(liste, liste.Count)); return Results.Ok(new Page<ContinueEntry>(liste, liste.Count));
}); });
@@ -319,9 +409,24 @@ public static class PlaybackEndpoints
.Select(x => x.Film) .Select(x => x.Film)
.ToListAsync(); .ToListAsync();
// **Mit Datei und Termin.** Ein Vorschlag ohne Datei liesse den
// Aufmacher „Abspielen" anbieten und dann doch nur blättern; ein
// Vorschlag ohne Termin verschwiege, dass es ihn noch nicht gibt.
var vorschlagIds = treffer.Select(m => m.Id).ToList();
var vorschlagsdateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.MovieId != null && vorschlagIds.Contains(d.MovieId!.Value))
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
.ToListAsync();
var items = treffer.Select(m => new MovieResponse( var items = treffer.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
[], null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList(); vorschlagsdateien.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(),
null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
PublishAt: m.PublishAt)).ToList();
return Results.Ok(new Page<MovieResponse>(items, items.Count)); return Results.Ok(new Page<MovieResponse>(items, items.Count));
}); });
@@ -348,32 +453,69 @@ public static class PlaybackEndpoints
var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id)) 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 }) .Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt })
.ToDictionaryAsync(m => m.Id); .ToDictionaryAsync(m => m.Id);
var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id)) var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id))
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new .Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
{ {
f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage, f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage,
SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt,
SerienBild = s.BackdropImage,
}) })
.ToDictionaryAsync(f => f.Id); .ToDictionaryAsync(f => f.Id);
var dateien = await Erstdateien(db, filme, folgen);
var liste = new List<ContinueEntry>(zustände.Count); var liste = new List<ContinueEntry>(zustände.Count);
foreach (var z in zustände) foreach (var z in zustände)
{ {
if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m)) if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m))
liste.Add(new ContinueEntry( liste.Add(new ContinueEntry(
m.Id, "movie", m.Title, null, null, null, m.Year, m.Id, "movie", m.Title, null, null, null, m.Year,
m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z))); m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z),
FileId: dateien.GetValueOrDefault(m.Id).Id,
DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden,
PublishAt: m.PublishAt));
else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f)) else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f))
liste.Add(new ContinueEntry( liste.Add(new ContinueEntry(
f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null, f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null,
f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z))); f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z),
SeriesId: f.SerienId,
FileId: dateien.GetValueOrDefault(f.Id).Id,
DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden,
PublishAt: f.PublishAt,
SeriesBackdropImage: f.SerienBild));
} }
return liste; return liste;
} }
/// <summary>Je Titel die erste Datei — die, die abgespielt würde.</summary>
///
/// <remarks>
/// <b>In einer Abfrage für alle.</b> Zwanzig Zeilen auf der Startseite
/// wären sonst zwanzig Abfragen, und SQLite führt die nacheinander aus.
/// </remarks>
private static async Task<Dictionary<Guid, (Guid? Id, double? Sekunden)>> Erstdateien(
FlaroContext db, List<Guid> filme, List<Guid> folgen)
{
if (filme.Count == 0 && folgen.Count == 0) return [];
var roh = await db.MediaFiles.AsNoTracking()
.Where(d => (d.MovieId != null && filme.Contains(d.MovieId!.Value))
|| (d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value)))
.Select(d => new { d.Id, d.MovieId, d.EpisodeId, d.Duration })
.ToListAsync();
return roh
.GroupBy(d => d.MovieId ?? d.EpisodeId!.Value)
.ToDictionary(g => g.Key, g =>
{
var erste = g.First();
return ((Guid?)erste.Id, erste.Duration?.TotalSeconds);
});
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Gemeinsames // Gemeinsames
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -385,8 +527,104 @@ public static class PlaybackEndpoints
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite, /// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen. /// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
/// </remarks> /// </remarks>
private static void Nachfuehren(UserItemState zustand) => /// <summary>Schreibt die tatsächlich gesehene Zeit ins Protokoll.</summary>
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand); ///
/// <remarks>
/// <para>
/// <b>Der Zuwachs, gedeckelt auf die verstrichene Zeit.</b> Zwischen zwei
/// Meldungen liegen zehn Sekunden; wer in dieser Zeit von Minute 5 auf
/// Minute 90 springt, hat nicht 85 Minuten gesehen. Ohne Deckel erfände
/// jedes Vorspulen Sehzeit — und ein Film, den man einmal durchgespult
/// hat, stünde in der Statistik wie zweimal gesehen.
/// </para>
/// <para>
/// <b>Das Doppelte als Toleranz</b>, nicht genau die verstrichene Zeit:
/// eine ausgefallene Meldung, ein kurzer Aussetzer im Netz, doppelte
/// Abspielgeschwindigkeit — all das ist echtes Schauen und soll zählen.
/// Erst darüber wird gekappt.
/// </para>
/// <para>
/// <b>Rückwärts zählt nichts.</b> Wer zurückspringt und dieselbe Stelle
/// noch einmal sieht, bekommt sie nicht abgezogen und auch nicht doppelt
/// gutgeschrieben: der Zuwachs ist dann null, bis er die alte Stelle
/// wieder erreicht. Das ist die freundlichere Ungenauigkeit von beiden.
/// </para>
/// </remarks>
private static async Task Mitzaehlen(
FlaroContext db, Guid benutzer, UserItemState zustand,
TimeSpan neueStelle, TimeProvider clock, CancellationToken ct = default)
{
if (zustand.MovieId is null && zustand.EpisodeId is null) return;
var jetzt = clock.GetUtcNow();
var verstrichen = jetzt - zustand.UpdatedAt;
var zuwachs = (neueStelle - zustand.Position).TotalSeconds;
// **Ohne verstrichene Zeit gibt es nichts gutzuschreiben.** Das ist
// die erste Meldung einer Zeile — sie legt den Nullpunkt, mehr nicht.
// Stünde hier stattdessen „kein Deckel", zählte eine frisch angelegte
// Zeile ihre Anfangsstelle voll mit: wer einen Film öffnet und sofort
// auf Minute fünf springt, hätte fünf Minuten gesehen.
if (zuwachs <= 0 || verstrichen <= TimeSpan.Zero) return;
zuwachs = Math.Min(zuwachs, verstrichen.TotalSeconds * 2);
if (zuwachs <= 0) return;
// Ortszeit des Servers: wer um halb eins zu Ende sieht, hat das am
// Abend getan.
var tag = DateOnly.FromDateTime(jetzt.ToLocalTime().DateTime);
// **Erhöhen, und nur wenn es die Zeile nicht gibt, anlegen.**
//
// Vorher stand hier „nachsehen, dann anlegen oder erhöhen" — und das
// ist ein Wettlauf: der Spieler meldet alle zehn Sekunden, ein Sprung
// meldet sofort, und zwei Meldungen können sich überholen. Dann fanden
// beide keine Zeile, beide legten eine an, und die zweite lief in
// „UNIQUE constraint failed: Sehtage.UserId, Sehtage.Tag,
// Sehtage.MovieId". Gemeldet wurde das als Fehler 500 auf einen
// Fortschrittsbericht — für den Zuschauer unsichtbar, im Protokoll ein
// Stapelabzug.
//
// `ExecuteUpdateAsync` rechnet in der Datenbank statt im Speicher und
// kann deshalb nicht danebengreifen. Trifft es nichts, wird angelegt;
// kommt dabei jemand zuvor, fängt der zweite Versuch es auf.
if (await Erhoehen(db, benutzer, tag, zustand, zuwachs, ct) > 0) return;
db.Sehtage.Add(new Sehtag
{
UserId = benutzer,
MovieId = zustand.MovieId,
EpisodeId = zustand.EpisodeId,
Tag = tag,
Sekunden = zuwachs,
});
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Jemand war schneller. Die angefangene Zeile wieder vergessen,
// sonst versucht der Aufrufer sie beim nächsten Speichern erneut.
db.ChangeTracker.Clear();
await Erhoehen(db, benutzer, tag, zustand, zuwachs, ct);
}
}
/// <summary>Schreibt die Sekunden an einer vorhandenen Zeile fort.</summary>
///
/// <remarks>Gibt zurück, wie viele Zeilen getroffen wurden — 0 oder 1.</remarks>
private static Task<int> Erhoehen(
FlaroContext db, Guid benutzer, DateOnly tag, UserItemState zustand,
double zuwachs, CancellationToken ct) =>
db.Sehtage
.Where(z => z.UserId == benutzer && z.Tag == tag
&& z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId)
.ExecuteUpdateAsync(s => s.SetProperty(z => z.Sekunden, z => z.Sekunden + zuwachs), ct);
private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) =>
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln);
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary> /// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
/// ///
@@ -455,14 +693,23 @@ public static class PlaybackEndpoints
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary> /// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
/// ///
/// <remarks> /// <remarks>
/// <b>Erst Film, dann Folge.</b> Zwei Abfragen im schlechteren Fall. Eine /// <para>
/// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder /// <b>Erst Film, dann Folge, dann Serie.</b> Drei Abfragen im schlechtesten
/// anderen Stelle im Modell kosten. /// Fall. Eine gemeinsame Tabelle für alles würde zwei sparen und dafür an
/// jeder anderen Stelle im Modell kosten.
/// </para>
/// <para>
/// <b>Dass die Serie fehlte, war ein stiller Fehler.</b> Jede Aktion auf
/// einer Serienkachel — als gesehen markieren, zu den Favoriten — bekam
/// eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
/// sichtbar gar nichts.
/// </para>
/// </remarks> /// </remarks>
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel) private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
{ {
var vorhanden = await db.UserItemStates var vorhanden = await db.UserItemStates
.FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel)); .FirstOrDefaultAsync(z => z.UserId == benutzer
&& (z.MovieId == titel || z.EpisodeId == titel || z.SeriesId == titel));
if (vorhanden is not null) return vorhanden; if (vorhanden is not null) return vorhanden;
if (await db.Movies.AnyAsync(m => m.Id == titel)) if (await db.Movies.AnyAsync(m => m.Id == titel))
@@ -479,9 +726,68 @@ public static class PlaybackEndpoints
return neu; return neu;
} }
if (await db.Series.AnyAsync(s => s.Id == titel))
{
var neu = new UserItemState { UserId = benutzer, SeriesId = titel };
db.UserItemStates.Add(neu);
return neu;
}
return null; return null;
} }
/// <summary>Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg.</summary>
///
/// <remarks>
/// <para>
/// <b>„Serie gesehen" heisst: jede Folge gesehen.</b> Nur die Zeile der
/// Serie zu setzen liesse die Folgenliste unverändert und den Zähler
/// „noch 42 offen" daneben stehen — zwei Angaben, die sich widersprechen.
/// </para>
/// <para>
/// <b>Die Stelle bleibt, wo sie ist.</b> Wer eine Serie von Hand abhakt,
/// will sie aus dem Weg haben; eine halb gesehene Folge soll dadurch nicht
/// ihren Fortschritt verlieren, falls er doch zurückkommt. Nur
/// „Weiterschauen" räumt sich auf, weil ein Abgehaktes dort nichts zu
/// suchen hat.
/// </para>
/// </remarks>
private static async Task FolgenSetzen(
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock,
Wiedergaberegeln regeln)
{
var folgen = await db.Episodes.AsNoTracking()
.Where(f => f.SeriesId == serie)
.Select(f => f.Id)
.ToListAsync();
if (folgen.Count == 0) return;
var staende = await db.UserItemStates
.Where(z => z.UserId == benutzer && z.EpisodeId != null && folgen.Contains(z.EpisodeId.Value))
.ToListAsync();
var bekannt = staende.ToDictionary(z => z.EpisodeId!.Value);
var jetzt = clock.GetUtcNow();
foreach (var folge in folgen)
{
if (!bekannt.TryGetValue(folge, out var zustand))
{
// Ungesehen ist der Normalfall — dafür braucht es keine Zeile.
if (!gesehen) continue;
zustand = new UserItemState { UserId = benutzer, EpisodeId = folge };
db.UserItemStates.Add(zustand);
}
if (gesehen && !zustand.Watched) zustand.PlayCount++;
zustand.Watched = gesehen;
zustand.Position = TimeSpan.Zero;
zustand.Dismissed = false;
if (gesehen) zustand.LastPlayedAt = jetzt;
zustand.UpdatedAt = jetzt;
Nachfuehren(zustand, regeln);
}
}
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary> /// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -489,12 +795,19 @@ public static class PlaybackEndpoints
/// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen /// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen
/// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat. /// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat.
/// </remarks> /// </remarks>
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand) => private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand)
await db.MediaFiles.AsNoTracking() {
// Eine Serie hat keine eigene Datei. Ohne diese Zeile verglich die
// Abfrage `EpisodeId == null` und nahm die längste herrenlose Datei.
if (zustand.MovieId is null && zustand.EpisodeId is null) return null;
return await db.MediaFiles.AsNoTracking()
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId) .Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
.MaxAsync(d => (TimeSpan?)d.Duration); .MaxAsync(d => (TimeSpan?)d.Duration);
}
internal static ItemStateResponse Abbild(UserItemState z) => new( internal static ItemStateResponse Abbild(UserItemState z) => new(
z.Position.TotalSeconds, z.Runtime?.TotalSeconds, z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt); z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt,
z.InProgress);
} }
@@ -0,0 +1,278 @@
using System.Security.Claims;
using Flaro.Core.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Das eigene Konto — für jeden, nicht nur für Administratoren.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine eigene Gruppe, weil die Rechte andere sind.</b> Alles unter
/// <c>/api/v1/users</c> war bisher Verwaltung und damit Administratoren
/// vorbehalten. Hier geht es um das eigene Konto: wer angemeldet ist, darf
/// sein Bild tauschen, und niemand darf das eines anderen anfassen. Die
/// Kennung kommt deshalb aus dem Token und nie aus der Adresse.
/// </para>
/// <para>
/// <b>Es war bisher nicht erreichbar.</b> „Meine Sprache" stand mitten in der
/// Verwaltung, und die zeigt der Client nur Administratoren — der Endpunkt
/// erlaubte es längst jedem. Wer kein Admin war, konnte seine Sprache also
/// nirgends ändern.
/// </para>
/// </remarks>
public static class ProfilEndpoints
{
/// <summary>Höchstens so gross darf ein Profilbild sein.</summary>
///
/// <remarks>
/// Fünf Megabyte sind für ein Bild von 200 Pixeln reichlich. Dieselbe
/// Grenze wie bei Plakaten wäre hier zu grosszügig: ein Plakat sieht man
/// gross, einen Kreis von 28 Pixeln nicht.
/// </remarks>
private const long HoechstensBytes = 5 * 1024 * 1024;
public static void MapProfil(this IEndpointRouteBuilder app)
{
var gruppe = app.MapGroup("/api/v1/users/me").RequireAuthorization();
gruppe.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct);
return benutzer is null
? Results.Unauthorized()
: Results.Ok(new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage, benutzer.IsOwner));
});
gruppe.MapPost("/avatar", async (
HttpRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
IImageStore ablage, CancellationToken ct) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
if (!anfrage.HasFormContentType)
return Einwand.Bitte("Kein Formular.");
var form = await anfrage.ReadFormAsync(ct);
var datei = form.Files.FirstOrDefault();
if (datei is null || datei.Length == 0)
return Einwand.Bitte("Keine Datei dabei.");
if (datei.Length > HoechstensBytes)
return Einwand.Bitte("Höchstens 5 MB.");
await using var strom = datei.OpenReadStream();
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
if (schluessel is null)
return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (benutzer is null) return Results.Unauthorized();
benutzer.AvatarImage = schluessel;
await db.SaveChangesAsync(ct);
return Results.Ok(new { image = schluessel });
}).DisableAntiforgery();
// **Entfernen heisst vergessen, nicht löschen.** Die Datei bleibt in
// der Bildablage liegen — dort liegt sie unter dem Hash ihres Inhalts
// und könnte an einem anderen Konto hängen. Was dort aufräumt, ist
// „Bildspeicher leeren" in der Verwaltung, und das kennt alle Bezüge.
gruppe.MapDelete("/avatar", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (benutzer is null) return Results.Unauthorized();
benutzer.AvatarImage = null;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
MapStatistik(gruppe);
}
/// <summary>Was jemand geschaut hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Jeder nur sich selbst, auch ein Administrator.</b> Das ist keine
/// Erfindung für diese Seite, sondern die Linie, die im Server schon
/// steht: wer wann welchen Film anschaut, ist eine Auskunft über Menschen
/// und nicht über den Server. Es gibt deshalb keinen Weg, ein fremdes
/// Konto zu meinen — die Kennung kommt aus dem Token.
/// </para>
/// <para>
/// <b>Wochen beginnen am Montag.</b> Nicht am Sonntag und nicht am Tag der
/// Anfrage: eine Kurve, deren Balken je nach Abfragezeitpunkt anders
/// zusammenfallen, lässt sich mit sich selbst nicht vergleichen.
/// </para>
/// </remarks>
private static void MapStatistik(RouteGroupBuilder gruppe)
{
gruppe.MapGet("/statistics", async (
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
int wochen = 12, CancellationToken ct = default) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
wochen = Math.Clamp(wochen, 1, 104);
var heute = DateOnly.FromDateTime(clock.GetUtcNow().ToLocalTime().DateTime);
var ersterMontag = Montag(heute).AddDays(-7 * (wochen - 1));
// ---- Das Protokoll ----
var tage = await db.Sehtage.AsNoTracking()
.Where(z => z.UserId == id)
.Select(z => new { z.Tag, z.Sekunden, z.MovieId, z.EpisodeId })
.ToListAsync(ct);
var gesamt = tage.Sum(z => z.Sekunden);
var seit = tage.Count == 0 ? (DateTimeOffset?)null
: new DateTimeOffset(tage.Min(z => z.Tag).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
var jeWoche = tage
.Where(z => z.Tag >= ersterMontag)
.GroupBy(z => Montag(z.Tag))
.ToDictionary(g => g.Key, g => g.Sum(x => x.Sekunden));
var kurve = Enumerable.Range(0, wochen)
.Select(n => ersterMontag.AddDays(7 * n))
.Select(montag => new Wochenwert(montag, jeWoche.GetValueOrDefault(montag)))
.ToList();
// ---- Was ohne Protokoll vollständig ist ----
var staende = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == id && (z.MovieId != null || z.EpisodeId != null))
.Select(z => new
{
z.MovieId, z.EpisodeId, z.Watched, z.LastPlayedAt,
Laufzeit = z.Runtime,
})
.ToListAsync(ct);
var gesehene = staende.Where(z => z.Watched).ToList();
// ---- Zuletzt gesehen ----
var letzte = staende
.Where(z => z.LastPlayedAt != null)
.OrderByDescending(z => z.LastPlayedAt)
.Take(12)
.ToList();
var filmIds = letzte.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
var folgenIds = letzte.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
{
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
})
.ToDictionaryAsync(f => f.Id, ct);
var zuletzt = new List<Zuletztgesehen>();
foreach (var z in letzte)
{
if (z.MovieId is { } film && filme.TryGetValue(film, out var m))
{
zuletzt.Add(new Zuletztgesehen(
m.Id, "movie", m.Title, null, m.PosterImage, z.LastPlayedAt!.Value, z.Watched));
}
else if (z.EpisodeId is { } folge && folgen.TryGetValue(folge, out var f))
{
zuletzt.Add(new Zuletztgesehen(
f.SerienId, "episode", f.Serientitel,
$"S{f.SeasonNumber} · F{f.EpisodeNumber}" +
(f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
f.PosterImage, z.LastPlayedAt!.Value, z.Watched));
}
}
// ---- Genres, aus dem Protokoll ----
var genres = await GenresAus(db, tage.Select(t => (t.MovieId, t.EpisodeId, t.Sekunden)), ct);
return Results.Ok(new StatistikResponse(
gesamt,
gesehene.Count,
gesehene.Sum(z => z.Laufzeit?.TotalSeconds ?? 0),
seit,
kurve,
zuletzt,
genres));
});
}
/// <summary>Der Montag der Woche, in der dieser Tag liegt.</summary>
private static DateOnly Montag(DateOnly tag) =>
tag.AddDays(-((int)tag.DayOfWeek + 6) % 7);
/// <summary>Welche Genres die gesehene Zeit ausmachen.</summary>
///
/// <remarks>
/// Eine Folge hat keine Genres — ihre Serie hat sie. Deshalb wird jede
/// Folge erst zu ihrer Serie aufgelöst; sonst stünden Serien gar nicht in
/// der Auswertung, obwohl sie den grösseren Teil der Zeit ausmachen.
/// </remarks>
private static async Task<List<Genrewert>> GenresAus(
FlaroContext db,
IEnumerable<(Guid? Film, Guid? Folge, double Sekunden)> zeilen,
CancellationToken ct)
{
var liste = zeilen.ToList();
if (liste.Count == 0) return [];
var folgenIds = liste.Where(z => z.Folge != null).Select(z => z.Folge!.Value).Distinct().ToList();
var serieZuFolge = await db.Episodes.AsNoTracking()
.Where(f => folgenIds.Contains(f.Id))
.ToDictionaryAsync(f => f.Id, f => f.SeriesId, ct);
// Sekunden je Titel zusammenziehen, bevor die Genres dazukommen —
// sonst wird derselbe Tag so oft gezählt, wie der Titel Genres hat.
var jeFilm = new Dictionary<Guid, double>();
var jeSerie = new Dictionary<Guid, double>();
foreach (var (film, folge, sekunden) in liste)
{
if (film is { } f) jeFilm[f] = jeFilm.GetValueOrDefault(f) + sekunden;
else if (folge is { } e && serieZuFolge.TryGetValue(e, out var serie))
jeSerie[serie] = jeSerie.GetValueOrDefault(serie) + sekunden;
}
var filmIds = jeFilm.Keys.ToList();
var serienIds = jeSerie.Keys.ToList();
var verweise = await db.GenreLinks.AsNoTracking()
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id,
(l, g) => new { l.MovieId, l.SeriesId, g.Name })
.ToListAsync(ct);
var summen = new Dictionary<string, double>();
foreach (var v in verweise)
{
var sekunden = v.MovieId is { } f ? jeFilm.GetValueOrDefault(f)
: v.SeriesId is { } s ? jeSerie.GetValueOrDefault(s)
: 0;
if (sekunden <= 0) continue;
summen[v.Name] = summen.GetValueOrDefault(v.Name) + sekunden;
}
return summen.OrderByDescending(p => p.Value).Take(6)
.Select(p => new Genrewert(p.Key, p.Value)).ToList();
}
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
}
@@ -1,4 +1,7 @@
using Flaro.Api.Auth;
using Flaro.Core; using Flaro.Core;
using Flaro.Core.Delivery;
using Flaro.Core.Playback;
using Flaro.Data; using Flaro.Data;
using Flaro.Metadata; using Flaro.Metadata;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -30,6 +33,57 @@ public static class SettingsEndpoints
UsingProjectKey: aufgelöst.UsingProjectKey)); UsingProjectKey: aufgelöst.UsingProjectKey));
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **In welcher Sprache der Bestand vorliegt.**
//
// Der eigentliche Fehler war nicht, dass Titel englisch blieben —
// sondern dass niemand feststellen konnte, ob sie es sind. „Alles ist
// noch auf Englisch, obwohl Deutsch eingestellt ist" liess sich nicht
// beantworten, nur bestreiten. Hier steht die Zahl.
app.MapGet("/api/v1/settings/metadata/languages", async (FlaroContext db) =>
{
var eingestellt = await db.Settings.AsNoTracking()
.Where(x => x.Key == DatabaseTmdbSettings.LanguageName)
.Select(x => x.Value).FirstOrDefaultAsync();
var serversprache = TmdbSettings.Resolve(null, eingestellt).Language;
// Nur zugeordnete Titel zählen: was keine Kennung hat, hat auch
// keine Sprache — das ist ein Zuordnungsproblem, kein Sprachproblem.
var filme = await db.Movies.AsNoTracking().Where(m => m.TmdbId != null)
.GroupBy(m => m.MetadataLanguage)
.Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync();
var serien = await db.Series.AsNoTracking().Where(x => x.TmdbId != null)
.GroupBy(x => x.MetadataLanguage)
.Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync();
var zusammen = filme.Concat(serien)
.GroupBy(x => x.Sprache)
.Select(g => new Sprachanteil(g.Key, g.Sum(x => x.Anzahl)))
.OrderByDescending(x => x.Anzahl)
.ToList();
return Results.Ok(new Sprachverteilung(
serversprache,
zusammen,
Abweichend: zusammen.Where(x => x.Sprache != serversprache).Sum(x => x.Anzahl)));
}).RequireAuthorization(FlaroPolicies.Administrator);
// Frischt genau die auf, die nicht zur Serversprache passen.
app.MapPost("/api/v1/settings/metadata/languages/refresh", async (
FlaroContext db, ScanCoordinator koordinator) =>
{
var eingestellt = await db.Settings.AsNoTracking()
.Where(x => x.Key == DatabaseTmdbSettings.LanguageName)
.Select(x => x.Value).FirstOrDefaultAsync();
var serversprache = TmdbSettings.Resolve(null, eingestellt).Language;
var mediatheken = await db.Libraries.AsNoTracking().Select(l => l.Id).ToListAsync();
var gestartet = mediatheken.Count(id => koordinator.TryStart(id, erneut: true, serversprache));
return gestartet > 0
? Results.Accepted(value: new { started = gestartet, language = serversprache })
: Einwand.Belegt("In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.");
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/metadata", async ( app.MapPut("/api/v1/settings/metadata", async (
MetadataSettingsRequest anfrage, FlaroContext db) => MetadataSettingsRequest anfrage, FlaroContext db) =>
{ {
@@ -40,16 +94,99 @@ public static class SettingsEndpoints
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als // Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
// Englisch an, und niemand wüsste, warum. // Englisch an, und niemand wüsste, warum.
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$")) if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte($"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“.");
{
["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."],
});
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache); await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// ------------------------------------------------------------------
// Wiedergabe
// ------------------------------------------------------------------
app.MapGet("/api/v1/settings/playback", async (Wiedergabeeinstellungen einstellungen) =>
{
var regeln = await einstellungen.RegelnAsync();
var stufe = await einstellungen.StufeAsync();
return Results.Ok(new PlaybackSettingsResponse(
regeln.FortsetzenAb.TotalSeconds, regeln.GesehenAnteil, stufe.AlsWort()));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/playback", async (
PlaybackSettingsRequest anfrage, FlaroContext db, TimeProvider clock) =>
{
var vorher = Wiedergabeeinstellungen.Lesen(
(await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.FortsetzenAbName))?.Value,
(await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.GesehenAnteilName))?.Value);
var nachher = Wiedergabeeinstellungen.Lesen(
anfrage.ResumeAfterSeconds?.ToString(System.Globalization.CultureInfo.InvariantCulture)
?? vorher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
anfrage.WatchedFraction?.ToString(System.Globalization.CultureInfo.InvariantCulture)
?? vorher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
await Setzen(db, Wiedergabeeinstellungen.FortsetzenAbName,
nachher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
await Setzen(db, Wiedergabeeinstellungen.GesehenAnteilName,
nachher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
if (anfrage.Encoder is not null)
await Setzen(db, Wiedergabeeinstellungen.TranscoderName, Kodierstufen.Lesen(anfrage.Encoder).AlsWort());
await db.SaveChangesAsync();
// **Was schon dasteht, muss neu bewertet werden.**
//
// `UserItemState.InProgress` ist eine gespeicherte Spalte — die
// Bruchrechnung lässt sich nicht nach SQL übersetzen. Eine neue
// Schwelle wirkte deshalb nur auf künftige Meldungen: wer gestern
// eine Sekunde hineingeschaut hat, stünde bis in alle Ewigkeit in
// „Weiterschauen". Also einmal durch, in Stapeln.
var geaendert = nachher == vorher ? 0 : await NeuBewerten(db, nachher, clock);
return Results.Ok(new PlaybackSettingsApplied(
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
///
/// <remarks>
/// In Stapeln zu fünfhundert: ein Haushalt mit drei Leuten und
/// fünftausend Titeln hat höchstens ein paar tausend Zeilen, aber alle
/// auf einmal zu laden ist eine Zahl, die man nicht kennt.
/// </remarks>
private static async Task<int> NeuBewerten(
FlaroContext db, Wiedergaberegeln regeln, TimeProvider clock)
{
const int Stapel = 500;
var geaendert = 0;
var jetzt = clock.GetUtcNow();
for (var sprung = 0; ; sprung += Stapel)
{
var zeilen = await db.UserItemStates
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync();
if (zeilen.Count == 0) break;
foreach (var z in zeilen)
{
var soll = ProgressRule.BelongsInContinueWatching(z, regeln);
if (soll == z.InProgress) continue;
z.InProgress = soll;
z.UpdatedAt = jetzt;
geaendert++;
}
await db.SaveChangesAsync();
if (zeilen.Count < Stapel) break;
}
return geaendert;
} }
private static async Task Setzen(FlaroContext db, string schlüssel, string wert) private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
@@ -0,0 +1,367 @@
using System.Security.Claims;
using System.Text;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Wiedergabe, die sofort anfängt — statt auf eine fertige Datei zu warten.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es diesen zweiten Weg gibt.</b> <see cref="DeliveryEndpoints"/>
/// baut eine fertige MP4 und antwortet solange mit 202. Für eine App, die den
/// Film mitnehmen will, ist das richtig. Im Browser heisst es: Minuten vor dem
/// ersten Bild — und bei einem Bildcodec, den der Browser nicht kennt, ein
/// Umpacken, das nichts nützt, weil es das Bild gar nicht anfasst.
/// </para>
/// <para>
/// <b>Der Browser sagt, was er kann.</b> Vorher bekam er stillschweigend
/// Apples Geräteprofil, das HEVC, AC3 und EAC3 erlaubt — und stand dann vor
/// einer Datei, von der er nichts öffnen konnte.
/// </para>
/// </remarks>
public static class StreamEndpoints
{
public static void MapStream(this IEndpointRouteBuilder app)
{
// **Eine Sitzung anlegen.** Der Rumpf trägt, was das Gerät kann und
// was der Benutzer gewählt hat.
app.MapPost("/api/v1/files/{id:guid}/stream", async (
Guid id, StreamRequest anfrage, ClaimsPrincipal wer,
Streamkoordinator koordinator, FlaroContext db, TimeProvider uhr) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
// **Geplant heisst sichtbar, nicht abspielbar.** Ohne diese
// Prüfung liesse sich ein Titel, der mit „Verfügbar ab …" dasteht,
// trotzdem abspielen — und die Angabe wäre eine Zierde.
var gewuenschte = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
if (gewuenschte is null) return Results.NotFound();
if (await DeliveryEndpoints.FreiAb(db, gewuenschte, uhr.GetUtcNow()) is { } nochNicht)
return DeliveryEndpoints.NochNicht(nochNicht);
var profil = Profil(anfrage);
var hoehe = anfrage.Height is { } gewuenscht
&& Stromentscheid.Stufen.Contains(gewuenscht) ? gewuenscht : (int?)null;
try
{
// **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
// Browser verlöre dabei das, was er von sich aus am besten
// kann: springen über Bereichsanfragen, ohne dass irgendwo
// etwas neu gerechnet wird.
//
// **Nur ohne Wünsche.** Wer eine Tonspur oder eine Stufe
// wählt, bekommt eine Sitzung: beides lässt sich an einer
// durchgereichten Datei nicht einstellen.
//
// **Und nicht, wenn der Spieler schon gescheitert ist.** Dann
// hat er die Datei gesehen und nicht geöffnet; sie ihm ein
// zweites Mal unverändert hinzulegen wäre derselbe Fehler mit
// mehr Geduld.
if (hoehe is null && anfrage.AudioStream is null && !anfrage.Transcode
&& anfrage.AudioOffsetMs == 0
&& await Durchreichbar(db, id, profil) is { } datei)
{
return Results.Ok(await Direktauskunft(db, datei, anfrage.StartSeconds ?? 0));
}
var sitzung = await koordinator.StartenAsync(
id, benutzer, profil, hoehe, anfrage.AudioStream, anfrage.StartSeconds ?? 0,
erzwingen: anfrage.Transcode,
tonversatz: Math.Clamp(anfrage.AudioOffsetMs, -5000, 5000) / 1000.0);
return Results.Ok(await Auskunft(db, sitzung));
}
catch (InvalidOperationException fehler)
{
// Ein deutscher Satz, kein Fehlercode — hier wird gelesen,
// nicht gesucht. Die Einzelheiten stehen im Protokoll.
return Results.Problem(fehler.Message, statusCode: StatusCodes.Status409Conflict);
}
}).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) =>
{
// **Diese Datei darf nie zwischengespeichert werden**, und das ist
// kein Vorsichtsmass, sondern aus einem Fehler gelernt: der Film
// blieb nach zwanzig Sekunden stehen, obwohl auf der Platte längst
// der ganze Rest lag. `Results.File` setzt `Last-Modified`, der
// Browser legte die Liste mit zwei Einträgen ab, und hls.js bekam
// bei jedem Nachfragen dieselben zwei zurück. Eine wachsende Liste
// ist das Gegenteil von zwischenspeicherbar.
antwort.Headers.CacheControl = "no-store, no-cache, must-revalidate";
antwort.Headers.Pragma = "no-cache";
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
koordinator.Lebenszeichen(sitzung);
// **Hier wird gewartet, nicht abgewiesen.**
//
// Zuerst stand hier eine 204, solange noch kein Segment da war —
// der Spieler sollte gleich noch einmal fragen. Gemessen: hls.js
// hält jede Antwort ohne Playlist für einen Fehler und gibt sofort
// auf. Eine Antwort, die ein paar Sekunden braucht, ist für ihn
// dagegen das Normale.
//
// Gemessen am Prüfbestand: beim Umpacken liegt das erste Segment
// nach deutlich unter einer Sekunde vor. Die Spanne hier ist für
// den anderen Fall gedacht — ein 4K-Film, der neu gerechnet wird.
for (var versuch = 0; versuch < 60; versuch++)
{
if (ct.IsCancellationRequested) return Results.NoContent();
if (koordinator.Sitzung(sitzung) is not { } jetzt) return Results.NotFound();
if (jetzt.Fehler is { Length: > 0 })
{
return Results.Problem(
"Die Wiedergabe liess sich nicht aufbereiten. "
+ "Die Einzelheiten stehen im Protokoll von heute.",
statusCode: StatusCodes.Status500InternalServerError);
}
// **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)
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
// Minute auf etwas zu warten, das es nicht geben wird.
if (jetzt.Beendet)
{
return Results.Problem(
"Für diese Datei kam keine abspielbare Fassung zustande. "
+ "Die Einzelheiten stehen im Protokoll von heute.",
statusCode: StatusCodes.Status500InternalServerError);
}
await Task.Delay(250, ct);
}
return Results.Problem(
"Die Aufbereitung dauert ungewöhnlich lange. Versuche eine kleinere Auflösung.",
statusCode: StatusCodes.Status504GatewayTimeout);
}).RequireAuthorization();
// **Die Segmente.** Jedes Abholen ist zugleich das Lebenszeichen.
app.MapGet("/api/v1/stream/{sitzung:guid}/{datei}", (
Guid sitzung, string datei, HttpResponse antwort, Streamkoordinator koordinator) =>
{
if (koordinator.Sitzung(sitzung) is not { } laufend) return Results.NotFound();
// **Nur der blosse Name.** Ohne diese Prüfung käme man mit
// `../../config/flaro.db` an die Datenbank — ein Pfad aus der
// Anfrage darf nie ungeprüft an das Dateisystem.
if (datei != Path.GetFileName(datei) || datei.Contains("..", StringComparison.Ordinal))
return Results.BadRequest();
if (!datei.EndsWith(".m4s", StringComparison.Ordinal)
&& !datei.EndsWith(".mp4", StringComparison.Ordinal))
{
return Results.NotFound();
}
koordinator.Lebenszeichen(sitzung);
var pfad = Path.Combine(laufend.Ordner, datei);
if (!File.Exists(pfad)) return Results.NotFound();
// **Segmente ändern sich nie.** Anders als die Liste: einmal
// geschrieben, bleibt eines, wie es ist — es darf also beliebig
// lange liegenbleiben. Das spart beim Zurückspringen den ganzen
// Weg über die Leitung.
antwort.Headers.CacheControl = "private, max-age=86400, immutable";
return Results.File(pfad, "video/mp4", enableRangeProcessing: true);
}).RequireAuthorization();
// **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.
app.MapDelete("/api/v1/stream/{sitzung:guid}", (Guid sitzung, Streamkoordinator koordinator) =>
{
koordinator.Beenden(sitzung);
return Results.NoContent();
}).RequireAuthorization();
// **Dasselbe noch einmal als POST, für `sendBeacon`.**
//
// Beim Schliessen eines Reiters kommt eine gewöhnliche Anfrage oft
// nicht mehr durch — der Browser bricht sie mit der Seite ab.
// `sendBeacon` überlebt das, kann aber nur POST. Ohne diesen Weg
// bliebe nach jedem geschlossenen Reiter ein ffmpeg stehen, bis die
// Geduld der Sitzung abgelaufen ist.
app.MapPost("/api/v1/stream/{sitzung:guid}/schliessen", (
Guid sitzung, Streamkoordinator koordinator) =>
{
koordinator.Beenden(sitzung);
return Results.NoContent();
}).RequireAuthorization();
// **Untertitel als eigene Datei, nicht im Strom.**
//
// So lassen sie sich während der Wiedergabe umschalten, ohne dass
// irgendetwas neu gerechnet wird. In einer fMP4-Segmentierung hätten
// sie ohnehin keinen Platz.
app.MapGet("/api/v1/files/{id:guid}/subtitles/{spur:int}.vtt", async (
Guid id, int spur, FlaroContext db, UntertitelLeser leser, CancellationToken ct) =>
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id, ct);
if (datei is null || !File.Exists(datei.Path)) return Results.NotFound();
var vtt = await leser.AlsWebVttAsync(datei.Path, spur, ct);
return vtt is null
? Results.NotFound()
: Results.Text(vtt, "text/vtt", Encoding.UTF8);
}).RequireAuthorization();
}
/// <summary>Die Datei, wenn der Browser sie unverändert öffnen kann — sonst <c>null</c>.</summary>
private static async Task<Guid?> Durchreichbar(FlaroContext db, Guid id, DeviceProfile profil)
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
if (datei is null || !File.Exists(datei.Path)) return null;
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == id).OrderBy(s => s.Index).ToListAsync();
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
return Stromentscheid.Fuer(datei.Container, gelesen, profil).Art == Stromart.Direkt
? datei.Id
: null;
}
private static async Task<StreamResponse> Direktauskunft(FlaroContext db, Guid datei, double ab)
{
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == datei)
.OrderBy(s => s.Index)
.ToListAsync();
return new StreamResponse(
// **Keine Sitzung, also keine Kennung.** Der Spieler merkt daran,
// dass es nichts zu beenden und nichts aufzuräumen gibt.
SessionId: Guid.Empty,
Url: $"/api/v1/files/{datei}/download",
Method: "direct",
// **Null, nicht `ab`.** Bei einer durchgereichten Datei springt
// der Spieler selbst; es gibt keinen Vorlauf, den er aufrechnen
// müsste. Ihn hier zu melden hiesse, die Stelle doppelt zu zählen.
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 || !IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
}
/// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
///
/// <remarks>
/// <b>Die Rückfallebene ist nicht harmlos.</b> Sie erlaubt HEVC und EAC3;
/// ein Browser, der sein Profil nicht mitschickt, bekommt damit womöglich
/// etwas, das er nicht abspielen kann. Alte Clients sollen aber nicht
/// plötzlich gar nichts mehr bekommen — die Apps schicken ohnehin bald
/// ihr eigenes.
/// </remarks>
private static DeviceProfile Profil(StreamRequest anfrage)
{
if (anfrage.Containers is not { Count: > 0 }
|| anfrage.VideoCodecs is not { Count: > 0 }
|| anfrage.AudioCodecs is not { Count: > 0 })
{
return DeviceProfile.AppleDefault;
}
return new DeviceProfile(
new HashSet<string>(anfrage.Containers, StringComparer.OrdinalIgnoreCase),
new HashSet<string>(anfrage.VideoCodecs, StringComparer.OrdinalIgnoreCase),
new HashSet<string>(anfrage.AudioCodecs, StringComparer.OrdinalIgnoreCase));
}
private static async Task<StreamResponse> Auskunft(FlaroContext db, Stromsitzung sitzung)
{
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == sitzung.DateiId)
.OrderBy(s => s.Index)
.ToListAsync();
return new StreamResponse(
SessionId: sitzung.Kennung,
Url: $"/api/v1/stream/{sitzung.Kennung}/index.m3u8",
Method: sitzung.Art switch
{
Stromart.Direkt => "direct",
Stromart.Umpacken => "remux",
_ => "transcode",
},
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 || !IstBild(s.Codec))
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault))
.ToList());
}
/// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
///
/// <remarks>
/// <b>Sie anzubieten wäre eine Zusage, die nicht eingelöst werden kann.</b>
/// PGS ist auf jedem Blu-ray-Rip die Regel — ein Eintrag im Menü, der zu
/// nichts führt, ist schlechter als kein Eintrag.
/// </remarks>
private static bool IstBild(string codec) =>
codec is "hdmv_pgs_subtitle" or "pgssub" or "dvd_subtitle" or "dvdsub" or "xsub";
/// <summary>Wie eine Spur im Menü heisst.</summary>
private static string Benennung(MediaStream spur)
{
if (spur.Title is { Length: > 0 } eigen) return eigen;
var sprache = Sprache(spur.Language);
var teile = new List<string> { sprache };
if (spur.Channels is { } k and > 2) teile.Add($"{k} Kanäle");
if (spur.IsForced) teile.Add("erzwungen");
return string.Join(" · ", teile);
}
/// <summary>Dreibuchstabige Kennungen in Worte — für die üblichen.</summary>
///
/// <remarks>
/// <b>Keine vollständige Tabelle.</b> „deu" steht auf fast jeder Datei
/// hier; die übrigen bleiben, wie sie sind, statt eine Liste zu pflegen,
/// die nie fertig wird.
/// </remarks>
private static string Sprache(string? kennung) => kennung?.ToLowerInvariant() switch
{
null or "" or "und" => "Unbenannt",
"deu" or "ger" or "de" => "Deutsch",
"eng" or "en" => "Englisch",
"fra" or "fre" or "fr" => "Französisch",
"ita" or "it" => "Italienisch",
"spa" or "es" => "Spanisch",
"jpn" or "ja" => "Japanisch",
_ => kennung!.ToUpperInvariant(),
};
}
+228 -13
View File
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Flaro.Core.Users; using Flaro.Core.Users;
using Flaro.Core; using Flaro.Core;
using Flaro.Metadata; using Flaro.Metadata;
@@ -27,7 +28,11 @@ public static class SystemEndpoints
{ {
app.MapGet("/api/v1/system/info", async (FlaroContext db) => app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
Results.Ok(new SystemInfo( Results.Ok(new SystemInfo(
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0", // **Beides aus einer Hand.** Die Assembly-Version war hier
// immer „1.0.0" und sagte deshalb nichts darüber, welches
// Abbild läuft. Der Build daneben tut genau das.
Version: Buildkennung.Version,
Build: Buildkennung.Build,
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients // **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei // den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
// Server hat, unterscheidet sie an einer Zahl. // Server hat, unterscheidet sie an einer Zahl.
@@ -43,7 +48,9 @@ public static class SystemEndpoints
Attributions: Attributions:
[ [
new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl), new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl),
]))) ],
HeroHeight: Aufmacherhoehe.Lesen((await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(e => e.Key == Aufmacherhoehe.Schlüssel))?.Value))))
.AllowAnonymous(); .AllowAnonymous();
// **Wer schaut gerade was.** // **Wer schaut gerade was.**
@@ -71,14 +78,111 @@ public static class SystemEndpoints
Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds))) Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds)))
.RequireAuthorization(FlaroPolicies.Administrator); .RequireAuthorization(FlaroPolicies.Administrator);
// **Ordner zum Durchklicken.** Der Pfad einer Mediathek ist der im
// Container, nicht der auf dem NAS — eine Unterscheidung, die nur
// kennt, wer die Compose-Datei selbst geschrieben hat. Ein Tippfehler
// darin ergibt eine leere Mediathek ohne erkennbaren Grund. Wer sich
// durchklickt, sieht stattdessen, was wirklich da ist.
//
// Das Textfeld daneben bleibt trotzdem: eintippen ist schneller, wenn
// man den Pfad kennt, und bei sehr tiefen Bäumen die einzige Geduld
// schonende Möglichkeit.
app.MapGet("/api/v1/system/folders", (string? path) =>
Ordnerbaum.Lesen(path) is { } baum
? Results.Ok(baum)
: Einwand.Fehlt($"Den Ordner {path} gibt es nicht, oder es ist keiner."))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Dubletten von Hand zusammenlegen.** Nach einem Scan passiert das
// von selbst; hier steht es für den Fall, dass doch wieder welche
// auftauchen — etwa nachdem eine Zuordnung von Hand geändert wurde.
app.MapPost("/api/v1/system/merge-duplicates",
async (Zusammenlegung zusammenlegung, CancellationToken ct) =>
Results.Ok(await zusammenlegung.AlleAsync(null, ct)))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Protokolle, eine Datei je Tag.** Das Containerprotokoll ist nach
// einem Neustart weg und nur erreichbar, wenn man sich auf das NAS
// verbindet — die Frage „warum wurde der Film gestern Nacht nicht
// erkannt?" lässt sich damit nicht beantworten.
app.MapGet("/api/v1/system/logs", (Tagebuch tagebuch) =>
Results.Ok(tagebuch.Tage()
.Select(t => new Protokolltag(
t.ToString("yyyy-MM-dd"),
new FileInfo(tagebuch.DateiFuer(t)) is { Exists: true } f ? f.Length : 0))
.ToList()))
.RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch, int? tail) =>
{
// **Selbst zusammengesetzt, nicht aus der Anfrage übernommen.**
// Ein Datum, das durch `DateOnly` gegangen ist, kann kein
// `../../etc/passwd` mehr sein.
if (!DateOnly.TryParseExact(tag, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out var tagesdatum))
{
return Einwand.Bitte("Erwartet wird ein Datum als JJJJ-MM-TT.");
}
var datei = tagebuch.DateiFuer(tagesdatum);
if (!File.Exists(datei)) return Results.NotFound();
// **Mit `tail` nur das Ende.** Die Ansicht braucht die letzten
// Zeilen; die ganze Datei dafür zu schicken endete bei 2 MB über
// eine wackelige Verbindung in „Failed to fetch". Ohne `tail`
// kommt weiterhin alles — das ist der Weg zum Herunterladen.
if (tail is > 0 and <= 5000)
{
return Results.Text(
tagebuch.Ende(tagesdatum, tail.Value), "text/plain", System.Text.Encoding.UTF8);
}
return Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log");
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
// hier eingestellt wird, greift nur, soweit Übersetzungen da sind.
// Deshalb kommt die Abdeckung gleich mit — ohne sie wäre „Englisch"
// ein Versprechen, das der Server nicht hält.
app.MapPut("/api/v1/users/me/language", async (
SprachRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
Uebersetzer uebersetzer, CancellationToken ct) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return Results.Unauthorized();
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == kennung, ct);
if (benutzer is null) return Results.NotFound();
var sprache = anfrage.Language?.Trim();
benutzer.Language = string.IsNullOrEmpty(sprache) ? null : sprache;
await db.SaveChangesAsync(ct);
if (benutzer.Language is null) return Results.Ok(new SprachStand(null, 0, 0));
var abdeckung = await uebersetzer.AbdeckungAsync(benutzer.Language, ct);
return Results.Ok(new SprachStand(benutzer.Language, abdeckung.Vorhanden, abdeckung.Gesamt));
}).RequireAuthorization();
// Die Sammelaktion dazu — Administratoren, weil sie je Titel eine
// Anfrage an TMDB kostet.
app.MapPost("/api/v1/system/translations", async (
SprachRequest anfrage, Uebersetzer uebersetzer, Tagebuch tagebuch, CancellationToken ct) =>
{
var sprache = anfrage.Language?.Trim();
if (string.IsNullOrEmpty(sprache))
return Einwand.Bitte("Ohne Sprache gibt es nichts zu holen.");
tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt.");
var bericht = await uebersetzer.HolenAsync(sprache, null, ct);
tagebuch.Schreiben(
$"Übersetzungen für {sprache}: {bericht.Gesamt} geholt, {bericht.Fehlend} ohne Treffer.");
return Results.Ok(bericht);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) => app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
{ {
var name = anfrage.Name.Trim(); var name = anfrage.Name.Trim();
if (name.Length == 0) if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte("Ein Name darf nicht leer sein.");
{
["name"] = ["Ein Name darf nicht leer sein."],
});
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel); var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name }); if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
@@ -88,13 +192,30 @@ public static class SystemEndpoints
return Results.NoContent(); return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
// **Wie hoch der Aufmacher steht.** Gelesen wird er mit der
// Systemauskunft weiter oben — die holt der Client ohnehin als
// Erstes, und so steht die Höhe schon beim ersten Zeichnen fest.
app.MapPut("/api/v1/system/hero-height", async (
HoeheRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (!Aufmacherhoehe.Kennt(anfrage.Height))
return Einwand.Bitte("Unbekannte Stufe. Möglich sind: niedrig, mittel, hoch, sehrhoch.");
var wert = anfrage.Height.Trim().ToLowerInvariant();
var vorhanden = await db.Settings
.FirstOrDefaultAsync(e => e.Key == Aufmacherhoehe.Schlüssel, ct);
if (vorhanden is null)
db.Settings.Add(new ServerSetting { Key = Aufmacherhoehe.Schlüssel, Value = wert });
else vorhanden.Value = wert;
await db.SaveChangesAsync(ct);
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) => app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
{ {
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8) if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
return Results.ValidationProblem(new Dictionary<string, string[]> return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
{
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
});
await EinrichtungsSchloss.WaitAsync(); await EinrichtungsSchloss.WaitAsync();
try try
@@ -102,14 +223,17 @@ public static class SystemEndpoints
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die // Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
// Antwort schon veraltet, wenn sie ankommt. // Antwort schon veraltet, wenn sie ankommt.
if (await db.Users.AnyAsync()) if (await db.Users.AnyAsync())
return Results.Problem( return Einwand.Belegt("Dieser Server ist bereits eingerichtet.");
"Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict);
var administrator = new User var administrator = new User
{ {
UserName = anfrage.UserName.Trim(), UserName = anfrage.UserName.Trim(),
PasswordHash = PasswordHasher.Hash(anfrage.Password), PasswordHash = PasswordHasher.Hash(anfrage.Password),
IsAdministrator = true, IsAdministrator = true,
// **Wer einrichtet, besitzt.** Ihm nimmt niemand das
// Adminrecht — sonst gäbe es einen Zustand ohne jeden
// Administrator, und aus dem führt kein Weg zurück.
IsOwner = true,
}; };
db.Users.Add(administrator); db.Users.Add(administrator);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -121,6 +245,97 @@ public static class SystemEndpoints
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können. // Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
// Verrät nichts ausser der Tatsache, dass etwas antwortet. // Verrät nichts ausser der Tatsache, dass etwas antwortet.
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous(); // **Wo was liegt, und wie viel davon.**
//
// Die drei Pfade standen nirgends in der Oberfläche; wer wissen wollte,
// 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) =>
{
Speicherort Messen(string name, string variable, string pfad) =>
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
return Results.Ok(new[]
{
Messen("Daten", "FLARO_DATA", pfade.Data),
Messen("Bilder", "FLARO_CACHE", pfade.Cache),
Messen("Umpacken", "FLARO_TRANSCODE", pfade.Transcode),
});
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Den Bildspeicher leeren.**
//
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene
// liegen als Datei genauso wieder da, sobald jemand sie neu wählt.
// Darum ohne Umweg über eine Wartungsaktion — es ist ein Cache.
app.MapDelete("/api/v1/system/storage/images", (FlaroPaths pfade) =>
{
var ordner = Path.Combine(pfade.Cache, "images");
if (!Directory.Exists(ordner)) return Results.NoContent();
var befreit = Belegt(ordner);
try
{
Directory.Delete(ordner, recursive: true);
Directory.CreateDirectory(ordner);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
return Results.Problem(Fehlertext.FuerMenschen(fehler));
}
return Results.Ok(new { freed = befreit });
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Auch hier die Kennung.** Wer auf dem NAS nur „curl" hat, soll nicht
// erst die Oberfläche öffnen müssen, um sie zu sehen.
app.MapGet("/health", () => Results.Ok(new
{
status = "ok",
version = Buildkennung.Version,
build = Buildkennung.Build,
})).AllowAnonymous();
}
/// <summary>Wie viel ein Ordner belegt — rekursiv, ohne zu werfen.</summary>
///
/// <remarks>
/// Ein unlesbarer Unterordner darf die Auskunft nicht verhindern; dann
/// steht dort eben etwas zu wenig. Eine Zahl, die fast stimmt, ist besser
/// als eine Fehlerseite.
/// </remarks>
private static long Belegt(string pfad)
{
if (!Directory.Exists(pfad)) return 0;
try
{
return new DirectoryInfo(pfad)
.EnumerateFiles("*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
})
.Sum(d => d.Length);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
return 0;
}
}
/// <summary>Wie viel auf dem Volume noch frei ist — <c>null</c>, wenn unbekannt.</summary>
private static long? Frei(string pfad)
{
try
{
return new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfad)) ?? "/").AvailableFreeSpace;
}
catch (Exception fehler) when (fehler is ArgumentException or IOException
or UnauthorizedAccessException)
{
return null;
}
} }
} }
+190
View File
@@ -0,0 +1,190 @@
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Flaro.Data;
namespace Flaro.Api;
/// <summary>Macht aus einer Ausnahme einen Satz, den ein Mensch lesen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es das braucht.</b> In der Oberfläche stand tagelang „An error
/// occurred while saving the entity changes. See the inner exception for
/// details." — englisch, in einem deutschen Server, und mit dem Verweis auf
/// eine innere Ausnahme, die niemand zu sehen bekam. Das ist keine Meldung,
/// das ist eine Sackgasse: sie nennt weder die Ursache noch einen nächsten
/// Schritt.
/// </para>
/// <para>
/// <b>Zwei Texte, nicht einer.</b> <see cref="FuerMenschen"/> liefert, was in
/// der Oberfläche steht: deutsch, ohne Fehlercode, mit einem Hinweis, was zu
/// tun ist. <see cref="FuersProtokoll"/> liefert alles — Typ, Meldung, jede
/// innere Ausnahme — und landet in der Tagesdatei. Wer den Fehler sucht,
/// findet dort die Einzelheiten; wer den Server nur benutzt, wird nicht
/// damit behelligt.
/// </para>
/// <para>
/// <b>Erkannt wird am Text, nicht am Typ.</b> Ein Verweis auf
/// <c>SqliteException</c> hiesse, den Datenbanktreiber in dieses Projekt zu
/// ziehen, nur um eine Meldung zu formulieren. Die Zeichenkette
/// „UNIQUE constraint failed: Tabelle.Spalte" ist seit Jahren stabil und
/// kostet nichts.
/// </para>
/// </remarks>
public static partial class Fehlertext
{
/// <summary>Was in der Oberfläche steht — deutsch, ohne Fehlercode.</summary>
public static string FuerMenschen(Exception fehler)
{
var regel = VerletzteRegel(fehler);
if (regel is not null) return regel;
// Fehler, die ihren eigenen Text schon für Menschen schreiben, dürfen
// ihn behalten: eine eigene Ausnahme sagt mehr als jede Verallgemeinerung.
if (fehler is BestandVerschwundenException or InvalidOperationException { Source: "Flaro.Data" })
return fehler.Message;
return fehler switch
{
UnauthorizedAccessException =>
"Auf einen Ordner oder eine Datei fehlt das Zugriffsrecht. "
+ "Prüfe die Einhängung und die Benutzerkennung des Containers.",
DirectoryNotFoundException or FileNotFoundException =>
"Ein Ordner oder eine Datei war nicht mehr da. "
+ "Ist das Laufwerk noch eingehängt?",
IOException =>
"Beim Lesen oder Schreiben ist etwas schiefgegangen. "
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
TaskCanceledException or OperationCanceledException =>
"Der Vorgang wurde abgebrochen.",
// Tritt auf, wenn jemand dieselben Zeilen gleichzeitig entfernt —
// etwa ein „Zurücksetzen" während eines laufenden Scans.
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException =>
"Jemand hat dieselben Einträge gleichzeitig geändert. "
+ "Der Lauf wurde abgebrochen; starte ihn noch einmal.",
HttpRequestException =>
"Die Metadatenquelle war nicht erreichbar. "
+ "Beim nächsten Lauf wird es erneut versucht.",
_ =>
"Etwas ist schiefgegangen. Die Einzelheiten stehen im Protokoll "
+ "von heute, unter „Protokolle“.",
};
}
/// <summary>Alles, was es über den Fehler zu sagen gibt — für die Tagesdatei.</summary>
public static string FuersProtokoll(Exception fehler)
{
var text = new StringBuilder();
var tiefe = 0;
Exception innerste = fehler;
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
{
if (tiefe > 0) text.Append(" ← ");
text.Append(e.GetType().Name).Append(": ").Append(e.Message);
innerste = e;
}
// **Und wo es passiert ist.**
//
// Ohne diese Zeilen stand in der Tagesdatei „InvalidOperationException:
// Nullable object must have a value." und sonst nichts — eine Meldung,
// mit der weder der Betreiber noch der Entwickler etwas anfangen kann.
// Gerade die Fehler, die man nicht erwartet hat, sind die, bei denen
// die Stelle alles ist.
//
// Nur die innerste Ausnahme und nur die eigenen Bilder: der Rahmenwerk-
// Anteil einer Spur ist dreimal so lang und sagt nichts über Flaro.
var spur = Eigene(innerste.StackTrace);
if (spur.Length > 0) text.Append('\n').Append(spur);
return text.ToString();
}
/// <summary>Die Zeilen der Spur, die zu Flaro gehören.</summary>
///
/// <remarks>
/// <b>Höchstens zwölf.</b> Was darunter liegt, ist ASP.NET und EF Core —
/// bei einem Fehler im eigenen Code steht die Antwort immer oben.
/// </remarks>
private static string Eigene(string? spur)
{
if (string.IsNullOrEmpty(spur)) return "";
var zeilen = spur.Split('\n')
.Select(z => z.TrimEnd('\r'))
.Where(z => z.Contains(" Flaro.", StringComparison.Ordinal))
.Take(12)
.ToList();
// Steckt gar nichts Eigenes darin, ist die ganze Spur besser als keine.
if (zeilen.Count == 0)
{
zeilen = spur.Split('\n').Select(z => z.TrimEnd('\r')).Take(6).ToList();
}
return string.Join('\n', zeilen);
}
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
///
/// <remarks>
/// <b>Die Tabellen- und Spaltennamen werden übersetzt, nicht
/// durchgereicht.</b> „UNIQUE constraint failed: MediaFiles.LibraryId,
/// MediaFiles.Path" ist für den, der es gebaut hat, eine vollständige
/// Diagnose — und für alle anderen eine Zeichenkette. Was dem Benutzer
/// nützt, ist der Satz dahinter: was kollidiert ist und was er dagegen
/// tun kann.
/// </remarks>
private static string? VerletzteRegel(Exception fehler)
{
string? spalten = null;
for (Exception? e = fehler; e is not null && spalten is null; e = e.InnerException)
{
var treffer = Eindeutigkeit().Match(e.Message);
if (treffer.Success) spalten = treffer.Groups[1].Value;
}
if (spalten is null) return null;
// Normalisiert auf „Tabelle.Spalte, Spalte", damit die Zuordnung unten
// nicht an einem Leerzeichen scheitert.
var schluessel = spalten.Replace(" ", "");
if (schluessel.Contains("MediaFiles.Path"))
{
return "Zwei Einträge beanspruchen denselben Dateipfad. "
+ "Das deutet auf einen Ordner hin, der doppelt eingehängt ist, "
+ "oder auf eine Verknüpfung, die zurück in die Mediathek zeigt. "
+ "Es wurde nichts verändert.";
}
if (schluessel.Contains("Episodes.SeasonNumber") || schluessel.Contains("Episodes.EpisodeNumber"))
{
return "Zwei Dateien beanspruchen dieselbe Folge derselben Staffel. "
+ "Sind es zwei Fassungen, gehören sie an dieselbe Folge — "
+ "dann hilft „Zuordnung korrigieren“. Es wurde nichts verändert.";
}
if (schluessel.Contains("TmdbId"))
{
return "Zwei Titel wurden derselben Vorlage zugeordnet. "
+ "Unter „Doppelte zusammenführen“ lässt sich das auflösen. "
+ "Es wurde nichts verändert.";
}
if (schluessel.Contains("MediaStreams"))
{
return "Eine Datei meldet dieselbe Spur zweimal. "
+ "Beim nächsten Lauf wird sie erneut untersucht.";
}
return "Ein Eintrag gibt es bereits ein zweites Mal. "
+ "Es wurde nichts verändert; die Einzelheiten stehen im Protokoll von heute.";
}
[GeneratedRegex(@"UNIQUE constraint failed:\s*([^'""\r\n]+)", RegexOptions.IgnoreCase)]
private static partial Regex Eindeutigkeit();
}
+38
View File
@@ -0,0 +1,38 @@
using System.Security.Claims;
using Flaro.Api.Auth;
using Flaro.Data;
namespace Flaro.Api;
/// <summary>Setzt je Anfrage, wer Unveröffentlichtes sehen darf.</summary>
///
/// <remarks>
/// <para>
/// <b>Als Middleware und nicht in den Endpunkten.</b> Zwanzig Endpunkte
/// müssten daran denken; diese eine Stelle läuft vor allen. Was sie setzt,
/// greift über den globalen Abfragefilter in <see cref="FlaroContext"/> auf
/// jede Abfrage — auch auf die, die erst nächstes Jahr geschrieben wird.
/// </para>
/// <para>
/// <b>Nach der Authentifizierung, vor den Endpunkten.</b> Vorher stünde im
/// <c>ClaimsPrincipal</c> noch nichts, und jeder gälte als gewöhnlicher
/// Benutzer — die Verwaltung bekäme ihre eigene Prüfliste nicht zu sehen.
/// </para>
/// </remarks>
public static class Freigabefilter
{
public static IApplicationBuilder UseFreigabefilter(this IApplicationBuilder app) =>
app.Use(async (zusammenhang, weiter) =>
{
var db = zusammenhang.RequestServices.GetService<FlaroContext>();
if (db is not null)
{
db.Jetzt = zusammenhang.RequestServices
.GetRequiredService<TimeProvider>().GetUtcNow();
db.AuchUnveroeffentlichtes =
zusammenhang.User.IsInRole(FlaroRoles.Administrator);
}
await weiter();
});
}
+4 -2
View File
@@ -26,7 +26,9 @@ public sealed class MatchApplier(
film.TmdbId = gefunden.ProviderId; film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId; film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title; film.Title = gefunden.Title;
film.SortTitleValue = SortTitle.For(gefunden.Title); // Ein von Hand gesetzter Sortiertitel überlebt auch eine Korrektur der
// Zuordnung — sonst wäre die Reihenfolge nach jedem Geradeziehen hin.
if (!film.SortTitlePinned) film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year; film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview; film.Overview = gefunden.Overview;
film.PosterImage = await Ablegen(gefunden.Poster, ct); film.PosterImage = await Ablegen(gefunden.Poster, ct);
@@ -51,7 +53,7 @@ public sealed class MatchApplier(
serie.TmdbId = gefunden.ProviderId; serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId; serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title; serie.Title = gefunden.Title;
serie.SortTitleValue = SortTitle.For(gefunden.Title); if (!serie.SortTitlePinned) serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year; serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview; serie.Overview = gefunden.Overview;
serie.PosterImage = await Ablegen(gefunden.Poster, ct); serie.PosterImage = await Ablegen(gefunden.Poster, ct);
+123
View File
@@ -0,0 +1,123 @@
namespace Flaro.Api;
/// <summary>Zeigt Ordner des Dateisystems, damit Pfade nicht getippt werden müssen.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum das überhaupt nötig ist.</b> Ein Pfad in einer Mediathek ist der
/// Pfad <i>im Container</i>, nicht der auf dem NAS — also das, was in der
/// Compose-Datei hinter dem Doppelpunkt steht. Diese Unterscheidung kennt
/// niemand, der nicht gerade die Compose-Datei geschrieben hat, und ein
/// Tippfehler darin ergibt eine leere Mediathek ohne erkennbaren Grund.
/// Wer sich durchklicken kann, sieht stattdessen, was wirklich da ist.
/// </para>
/// <para>
/// <b>Nur für Administratoren, und das ist keine Förmlichkeit.</b> Diese
/// Auskunft legt den Aufbau des Dateisystems offen. Im Container ist das
/// wenig, aber eingehängte Ordner tragen die Namen echter Verzeichnisse.
/// </para>
/// </remarks>
public static class Ordnerbaum
{
/// <summary>Wo die Suche beginnt, wenn niemand einen Pfad nennt.</summary>
///
/// <remarks>
/// In dieser Reihenfolge, weil die Anleitung Medien nach <c>/media</c>
/// einhängen lässt. Steht dort nichts, ist die Wurzel der ehrlichste
/// Anfang — raten hilft niemandem.
/// </remarks>
private static readonly string[] Anfänge = ["/media", "/mnt", "/data", "/volume1"];
/// <summary>Was an der Wurzel nicht angezeigt wird.</summary>
///
/// <remarks>
/// <b>Keine Sicherheitsmassnahme, eine Aufräumarbeit.</b> Wer den Pfad
/// kennt, kann ihn weiterhin eintippen; verborgen wird nichts. Es geht
/// darum, dass eine Liste aus zwanzig Einträgen, von denen achtzehn
/// <c>/proc</c>, <c>/sys</c> und <c>/usr</c> heissen, unbrauchbar ist.
/// Medien liegen dort nie.
/// </remarks>
private static readonly HashSet<string> AnDerWurzelVerborgen = new(StringComparer.Ordinal)
{
"bin", "boot", "dev", "etc", "lib", "lib32", "lib64", "libx32",
"proc", "root", "run", "sbin", "sys", "tmp", "usr", "var",
};
public sealed record Eintrag(string Name, string Path);
public sealed record Antwort(
string Path,
string? Parent,
IReadOnlyList<Eintrag> Entries,
/// <summary>Warum die Liste leer ist — oder <c>null</c>, wenn sie es nicht ist.</summary>
string? Note);
/// <summary>Der Pfad, mit dem die Auswahl aufgeht.</summary>
public static string Anfang()
{
foreach (var kandidat in Anfänge)
{
if (Directory.Exists(kandidat)) return kandidat;
}
// Nicht hart „/": unter Windows und macOS gibt es das so nicht, und
// die Tests laufen dort.
return Path.GetPathRoot(Environment.CurrentDirectory) is { Length: > 0 } wurzel
? wurzel
: "/";
}
/// <summary>Listet die Unterordner eines Verzeichnisses.</summary>
///
/// <remarks>
/// <b>Gibt auch dann eine Antwort, wenn das Lesen scheitert.</b> Ein
/// Ordner ohne Leserecht ist kein Fehler des Aufrufers, sondern eine
/// Tatsache über das Dateisystem — und eine, die in der Oberfläche
/// stehen soll, statt als 500 im Protokoll zu landen. <c>null</c> heisst
/// dagegen: diesen Pfad gibt es nicht.
/// </remarks>
public static Antwort? Lesen(string? angefragt)
{
var pfad = string.IsNullOrWhiteSpace(angefragt) ? Anfang() : angefragt.Trim();
// Macht aus „/media/../etc" wieder „/etc". Das ist keine Abwehr —
// Administratoren dürfen überallhin — sondern sorgt dafür, dass der
// angezeigte Pfad der ist, den man auch eintippen würde.
try { pfad = Path.GetFullPath(pfad); }
catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException)
{
return null;
}
if (!Directory.Exists(pfad)) return null;
var eltern = Path.GetDirectoryName(pfad.TrimEnd(Path.DirectorySeparatorChar));
var anDerWurzel = string.IsNullOrEmpty(eltern);
List<Eintrag> eintraege;
string? hinweis = null;
try
{
eintraege = Directory.EnumerateDirectories(pfad)
.Select(p => new Eintrag(Path.GetFileName(p), p))
.Where(e => e.Name.Length > 0)
.Where(e => !anDerWurzel || !AnDerWurzelVerborgen.Contains(e.Name))
.OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
if (eintraege.Count == 0) hinweis = "Keine Unterordner.";
}
catch (UnauthorizedAccessException)
{
eintraege = [];
hinweis = "Kein Leserecht für diesen Ordner.";
}
catch (IOException)
{
eintraege = [];
hinweis = "Der Ordner liess sich nicht lesen.";
}
return new Antwort(pfad, anDerWurzel ? null : eltern, eintraege, hinweis);
}
}
+3 -1
View File
@@ -118,8 +118,10 @@ public sealed class PackageCoordinator(
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId); log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
// Der rohe Ausnahmetext bleibt im Protokoll; angezeigt wird ein Satz.
_zustand[fileId] = new PackageState( _zustand[fileId] = new PackageState(
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), fehler.Message); "failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(),
Fehlertext.FuerMenschen(fehler));
} }
} }
+111 -3
View File
@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
using Flaro.Api; using Flaro.Api;
using Flaro.Api.Auth; using Flaro.Api.Auth;
@@ -19,7 +20,15 @@ pfade.EnsureExists();
builder.Services.AddSingleton(pfade); builder.Services.AddSingleton(pfade);
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddDbContext<FlaroContext>(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}")); // **Mit Wartezeit statt sofortigem Abbruch.**
//
// SQLite lässt genau einen Schreiber zu. Ohne `Default Timeout` scheitert
// jeder, der dazwischenkommt, auf der Stelle mit „database is locked" —
// während eines Scans über tausende Dateien ist das jede zweite Anfrage aus
// der Oberfläche. Dreissig Sekunden sind grosszügig; gebraucht werden
// Millisekunden.
builder.Services.AddDbContext<FlaroContext>(o =>
o.UseSqlite($"Data Source={pfade.DatabaseFile};Default Timeout=30"));
builder.Services builder.Services
.AddAuthentication(TokenAuthenticationHandler.SchemeName) .AddAuthentication(TokenAuthenticationHandler.SchemeName)
@@ -66,6 +75,7 @@ builder.Services.AddRateLimiter(o =>
// und lässt einen Absturz dort den Server nicht mitnehmen. // und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment()); builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>(); builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<Schluesselbilder>();
builder.Services.AddSingleton<FfmpegRemuxer>(); builder.Services.AddSingleton<FfmpegRemuxer>();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu // Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
@@ -74,10 +84,13 @@ builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>(); builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
builder.Services.AddScoped<LibraryScanner>(); builder.Services.AddScoped<LibraryScanner>();
builder.Services.AddScoped<Zusammenlegung>();
builder.Services.AddScoped<Uebersetzer>();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was // Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann. // ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>(); builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
builder.Services.AddScoped<Wiedergabeeinstellungen>();
builder.Services.AddHttpClient<TmdbClient>(c => builder.Services.AddHttpClient<TmdbClient>(c =>
{ {
c.BaseAddress = new Uri(TmdbClient.BaseAddress); c.BaseAddress = new Uri(TmdbClient.BaseAddress);
@@ -93,9 +106,31 @@ builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>(); builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>(); builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>(); builder.Services.AddSingleton<PackageCoordinator>();
// **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei
// zum Mitnehmen; dieser lässt sie laufen, während sie entsteht.
builder.Services.AddSingleton<Streamkoordinator>();
builder.Services.AddSingleton<UntertitelLeser>();
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage // Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand. // ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
builder.Services.AddSingleton<PlaybackSessions>(); builder.Services.AddSingleton<PlaybackSessions>();
builder.Services.AddSingleton<Tagebuch>();
// **Die Schlüssel der Datenverschlüsselung gehören ins Volume.**
//
// Ohne diese Zeile legt ASP.NET sie unter `~/.aspnet/DataProtection-Keys` ab —
// also im Container, der beim nächsten Update weg ist. Heute fällt das nicht
// auf, weil Flaros Sitzungs-Cookie das Gerätetoken selbst trägt und nichts
// davon verschlüsselt wird. Es fällt erst auf, wenn irgendwann etwas doch
// darauf baut, und dann sieht es aus wie ein Fehler anderswo.
//
// Nebenbei verschwindet damit die Warnung, die bei jedem Start im Protokoll
// stand und nach einem Problem aussah, das keines war.
var schluesselOrdner = Path.Combine(pfade.Data, "keys");
Directory.CreateDirectory(schluesselOrdner);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(schluesselOrdner))
.SetApplicationName("Flaro");
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
var app = builder.Build(); var app = builder.Build();
@@ -104,10 +139,57 @@ var app = builder.Build();
// hochfährt, soll nicht erst eine Migration von Hand anstossen müssen. // hochfährt, soll nicht erst eine Migration von Hand anstossen müssen.
using (var bereich = app.Services.CreateScope()) using (var bereich = app.Services.CreateScope())
{ {
await bereich.ServiceProvider.GetRequiredService<FlaroContext>().Database.MigrateAsync(); var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
// **WAL, und zwar vor der Migration.**
//
// In der Vorgabe („delete") sperrt ein Schreiber die ganze Datei: solange
// der Scan schreibt, kommt *keine* Leseanfrage durch. Genau so sah es in
// der Oberfläche aus — Bilddialog und Protokoll liefen während eines
// Laufs sporadisch ins Leere. Mit WAL lesen beliebig viele weiter,
// während einer schreibt.
//
// Die Einstellung hält sich in der Datei; einmal gesetzt bleibt sie. Sie
// trotzdem bei jedem Start zu setzen kostet nichts und deckt den Fall ab,
// dass jemand mit einer frischen Datei kommt.
await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;");
// Ohne das wartet SQLite nach jeder Schreibung auf die Platte. Mit WAL
// ist „normal" die übliche Wahl: ein Absturz des Prozesses kostet nichts,
// nur ein Stromausfall mitten im Schreiben könnte die letzte Transaktion
// kosten — und die ist bei einem Medienserver ein Scanfortschritt.
await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;");
await db.Database.MigrateAsync();
} }
app.UseExceptionHandler(); // **Auch das Letzte, was durchrutscht, spricht Deutsch.**
//
// Die Vorgabe von ASP.NET antwortet mit „An error occurred while processing
// your request." — englisch, ohne Grund, ohne nächsten Schritt. Genau das
// stand im Zuordnungsdialog, als eine Kennung doppelt vergeben werden
// sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen
// deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie
// sucht.
app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang =>
{
var fehler = zusammenhang.Features
.Get<Microsoft.AspNetCore.Diagnostics.IExceptionHandlerFeature>()?.Error;
if (fehler is not null)
{
zusammenhang.RequestServices.GetRequiredService<Tagebuch>().Schreiben(
$"{zusammenhang.Request.Method} {zusammenhang.Request.Path}: "
+ Fehlertext.FuersProtokoll(fehler),
Tagebuch.Art.Fehler);
}
zusammenhang.Response.StatusCode = StatusCodes.Status500InternalServerError;
await Results.Problem(
fehler is null ? "Etwas ist schiefgegangen." : Fehlertext.FuerMenschen(fehler),
statusCode: StatusCodes.Status500InternalServerError)
.ExecuteAsync(zusammenhang);
}));
app.UseStatusCodePages(); app.UseStatusCodePages();
app.UseRateLimiter(); app.UseRateLimiter();
@@ -173,6 +255,10 @@ if (Directory.Exists(oberfläche))
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// **Nach der Anmeldung, vor den Endpunkten.** Davor stünde im Principal noch
// nichts, und ein Administrator sähe seine eigene Prüfliste nicht.
app.UseFreigabefilter();
app.MapSystem(); app.MapSystem();
app.MapAuth(); app.MapAuth();
app.MapManagement(); app.MapManagement();
@@ -180,8 +266,14 @@ app.MapLibraryContent();
app.MapSettings(); app.MapSettings();
app.MapImages(); app.MapImages();
app.MapDelivery(); app.MapDelivery();
app.MapStream();
app.MapPlayback(); app.MapPlayback();
app.MapMatching(); app.MapMatching();
app.MapArtwork();
app.MapProfil();
app.MapFreigabe();
app.MapMeldungen();
app.MapEinladungen();
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die // fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
@@ -208,6 +300,22 @@ if (Directory.Exists(oberfläche))
}).AllowAnonymous(); }).AllowAnonymous();
} }
// **Start und Ende gehören ins Protokoll.**
//
// Ohne das war nicht zu unterscheiden, ob der Server während eines langen
// Scans nur beschäftigt oder zwischendurch neu gestartet war — und genau das
// 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(() =>
tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"));
leben.ApplicationStopping.Register(() =>
tagebuch.Schreiben("Server fährt herunter."));
}
app.Run(); app.Run();
/// <summary>Damit die Tests den Server hochfahren können.</summary> /// <summary>Damit die Tests den Server hochfahren können.</summary>
+91 -8
View File
@@ -1,3 +1,4 @@
using Flaro.Core.Library;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Flaro.Data; using Flaro.Data;
@@ -9,7 +10,15 @@ public sealed record ScanState(
DateTimeOffset? FinishedAt, DateTimeOffset? FinishedAt,
ScanReport? Report, ScanReport? Report,
EnrichReport? Enrichment, EnrichReport? Enrichment,
string? Error); string? Error,
/// <summary>Woran der Lauf gerade ist — nur der letzte Schritt.</summary>
///
/// <remarks>
/// <b>Nur der letzte, nicht eine Liste.</b> Der Zustand liegt im
/// Arbeitsspeicher, und eine wachsende Liste über vierzigtausend Titel
/// wäre ein Leck. Wer die Vorgeschichte braucht, liest das Protokoll.
/// </remarks>
Scanschritt? Schritt = null);
/// <summary>Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind.</summary> /// <summary>Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind.</summary>
/// ///
@@ -47,7 +56,20 @@ public sealed class ScanCoordinator(
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running"; public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary> /// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
public bool TryStart(Guid libraryId, bool erneut = false) ///
/// <param name="nurSprache">
/// Nur Titel anfassen, die nicht in dieser Sprache vorliegen. Dann wird
/// auch <b>nicht gescannt</b>: nach einer Sprachumstellung hat sich am
/// Dateisystem nichts geändert, und 5884 Dateien noch einmal abzulaufen
/// kostet Minuten für nichts.
/// </param>
/// <param name="nurDiese">
/// <b>Nur diese Titel auffrischen</b> — für „Metadaten neu holen" an einer
/// Auswahl in der Mediathek. <c>null</c> heisst: der ganze Bestand.
/// </param>
public bool TryStart(
Guid libraryId, bool erneut = false, string? nurSprache = null,
IReadOnlySet<Guid>? nurDiese = null)
{ {
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null); var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
@@ -59,17 +81,47 @@ public sealed class ScanCoordinator(
// Lief schon einer, steht hier dessen Zustand und nicht unserer. // Lief schon einer, steht hier dessen Zustand und nicht unserer.
if (!ReferenceEquals(vorher, start)) return false; if (!ReferenceEquals(vorher, start)) return false;
_ = Task.Run(() => Laufen(libraryId, erneut)); _ = Task.Run(() => Laufen(libraryId, erneut, nurSprache, nurDiese));
return true; return true;
} }
private async Task Laufen(Guid libraryId, bool erneut) private async Task Laufen(
Guid libraryId, bool erneut, string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
{ {
try try
{ {
using var bereich = scopes.CreateScope(); using var bereich = scopes.CreateScope();
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
var bericht = await scanner.ScanAsync(libraryId); // **Jeder Schritt landet im Zustand und im Protokoll.** Der
// Zustand ist für die Oberfläche, das Protokoll für die Frage
// „was war gestern Nacht?" — die ein Zustand im Arbeitsspeicher
// nach einem Neustart nicht mehr beantworten kann.
var tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
var melder = new Progress<Scanschritt>(schritt =>
{
tagebuch.Schreiben(schritt.Text());
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
// Flackern, in der Tagesdatei ist es die Antwort auf „wieso 737
// Serien?".
if (schritt.Phase == Scanphase.Vermerk) return;
_zustand.AddOrUpdate(libraryId,
_ => new ScanState("running", clock.GetUtcNow(), null, null, null, null, schritt),
(_, alt) => alt with { Schritt = schritt });
});
// **Nach einer Sprachumstellung wird nicht gescannt.** Am
// Dateisystem hat sich nichts geändert; 5884 Dateien noch einmal
// abzulaufen kostete Minuten für nichts. Dasselbe gilt, wenn
// jemand eine Handvoll Titel ausgewählt hat.
ScanReport? bericht = null;
if (nurSprache is null && nurDiese is null)
{
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
bericht = await scanner.ScanAsync(libraryId, melder);
}
// Der Scan ist durch, die Dateien stehen in der Mediathek. Was // Der Scan ist durch, die Dateien stehen in der Mediathek. Was
// jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren // jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren
@@ -77,23 +129,54 @@ public sealed class ScanCoordinator(
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht }; _zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
var metadaten = await bereich.ServiceProvider var metadaten = await bereich.ServiceProvider
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId, erneut); .GetRequiredService<MetadataEnricher>()
.EnrichAsync(libraryId, erneut, melder, nurSprache: nurSprache, nurDiese: nurDiese);
// **Erst jetzt lassen sich Dubletten überhaupt erkennen.**
// Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich"
// und der andere „Despicable Me"; dass es derselbe Film ist,
// verrät allein die TMDB-Kennung, und die steht erst nach den
// Metadaten fest.
//
// **Nicht bei einer Auswahl.** Wer zehn Titel auffrischt, erwartet
// nicht, dass nebenbei anderswo zwei Einträge verschwinden — und
// genau das sähe aus wie ein verlorener Film.
var zusammengelegt = nurDiese is null
? await bereich.ServiceProvider.GetRequiredService<Zusammenlegung>().AlleAsync(libraryId)
: Zusammenlegungsbericht.Nichts;
if (zusammengelegt.Gesamt > 0)
{
log.LogInformation(
"Zusammengelegt: {Filme} Film(e), {Serien} Serie(n) der Bibliothek {Kennung}.",
zusammengelegt.Filme, zusammengelegt.Serien, libraryId);
}
_zustand[libraryId] = _zustand[libraryId] with _zustand[libraryId] = _zustand[libraryId] with
{ {
Status = "finished", Status = "finished",
FinishedAt = clock.GetUtcNow(), FinishedAt = clock.GetUtcNow(),
Enrichment = metadaten, Enrichment = metadaten,
Schritt = null,
}; };
} }
catch (Exception fehler) catch (Exception fehler)
{ {
// **Zwei Texte, und zwar absichtlich.** Ins Protokoll geht alles,
// bis zur innersten Ausnahme — dort wird gesucht. In die
// Oberfläche geht ein deutscher Satz ohne Fehlercode; dort wird
// nicht gesucht, dort wird gelesen.
log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId); log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId);
using (var bereich = scopes.CreateScope())
{
bereich.ServiceProvider.GetRequiredService<Tagebuch>().Schreiben(
$"Scan gescheitert: {Fehlertext.FuersProtokoll(fehler)}", Tagebuch.Art.Fehler);
}
_zustand[libraryId] = _zustand[libraryId] with _zustand[libraryId] = _zustand[libraryId] with
{ {
Status = "failed", Status = "failed",
FinishedAt = clock.GetUtcNow(), FinishedAt = clock.GetUtcNow(),
Error = fehler.Message, Error = Fehlertext.FuerMenschen(fehler),
}; };
} }
} }
+372
View File
@@ -0,0 +1,372 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Eine laufende Wiedergabe mit ihrem ffmpeg dahinter.</summary>
///
/// <param name="Ab">
/// Ab welcher Sekunde des Films diese Sitzung beginnt. <b>Die Zeitachse des
/// Spielers fängt bei null an</b>, nicht beim Film — wer ab Minute 30
/// einsteigt, dessen Spieler steht bei 0, während der Film bei 1800 steht.
/// Ohne diesen Wert ginge jeder gemeldete Stand um eine halbe Stunde daneben.
/// </param>
public sealed record Stromsitzung(
Guid Kennung,
Guid DateiId,
Guid Benutzer,
string Ordner,
double Ab,
int? Hoehe,
int? Tonspur,
Stromart Art,
DateTimeOffset Begonnen)
{
/// <summary>Wann zuletzt ein Segment abgeholt wurde.</summary>
///
/// <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.
/// </remarks>
public DateTimeOffset Gesehen { get; set; } = Begonnen;
public string? Fehler { get; set; }
public bool Beendet { get; set; }
}
/// <summary>Verwaltet laufende Ströme: starten, bedienen, aufräumen.</summary>
///
/// <remarks>
/// <para>
/// <b>Neben <see cref="PackageCoordinator"/>, nicht an seiner Stelle.</b> Der
/// baut eine fertige Datei zum Mitnehmen — mit Länge, Bereichsanfragen und
/// Fortsetzen nach einem Abbruch; das bleibt für die Apps richtig. Hier geht
/// es um etwas anderes: der erste Ton soll nach Sekunden kommen, nicht nach
/// Minuten.
/// </para>
/// <para>
/// <b>Eine Sitzung je Wiedergabe, nicht je Datei.</b> Zwei Leute können
/// denselben Film in verschiedenen Auflösungen und mit verschiedenen
/// Tonspuren sehen. Das nach Dateikennung zu führen hiesse, dass der zweite
/// bekommt, was der erste gewählt hat.
/// </para>
/// </remarks>
public sealed class Streamkoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
FfmpegOptions ffmpeg,
Schluesselbilder schluesselbilder,
TimeProvider clock,
ILogger<Streamkoordinator> log) : IDisposable
{
private readonly ConcurrentDictionary<Guid, Eintrag> _sitzungen = new();
private sealed record Eintrag(Stromsitzung Sitzung, Process Prozess, CancellationTokenSource Abbruch);
/// <summary>Nach dieser Stille gilt eine Sitzung als verlassen.</summary>
///
/// <remarks>
/// <b>Grosszügiger als ein Segment lang.</b> Wer pausiert, holt eine
/// Weile nichts ab; ihn nach zwanzig Sekunden abzuwürgen wäre
/// Sparsamkeit an der falschen Stelle. Zwei Minuten reichen für eine
/// Pause und sind kurz genug, dass ein geschlossener Browser nicht
/// stundenlang Platten füllt.
/// </remarks>
public static readonly TimeSpan Geduld = TimeSpan.FromMinutes(2);
/// <summary>Wie viele Ströme gleichzeitig laufen dürfen.</summary>
///
/// <remarks>
/// <b>Weil Umkodieren einen ganzen Rechner beschäftigt.</b> Vier
/// gleichzeitige 4K-Läufe bringen jedes NAS zum Stehen — und dann
/// ruckelt nicht einer, sondern alle vier.
/// </remarks>
private const int HoechstensGleichzeitig = 6;
public int LaufendeAnzahl => _sitzungen.Count;
public Stromsitzung? Sitzung(Guid kennung) => _sitzungen.GetValueOrDefault(kennung)?.Sitzung;
/// <summary>Startet einen Strom und gibt die Sitzung zurück.</summary>
/// <param name="erzwingen">
/// Umkodieren, auch wenn das Profil etwas anderes sagt — der Spieler hat
/// es mit dem ersten Plan probiert und ist gescheitert.
/// </param>
public async Task<Stromsitzung> StartenAsync(
Guid dateiId, Guid benutzer, DeviceProfile profil,
int? hoehe, int? tonspur, double ab, CancellationToken ct = default,
bool erzwingen = false, double tonversatz = 0)
{
AbgelaufeneWegraeumen();
if (_sitzungen.Count >= HoechstensGleichzeitig)
{
throw new InvalidOperationException(
$"Es laufen bereits {HoechstensGleichzeitig} Wiedergaben, die umgerechnet werden müssen. "
+ "Warte, bis eine davon zu Ende ist.");
}
using var bereich = scopes.CreateScope();
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == dateiId, ct)
?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek.");
if (!File.Exists(datei.Path))
throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.");
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == dateiId).OrderBy(s => s.Index)
.ToListAsync(ct);
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
var plan = Stromentscheid.Fuer(datei.Container, gelesen, profil, hoehe, erzwingen);
var kennung = Guid.CreateVersion7();
var ordner = Path.Combine(pfade.Transcode, "stroeme", kennung.ToString("N"));
Directory.CreateDirectory(ordner);
// Die eingestellte Stufe aus demselben Bereich — ein Lesevorgang, und
// er steht hier, weil nur hier feststeht, dass überhaupt kodiert wird.
var stufe = await bereich.ServiceProvider
.GetRequiredService<Wiedergabeeinstellungen>().StufeAsync(ct);
// **Beim Kopieren wird auf das Schlüsselbild gerundet.**
//
// Ein kopierter Bildstrom lässt sich nicht mitten in einer Bildgruppe
// aufschneiden; ffmpeg beginnt beim Schlüsselbild 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
// 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.**
//
// Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und
// landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton
// fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
// keiner Rechnung hier.
//
// Wo genau das war, sagt die Suche. Sie ist dafür da, dem Spieler
// eine ehrliche Uhr zu geben — nicht dafür, die Ausrichtung
// herzustellen. Vergreift sie sich, geht die Uhr falsch; Bild und Ton
// 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 gemeldet = plan.BildKopieren
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
: gewuenscht;
var anfrage = new Stromanfrage(
datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
// **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst
// stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und
// bei einer Spur, die es nicht mehr gibt, bei der falschen.
var sitzung = new Stromsitzung(
kennung, dateiId, benutzer, ordner, gemeldet,
plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow());
var prozess = Starten(anfrage, sitzung);
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" : "");
return sitzung;
}
/// <summary>Hält eine Sitzung am Leben — bei jedem abgeholten Segment.</summary>
public void Lebenszeichen(Guid kennung)
{
if (_sitzungen.TryGetValue(kennung, out var eintrag))
eintrag.Sitzung.Gesehen = clock.GetUtcNow();
}
/// <summary>Beendet eine Sitzung und räumt ihren Ordner weg.</summary>
public void Beenden(Guid kennung)
{
if (!_sitzungen.TryRemove(kennung, out var eintrag)) return;
Abraeumen(eintrag);
}
/// <summary>Beendet, was niemand mehr abholt.</summary>
///
/// <remarks>
/// <b>Beim Starten einer neuen Sitzung, nicht in einem eigenen Takt.</b>
/// Ein Hintergrunddienst für einen Handgriff, der ohnehin selten
/// geschieht, wäre ein Faden mehr, der schiefgehen kann.
/// </remarks>
public void AbgelaufeneWegraeumen()
{
var grenze = clock.GetUtcNow() - Geduld;
foreach (var (kennung, eintrag) in _sitzungen)
{
if (eintrag.Sitzung.Gesehen > grenze) continue;
if (!_sitzungen.TryRemove(kennung, out var weg)) continue;
log.LogInformation("Strom {Kennung} wurde seit {Geduld} nicht abgeholt — beendet.",
kennung, Geduld);
Abraeumen(weg);
}
}
/// <summary>Ob die Playlist schon etwas enthält, das sich abspielen lässt.</summary>
///
/// <remarks>
/// <b>Die Datei allein genügt nicht.</b> ffmpeg legt sie an, bevor das
/// erste Segment fertig ist; ein Spieler, der sie dann bekommt, sieht
/// eine leere Liste und gibt auf.
/// </remarks>
public static string? Playlist(string ordner)
{
var pfad = Path.Combine(ordner, HlsArgumente.Playlist);
if (!File.Exists(pfad)) return null;
try
{
// `ReadAllText` auf einer Datei, in die ffmpeg gerade schreibt:
// deshalb ausdrücklich teilend geöffnet, sonst gäbe es hier je
// nach Zeitpunkt eine Ausnahme statt einer Antwort.
using var strom = new FileStream(
pfad, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var leser = new StreamReader(strom);
var text = leser.ReadToEnd();
// Ohne Segment ist die Liste wertlos — der Spieler sähe eine
// leere Playlist und gäbe auf.
return text.Contains(".m4s", StringComparison.Ordinal) ? text : null;
}
catch (IOException) { return null; }
}
/// <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>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.
/// </remarks>
private Beschleunigung Hardware()
{
var gewuenscht = Environment.GetEnvironmentVariable("FLARO_HWACCEL")?.Trim().ToLowerInvariant();
return gewuenscht switch
{
"none" or "software" => Beschleunigung.Software,
"vaapi" => Beschleunigung.Vaapi,
_ => File.Exists("/dev/dri/renderD128") ? Beschleunigung.Vaapi : Beschleunigung.Software,
};
}
private Process Starten(Stromanfrage anfrage, Stromsitzung sitzung)
{
var argumente = HlsArgumente.Fuer(anfrage);
var start = new ProcessStartInfo(ffmpeg.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var a in argumente) start.ArgumentList.Add(a);
var prozess = Process.Start(start)
?? throw new InvalidOperationException($"{ffmpeg.Ffmpeg} liess sich nicht starten.");
// **Der Fehlerkanal wird gelesen, nicht nur umgeleitet.** Läuft die
// Röhre voll, bleibt ffmpeg beim Schreiben stehen — und der Strom
// hört mitten im Film auf, ohne dass irgendwo etwas stünde.
_ = Task.Run(async () =>
{
var text = await prozess.StandardError.ReadToEndAsync();
await prozess.WaitForExitAsync();
sitzung.Beendet = true;
if (prozess.ExitCode == 0)
{
// **Auch ein geglückter Lauf darf etwas zu sagen haben.**
// Vorher wurde der Text weggeworfen, sobald der Rückgabewert 0
// war — und genau die Meldungen, die einen Tonversatz
// ankündigen, sind Warnungen, keine Fehler. Ein Film, der
// schief klingt, aber durchläuft, hinterliess damit nirgends
// eine Spur.
if (text.Trim() is { Length: > 0 } warnungen)
{
log.LogWarning("Strom {Kennung}: ffmpeg meldet {Warnungen}",
sitzung.Kennung, warnungen);
}
return;
}
sitzung.Fehler = text.Trim();
log.LogWarning("Strom {Kennung}: ffmpeg endete mit {Code}: {Fehler}",
sitzung.Kennung, prozess.ExitCode, sitzung.Fehler);
});
_ = Task.Run(() => prozess.StandardOutput.ReadToEndAsync());
return prozess;
}
private void Abraeumen(Eintrag eintrag)
{
try
{
if (!eintrag.Prozess.HasExited) eintrag.Prozess.Kill(entireProcessTree: true);
}
catch (Exception fehler) when (fehler is InvalidOperationException or NotSupportedException)
{
log.LogDebug(fehler, "ffmpeg zu {Kennung} liess sich nicht beenden.", eintrag.Sitzung.Kennung);
}
eintrag.Prozess.Dispose();
eintrag.Abbruch.Dispose();
try
{
if (Directory.Exists(eintrag.Sitzung.Ordner))
Directory.Delete(eintrag.Sitzung.Ordner, recursive: true);
}
catch (IOException fehler)
{
log.LogWarning(fehler, "Der Ordner zu Strom {Kennung} blieb liegen.", eintrag.Sitzung.Kennung);
}
}
/// <summary>Beim Herunterfahren bleibt nichts laufen und nichts liegen.</summary>
public void Dispose()
{
foreach (var kennung in _sitzungen.Keys) Beenden(kennung);
// **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.
try
{
var wurzel = Path.Combine(pfade.Transcode, "stroeme");
if (Directory.Exists(wurzel)) Directory.Delete(wurzel, recursive: true);
}
catch (IOException) { }
}
}
+177
View File
@@ -0,0 +1,177 @@
using System.Globalization;
using System.Text;
namespace Flaro.Api;
/// <summary>Schreibt mit, was der Server tut — eine Datei je Tag.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür, wenn es doch ein Containerprotokoll gibt.</b> Das ist nach einem
/// Neustart weg, und um es überhaupt zu sehen, muss man sich auf das NAS
/// verbinden. Die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
/// lässt sich damit nicht beantworten. Diese Dateien liegen im
/// <c>config</c>-Volume, überleben jeden Neustart und sind aus dem Browser
/// lesbar.
/// </para>
/// <para>
/// <b>Eine Datei je Tag, nach lokaler Zeit.</b> Nicht nach UTC: wer um
/// 23 Uhr einen Scan anstösst, sucht ihn am heutigen Datum, nicht am
/// morgigen. Die Zeitzone kommt aus <c>TZ</c>, so wie überall sonst im
/// Container auch.
/// </para>
/// <para>
/// <b>Mit Aufbewahrungsgrenze.</b> Ohne sie läuft die <c>config</c>-Partition
/// eines NAS irgendwann voll, und zwar lautlos.
/// </para>
/// </remarks>
public sealed class Tagebuch
{
public enum Art { Info, Warnung, Fehler }
private readonly string _ordner;
private readonly TimeProvider _uhr;
private readonly int _tageAufbewahren;
/// <summary>Ein Schreiber je Prozess — zwei gleichzeitige Zeilen vermischen sich sonst.</summary>
private readonly Lock _schloss = new();
private DateOnly _zuletztAufgeraeumt = DateOnly.MinValue;
public Tagebuch(FlaroPaths pfade, TimeProvider uhr)
{
_ordner = Path.Combine(pfade.Data, "logs");
_uhr = uhr;
_tageAufbewahren = int.TryParse(
Environment.GetEnvironmentVariable("FLARO_LOG_TAGE"), out var tage) && tage > 0
? tage
: 30;
Directory.CreateDirectory(_ordner);
}
/// <summary>Die Datei für einen Tag — auch dann, wenn es sie nicht gibt.</summary>
public string DateiFuer(DateOnly tag) =>
Path.Combine(_ordner, $"flaro-{tag:yyyy-MM-dd}.log");
/// <summary>Welche Tage protokolliert sind, neueste zuerst.</summary>
public IReadOnlyList<DateOnly> Tage()
{
if (!Directory.Exists(_ordner)) return [];
return Directory.EnumerateFiles(_ordner, "flaro-*.log")
.Select(p => Path.GetFileNameWithoutExtension(p)["flaro-".Length..])
.Select(t => DateOnly.TryParseExact(t, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var tag) ? tag : (DateOnly?)null)
.Where(t => t is not null)
.Select(t => t!.Value)
.OrderByDescending(t => t)
.ToList();
}
/// <summary>Die letzten Zeilen eines Tages — ohne die ganze Datei zu lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil das Ende interessiert, nicht der Anfang.</b> Wer ins Protokoll
/// sieht, sucht fast immer das Letzte. Die Oberfläche holte bisher die
/// ganze Datei und warf davon 95 % weg — bei 2 MB über eine wackelige
/// Verbindung endete das in „Failed to fetch", und nach einem langen Scan
/// wären es Dutzende Megabyte.
/// </para>
/// <para>
/// Rückwärts in Blöcken gelesen, nicht Zeile für Zeile von vorn: sonst
/// kostet die Ansicht dasselbe wie das Herunterladen, nur ohne den Nutzen.
/// </para>
/// </remarks>
public string Ende(DateOnly tag, int zeilen)
{
var datei = DateiFuer(tag);
if (!File.Exists(datei)) return "";
const int block = 64 * 1024;
try
{
using var strom = new FileStream(
datei, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
var gefunden = 0;
var position = strom.Length;
var puffer = new byte[block];
while (position > 0 && gefunden <= zeilen)
{
var lesen = (int)Math.Min(block, position);
position -= lesen;
strom.Position = position;
strom.ReadExactly(puffer, 0, lesen);
for (var i = lesen - 1; i >= 0 && gefunden <= zeilen; i--)
{
if (puffer[i] != (byte)'\n') continue;
gefunden++;
// Eine Zeile zu viel zählen und danach die angebrochene
// erste wegwerfen — sonst beginnt die Ausgabe mitten im
// Satz.
if (gefunden > zeilen) position += i + 1;
}
}
strom.Position = position;
using var leser = new StreamReader(strom, Encoding.UTF8);
return leser.ReadToEnd();
}
catch (IOException)
{
return "";
}
}
/// <summary>Hängt eine Zeile an das Protokoll des heutigen Tages.</summary>
///
/// <remarks>
/// <b>Wirft nie.</b> Ein volles Dateisystem oder ein schreibgeschütztes
/// Volume darf einen Scan nicht abbrechen — das Protokoll ist Beiwerk, und
/// Beiwerk, das den Betrieb anhält, ist schlechter als keines.
/// </remarks>
public void Schreiben(string text, Art art = Art.Info)
{
try
{
var jetzt = _uhr.GetLocalNow();
var heute = DateOnly.FromDateTime(jetzt.DateTime);
var stufe = art switch
{
Art.Fehler => "FEHLER ",
Art.Warnung => "WARNUNG",
_ => "INFO ",
};
lock (_schloss)
{
File.AppendAllText(
DateiFuer(heute),
$"{jetzt:HH:mm:ss} {stufe} {text}{Environment.NewLine}",
Encoding.UTF8);
Aufraeumen(heute);
}
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
/// <summary>Löscht, was älter ist als die Aufbewahrungsfrist — höchstens einmal je Tag.</summary>
private void Aufraeumen(DateOnly heute)
{
if (_zuletztAufgeraeumt == heute) return;
_zuletztAufgeraeumt = heute;
var grenze = heute.AddDays(-_tageAufbewahren);
foreach (var tag in Tage().Where(t => t < grenze))
{
try { File.Delete(DateiFuer(tag)); } catch (IOException) { }
}
}
}
@@ -0,0 +1,83 @@
using Flaro.Core.Delivery;
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Holt die Wiedergabe-Einstellungen aus der Datenbank.</summary>
///
/// <remarks>
/// <b>Dasselbe Muster wie <see cref="DatabaseTmdbSettings"/>:</b> je Anfrage
/// einmal gelesen und dann gemerkt. Eine Fortschrittsmeldung kommt alle zehn
/// Sekunden und von jedem Gerät; dreimal dieselbe Zeile je Meldung zu lesen
/// wäre Arbeit für nichts.
///
/// <b>Serverweit, nicht je Nutzer.</b> Der Transcoder gehört der Maschine —
/// es gibt eine CPU und eine Entscheidung. Die beiden Schwellen könnten je
/// Nutzer gelten; sie tun es nicht, weil zwei Wahrheiten darüber, was
/// „angefangen" heisst, mehr kosten als sie einbringen.
/// </remarks>
public sealed class Wiedergabeeinstellungen(FlaroContext db)
{
public const string FortsetzenAbName = "wiedergabe.fortsetzen.abSekunden";
public const string GesehenAnteilName = "wiedergabe.gesehen.anteil";
public const string TranscoderName = "wiedergabe.transcoder.stufe";
private Wiedergaberegeln? _regeln;
private Kodierstufe? _stufe;
/// <summary>Wann ein Titel angefangen ist und wann er gesehen ist.</summary>
public async Task<Wiedergaberegeln> RegelnAsync(CancellationToken ct = default)
{
if (_regeln is { } fertig) return fertig;
await LesenAsync(ct);
return _regeln!.Value;
}
/// <summary>Wie der Transcoder zwischen Bild und Tempo abwägt.</summary>
public async Task<Kodierstufe> StufeAsync(CancellationToken ct = default)
{
if (_stufe is { } fertig) return fertig;
await LesenAsync(ct);
return _stufe!.Value;
}
private async Task LesenAsync(CancellationToken ct)
{
var zeilen = await db.Settings
.AsNoTracking()
.Where(s => s.Key == FortsetzenAbName
|| s.Key == GesehenAnteilName
|| s.Key == TranscoderName)
.ToDictionaryAsync(s => s.Key, s => s.Value, ct);
_regeln = Lesen(zeilen.GetValueOrDefault(FortsetzenAbName),
zeilen.GetValueOrDefault(GesehenAnteilName));
_stufe = Kodierstufen.Lesen(zeilen.GetValueOrDefault(TranscoderName));
}
/// <summary>Macht aus zwei gespeicherten Zeichenketten geprüfte Regeln.</summary>
///
/// <remarks>
/// <b>Ohne Ortsangabe gerechnet</b> — <see cref="double.TryParse(string?,
/// IFormatProvider?, out double)"/> mit <see
/// cref="System.Globalization.CultureInfo.InvariantCulture"/>: in der
/// Datenbank steht ein Punkt, und auf einem Server mit deutscher
/// Spracheinstellung läse ein Komma-Parser aus „0.9" eine Neun.
/// </remarks>
public static Wiedergaberegeln Lesen(string? abSekunden, string? anteil)
{
var regeln = Wiedergaberegeln.Vorgabe;
if (double.TryParse(abSekunden, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var sekunden))
regeln = regeln with { FortsetzenAb = TimeSpan.FromSeconds(sekunden) };
if (double.TryParse(anteil, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var wert))
regeln = regeln with { GesehenAnteil = wert };
return regeln.Geprueft();
}
}
+54
View File
@@ -0,0 +1,54 @@
namespace Flaro.Core.Auth;
/// <summary>Ein Link, mit dem sich jemand selbst ein Konto anlegt.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum nicht der vorhandene <see cref="AuthCode"/>.</b> Der ist ein
/// Gerätecode: fünf Minuten Leben, ein Gerätename daneben, und sein
/// <c>ApprovedByUserId</c> zeigt auf ein Konto, das es schon gibt — jemand,
/// der angemeldet ist, winkt ein zweites Gerät durch. Eine Einladung ist das
/// Gegenteil: erlaubt wird vorab, und wer sie einlöst, hat noch gar kein
/// Konto. Dieselbe Tabelle für beides hiesse, die Hälfte ihrer Spalten
/// wechselweise leer zu lassen.
/// </para>
/// <para>
/// <b>Übernommen ist die Geheimnisdisziplin.</b> Wie bei
/// <see cref="DeviceToken"/> liegt hier nur der Fingerabdruck; den Link
/// selbst bekommt man genau einmal zu sehen, beim Erstellen. Wer die
/// Datenbank liest, kann daraus keine Einladung bauen.
/// </para>
/// <para>
/// <b>Keine Rolle.</b> Wer eingeladen wird, ist gewöhnlicher Benutzer. Ein
/// Feld, über das ein Link Adminrechte verschenken könnte, gibt es nicht —
/// und damit auch keinen Weg, es versehentlich zu setzen. Administratoren
/// legt man von Hand an, und das ist selten genug.
/// </para>
/// </remarks>
public sealed class Einladung
{
public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Siehe <see cref="Secrets.Fingerprint"/> — nie der Link selbst.</summary>
public required string TokenFingerprint { get; init; }
public required Guid CreatedByUserId { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>Wie oft er gilt. <c>null</c> heisst: unbegrenzt.</summary>
public int? MaxUses { get; init; }
public int UsedCount { get; set; }
/// <summary>Von Hand zurückgezogen — <c>null</c> heisst: gilt noch.</summary>
public DateTimeOffset? RevokedAt { get; set; }
/// <summary>Ob sich damit jetzt noch ein Konto anlegen lässt.</summary>
public bool Gilt(DateTimeOffset jetzt) =>
RevokedAt is null
&& jetzt < ExpiresAt
&& (MaxUses is not { } hoechstens || UsedCount < hoechstens);
}
@@ -0,0 +1,48 @@
namespace Flaro.Core.Delivery;
/// <summary>Wie der Transcoder zwischen Bild und Tempo abwägt.</summary>
///
/// <remarks>
/// <para>
/// <b>Drei Stufen, keine freie Zahl</b> — wie bei
/// <see cref="Stromentscheid.Stufen"/>. „crf 19" sagt niemandem etwas, der
/// nicht ffmpeg kennt; „Qualität" und „Tempo" sagen genau das, worum es geht.
/// </para>
/// <para>
/// <b>Und es ist eine echte Abwägung.</b> Was in Echtzeit laufen muss, darf
/// nicht langsamer rechnen als es abspielt. Auf einer schwachen Maschine kann
/// „Qualität" dazu führen, dass das Bild stockt — das ist kein Fehler, das ist
/// die Stufe. Deshalb steht der Satz auch in der Oberfläche.
/// </para>
/// </remarks>
public enum Kodierstufe
{
/// <summary>Schöneres Bild, langsamere Rechnung.</summary>
Qualitaet,
/// <summary>Was Flaro immer getan hat.</summary>
Ausgewogen,
/// <summary>Startet schneller, kostet Bild.</summary>
Tempo,
}
/// <summary>Hin und zurück zwischen Stufe und gespeichertem Wort.</summary>
public static class Kodierstufen
{
/// <summary>Unbekanntes wird ausgewogen — das war schon immer das Verhalten.</summary>
public static Kodierstufe Lesen(string? wert) => wert?.Trim().ToLowerInvariant() switch
{
"qualitaet" or "qualität" => Kodierstufe.Qualitaet,
"tempo" => Kodierstufe.Tempo,
_ => Kodierstufe.Ausgewogen,
};
/// <summary>Wie die Stufe in der Datenbank steht.</summary>
public static string AlsWort(this Kodierstufe stufe) => stufe switch
{
Kodierstufe.Qualitaet => "qualitaet",
Kodierstufe.Tempo => "tempo",
_ => "ausgewogen",
};
}
@@ -0,0 +1,150 @@
using Flaro.Core.Library;
namespace Flaro.Core.Delivery;
/// <summary>Wie eine Datei in einen laufenden Spieler kommt.</summary>
///
/// <remarks>
/// <b>Nicht zu verwechseln mit <see cref="DeliveryMethod"/>.</b> Dort geht es
/// um eine fertige Datei zum Mitnehmen; hier um einen Strom, der sofort
/// anfängt. Beides hat dieselbe Frage — passt das Gerät zur Datei? — und ganz
/// verschiedene Antworten: eine Datei darf Minuten brauchen, ein Spieler nicht.
/// </remarks>
public enum Stromart
{
/// <summary>Die Datei geht unverändert hinaus, der Browser spielt sie direkt.</summary>
Direkt,
/// <summary>
/// Umgepackt, aber nicht neu gerechnet — mkv nach fMP4, Bild Byte für Byte.
/// </summary>
///
/// <remarks>
/// <b>Das ist der häufigste Fall und der billigste.</b> Eine MKV mit H.264
/// und AAC kann jeder Browser abspielen; nur die Schachtel kennt er nicht.
/// Sie zu wechseln kostet den Server fast nichts.
/// </remarks>
Umpacken,
/// <summary>Das Bild wird neu gerechnet — anderer Codec oder kleinere Auflösung.</summary>
Umkodieren,
}
/// <summary>Warum es nicht beim Durchreichen bleibt.</summary>
public enum Stromgrund
{
PasstSchon,
SchachtelUnbekannt,
BildcodecUnbekannt,
TonUnbekannt,
KleinereAufloesungGewuenscht,
/// <summary>Der Spieler hat es versucht und kam nicht zurecht.</summary>
///
/// <remarks>
/// <b>Das Urteil des Browsers schlägt seine eigene Auskunft.</b>
/// <c>canPlayType</c> ist eine Behauptung — „kenne ich" heisst nicht
/// „kann ich diese Datei". Wer es ausprobiert hat und gescheitert ist,
/// weiss mehr als jede Abfrage vorher.
/// </remarks>
SpielerKamNichtZurecht,
}
/// <summary>Was für diesen Strom zu tun ist.</summary>
///
/// <param name="Hoehe">
/// Auf wie viele Bildzeilen herunterskaliert wird — <c>null</c> heisst:
/// Originalauflösung.
/// </param>
public sealed record Streamplan(
Stromart Art,
IReadOnlyList<Stromgrund> Gruende,
int? Hoehe)
{
public bool BildKopieren => Art != Stromart.Umkodieren;
}
/// <summary>Entscheidet, was ein Strom braucht, damit der Spieler ihn öffnet.</summary>
///
/// <remarks>
/// <para>
/// <b>Getrennt von <see cref="DeliveryDecider"/>, und das ist Absicht.</b> Der
/// entscheidet über eine Datei zum Herunterladen und kennt nur zwei Ausgänge:
/// durchreichen oder umpacken. Ein unbekannter Bildcodec führte dort ebenfalls
/// zum Umpacken — und das half nichts, weil Umpacken das Bild nicht anfasst.
/// Für den Browser war das der Fehler, an dem jede Wiedergabe mit HEVC
/// scheiterte: er bekam eine tadellose MP4, in der ein Codec steckte, den er
/// nicht kennt.
/// </para>
/// <para>
/// <b>Das Bild wird nur angefasst, wenn es sein muss.</b> Neu zu rechnen
/// kostet ein Vielfaches des Umpackens und verliert Bildqualität. Wer eine
/// kleinere Auflösung will, bekommt sie; wer nicht, bekommt das Original.
/// </para>
/// </remarks>
public static class Stromentscheid
{
/// <summary>Die Stufen, die ein Spieler anbieten darf.</summary>
///
/// <remarks>
/// <b>Keine freie Zahl.</b> Vier Stufen decken ab, wofür es Gründe gibt:
/// das Original, zwei Zwischenstufen und eine für die langsame Leitung.
/// Eine beliebige Höhe zuzulassen hiesse, für jede Zahl eine eigene
/// Sitzung zu rechnen.
/// </remarks>
public static readonly IReadOnlyList<int> Stufen = [1080, 720, 480];
/// <param name="hoeheGewuenscht">
/// Die vom Spieler gewählte Stufe, oder <c>null</c> für das Original.
/// </param>
/// <param name="erzwingen">
/// Umkodieren, egal was das Profil sagt. <b>Für den zweiten Versuch:</b>
/// der Spieler hat es mit dem ersten Plan probiert und ist gescheitert.
/// </param>
public static Streamplan Fuer(
string? schachtel,
IReadOnlyList<ProbedStream> spuren,
DeviceProfile profil,
int? hoeheGewuenscht = null,
bool erzwingen = false)
{
var gruende = new List<Stromgrund>();
var bild = spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Video);
var bildPasst = Passt(bild?.Codec, profil.VideoCodecs);
if (!bildPasst) gruende.Add(Stromgrund.BildcodecUnbekannt);
// **Eine Stufe über der Quelle ist keine Stufe.** Ein 720p-Film in
// „1080p" abzuspielen hiesse, ihn hochzurechnen: mehr Last, mehr
// Daten, kein besseres Bild.
var hoehe = hoeheGewuenscht is { } gewuenscht
&& (bild?.Height is not { } vorhanden || gewuenscht < vorhanden)
? gewuenscht
: (int?)null;
if (hoehe is not null) gruende.Add(Stromgrund.KleinereAufloesungGewuenscht);
if (erzwingen) gruende.Add(Stromgrund.SpielerKamNichtZurecht);
if (erzwingen || !bildPasst || hoehe is not null)
return new Streamplan(Stromart.Umkodieren, gruende, hoehe);
var schachtelPasst = Passt(schachtel, profil.Containers);
if (!schachtelPasst) gruende.Add(Stromgrund.SchachtelUnbekannt);
// **Beim Ton genügt eine passende Spur.** Welche gespielt wird,
// entscheidet der Benutzer; dass eine siebenkanalige DTS-Spur daneben
// liegt, ist kein Grund, den ganzen Film anzufassen.
var tonspuren = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
var tonPasst = tonspuren.Count == 0 || tonspuren.Any(s => Passt(s.Codec, profil.AudioCodecs));
if (!tonPasst) gruende.Add(Stromgrund.TonUnbekannt);
if (!schachtelPasst || !tonPasst)
return new Streamplan(Stromart.Umpacken, gruende, null);
return new Streamplan(Stromart.Direkt, [Stromgrund.PasstSchon], null);
}
/// <summary>Was fehlt, zählt als passend — eine Lücke ist kein Urteil.</summary>
private static bool Passt(string? wert, IReadOnlySet<string> erlaubt) =>
string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert);
}
@@ -17,4 +17,27 @@ public sealed class Library
public required LibraryKind Kind { get; set; } public required LibraryKind Kind { get; set; }
public required string Path { get; set; } public required string Path { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>Ob neue Funde erst geprüft werden, bevor sie erscheinen.</summary>
///
/// <remarks>
/// <b>Vorgabe: aus.</b> Wer eine Mediathek anlegt, will sie sehen. Die
/// Prüfung ist für den Fall gedacht, dass Zuordnung und Bilder stimmen
/// sollen, bevor jemand im Haushalt den Film findet — oder dass etwas
/// erst zu einem bestimmten Zeitpunkt erscheinen soll.
/// </remarks>
public bool ReviewNewItems { get; set; }
/// <summary>Wo sie in der Seitenleiste steht.</summary>
///
/// <remarks>
/// <b>Weil alphabetisch nicht immer richtig ist.</b> Wer „Filme" und
/// „Kinderfilme" und „Dokumentationen" hat, will nicht nach dem Alphabet
/// sortiert sein, sondern danach, was er am häufigsten öffnet.
///
/// <c>0</c> für alles, was nie umsortiert wurde; dann entscheidet der
/// Name. Damit bleibt eine frisch angelegte Mediathek da, wo man sie
/// erwartet, statt am Ende zu landen.
/// </remarks>
public int SortOrder { get; set; }
} }
+347 -9
View File
@@ -5,7 +5,13 @@ namespace Flaro.Core.Library;
public sealed record MovieName(string Title, int? Year); public sealed record MovieName(string Title, int? Year);
public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle); /// <param name="AusOrdner">
/// <b>Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen.</b> Steht
/// nur im Protokoll — aber genau diese Angabe fehlte, als aus 95 Serien 737
/// wurden: niemand konnte sagen, welcher der beiden Wege sie angelegt hatte.
/// </param>
public sealed record EpisodeName(
string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false);
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary> /// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
/// ///
@@ -57,8 +63,27 @@ public static partial class FileNames
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)] [GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
private static partial Regex Auflösung(); private static partial Regex Auflösung();
/// <summary>S01E02, s1e2, S01.E02, S01E02E03 — die erste Folge zählt.</summary> /// <summary>S01E02, s1e2, S01.E02, Serie_S02E18, S2024E04 — die erste Folge zählt.</summary>
[GeneratedRegex(@"\bS(?<staffel>\d{1,3})[\s._-]*E(?<folge>\d{1,4})", RegexOptions.IgnoreCase)] ///
/// <remarks>
/// <para>
/// <b>Kein <c>\b</c> vor dem S, und das hat 3445 Dateien gekostet.</b> Eine
/// Wortgrenze sitzt zwischen einem Wortzeichen und einem Nicht-Wortzeichen
/// — der Unterstrich ist aber selbst ein Wortzeichen. In
/// <c>The Good Doctor_S02E18_Trampolin.mkv</c> steht zwischen <c>r</c> und
/// <c>S</c> damit <i>keine</i> Grenze, und der ganze Bestand aus einem
/// Mediathek-Download fiel durch. Die Rückschau verlangt stattdessen
/// ausdrücklich: kein Buchstabe, keine Ziffer davor — ein Unterstrich
/// schon.
/// </para>
/// <para>
/// <b>Vier Ziffern für die Staffel.</b> Sendungen aus Mediatheken
/// nummerieren nach Jahr: <c>Die Anstalt_S2024E04</c>. Mit drei Ziffern
/// fiel jede davon durch.
/// </para>
/// </remarks>
[GeneratedRegex(@"(?<![A-Za-z0-9])S(?<staffel>\d{1,4})[\s._-]*E(?<folge>\d{1,4})",
RegexOptions.IgnoreCase)]
private static partial Regex StaffelUndFolge(); private static partial Regex StaffelUndFolge();
/// <summary>1x02, 01x02 — die ältere Schreibweise.</summary> /// <summary>1x02, 01x02 — die ältere Schreibweise.</summary>
@@ -80,6 +105,115 @@ public static partial class FileNames
return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr); return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr);
} }
/// <summary>Der Name der Fassung — oder <c>null</c>, wenn der Name keine nennt.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Fassung ist kein eigener Film.</b> Die mobile Fassung, die
/// 4K-Fassung und das Original gehören zu einem Eintrag mit drei Dateien.
/// <see cref="ParseMovie"/> wirft den Zusatz deshalb weg — hier wird er
/// gelesen, damit die Dateien in der Auswahl Namen tragen statt Pfade.
/// </para>
/// <para>
/// Drei Schreibweisen, in dieser Reihenfolge: die Plex-Form
/// <c>{edition-Mobil}</c>, ein Zusatz hinter dem letzten Bindestrich
/// (<c>Film (2021) - Mobil.mp4</c>), und ein Elternordner, der nur die
/// Fassung benennt (<c>Mobil/Film (2021).mp4</c>).
/// </para>
/// <para>
/// Findet sich nichts, bleibt es bei <c>null</c> — dann bildet der Aufrufer
/// den Namen aus der gemessenen Auflösung. Zu raten wäre hier schlecht:
/// „Directors Cut" ist eine Fassung, „GRP" ist eine Veröffentlichungsgruppe.
/// </para>
/// </remarks>
public static string? Fassung(string pfad)
{
var roh = Rohname(pfad);
var klammer = EditionInKlammern().Match(roh);
if (klammer.Success) return Saeubern(klammer.Groups["name"].Value);
var strich = roh.LastIndexOf(" - ", StringComparison.Ordinal);
if (strich > 0)
{
var hinten = Saeubern(roh[(strich + 3)..]);
if (hinten.Length > 0 && IstFassungsname(hinten)) return Vereinheitlichen(hinten);
}
// **Drei Ebenen hoch, nicht nur eine.** Plex legt seine zweite Fassung
// als `Plex Versions/Optimized for Mobile/<Serie>/<Staffel>/…` ab —
// der Ordner, der die Fassung benennt, steht dort also weit über der
// Datei. Drei Ebenen decken das ab, ohne irgendwann die Mediathek
// selbst für eine Fassung zu halten.
var ordner = Path.GetDirectoryName(pfad);
for (var ebene = 0; ebene < 3 && !string.IsNullOrEmpty(ordner); ebene++)
{
var name = Path.GetFileName(ordner);
if (name.Length > 0 && IstFassungsname(name)) return Vereinheitlichen(Saeubern(name));
ordner = Path.GetDirectoryName(ordner);
}
return null;
}
/// <summary>Ordner, die nur Fassungen einsammeln und keine Serie benennen.</summary>
///
/// <remarks>
/// <c>Plex Versions</c> ist kein Titel, sondern eine Schublade. Ohne diese
/// Prüfung hiesse die Serie so, und darunter läge der halbe Bestand ein
/// zweites Mal.
/// </remarks>
private static bool IstFassungsordner(string name) =>
IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase);
private static bool IstFassungsname(string wort)
{
var blank = wort.Trim();
return Auflösung().IsMatch(blank) || Fassungsnamen.ContainsKey(Schlank(blank));
}
/// <summary>Schreibweisen, die dasselbe meinen, bekommen denselben Namen.</summary>
private static string Vereinheitlichen(string wort) =>
Fassungsnamen.GetValueOrDefault(Schlank(wort), wort.Trim());
private static string Schlank(string wort) =>
wort.Trim().Replace(" ", "").Replace("'", "").Replace("-", "");
private static string Saeubern(string wort) =>
Leerraum().Replace(wort.Replace('_', ' ').Replace('.', ' '), " ")
.Trim(' ', '-', '–', ',', '[', ']', '(', ')', '{', '}');
/// <summary>Was als Fassung durchgeht — knapp gehalten, mit Absicht.</summary>
///
/// <remarks>
/// <b>Eine geschlossene Liste.</b> Nähme man jedes Wort hinter dem letzten
/// Bindestrich, hiesse eine Fassung irgendwann „YIFY" oder „GRP" — das
/// sind Veröffentlichungsgruppen, keine Fassungen. Lieber kein Name als
/// ein falscher: ohne Treffer rechnet die Antwort sie aus der gemessenen
/// Bildgrösse — <c>Benennung.Aufloesungswort</c>.
/// </remarks>
private static readonly Dictionary<string, string> Fassungsnamen =
new(StringComparer.OrdinalIgnoreCase)
{
["mobil"] = "Mobil", ["mobile"] = "Mobil",
["optimiert"] = "Mobil", ["optimized"] = "Mobil",
// Wie Plex seine zweite Fassung nennt. Nicht um Plex zu bedienen,
// sondern weil die Dateien nun einmal so auf der Platte liegen:
// ohne diese Zeilen steht jede Folge zweimal untereinander, mit
// zwei gleich aussehenden Knopfreihen und ohne Hinweis, warum.
["optimizedformobile"] = "Mobil",
["optimiertfürmobilgeräte"] = "Mobil",
["optimiertfuermobilgeraete"] = "Mobil",
["directorscut"] = "Director's Cut", ["directorcut"] = "Director's Cut",
["extended"] = "Extended", ["extendedcut"] = "Extended",
["theatrical"] = "Kinofassung", ["kinofassung"] = "Kinofassung",
["uncut"] = "Uncut", ["remastered"] = "Remastered", ["imax"] = "IMAX",
["4k"] = "4K", ["8k"] = "8K", ["sd"] = "SD",
};
[GeneratedRegex(@"\{edition-(?<name>[^}]+)\}", RegexOptions.IgnoreCase)]
private static partial Regex EditionInKlammern();
/// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary> /// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -87,16 +221,40 @@ public static partial class FileNames
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre /// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
/// eine geratene Staffel 1 schlimmer als gar keine. /// eine geratene Staffel 1 schlimmer als gar keine.
/// </remarks> /// </remarks>
public static EpisodeName? ParseEpisode(string dateiname) /// <param name="wurzel">
/// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste
/// Ordner darunter die Serie — und zwar <b>allein</b>.
/// </param>
public static EpisodeName? ParseEpisode(string dateiname, string? wurzel = null)
{ {
var roh = Rohname(dateiname); var roh = Rohname(dateiname);
var treffer = StaffelUndFolge().Match(roh); var treffer = StaffelUndFolge().Match(roh);
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh); if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
if (!treffer.Success) return null;
if (!int.TryParse(treffer.Groups["staffel"].Value, out var staffel) || int staffel, folge;
!int.TryParse(treffer.Groups["folge"].Value, out var folge)) return null;
if (treffer.Success)
{
if (!int.TryParse(treffer.Groups["staffel"].Value, out staffel) ||
!int.TryParse(treffer.Groups["folge"].Value, out folge)) return null;
}
else
{
// **„Folge 12" ist eine Folgennummer, nur auf Deutsch.**
//
// Mediatheken und Mitschnitte benennen so:
// „Weihnachtsmann & Co. KG # Folge 12 # Strafe muss sein".
// Eine Staffel steht dort nie — die kommt, wenn überhaupt, vom
// Ordner darüber. Fehlt auch der, gilt Staffel 1: das ist die
// Übereinkunft für eine Serie ohne Staffeln, nicht geraten.
var nurFolge = NurFolgennummer().Match(roh);
if (!nurFolge.Success || !int.TryParse(nurFolge.Groups["folge"].Value, out folge))
return null;
staffel = StaffelAusOrdner(dateiname) ?? 1;
treffer = nurFolge;
}
var davor = roh[..treffer.Index]; var davor = roh[..treffer.Index];
var danach = roh[(treffer.Index + treffer.Length)..]; var danach = roh[(treffer.Index + treffer.Length)..];
@@ -107,13 +265,185 @@ public static partial class FileNames
var (worteDanach, _) = Zerlegen(danach, istDateiname: false); var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length); var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
// **Der Ordner entscheidet, nicht der Dateiname.**
//
// Was unter „Serien/Pingu/" liegt, gehört zu Pingu — ohne Rücksicht
// darauf, wie die Datei heisst. Vorher entschied der Dateiname, und
// das ging reihenweise schief: Dateien namens „S01E001_Aang.mkv"
// tragen vor der Folgennummer nichts, also wurde der ganze Dateiname
// zum Serientitel. Aus einer Serie mit zwanzig Folgen wurden zwanzig
// Serien.
//
// Umgekehrt ebenso wichtig: „Doctor Who (2005) - S12E04.mkv" im Ordner
// „Doctor Who" ergab zwei Serien, weil die Jahresangabe im Dateinamen
// stand und im Ordner nicht. Der Ordner ist die verlässlichere Angabe,
// weil ihn ein Mensch angelegt hat.
//
// So macht es Plex seit je, und aus demselben Grund.
// **Mit Wurzel entscheidet der Ordner, ohne Wurzel nur im Notfall.**
//
// Steht die Wurzel fest, weiss man, welcher Ordner die Serie benennt —
// der erste darunter, von einem Menschen angelegt. Fehlt sie, könnte
// über dem Staffelordner irgendetwas stehen; dann ist der Dateiname
// die bessere Angabe, und der Ordner greift nur, wenn er gar nichts
// hergibt.
var ausOrdner = wurzel is { Length: > 0 }
? SerieAusOrdner(dateiname, wurzel)
: serie.Length > 0 ? null : SerieAusOrdner(dateiname, null);
// **Der Rohname ist keine Serie.**
//
// Hier stand als letzter Rückfall `Rohname(dateiname)`. Das war der
// teuerste Fehler des Projekts: liegt eine Datei direkt in der Wurzel
// und trägt vor der Folgennummer nichts Brauchbares, wurde der
// komplette Dateiname — samt Folgentitel — zum Serientitel. Aus 95
// Serien wurden 737, jede mit genau einer Folge, und jede frisch bei
// jedem Lauf.
//
// Jetzt gilt: entweder der Ordner weiss es, oder der Teil vor der
// Folgennummer weiss es — oder niemand weiss es, und dann wird nichts
// erfunden. Was hier `null` ergibt, zählt im Scan als unerkannt und
// erscheint unter „Nicht zugeordnet"; eine Geisterserie mit einer
// Folge ist schlechter als ein ehrliches „weiss ich nicht".
var serientitel = ausOrdner ?? (serie.Length > 0 ? serie : null);
if (serientitel is null) return null;
return new EpisodeName( return new EpisodeName(
SeriesTitle: serie.Length > 0 ? serie : Rohname(dateiname), SeriesTitle: serientitel,
Season: staffel, Season: staffel,
Episode: folge, Episode: folge,
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null); EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null,
AusOrdner: ausOrdner is not null);
} }
/// <summary>Warum sich eine Datei keiner Serie zuordnen liess.</summary>
///
/// <remarks>
/// <b>Zwei Gründe, und sie verlangen Verschiedenes.</b> Fehlt die
/// Folgennummer, muss die Datei umbenannt werden. Fehlt der Serienname,
/// genügt ein Ordner darum herum. „Nicht zugeordnet" allein sagt beides
/// nicht — und bei 3559 Dateien ist der Unterschied die ganze Arbeit.
/// </remarks>
public static string WarumNicht(string dateiname, string? wurzel = null)
{
var roh = Rohname(dateiname);
var treffer = StaffelUndFolge().Match(roh);
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
if (!treffer.Success)
{
return "keine Folgennummer im Dateinamen (erwartet wird etwa S01E02 oder 1x02).";
}
return wurzel is { Length: > 0 } && SerieAusOrdner(dateiname, wurzel) is null
? "liegt ohne Ordner direkt in der Mediathek, und vor der Folgennummer "
+ "steht kein Serienname."
: "vor der Folgennummer steht kein Serienname.";
}
/// <summary>Der Serienname aus dem Pfad.</summary>
///
/// <remarks>
/// <para>
/// <b>Mit Wurzel: der erste Ordner darunter.</b> Alles, was in
/// <c>Serien/Pingu/</c> liegt, gehört zu Pingu — egal wie tief und egal
/// wie die Datei heisst. Das ist die Regel, die ein Mensch beim Anlegen
/// der Ordner im Kopf hatte.
/// </para>
/// <para>
/// <b>Ohne Wurzel: höchstens zwei Ebenen hoch</b>, Staffelordner
/// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden
/// arbeiten.
/// </para>
/// </remarks>
private static string? SerieAusOrdner(string pfad, string? wurzel)
{
if (wurzel is { Length: > 0 })
{
var unterhalb = UnterWurzel(pfad, wurzel);
// Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der
// sie benennt — dann bleibt nur der Dateiname.
if (unterhalb is null || !unterhalb.Contains('/')) return null;
// **Schubladen überspringen.** Unter `Plex Versions/Optimized for
// Mobile/` steht die Serie erst darunter; wer den ersten Ordner
// nimmt, legt eine Serie namens „Plex Versions" an.
var teile = unterhalb.Split('/');
foreach (var teil in teile[..^1])
{
if (IstFassungsordner(teil) || IstStaffelordner(teil)) continue;
return Saubermachen(teil);
}
return null;
}
var ordner = Path.GetDirectoryName(pfad);
for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++)
{
var name = Path.GetFileName(ordner);
if (name.Length == 0) return null;
if (!IstStaffelordner(name)) return Saubermachen(name);
ordner = Path.GetDirectoryName(ordner);
}
return null;
}
/// <summary>Der Pfad unterhalb der Wurzel, mit „/" getrennt.</summary>
private static string? UnterWurzel(string pfad, string wurzel)
{
var p = pfad.Replace('\\', '/');
var w = wurzel.Replace('\\', '/').TrimEnd('/');
if (!p.StartsWith(w + "/", StringComparison.OrdinalIgnoreCase)) return null;
return p[(w.Length + 1)..];
}
private static string? Saubermachen(string ordnername)
{
var (worte, _) = Zerlegen(ordnername, istDateiname: false);
var (titel, _) = TitelBis(worte, worte.Length);
return titel.Length > 0 ? titel : null;
}
/// <summary>„Folge 12", „Episode 3", „Teil 7" — eine Nummer ohne Staffel.</summary>
///
/// <remarks>
/// Mit Wortgrenze dahinter, damit „Folge 1" nicht aus „Folge 12" wird, und
/// nur bis drei Ziffern: „Folge 2024" wäre eher ein Datum.
/// </remarks>
[GeneratedRegex(@"(?<![A-Za-z])(?:folge|episode|teil|ep)[\s._#-]*(?<folge>\d{1,3})(?!\d)",
RegexOptions.IgnoreCase)]
private static partial Regex NurFolgennummer();
/// <summary>Die Staffelnummer aus einem Ordner „Season 4" über der Datei.</summary>
///
/// <remarks>
/// <b>Nur aus dem unmittelbaren Ordner.</b> Steht dort keine Zahl, wird
/// keine erfunden — der Aufrufer entscheidet, was dann gilt.
/// </remarks>
private static int? StaffelAusOrdner(string pfad)
{
var ordner = Path.GetFileName(Path.GetDirectoryName(pfad.Replace('\\', '/')) ?? "");
var treffer = StaffelZahl().Match(ordner.Trim());
return treffer.Success && int.TryParse(treffer.Groups["nr"].Value, out var nr) ? nr : null;
}
[GeneratedRegex(@"^(?:staffel|season|series|s)[\s._-]*(?<nr>\d{1,3})$", RegexOptions.IgnoreCase)]
private static partial Regex StaffelZahl();
/// <summary>„Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen.</summary>
private static bool IstStaffelordner(string name) =>
StaffelOrdnerName().IsMatch(name.Trim());
[GeneratedRegex(@"^(staffel|season|series|s)[\s._-]*\d{1,3}$|^(specials?|extras?)$",
RegexOptions.IgnoreCase)]
private static partial Regex StaffelOrdnerName();
/// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary> /// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary>
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze) private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
{ {
@@ -144,6 +474,14 @@ public static partial class FileNames
{ {
var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_'); var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_');
if (blank.Length == 0) return false; if (blank.Length == 0) return false;
// **Fassungsnamen beenden den Titel wie jedes andere Beiwerk.**
// Vorher tat das nur die Auflösung: aus
// „The Matrix (1999) - Mobil.mp4" wurde der Film „The Matrix - Mobil",
// also ein zweiter Eintrag neben „The Matrix" — und die mobile
// Fassung stand getrennt vom Original in der Mediathek.
if (Fassungsnamen.ContainsKey(Schlank(blank))) return true;
if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true; if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true;
// „x264-GRP" und „DD5.1" hängen am Beiwerk fest. // „x264-GRP" und „DD5.1" hängen am Beiwerk fest.
@@ -0,0 +1,35 @@
namespace Flaro.Core.Library;
/// <summary>Ein Titel in einer anderen Sprache als der des Servers.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum nicht einfach alles in allen Sprachen holen.</b> Das hiesse, den
/// ganzen Bestand je Sprache abzurufen und zu speichern — für einen Haushalt
/// mit zwei Sprachen ein Vielfaches an Abrufen und Speicher für etwas, das
/// fast niemand ansieht.
/// </para>
/// <para>
/// <b>Also auf Abruf.</b> Öffnet jemand mit abweichender Sprache einen Titel,
/// wird die Übersetzung einmal geholt und bleibt liegen. Was fehlt, erscheint
/// in der Sprache des Servers — nicht leer.
/// </para>
/// </remarks>
public sealed class ItemTranslation
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public Guid? MovieId { get; init; }
public Guid? SeriesId { get; init; }
public Guid? EpisodeId { get; init; }
/// <summary>Das Sprachkürzel, so wie es beim Abruf verwendet wurde.</summary>
public required string Language { get; init; }
public string? Title { get; set; }
public string? Overview { get; set; }
public string? Tagline { get; set; }
/// <summary>Wann sie geholt wurde — für ein späteres Auffrischen.</summary>
public DateTimeOffset FetchedAt { get; set; }
}
+44 -3
View File
@@ -46,11 +46,44 @@ public static class LibraryWalker
return new WalkResult(gefunden, probleme); return new WalkResult(gefunden, probleme);
} }
Absteigen(wurzel, 0, gefunden, probleme); // **Jedes echte Verzeichnis genau einmal.** Siehe <see cref="Echtpfad"/>.
var besucht = new HashSet<string>(StringComparer.Ordinal) { Echtpfad(wurzel) };
Absteigen(wurzel, 0, gefunden, probleme, besucht);
return new WalkResult(gefunden, probleme); return new WalkResult(gefunden, probleme);
} }
private static void Absteigen(string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme) /// <summary>Wohin ein Verzeichnis wirklich zeigt.</summary>
///
/// <remarks>
/// <b>Ohne das wird aus einem Ordner zwei.</b> Ein Verweis — ein Symlink,
/// ein zweiter Bind Mount derselben Freigabe — macht denselben Inhalt unter
/// einem zweiten Namen erreichbar. Der Scan lief beide Wege ab und legte
/// „Pingu" und „Pingu-nochmal" als zwei Serien an; dieselbe Datei bekam
/// zwei Pfade und damit zwei Zeilen, was am Eindeutigkeitsindex den ganzen
/// Speichervorgang zerriss.
///
/// Lässt sich das Ziel nicht auflösen, gilt der Pfad selbst. Lieber einmal
/// zu viel absteigen als wegen eines Rechtefehlers einen echten Ordner
/// auslassen.
/// </remarks>
private static string Echtpfad(string ordner)
{
try
{
var ziel = Directory.ResolveLinkTarget(ordner, returnFinalTarget: true);
return Path.GetFullPath(ziel?.FullName ?? ordner).TrimEnd(Path.DirectorySeparatorChar);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException
or ArgumentException or NotSupportedException)
{
return Path.GetFullPath(ordner).TrimEnd(Path.DirectorySeparatorChar);
}
}
private static void Absteigen(
string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme,
HashSet<string> besucht)
{ {
if (tiefe > MaximaleTiefe) if (tiefe > MaximaleTiefe)
{ {
@@ -87,7 +120,15 @@ public static class LibraryWalker
foreach (var unter in unterordner) foreach (var unter in unterordner)
{ {
if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue; if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue;
Absteigen(unter, tiefe + 1, gefunden, probleme);
if (!besucht.Add(Echtpfad(unter)))
{
probleme.Add(new WalkProblem(
unter, "Zeigt auf einen Ordner, der in diesem Lauf schon erfasst wurde."));
continue;
}
Absteigen(unter, tiefe + 1, gefunden, probleme, besucht);
} }
} }
} }
+248 -2
View File
@@ -53,9 +53,32 @@ public sealed class MediaFile
/// erneut aufbringen. /// erneut aufbringen.
/// </remarks> /// </remarks>
public DateTimeOffset? ProbedAt { get; set; } public DateTimeOffset? ProbedAt { get; set; }
/// <summary>Seit wann die Datei nicht mehr gefunden wird — sonst <c>null</c>.</summary>
///
/// <remarks>
/// <b>Markiert statt gelöscht.</b> Vorher räumte der Scan Dateien weg,
/// die er nicht mehr sah, und mit der letzten Datei fiel der Titel samt
/// Sehfortschritt aller Benutzer. Ein kurz nicht eingehängtes Volume
/// genügte dafür. Jetzt bleibt der Eintrag stehen und wird dem
/// Administrator gemeldet; gelöscht wird nur auf Ansage.
///
/// Taucht die Datei wieder auf, verschwindet die Markierung von selbst.
/// </remarks>
public DateTimeOffset? MissingSince { get; set; }
/// <summary>Wie diese Fassung heisst — „4K", „Mobil", „Director's Cut".</summary>
///
/// <remarks>
/// <b>Eine Fassung ist kein eigener Film.</b> Mehrere Dateien gehören zu
/// einem Eintrag, und ohne Namen stünden in der Auswahl mehrmals
/// dieselben Angaben. Kommt aus dem Dateinamen, sonst aus der gemessenen
/// Bildhöhe; bleibt leer, wenn es nur eine Fassung gibt.
/// </remarks>
public string? VersionLabel { get; set; }
} }
public sealed class Movie public sealed class Movie : Flaro.Core.Metadata.IBebildert
{ {
public Guid Id { get; init; } = Guid.CreateVersion7(); public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; } public required Guid LibraryId { get; init; }
@@ -87,6 +110,111 @@ public sealed class Movie
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary> /// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
public required string SortTitleValue { get; set; } public required string SortTitleValue { get; set; }
/// <summary>Ab wann dieser Titel für alle sichtbar ist.</summary>
///
/// <remarks>
/// <para>
/// <c>null</c> heisst: wartet auf Prüfung. Ein Zeitpunkt in der
/// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant.
/// </para>
/// <para>
/// <b>Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern.</b> Die
/// Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
/// umgeschaltet. Damit stimmt sie nach einem Neustart, nach einer
/// Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt
/// gar nicht lief — ein Ticker hätte in jedem dieser Fälle das Falsche
/// getan oder gar nichts.
/// </para>
/// </remarks>
public DateTimeOffset? PublishAt { get; set; }
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
///
/// <remarks>
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
/// Merker zwänge zu einer Entscheidung für alle drei.
///
/// Ohne das überschreibt der nächste Lauf von
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
/// </remarks>
public bool PosterPinned { get; set; }
public bool BackdropPinned { get; set; }
public bool LogoPinned { get; set; }
/// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary>
///
/// <remarks>
/// Aus demselben Grund wie <see cref="PosterPinned"/>: ohne diesen Merker
/// überschreibt das nächste Auffrischen die Korrektur, und man tippt sie
/// nach jedem Lauf neu.
/// </remarks>
public bool TitlePinned { get; set; }
/// <summary>Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
/// durcheinander. „Harry Potter und der Orden des Phönix" kommt vor „Harry
/// Potter und der Stein der Weisen", weil O vor S liegt — obwohl der
/// Stein der erste Teil ist. Mit „Harry Potter 1", „Harry Potter 2" stehen
/// sie in der Reihenfolge, in der man sie sieht.
/// </para>
/// <para>
/// <b>Leer heisst: wie bisher.</b> Ohne Eintrag bleibt es bei dem Wert,
/// den <see cref="SortTitle"/> aus dem Titel errechnet — Artikel vorn
/// abgezogen. Nur wer etwas einträgt, weicht davon ab.
/// </para>
/// </remarks>
public bool SortTitlePinned { get; set; }
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
/// betreut, will das mitbekommen und nicht zufällig entdecken.
/// </para>
/// <para>
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
/// Liste bekommen.
/// </para>
/// </remarks>
public DateTimeOffset? ChangedSince { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil der Server es sonst selbst nicht weiss.</b> „Alles ist noch auf
/// Englisch, obwohl der Server auf Deutsch steht" liess sich nicht
/// beantworten, sondern nur bestreiten: nirgends stand, in welcher Sprache
/// ein Titel eigentlich vorliegt. Mit dieser Spalte wird aus der Frage
/// eine Zahl — und aus „frisch mal auf" ein Knopf, der genau die
/// abweichenden anfasst statt aller viertausend.
/// </para>
/// <para>
/// <c>null</c> heisst: vor dieser Änderung geholt, Sprache unbekannt.
/// </para>
/// </remarks>
public string? MetadataLanguage { get; set; }
/// <summary>Der Titel in der Sprache des Originals.</summary>
///
/// <remarks>
/// <b>Damit die Mediathek nicht zweisprachig aussieht.</b> Vorher stand
/// nur ein Titel da, und welcher es wurde, entschied die Sprache zum
/// Zeitpunkt des Scans — „Despicable Me" neben „Ich einfach
/// unverbesserlich". Mit beiden lässt sich zeigen, was passt, und über
/// beide suchen.
/// </remarks>
public string? OriginalTitle { get; set; }
public int? Year { get; set; } public int? Year { get; set; }
public string? Overview { get; set; } public string? Overview { get; set; }
@@ -151,7 +279,7 @@ public sealed class Movie
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
} }
public sealed class Series public sealed class Series : Flaro.Core.Metadata.IBebildert
{ {
public Guid Id { get; init; } = Guid.CreateVersion7(); public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; } public required Guid LibraryId { get; init; }
@@ -180,6 +308,111 @@ public sealed class Series
public required string Title { get; set; } public required string Title { get; set; }
public required string SortTitleValue { get; set; } public required string SortTitleValue { get; set; }
/// <summary>Ab wann dieser Titel für alle sichtbar ist.</summary>
///
/// <remarks>
/// <para>
/// <c>null</c> heisst: wartet auf Prüfung. Ein Zeitpunkt in der
/// Vergangenheit heisst veröffentlicht, einer in der Zukunft geplant.
/// </para>
/// <para>
/// <b>Ein Zeitpunkt, kein Zustandsfeld, und das ist der Kern.</b> Die
/// Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
/// umgeschaltet. Damit stimmt sie nach einem Neustart, nach einer
/// Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt
/// gar nicht lief — ein Ticker hätte in jedem dieser Fälle das Falsche
/// getan oder gar nichts.
/// </para>
/// </remarks>
public DateTimeOffset? PublishAt { get; set; }
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
///
/// <remarks>
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
/// Merker zwänge zu einer Entscheidung für alle drei.
///
/// Ohne das überschreibt der nächste Lauf von
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
/// </remarks>
public bool PosterPinned { get; set; }
public bool BackdropPinned { get; set; }
public bool LogoPinned { get; set; }
/// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary>
///
/// <remarks>
/// Aus demselben Grund wie <see cref="PosterPinned"/>: ohne diesen Merker
/// überschreibt das nächste Auffrischen die Korrektur, und man tippt sie
/// nach jedem Lauf neu.
/// </remarks>
public bool TitlePinned { get; set; }
/// <summary>Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
/// durcheinander. „Harry Potter und der Orden des Phönix" kommt vor „Harry
/// Potter und der Stein der Weisen", weil O vor S liegt — obwohl der
/// Stein der erste Teil ist. Mit „Harry Potter 1", „Harry Potter 2" stehen
/// sie in der Reihenfolge, in der man sie sieht.
/// </para>
/// <para>
/// <b>Leer heisst: wie bisher.</b> Ohne Eintrag bleibt es bei dem Wert,
/// den <see cref="SortTitle"/> aus dem Titel errechnet — Artikel vorn
/// abgezogen. Nur wer etwas einträgt, weicht davon ab.
/// </para>
/// </remarks>
public bool SortTitlePinned { get; set; }
/// <summary>Seit wann eine Änderung auf ihre Kenntnisnahme wartet.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht dasselbe wie <see cref="PublishAt"/>.</b> Der Titel bleibt
/// sichtbar — es geht nicht darum, ihn zurückzuhalten, sondern darum, dass
/// jemand hinsieht. Ein Auffrischen kann einen Titel umbenennen, ein
/// Plakat tauschen oder die Zuordnung wechseln; wer eine Mediathek
/// betreut, will das mitbekommen und nicht zufällig entdecken.
/// </para>
/// <para>
/// <c>null</c> heisst: nichts offen. Gesetzt wird es nur, wenn die
/// Mediathek auf Prüfung steht — wer keine Prüfung will, soll auch keine
/// Liste bekommen.
/// </para>
/// </remarks>
public DateTimeOffset? ChangedSince { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil der Server es sonst selbst nicht weiss.</b> „Alles ist noch auf
/// Englisch, obwohl der Server auf Deutsch steht" liess sich nicht
/// beantworten, sondern nur bestreiten: nirgends stand, in welcher Sprache
/// ein Titel eigentlich vorliegt. Mit dieser Spalte wird aus der Frage
/// eine Zahl — und aus „frisch mal auf" ein Knopf, der genau die
/// abweichenden anfasst statt aller viertausend.
/// </para>
/// <para>
/// <c>null</c> heisst: vor dieser Änderung geholt, Sprache unbekannt.
/// </para>
/// </remarks>
public string? MetadataLanguage { get; set; }
/// <summary>Der Titel in der Sprache des Originals.</summary>
///
/// <remarks>
/// <b>Damit die Mediathek nicht zweisprachig aussieht.</b> Vorher stand
/// nur ein Titel da, und welcher es wurde, entschied die Sprache zum
/// Zeitpunkt des Scans — „Despicable Me" neben „Ich einfach
/// unverbesserlich". Mit beiden lässt sich zeigen, was passt, und über
/// beide suchen.
/// </remarks>
public string? OriginalTitle { get; set; }
public int? Year { get; set; } public int? Year { get; set; }
public string? Overview { get; set; } public string? Overview { get; set; }
public int? TmdbId { get; set; } public int? TmdbId { get; set; }
@@ -253,5 +486,18 @@ public sealed class Episode
/// <summary>Das Standbild der Folge.</summary> /// <summary>Das Standbild der Folge.</summary>
public string? StillImage { get; set; } public string? StillImage { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden — siehe <see cref="Movie.MetadataLanguage"/>.</summary>
public string? MetadataLanguage { get; set; }
/// <summary>Ab wann diese Folge für alle sichtbar ist — siehe <see cref="Movie.PublishAt"/>.</summary>
///
/// <remarks>
/// <b>An der Folge, nicht an der Serie.</b> Eine neue Staffel einer
/// laufenden Serie soll sich einzeln planen lassen; eine Serie ist
/// sichtbar, sobald eine ihrer Folgen es ist, und braucht damit kein
/// eigenes Feld.
/// </remarks>
public DateTimeOffset? PublishAt { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
} }
+86
View File
@@ -0,0 +1,86 @@
namespace Flaro.Core.Library;
/// <summary>Worum es bei einer Meldung geht.</summary>
///
/// <remarks>
/// <b>Fünf Fächer, nicht fünfzig.</b> Sie sind nicht dazu da, das Problem zu
/// beschreiben — das tut der Kommentar —, sondern dazu, eine Liste sortierbar
/// zu machen. „Dreimal Ton an derselben Serie" ist eine Auskunft, die man
/// sieht, ohne zu lesen.
/// </remarks>
public enum Meldungsart
{
Bild,
Ton,
Untertitel,
/// <summary>Titel, Jahr, Plakat, Zuordnung — alles, was nicht im Bild ist.</summary>
Daten,
Sonstiges,
}
/// <summary>Was jemandem an einem Titel aufgefallen ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Vier Ziele, drei Spalten.</b> Film, Serie, Staffel, Folge — und eine
/// Staffel gibt es als Zeile nicht. Sie ist <c>SeriesId</c> plus
/// <c>SeasonNumber</c>, so wie sie überall sonst in diesem Server adressiert
/// wird. Eine Tabelle dafür anzulegen, nur damit eine Meldung irgendwohin
/// zeigen kann, wäre ein Modellumbau für einen Fremdschlüssel.
/// </para>
/// <para>
/// <b>Keine Zeile je Benutzer und Titel.</b> Anders als bei
/// <see cref="Playback.UserItemState"/> und <see cref="Playback.Sehtag"/> steht
/// hier kein eindeutiger Index: zwei Meldungen zu demselben Film sind zwei
/// Meldungen. „Der Ton setzt bei 0:30 aus" und „das Plakat gehört zu einem
/// anderen Film" haben miteinander nichts zu tun.
/// </para>
/// <para>
/// <b>Ein Zeitpunkt statt eines Hakens.</b> Wie bei <c>PublishAt</c> und
/// <c>ChangedSince</c>: ein <c>bool Erledigt</c> sagt, dass es vorbei ist,
/// aber nicht wann — und „seit wann liegt das hier" ist die erste Frage an
/// eine Liste von Aufgaben.
/// </para>
/// </remarks>
public sealed class Meldung
{
public Guid Id { get; init; } = Guid.CreateVersion7();
/// <summary>Wer gemeldet hat — aus dem Token, nie aus dem Rumpf.</summary>
public required Guid UserId { get; init; }
/// <summary>Entweder ein Film …</summary>
public Guid? MovieId { get; init; }
/// <summary>… oder eine Serie, ganz oder mit <see cref="SeasonNumber"/> …</summary>
public Guid? SeriesId { get; init; }
/// <summary>… oder eine einzelne Folge. Genau eines von dreien.</summary>
///
/// <remarks>
/// <b>Ohne die Serie daneben.</b> Welche Serie das ist, weiss die Folge
/// selbst; es hier ein zweites Mal abzulegen hiesse, zwei Wahrheiten zu
/// pflegen, von denen eine irgendwann die ältere ist.
/// </remarks>
public Guid? EpisodeId { get; init; }
/// <summary>Eine einzelne Staffel — nur zusammen mit <see cref="SeriesId"/>.</summary>
public int? SeasonNumber { get; init; }
public required Meldungsart Art { get; init; }
/// <summary>Wo im Titel. <c>null</c> heisst: nicht an einer Stelle.</summary>
public TimeSpan? Stelle { get; init; }
public required string Kommentar { get; init; }
public required DateTimeOffset GemeldetAm { get; init; }
/// <summary>Wann abgehakt — <c>null</c> heisst: offen.</summary>
public DateTimeOffset? ErledigtAm { get; set; }
/// <summary>Wer abgehakt hat.</summary>
public Guid? ErledigtVon { get; set; }
}
@@ -0,0 +1,86 @@
namespace Flaro.Core.Library;
/// <summary>Woran ein Lauf gerade ist.</summary>
public enum Scanphase
{
/// <summary>Das Dateisystem wird durchgegangen.</summary>
Suchen,
/// <summary>Eine Datei wird gelesen und vermessen.</summary>
Pruefen,
/// <summary>Ein Titel wird bei der Metadatenquelle gesucht.</summary>
Nachschlagen,
/// <summary>Die Angaben zu einem gefundenen Titel werden geholt.</summary>
Holen,
/// <summary>Plakate und Hintergründe werden geladen.</summary>
Bilder,
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
Aufraeumen,
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
///
/// <remarks>
/// <b>Warum getrennt.</b> „Serie ‚Pingu' neu angelegt, aus dem Ordner" ist
/// beim Suchen eines Fehlers Gold wert und in einer Zeile, die im
/// Sekundentakt wechselt, nur Flackern. Die Oberfläche überspringt diese
/// Schritte, die Tagesdatei nimmt sie auf.
/// </remarks>
Vermerk,
}
/// <summary>Ein Zwischenstand: was gerade passiert, und wie weit.</summary>
///
/// <remarks>
/// <para>
/// <b>Mit Namen, nicht nur mit Zahlen.</b> Vorher stand in der Verwaltung
/// minutenlang „Wird durchsucht…" — ohne Hinweis, ob überhaupt etwas
/// passiert. Wer bei vierzigtausend Dateien zusieht, braucht zu sehen, dass
/// sich etwas bewegt, und woran es gerade hängt, wenn es hängt.
/// </para>
/// <para>
/// Ein <c>record struct</c>, weil davon im Sekundentakt viele entstehen und
/// keiner davon überlebt.
/// </para>
/// </remarks>
/// <param name="Phase">Welcher Abschnitt des Laufs.</param>
/// <param name="Was">Woran genau — ein Titel oder ein Pfad.</param>
/// <param name="Erledigt">Wie viele Stücke schon durch sind.</param>
/// <param name="Gesamt">Wie viele es insgesamt sind; <c>0</c>, wenn unbekannt.</param>
public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erledigt = 0, int Gesamt = 0)
{
/// <summary>Der Satz, der in der Oberfläche steht.</summary>
///
/// <remarks>
/// <b>Hier und nicht in der Oberfläche.</b> Die Apps brauchen denselben
/// Satz, und zweimal formuliert driftet er auseinander.
/// </remarks>
public string Text()
{
var zaehler = Gesamt > 0 ? $" — {Erledigt} von {Gesamt}" : "";
return Phase switch
{
Scanphase.Suchen => $"Suche Medien in {Kurz(Was)}",
Scanphase.Pruefen => $"Prüfe {Kurz(Was)}{zaehler}",
Scanphase.Nachschlagen => $"Suche „{Was}“ bei der Metadatenquelle{zaehler}",
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
Scanphase.Vermerk => Was,
_ => Was,
};
}
/// <summary>Die letzten zwei Pfadbestandteile — ein ganzer Pfad sprengt die Zeile.</summary>
private static string Kurz(string pfad)
{
if (string.IsNullOrEmpty(pfad)) return pfad;
var teile = pfad.Split('/', StringSplitOptions.RemoveEmptyEntries);
return teile.Length <= 2 ? pfad : string.Join('/', teile[^2..]);
}
}
@@ -0,0 +1,117 @@
using System.Globalization;
using System.Linq;
using System.Text;
namespace Flaro.Core.Library;
/// <summary>Führt Schreibvarianten desselben Titels zusammen.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür das nötig wurde.</b> Der Scan suchte einen vorhandenen Film über
/// den Titel aus dem Dateinamen, zeichengenau verglichen. Zwei Dateien mit
/// <c>Dr. Dolittle 2</c> und <c>Dr Dolittle 2</c> ergaben deshalb zwei
/// Einträge — die danach beide demselben TMDB-Eintrag zugeordnet wurden und
/// ununterscheidbar nebeneinander standen. Dasselbe bei
/// <c>WALL·E</c> gegen <c>WALL-E</c> oder <c>Spider-Man</c> gegen
/// <c>Spider Man</c>.
/// </para>
/// <para>
/// <b>Nur zum Vergleichen, nie zum Anzeigen.</b> Was hier herauskommt, ist
/// eine Zeichenkette ohne Satzzeichen und ohne Grossschreibung — brauchbar
/// als Schlüssel, unbrauchbar als Titel.
/// </para>
/// <para>
/// <b>Bewusst nicht aggressiver.</b> Artikel zu streichen oder Zahlwörter
/// umzuschreiben würfe irgendwann zwei verschiedene Filme zusammen, und das
/// wäre schlimmer als zwei Einträge für einen: ein zusammengelegter Film
/// lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte.
/// </para>
/// </remarks>
public static class Titelschluessel
{
/// <summary>Der Vergleichsschlüssel zu einem Titel.</summary>
public static string Von(string? titel)
{
if (string.IsNullOrWhiteSpace(titel)) return "";
// Zerlegt „é" in „e" + Akzent, damit der Akzent gleich mit herausfällt.
// Ohne das wären „Amelie" und „Amélie" zwei Filme.
var zerlegt = titel.Normalize(NormalizationForm.FormD);
var bau = new StringBuilder(zerlegt.Length);
var letzterWarLeer = true;
foreach (var z in zerlegt)
{
if (CharUnicodeInfo.GetUnicodeCategory(z) == UnicodeCategory.NonSpacingMark) continue;
if (char.IsLetterOrDigit(z))
{
bau.Append(char.ToLowerInvariant(z));
letzterWarLeer = false;
}
else if (!letzterWarLeer)
{
// Jedes Satz- und Sonderzeichen wird zu einem Leerzeichen —
// damit „Spider-Man" und „Spider Man" denselben Schlüssel
// haben, „Spider-Man" und „Spiderman" aber nicht. Letzteres
// wäre ein Raten, das irgendwann danebenläge.
bau.Append(' ');
letzterWarLeer = true;
}
}
return OhneBindewort(bau.ToString().TrimEnd());
}
/// <summary>Die Wörter, die nur zwei Namen verbinden.</summary>
///
/// <remarks>
/// <b>Weil „&amp;" verschwindet und „und" stehenbleibt.</b> TMDB führt
/// den Film als „Hanni &amp; Nanni", auf der Platte heisst er „Hanni und
/// Nanni" — das Kaufmanns-Und fällt als Sonderzeichen heraus, das Wort
/// nicht, und die beiden Schlüssel gingen auseinander. Die Namensgleichheit
/// griff deshalb nicht, und gewählt wurde stattdessen der erstbeste
/// Treffer: „Hanni &amp; Nanni 3". Gemessen am echten Dienst — der richtige
/// Film stand auf Platz zwei.
///
/// <b>Gestrichen statt umgeschrieben.</b> „&amp;" nach „und" zu übersetzen
/// hiesse, sich für eine Sprache zu entscheiden; „Batman and Robin" bliebe
/// dann aussen vor. Ohne das Bindewort passt beides.
/// </remarks>
private static readonly string[] Bindewoerter = ["und", "and", "et", "e", "y"];
private static string OhneBindewort(string schluessel)
{
if (schluessel.Length == 0) return schluessel;
var worte = schluessel.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (worte.Length < 3) return schluessel; // „Ich und" ist kein Bindewort-Fall
// **Nur zwischen zwei Wörtern, nie am Rand.** „Und dann kam Polly"
// fängt mit einem Bindewort an, und das gehört zum Titel.
var behalten = worte
.Where((w, i) => i == 0 || i == worte.Length - 1
|| !Bindewoerter.Contains(w, StringComparer.Ordinal))
.ToArray();
return string.Join(' ', behalten);
}
/// <summary>Ob zwei Fundstücke derselbe Titel sind.</summary>
///
/// <remarks>
/// <b>Ein fehlendes Jahr passt auf jedes.</b> Eine Datei heisst
/// <c>Descendants 2 (2017).mkv</c>, die zweite nur <c>Descendants 2.mkv</c>
/// — das ist der häufigste Grund für eine Dublette gewesen. Stehen
/// dagegen <b>beide</b> Jahre da und sind verschieden, sind es zwei
/// Filme: die beiden „Dune" von 1984 und 2021 dürfen nie verschmelzen.
/// </remarks>
public static bool Gleich(string? titelA, int? jahrA, string? titelB, int? jahrB)
{
var a = Von(titelA);
if (a.Length == 0 || a != Von(titelB)) return false;
return jahrA is null || jahrB is null || jahrA == jahrB;
}
}
+300
View File
@@ -0,0 +1,300 @@
namespace Flaro.Core.Media;
/// <summary>Macht aus ffprobe-Rohwerten etwas, das man vorlesen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Was vorher in der Oberfläche stand.</b> „DEU EAC3 6.0" und
/// „DEU MOV_TEXT" — beides ist richtig und beides sagt niemandem etwas, der
/// nicht weiss, dass EAC3 Dolby Digital Plus heisst und dass sechs Kanäle
/// 5.1 ergeben und nicht 6.0.
/// </para>
/// <para>
/// <b>Hier und nicht in der Oberfläche.</b> Die Apps brauchen dieselben
/// Namen. Zweimal formuliert driften sie auseinander, und dann heisst
/// dieselbe Tonspur am Fernseher anders als im Browser.
/// </para>
/// <para>
/// <b>Unbekanntes bleibt stehen.</b> Ein Codec, den diese Liste nicht kennt,
/// erscheint in Grossbuchstaben — nicht als „Unbekannt". Die rohe Angabe ist
/// immer noch eine Auskunft; ein Platzhalter ist keine.
/// </para>
/// </remarks>
public static class Benennung
{
/// <summary>Der Klarname einer Sprache: „deu" wird „Deutsch".</summary>
///
/// <remarks>
/// <b>Aus einer eigenen Liste, nicht aus <see cref="CultureInfo"/>.</b>
/// Zwei Gründe, beide gemessen: dessen <c>NativeName</c> liefert den Namen
/// in der Sprache selbst — „English", „français" —, und für ein erfundenes
/// Kürzel wie „xyz" erfindet es stillschweigend eine Kultur, statt zu
/// scheitern. Beides taugt hier nicht.
/// </remarks>
public static string? Sprache(string? kuerzel, string? anzeigeSprache = null)
{
if (string.IsNullOrWhiteSpace(kuerzel)) return null;
// Nur der Sprachteil: „de-DE" und „de" meinen dasselbe.
var blank = kuerzel.Trim().Split('-', '_')[0];
// ffmpeg schreibt „und" für unbekannt. Das als Sprache anzuzeigen wäre
// schlimmer als gar nichts — es sieht aus wie eine Angabe.
if (blank.Equals("und", StringComparison.OrdinalIgnoreCase)
|| blank.Equals("unk", StringComparison.OrdinalIgnoreCase)
|| blank.Equals("mul", StringComparison.OrdinalIgnoreCase))
{
return null;
}
// Unbekanntes bleibt stehen. Die rohe Angabe ist immer noch eine
// Auskunft; „Unbekannt" ist keine.
return Sprachnamen.GetValueOrDefault(blank, blank.ToUpperInvariant());
}
/// <summary>Die übliche Schreibweise für eine Kanalzahl.</summary>
///
/// <remarks>
/// <b>Sechs Kanäle sind 5.1, nicht 6.0.</b> Der letzte ist der
/// Tiefton-Kanal, und der wird hinter dem Punkt gezählt. Vorher stand in
/// der Oberfläche „6.0" — eine Schreibweise, die es nicht gibt.
/// </remarks>
public static string? Kanaele(int? anzahl) => anzahl switch
{
null or <= 0 => null,
1 => "Mono",
2 => "Stereo",
3 => "2.1",
4 => "4.0",
5 => "5.0",
6 => "5.1",
7 => "6.1",
8 => "7.1",
_ => $"{anzahl} Kanäle",
};
/// <summary>Der geläufige Name eines Codecs.</summary>
public static string? Codec(string? kuerzel)
{
if (string.IsNullOrWhiteSpace(kuerzel)) return null;
var blank = kuerzel.Trim();
return Codecs.GetValueOrDefault(blank.ToLowerInvariant(), blank.ToUpperInvariant());
}
/// <summary>Die Auflösungsstufe zu einer gemessenen Bildhöhe.</summary>
///
/// <remarks>
/// <b>In Stufen, nicht roh.</b> Ein beschnittenes Breitbild misst 1040
/// Zeilen statt 1080; genau das stand in der Oberfläche und sah aus wie
/// ein Fehler. Der genaue Wert gehört in den Tooltip, nicht in die Zeile.
/// </remarks>
public static string? Aufloesung(int? hoehe) => AusHoehe(hoehe);
/// <summary>Die Stufe aus Breite <i>und</i> Höhe — die grössere gewinnt.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Höhe allein lügt bei Breitbild.</b> „Spider-Man" liegt als
/// 1920×800 vor — ein Kinoformat von 2.39:1, also Full HD. Nach der Höhe
/// gerechnet kam „720p" heraus, und das stand so in der Oberfläche: eine
/// Angabe, die jeder sofort als falsch erkennt, der die Datei kennt.
/// </para>
/// <para>
/// <b>Die grössere der beiden Stufen, nicht die Breite allein.</b> Sonst
/// kippt es beim Hochformat: ein 1080×1920 gedrehtes Bild wäre nach der
/// Breite „720p". So stimmt beides, und keine Richtung ist bevorzugt.
/// </para>
/// </remarks>
public static string? Aufloesung(int? breite, int? hoehe)
{
var a = Stufe(hoehe);
var b = Stufe(breite * 9 / 16); // Breite auf eine 16:9-Höhe umgerechnet
var groessere = Math.Max(a ?? 0, b ?? 0);
return groessere > 0 ? AusHoehe(groessere) : null;
}
/// <summary>Die Zeilenzahl, auf die eine Stufe lautet — zum Vergleichen.</summary>
private static int? Stufe(int? wert) => wert switch
{
null or <= 0 => null,
>= 2000 => 2160,
>= 1400 => 1440,
>= 1000 => 1080,
>= 700 => 720,
>= 500 => 576,
>= 300 => 480,
_ => wert,
};
private static string? AusHoehe(int? hoehe) => hoehe switch
{
null or <= 0 => null,
>= 2000 => "2160p",
>= 1400 => "1440p",
>= 1000 => "1080p",
>= 700 => "720p",
>= 500 => "576p",
>= 300 => "480p",
_ => $"{hoehe}p",
};
/// <summary>Dieselbe Messung als Wort: „4K", „Full HD", „HD", „SD".</summary>
///
/// <remarks>
/// <para>
/// <b>Wer schauen will, wählt keine Zeilenzahl.</b> „1080p" ist eine Angabe
/// für den, der eine Stufe einstellt; „Full HD" ist eine für den, der sich
/// fragt, ob es sich lohnt, dafür den Fernseher anzumachen. Der genaue Wert
/// geht nicht verloren — <see cref="Aufloesung(int?, int?)"/> liefert ihn
/// weiter, und die Oberfläche zeigt ihn beim Überfahren.
/// </para>
/// <para>
/// <b>1440p heisst hier Full HD.</b> „QHD" kennt kaum jemand, und ein
/// fünftes Wort für eine Auflösung, die zwischen zwei bekannten liegt,
/// hilft niemandem beim Entscheiden. „8K" steht nur da, damit eine
/// 8K-Datei nicht als 4K gemeldet wird.
/// </para>
/// <para>
/// <b>Gerechnet wird mit Breite und Höhe</b>, aus demselben Grund wie oben:
/// ein Film im Kinoformat hat 1920×800 und ist Full HD, nicht HD.
/// </para>
/// </remarks>
public static string? Aufloesungswort(int? breite, int? hoehe)
{
var a = Stufe(hoehe);
var b = Stufe(breite * 9 / 16);
return Math.Max(a ?? 0, b ?? 0) switch
{
<= 0 => null,
>= 3500 => "8K",
>= 2000 => "4K",
>= 1000 => "Full HD",
>= 700 => "HD",
_ => "SD",
};
}
/// <summary>Die ganze Zeile zu einer Tonspur.</summary>
public static string Tonspur(string? sprache, string? codec, int? kanaele, string? titel)
{
var teile = new List<string>();
if (Sprache(sprache) is { } s) teile.Add(s);
if (Codec(codec) is { } c) teile.Add(c);
if (Kanaele(kanaele) is { } k) teile.Add(k);
// **Der Titel nur, wenn er etwas hinzufügt.** „SoundHandler" ist kein
// Name, sondern ein Rest aus dem Umpacken. Und ein Titel, der bloss
// die Sprache wiederholt, ergibt „Deutsch · AAC · Mono · ‚Deutsch'".
var blank = titel?.Trim();
if (blank is { Length: > 0 }
&& !Nichtssagend.Contains(blank)
&& !string.Equals(blank, Sprache(sprache), StringComparison.OrdinalIgnoreCase)
&& !string.Equals(blank, sprache?.Trim(), StringComparison.OrdinalIgnoreCase))
{
teile.Add($"„{blank}“");
}
return teile.Count > 0 ? string.Join(" · ", teile) : "Tonspur";
}
/// <summary>Die ganze Zeile zu einer Untertitelspur.</summary>
public static string Untertitelspur(string? sprache, bool erzwungen, bool fuerHoergeschaedigte)
{
var name = Sprache(sprache) ?? "Untertitel";
if (erzwungen) name += " (erzwungen)";
else if (fuerHoergeschaedigte) name += " (für Hörgeschädigte)";
return name;
}
/// <summary>Die ganze Zeile zu einer Bildspur.</summary>
public static string Bildspur(string? codec, int? hoehe) => Bildspur(codec, null, hoehe);
/// <inheritdoc cref="Bildspur(string?, int?)"/>
public static string Bildspur(string? codec, int? breite, int? hoehe)
{
var teile = new List<string>();
// Das Wort, nicht die Zahl — siehe `Aufloesungswort`.
if (Aufloesungswort(breite, hoehe) is { } a) teile.Add(a);
if (Codec(codec) is { } c) teile.Add(c);
return teile.Count > 0 ? string.Join(" · ", teile) : "Bild";
}
/// <summary>„Deutsch (Deutschland)" wird „Deutsch".</summary>
private static readonly HashSet<string> Nichtssagend = new(StringComparer.OrdinalIgnoreCase)
{
"SoundHandler", "VideoHandler", "SubtitleHandler", "Stereo", "Audio", "Video",
};
private static readonly Dictionary<string, string> Codecs = new(StringComparer.OrdinalIgnoreCase)
{
// Ton
["eac3"] = "Dolby Digital Plus",
["ac3"] = "Dolby Digital",
["truehd"] = "Dolby TrueHD",
["dts"] = "DTS",
["aac"] = "AAC",
["opus"] = "Opus",
["vorbis"] = "Vorbis",
["flac"] = "FLAC",
["mp3"] = "MP3",
["pcm_s16le"] = "PCM",
// Bild
["h264"] = "H.264",
["avc"] = "H.264",
["hevc"] = "H.265",
["h265"] = "H.265",
["av1"] = "AV1",
["vp9"] = "VP9",
["mpeg2video"] = "MPEG-2",
["mpeg4"] = "MPEG-4",
// Untertitel
["mov_text"] = "Text",
["subrip"] = "SRT",
["ass"] = "ASS",
["ssa"] = "SSA",
["pgs"] = "PGS",
["hdmv_pgs_subtitle"] = "PGS",
["dvd_subtitle"] = "DVD",
};
/// <summary>Sprachnamen auf Deutsch, über das zwei- und das dreibuchstabige Kürzel.</summary>
///
/// <remarks>
/// Beide Schreibweisen kommen in Mediendateien vor, oft in derselben.
/// ISO 639-2 kennt für manche Sprachen zwei Kürzel — „ger" und „deu" —,
/// und welches eine Datei trägt, hängt am Werkzeug, das sie erzeugt hat.
/// </remarks>
private static readonly Dictionary<string, string> Sprachnamen = new(StringComparer.OrdinalIgnoreCase)
{
["de"] = "Deutsch", ["ger"] = "Deutsch", ["deu"] = "Deutsch",
["en"] = "Englisch", ["eng"] = "Englisch",
["fr"] = "Französisch", ["fre"] = "Französisch", ["fra"] = "Französisch",
["es"] = "Spanisch", ["spa"] = "Spanisch",
["it"] = "Italienisch", ["ita"] = "Italienisch",
["nl"] = "Niederländisch", ["dut"] = "Niederländisch", ["nld"] = "Niederländisch",
["pl"] = "Polnisch", ["pol"] = "Polnisch",
["pt"] = "Portugiesisch", ["por"] = "Portugiesisch",
["ru"] = "Russisch", ["rus"] = "Russisch",
["tr"] = "Türkisch", ["tur"] = "Türkisch",
["ja"] = "Japanisch", ["jpn"] = "Japanisch",
["ko"] = "Koreanisch", ["kor"] = "Koreanisch",
["zh"] = "Chinesisch", ["chi"] = "Chinesisch", ["zho"] = "Chinesisch",
["sv"] = "Schwedisch", ["swe"] = "Schwedisch",
["no"] = "Norwegisch", ["nor"] = "Norwegisch",
["da"] = "Dänisch", ["dan"] = "Dänisch",
["fi"] = "Finnisch", ["fin"] = "Finnisch",
["cs"] = "Tschechisch", ["cze"] = "Tschechisch", ["ces"] = "Tschechisch",
["hu"] = "Ungarisch", ["hun"] = "Ungarisch",
["el"] = "Griechisch", ["gre"] = "Griechisch", ["ell"] = "Griechisch",
["ar"] = "Arabisch", ["ara"] = "Arabisch",
["he"] = "Hebräisch", ["heb"] = "Hebräisch",
["hi"] = "Hindi", ["hin"] = "Hindi",
["ro"] = "Rumänisch", ["rum"] = "Rumänisch", ["ron"] = "Rumänisch",
["uk"] = "Ukrainisch", ["ukr"] = "Ukrainisch",
["th"] = "Thailändisch", ["tha"] = "Thailändisch",
["la"] = "Latein", ["lat"] = "Latein",
};
}
@@ -53,7 +53,21 @@ public sealed record MovieMetadata(
/// ///
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da. /// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
/// </remarks> /// </remarks>
RemoteImage? Logo = null); RemoteImage? Logo = null,
/// <summary>Der Titel in der Sprache des Originals.</summary>
string? OriginalTitle = null,
/// <summary>In welcher Sprache diese Angaben geholt wurden.</summary>
///
/// <remarks>
/// <b>Damit der Server es hinterher noch weiss.</b> Die Sprache steht in
/// den Einstellungen und ändert sich; was einmal geholt wurde, trägt sie
/// danach nirgends. „Alles ist noch auf Englisch, obwohl Deutsch
/// eingestellt ist" liess sich deshalb nicht beantworten, sondern nur
/// bestreiten. Die Quelle weiss es — also gibt sie es mit.
/// </remarks>
string? Language = null);
public sealed record SeriesMetadata( public sealed record SeriesMetadata(
int ProviderId, string Title, int? Year, string? Overview, int ProviderId, string Title, int? Year, string? Overview,
@@ -74,9 +88,24 @@ public sealed record SeriesMetadata(
/// ///
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da. /// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
/// </remarks> /// </remarks>
RemoteImage? Logo = null); RemoteImage? Logo = null,
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still); /// <summary>Der Titel in der Sprache des Originals.</summary>
string? OriginalTitle = null,
/// <summary>In welcher Sprache diese Angaben geholt wurden.</summary>
///
/// <remarks>
/// <b>Damit der Server es hinterher noch weiss.</b> Die Sprache steht in
/// den Einstellungen und ändert sich; was einmal geholt wurde, trägt sie
/// danach nirgends. „Alles ist noch auf Englisch, obwohl Deutsch
/// eingestellt ist" liess sich deshalb nicht beantworten, sondern nur
/// bestreiten. Die Quelle weiss es — also gibt sie es mit.
/// </remarks>
string? Language = null);
public sealed record EpisodeMetadata(
string? Title, string? Overview, RemoteImage? Still, string? Language = null);
/// <summary>Woher Titel, Beschreibungen und Bilder kommen.</summary> /// <summary>Woher Titel, Beschreibungen und Bilder kommen.</summary>
/// ///
@@ -119,8 +148,28 @@ public interface IMetadataProvider
/// </remarks> /// </remarks>
Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default); Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default);
/// <summary>Holt einen Titel in einer bestimmten Sprache.</summary>
///
/// <remarks>
/// <b>Für die Übersetzungstabelle, nicht für den Scan.</b> Der Scan nimmt
/// die Sprache des Servers; dies hier füllt nach, was ein Benutzer mit
/// abweichender Sprache sehen will.
/// </remarks>
/// <remarks>
/// <b>Mit Vorgabe.</b> Ein Anbieter, der keine Sprachen kennt, liefert
/// schlicht die des Servers — das ist ehrlicher als eine leere Antwort und
/// erspart jedem Test-Doppelgänger eine Pflichtmethode, die er nicht
/// braucht.
/// </remarks>
Task<MovieMetadata?> GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
GetMovieAsync(providerId, ct);
Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default); Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default);
/// <summary>Holt eine Serie in einer bestimmten Sprache.</summary>
Task<SeriesMetadata?> GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
GetSeriesAsync(providerId, ct);
/// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary> /// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary>
Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default); Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
@@ -148,6 +197,38 @@ public interface IImageStore
{ {
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary> /// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default); Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
///
/// <remarks>
/// Für eigene Plakate. Dieselbe Ablage wie für geholte — damit ein Bild
/// unter derselben Adresse liegt, egal woher es stammt.
/// </remarks>
/// <remarks>
/// <b>Mit Vorgabe.</b> Eine Ablage, die nur Geholtes kennt, lehnt das
/// Hochladen ab — das ist ehrlicher als eine Pflichtmethode, die jeder
/// Test-Doppelgänger nachbauen müsste, obwohl er sie nie braucht.
/// </remarks>
Task<string?> StoreStreamAsync(Stream inhalt, string? medientyp, CancellationToken ct = default) =>
Task.FromResult<string?>(null);
}
/// <summary>Was Bilder trägt — Film oder Serie.</summary>
///
/// <remarks>
/// <b>Damit das Setzen eines Plakats nur einmal geschrieben werden muss.</b>
/// Film und Serie tragen dieselben sechs Eigenschaften; ohne gemeinsame
/// Schnittstelle stünde derselbe Code zweimal da und liefe irgendwann
/// auseinander.
/// </remarks>
public interface IBebildert
{
string? PosterImage { get; set; }
string? BackdropImage { get; set; }
string? LogoImage { get; set; }
bool PosterPinned { get; set; }
bool BackdropPinned { get; set; }
bool LogoPinned { get; set; }
} }
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary> /// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
@@ -160,3 +241,12 @@ public interface IImageReader
{ {
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default); Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
} }
/// <summary>Ein Bild zur Auswahl — Plakat, Hintergrund oder Schriftzug.</summary>
///
/// <remarks>
/// <b>Mit Vorschau und Sprache.</b> Wer ein Plakat aussucht, sucht meist eins
/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
/// </remarks>
public sealed record Bildkandidat(
string Kind, string Url, string ThumbUrl, string? Language, double? Vote);
+17 -18
View File
@@ -25,23 +25,15 @@ namespace Flaro.Core.Playback;
/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus — /// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus —
/// wer einen Film ein zweites Mal anfängt, soll beides haben. /// wer einen Film ein zweites Mal anfängt, soll beides haben.
/// </para> /// </para>
/// <para>
/// <b>Was einstellbar ist, steht in <see cref="Wiedergaberegeln"/>.</b> Es
/// gibt inzwischen eine Untergrenze fürs Weiterschauen — aber sie entscheidet
/// nur, ob der Titel in der Reihe steht, nicht ob die Stelle überlebt. Das
/// bleibt der Unterschied.
/// </para>
/// </remarks> /// </remarks>
public static class ProgressRule public static class ProgressRule
{ {
/// <summary>Ab welchem Anteil ein Titel als gesehen gilt.</summary>
///
/// <remarks>
/// <b>Eine Schwelle ist hier unvermeidlich</b>, und das ist kein
/// Widerspruch zum Obigen: Niemand sieht einen Film bis zur letzten
/// Sekunde des Abspanns. Ohne diese Regel stünde jeder zu Ende geschaute
/// Film für immer bei 98 % in „Weiterschauen".
///
/// Der Unterschied zu Jellyfin ist nicht, <i>dass</i> es diese Grenze
/// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort
/// zusätzlich die Position weggeworfen.
/// </remarks>
public const double WatchedFraction = 0.90;
/// <summary>Was aus einer Meldung folgt.</summary> /// <summary>Was aus einer Meldung folgt.</summary>
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param> /// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param> /// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
@@ -57,7 +49,8 @@ public static class ProgressRule
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne /// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
/// es zu merken. /// es zu merken.
/// </param> /// </param>
public static Outcome Apply(TimeSpan position, TimeSpan? runtime) /// <param name="regeln">Ab welchem Anteil ein Titel als gesehen gilt.</param>
public static Outcome Apply(TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln)
{ {
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes // Eine negative Stelle gibt es nicht, und eine jenseits des Endes
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen // auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
@@ -69,7 +62,7 @@ public static class ProgressRule
if (stelle > laenge) stelle = laenge; if (stelle > laenge) stelle = laenge;
return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction); return new Outcome(stelle, Watched: stelle >= laenge * regeln.GesehenAnteil);
} }
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary> /// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
@@ -80,9 +73,15 @@ public static class ProgressRule
/// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander, /// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander,
/// fällt es hier auf und nicht beim Benutzer. /// fällt es hier auf und nicht beim Benutzer.
/// </remarks> /// </remarks>
public static bool BelongsInContinueWatching(UserItemState zustand) => public static bool BelongsInContinueWatching(UserItemState zustand, Wiedergaberegeln regeln) =>
zustand is { Dismissed: false, Position.Ticks: > 0 } zustand is { Dismissed: false, Position.Ticks: > 0 }
// **Die Untergrenze ist einstellbar und war es nicht.** Ein Tick
// genügte: wer im Aufmacher eine Sekunde hineinschaute, fand den
// Titel danach in „Weiterschauen". Was oben über Jellyfin steht,
// gilt weiter — die Stelle wird nicht weggeworfen, der Titel steht
// nur nicht in der Reihe.
&& zustand.Position >= regeln.FortsetzenAb
&& (zustand.Runtime is not { } laenge && (zustand.Runtime is not { } laenge
|| laenge <= TimeSpan.Zero || laenge <= TimeSpan.Zero
|| zustand.Position < laenge * WatchedFraction); || zustand.Position < laenge * regeln.GesehenAnteil);
} }
+60
View File
@@ -0,0 +1,60 @@
namespace Flaro.Core.Playback;
/// <summary>Wie lange an einem Tag an einem Titel gesehen wurde.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum es das überhaupt gibt.</b> <see cref="UserItemState"/> ist eine
/// Zeile je Titel und Nutzer — der <i>letzte</i> Stand. Wer einen Film zweimal
/// sieht, überschreibt den ersten Durchgang; wann etwas lief, weiß der Server
/// nur für das jeweils letzte Mal. Für „wie viel habe ich letzte Woche
/// geschaut" reicht das nicht, und raten kann man es nicht.
/// </para>
/// <para>
/// <b>Warum Tagesbündel und keine Sitzungen.</b> Eine Sitzungstabelle braucht
/// Anfang <i>und</i> Ende, und das Ende kommt oft nie an: der Browser wird
/// zugemacht, das Telefon schläft ein. Dann stehen offene Sitzungen herum, die
/// niemand schliesst, und jede Auswertung muss raten, was davon zählt.
/// Tagesbündel haben diesen Lebenslauf nicht — es wird nur addiert. Für die
/// Fragen, um die es geht (wie viel insgesamt, wie viel je Woche, was
/// zuletzt), reicht das vollständig.
/// </para>
/// <para>
/// <b>Es fängt bei null an.</b> Was vor dem Einbau lief, steht nicht darin —
/// die Daten gibt es nicht und lassen sich nicht nachträglich erfinden. Was
/// sofort vollständig ist, steht weiter in <see cref="UserItemState"/>:
/// gesehen ja/nein, wie oft, wann zuletzt.
/// </para>
/// </remarks>
public sealed class Sehtag
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid UserId { get; init; }
/// <summary>Entweder ein Film …</summary>
public Guid? MovieId { get; init; }
/// <summary>… oder eine Folge. Nie beides, nie keines.</summary>
public Guid? EpisodeId { get; init; }
/// <summary>Der Tag in der Zeitzone des Servers.</summary>
///
/// <remarks>
/// <b>Ortszeit, nicht UTC.</b> Wer um halb eins nachts einen Film zu Ende
/// sieht, hat das am Abend getan und nicht am nächsten Morgen — eine
/// Wochenkurve nach UTC verschöbe jeden späten Abend auf den Folgetag.
/// Der Server hat eine Zeitzone, und die ist die des Haushalts.
/// </remarks>
public required DateOnly Tag { get; init; }
/// <summary>Wie viele Sekunden an diesem Tag tatsächlich liefen.</summary>
///
/// <remarks>
/// <b>Tatsächlich, nicht übersprungen.</b> Gezählt wird der Zuwachs
/// zwischen zwei Meldungen, gedeckelt auf die Zeit, die dazwischen
/// verstrichen ist. Ein Sprung von Minute 5 auf Minute 90 ergäbe sonst
/// 85 Minuten Sehzeit in zehn Sekunden.
/// </remarks>
public double Sekunden { get; set; }
}
@@ -23,6 +23,25 @@ public sealed class UserItemState
public Guid? MovieId { get; init; } public Guid? MovieId { get; init; }
public Guid? EpisodeId { get; init; } public Guid? EpisodeId { get; init; }
/// <summary>Die Serie als Ganzes — für Favorit, Bewertung und das Häkchen.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Serie wird nicht angeschaut, ihre Folgen werden es</b> — das
/// bleibt richtig, und deshalb steht hier kein Fortschritt. Was hier steht,
/// ist, was sich an einer Folge nicht festmachen lässt: ob die Serie ein
/// Favorit ist, wie der Benutzer sie bewertet, und ob er sie für erledigt
/// erklärt hat.
/// </para>
/// <para>
/// <b>Warum überhaupt eine Zeile und nicht nur gezählte Folgen.</b> Ohne
/// sie lief jede Aktion auf einer Serienkachel — Häkchen, Favorit — in eine
/// 404: der Server kannte zu dieser Kennung keinen Zustand. Sichtbar war
/// davon nichts; der Klick tat einfach nichts.
/// </para>
/// </remarks>
public Guid? SeriesId { get; init; }
/// <summary>Wo der Benutzer steht.</summary> /// <summary>Wo der Benutzer steht.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -0,0 +1,47 @@
namespace Flaro.Core.Playback;
/// <summary>Wann ein Titel angefangen ist und wann er als gesehen gilt.</summary>
///
/// <remarks>
/// <para>
/// <b>Zwei Zahlen, die niemand raten kann.</b> Wie lange man hineinschauen
/// muss, damit es zählt, und ab welchem Anteil ein Film durch ist — das hängt
/// davon ab, wie jemand schaut, nicht davon, was technisch richtig wäre. Beide
/// standen als Konstante im Quelltext; jetzt stehen sie in den Einstellungen.
/// </para>
/// <para>
/// <b>Als Wert, nicht als Abfrage.</b> <see cref="ProgressRule"/> bleibt damit
/// ein reiner Typ: er rechnet, er liest nichts. Prüfbar ohne Datenbank, und
/// derselbe Satz Regeln gilt für eine Meldung und für die Neubewertung aller
/// vorhandenen Zeilen.
/// </para>
/// </remarks>
///
/// <param name="FortsetzenAb">
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
/// <see cref="TimeSpan.Zero"/> heisst: jede angefangene Sekunde zählt.
/// </param>
/// <param name="GesehenAnteil">Ab welchem Anteil der Laufzeit ein Titel gesehen ist.</param>
public readonly record struct Wiedergaberegeln(TimeSpan FortsetzenAb, double GesehenAnteil)
{
/// <summary>Was gilt, solange niemand etwas anderes eingestellt hat.</summary>
///
/// <remarks>
/// <b>Neunzig Sekunden, nicht null.</b> Ohne Untergrenze genügte ein
/// einziger Tick: wer im Aufmacher eine Sekunde hineinschaute, fand den
/// Titel danach in „Weiterschauen" wieder. Eineinhalb Minuten sind lang
/// genug, dass niemand aus Versehen hineinfällt, und kurz genug, dass eine
/// abgebrochene Folge noch zählt.
/// </remarks>
public static readonly Wiedergaberegeln Vorgabe = new(TimeSpan.FromSeconds(90), 0.90);
/// <summary>Dieselben Regeln mit geprüften Werten.</summary>
///
/// <remarks>
/// Was aus der Datenbank kommt, hat dort irgendwann jemand hineingeschrieben.
/// Ein Anteil von 0 hiesse „alles ist gesehen", einer von 2 „nichts".
/// </remarks>
public Wiedergaberegeln Geprueft() => new(
FortsetzenAb < TimeSpan.Zero ? TimeSpan.Zero : FortsetzenAb,
Math.Clamp(GesehenAnteil, 0.5, 1.0));
}
+43
View File
@@ -19,5 +19,48 @@ public sealed class User
public bool IsAdministrator { get; set; } public bool IsAdministrator { get; set; }
/// <summary>Dem Besitzer kann niemand das Adminrecht nehmen.</summary>
///
/// <remarks>
/// <para>
/// <b>Damit sich ein Server nicht selbst aussperren kann.</b> Sobald
/// Administratoren einander das Recht entziehen können, gibt es einen
/// Zustand ohne jeden Administrator — und aus dem führt kein Weg zurück
/// ausser dem an der Datenbank. Ein Konto, das ausgenommen ist, schliesst
/// diesen Zustand aus.
/// </para>
/// <para>
/// <b>Es ist das erste Konto</b>, also das, mit dem der Server
/// eingerichtet wurde. Übertragen lässt es sich — an einen anderen
/// Administrator, und nur vom Besitzer selbst.
/// </para>
/// </remarks>
public bool IsOwner { get; set; }
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
///
/// <remarks>
/// <b>Und sie ist nicht kostenlos.</b> Metadaten werden in der Sprache des
/// Servers geholt und gespeichert. Wer eine andere wählt, bekommt, was in
/// der Übersetzungstabelle liegt — und für den Rest die Serversprache.
/// Darauf wird beim Umstellen deutlich hingewiesen; es stillschweigend
/// halb zu übersetzen wäre schlechter als gar nicht.
/// </remarks>
public string? Language { get; set; }
/// <summary>Das eigene Bild — der Schlüssel aus derselben Ablage wie Plakate.</summary>
///
/// <remarks>
/// <b>Kein eigener Speicher für Profilbilder.</b> Es ist ein Bild wie
/// jedes andere: derselbe Ordner, dieselbe Adresse, derselbe
/// Zwischenspeicher im Browser. Ein zweiter Weg hiesse ein zweites Mal
/// Pfadprüfung, Grössengrenze und Aufräumen — dreimal dieselbe Arbeit für
/// ein Bild von 200 Pixeln.
///
/// <c>null</c> heisst: der Buchstabenkreis. Der ist keine Notlösung,
/// sondern eine gültige Antwort — niemand muss ein Bild haben.
/// </remarks>
public string? AvatarImage { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
} }
+216
View File
@@ -22,12 +22,51 @@ namespace Flaro.Data;
/// </remarks> /// </remarks>
public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options) public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbContext(options)
{ {
/// <summary>Ob diese Anfrage auch Unveröffentlichtes sehen darf.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier und nicht in zwanzig Abfragen.</b> Auf Filme und Folgen wird an
/// rund zwanzig Stellen in vier Dateien zugegriffen. Schriebe jede ihren
/// eigenen Filter, reichte eine vergessene — und auffallen würde es nicht
/// beim Programmieren, sondern wenn jemand im Haushalt einen Titel sieht,
/// den er nicht sehen sollte. Ein globaler Abfragefilter lässt sich nicht
/// vergessen.
/// </para>
/// <para>
/// <b>Vorgabe <c>true</c>, gesetzt wird je Anfrage.</b> Der Scanner und
/// die Metadaten laufen ohne Benutzer und müssen alles sehen — sonst legt
/// der nächste Lauf einen wartenden Titel ein zweites Mal an. Für jede
/// HTTP-Anfrage setzt die Middleware den Wert, bevor irgendein Endpunkt
/// läuft; dass sie das tut, hält ein Test fest.
/// </para>
/// </remarks>
public bool AuchUnveroeffentlichtes { get; set; } = true;
/// <summary>Der Zeitpunkt, gegen den geplante Titel geprüft werden.</summary>
///
/// <remarks>
/// Am Kontext und nicht als <c>DateTimeOffset.UtcNow</c> im Filter: ein
/// Aufruf mitten im Ausdruck liesse sich nicht nach SQL übersetzen, und
/// in Tests wäre die Zeit nicht zu stellen.
/// </remarks>
public DateTimeOffset Jetzt { get; set; } = DateTimeOffset.UtcNow;
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>(); public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AuthCode> AuthCodes => Set<AuthCode>(); public DbSet<AuthCode> AuthCodes => Set<AuthCode>();
/// <summary>Links, mit denen sich jemand selbst ein Konto anlegt.</summary>
public DbSet<Einladung> Einladungen => Set<Einladung>();
public DbSet<Library> Libraries => Set<Library>(); public DbSet<Library> Libraries => Set<Library>();
public DbSet<ServerSetting> Settings => Set<ServerSetting>(); public DbSet<ServerSetting> Settings => Set<ServerSetting>();
/// <summary>Wie lange an welchem Tag gesehen wurde — siehe <see cref="Sehtag"/>.</summary>
public DbSet<Sehtag> Sehtage => Set<Sehtag>();
/// <summary>Was Benutzer an Titeln gemeldet haben — siehe <see cref="Meldung"/>.</summary>
public DbSet<Meldung> Meldungen => Set<Meldung>();
public DbSet<MediaFile> MediaFiles => Set<MediaFile>(); public DbSet<MediaFile> MediaFiles => Set<MediaFile>();
public DbSet<Movie> Movies => Set<Movie>(); public DbSet<Movie> Movies => Set<Movie>();
public DbSet<Series> Series => Set<Series>(); public DbSet<Series> Series => Set<Series>();
@@ -38,6 +77,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<GenreLink> GenreLinks => Set<GenreLink>(); public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Person> People => Set<Person>(); public DbSet<Person> People => Set<Person>();
public DbSet<Credit> Credits => Set<Credit>(); public DbSet<Credit> Credits => Set<Credit>();
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>();
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary> /// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
/// ///
@@ -62,7 +102,10 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
// Zwei Konten „Guido" und „guido" wären eine Falle, keine Funktion. // Zwei Konten „Guido" und „guido" wären eine Falle, keine Funktion.
e.HasIndex(x => x.UserName).IsUnique(); e.HasIndex(x => x.UserName).IsUnique();
e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE"); e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE");
e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.PasswordHash).HasMaxLength(256); e.Property(x => x.PasswordHash).HasMaxLength(256);
// Ein Hash aus 64 Hexzeichen, wie jeder Bildschlüssel.
e.Property(x => x.AvatarImage).HasMaxLength(64);
}); });
b.Entity<DeviceToken>(e => b.Entity<DeviceToken>(e =>
@@ -89,6 +132,24 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.DeviceId).HasMaxLength(128); e.Property(x => x.DeviceId).HasMaxLength(128);
}); });
b.Entity<Einladung>(e =>
{
e.HasKey(x => x.Id);
// Gesucht wird nur über den Fingerabdruck — und eindeutig, damit
// zwei Links niemals dasselbe Konto füttern können.
e.HasIndex(x => x.TokenFingerprint).IsUnique();
e.Property(x => x.TokenFingerprint).HasMaxLength(64);
// Die Liste in der Verwaltung: die neuesten zuerst.
e.HasIndex(x => x.CreatedAt);
// Wer einlädt und später gelöscht wird, nimmt seine offenen
// Einladungen mit. Ein Link ohne Absender ist keiner.
e.HasOne<User>().WithMany().HasForeignKey(x => x.CreatedByUserId)
.OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Library>(e => b.Entity<Library>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -113,6 +174,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
// ein zweimal angestossener Scan jede Datei zweimal an, und die // ein zweimal angestossener Scan jede Datei zweimal an, und die
// Mediathek wächst bei jedem Lauf. // Mediathek wächst bei jedem Lauf.
e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique(); e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique();
e.Property(x => x.VersionLabel).HasMaxLength(64);
e.HasIndex(x => x.MovieId); e.HasIndex(x => x.MovieId);
e.HasIndex(x => x.EpisodeId); e.HasIndex(x => x.EpisodeId);
e.Property(x => x.Path).HasMaxLength(1024); e.Property(x => x.Path).HasMaxLength(1024);
@@ -125,11 +187,31 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue }); e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
// **Ein Film kommt je Mediathek genau einmal vor.**
//
// Der Scan ordnet über den Dateinamen zu, lange bevor jemand
// weiss, welcher Film das ist — weichen zwei Dateien darin ab,
// entstehen zwei Einträge, die anschliessend beide demselben
// TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das
// unmöglich macht, statt nur Code, der sie einhalten soll.
//
// Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und
// ein ungefilterter Index dann nur einen einzigen zuliesse.
e.HasIndex(x => new { x.LibraryId, x.TmdbId })
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.OriginalTitle).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512); e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32); e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512); e.Property(x => x.Tagline).HasMaxLength(512);
e.Property(x => x.ContentRating).HasMaxLength(16); e.Property(x => x.ContentRating).HasMaxLength(16);
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
// Gefragt wird danach genau einmal je Ansicht — aber über den
// ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile.
e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage });
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId) e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
@@ -138,11 +220,31 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue }); e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
// **Eine Serie kommt je Mediathek genau einmal vor.**
//
// Der Scan ordnet über den Dateinamen zu, lange bevor jemand
// weiss, welcher Film das ist — weichen zwei Dateien darin ab,
// entstehen zwei Einträge, die anschliessend beide demselben
// TMDB-Eintrag zugeordnet werden. Hier steht die Regel, die das
// unmöglich macht, statt nur Code, der sie einhalten soll.
//
// Gefiltert, weil ohne Metadaten alle Einträge `null` tragen und
// ein ungefilterter Index dann nur einen einzigen zuliesse.
e.HasIndex(x => new { x.LibraryId, x.TmdbId })
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.OriginalTitle).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512); e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32); e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512); e.Property(x => x.Tagline).HasMaxLength(512);
e.Property(x => x.ContentRating).HasMaxLength(16); e.Property(x => x.ContentRating).HasMaxLength(16);
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
// Gefragt wird danach genau einmal je Ansicht — aber über den
// ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile.
e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage });
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId) e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
@@ -203,6 +305,54 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
}); });
// **Die Freigaberegel, einmal und für jede Abfrage.**
//
// <b>Zwei Zustände, nicht einer.</b> Ohne Zeitpunkt wartet ein Titel
// auf eine Entscheidung — den soll niemand sehen, er ist noch nicht
// einmal geprüft. Mit einem Zeitpunkt in der Zukunft ist die
// Entscheidung gefallen: er kommt, nur noch nicht. Den zu verstecken
// hiesse, eine Vorfreude zu unterschlagen, die der Sinn des Planens
// ist — bei Netflix steht „Ab 14. März" auf der Kachel, und genau das
// soll hier auch stehen.
//
// <b>Sichtbar heisst nicht abspielbar.</b> Dass die Datei vor dem
// Zeitpunkt nicht herausgeht, steht nicht hier, sondern bei der
// Auslieferung — ein Filter, der etwas zeigt, darf nicht zugleich der
// sein, der es herausgibt.
//
// Gerechnet statt gespeichert: damit stimmt es nach einem Neustart,
// nach einer Zeitumstellung und auch dann, wenn der Server zum
// geplanten Zeitpunkt gar nicht lief.
b.Entity<Movie>().HasQueryFilter(m =>
AuchUnveroeffentlichtes || m.PublishAt != null);
b.Entity<Episode>().HasQueryFilter(f =>
AuchUnveroeffentlichtes || f.PublishAt != null);
// **Die Serie hängt an ihren Folgen.** Ein eigenes Feld gäbe zwei
// Wahrheiten — eine veröffentlichte Serie, deren Folgen alle noch
// warten, wäre ein leerer Eintrag in der Mediathek.
b.Entity<Series>().HasQueryFilter(s =>
AuchUnveroeffentlichtes
|| Episodes.Any(f => f.SeriesId == s.Id && f.PublishAt != null));
b.Entity<ItemTranslation>(e =>
{
e.HasKey(x => x.Id);
e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.Tagline).HasMaxLength(512);
// Je Titel und Sprache genau eine Zeile — sonst sammeln sich
// Übersetzungen an, und welche gezeigt wird, entscheidet der Zufall.
e.HasIndex(x => new { x.MovieId, x.Language }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.SeriesId, x.Language }).IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
e.HasIndex(x => new { x.EpisodeId, x.Language }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
});
b.Entity<UserItemState>(e => b.Entity<UserItemState>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -220,6 +370,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
.HasFilter("\"MovieId\" IS NOT NULL"); .HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique() e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL"); .HasFilter("\"EpisodeId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.SeriesId }).IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
// Der Weg von „Weiterschauen": die offenen Zeilen eines // Der Weg von „Weiterschauen": die offenen Zeilen eines
// Benutzers, neueste zuerst. Ohne Index ein vollständiger // Benutzers, neueste zuerst. Ohne Index ein vollständiger
@@ -232,6 +384,69 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
// gibt, ist keine Erinnerung, sondern Datenmüll. // gibt, ist keine Erinnerung, sondern Datenmüll.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade); e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Sehtag>(e =>
{
e.HasKey(x => x.Id);
// Je Benutzer, Tag und Titel eine Zeile — sonst addierte sich
// derselbe Nachmittag in zwei Zeilen, und keine davon wäre falsch.
e.HasIndex(x => new { x.UserId, x.Tag, x.MovieId }).IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
e.HasIndex(x => new { x.UserId, x.Tag, x.EpisodeId }).IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
// Der Weg jeder Auswertung: die Tage eines Benutzers in einem
// Zeitraum. Ohne Index ein vollständiger Durchlauf je Aufruf.
e.HasIndex(x => new { x.UserId, x.Tag });
// **Als Zahl, nicht als Text.** Aus demselben Grund wie bei den
// Zeitpunkten: „2026-10-6" sortierte sich als Text vor
// „2026-10-31", und eine Wochenkurve stünde durcheinander.
e.Property(x => x.Tag).HasConversion(
tag => tag.DayNumber, zahl => DateOnly.FromDayNumber(zahl));
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
// Verschwindet der Titel, verschwindet seine Sehzeit mit. Eine
// Statistik über Filme, die es nicht mehr gibt, zählt Stunden auf
// nichts.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Meldung>(e =>
{
e.HasKey(x => x.Id);
// **Als Text, nicht als Zahl.** Eine 2 in der Datenbank sagt keinem
// Menschen etwas, und eine später eingeschobene Art machte aus
// jedem gespeicherten „Ton" stillschweigend „Untertitel".
e.Property(x => x.Art).HasConversion<string>().HasMaxLength(32);
// Zweitausend Zeichen: Platz für eine Schilderung mit Zeiten und
// Fassungen, zu wenig für einen Aufsatz. Zum Vergleich: „Der
// deutsche Ton setzt nach einer halben Minute aus, Englisch läuft
// weiter" sind 72.
e.Property(x => x.Kommentar).HasMaxLength(2000);
// Der Weg der Verwaltung: die offenen, älteste zuerst.
e.HasIndex(x => new { x.ErledigtAm, x.GemeldetAm });
// Der Weg des Melders: seine eigenen, neueste zuerst.
e.HasIndex(x => new { x.UserId, x.GemeldetAm });
// **Kein eindeutiger Index.** Zwei Meldungen zu demselben Film sind
// zwei Meldungen; sie zusammenzulegen hiesse, die zweite zu
// verschlucken.
e.HasOne<User>().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
// Verschwindet der Titel, verschwindet die Meldung dazu. Eine
// Aufgabe zu einem Film, den es nicht mehr gibt, ist keine mehr.
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
}); });
b.Entity<Episode>(e => b.Entity<Episode>(e =>
@@ -242,6 +457,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
// und die hängen beide an diesem einen Eintrag. // und die hängen beide an diesem einen Eintrag.
e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique(); e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique();
e.Property(x => x.Title).HasMaxLength(512); e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.MetadataLanguage).HasMaxLength(16);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
}); });
} }
+253 -24
View File
@@ -12,7 +12,12 @@ public sealed record ScanReport(
int Removed, int Removed,
int Unrecognised, int Unrecognised,
int Probed, int Probed,
IReadOnlyList<WalkProblem> Problems); IReadOnlyList<WalkProblem> Problems,
/// <summary>Wie viele Dateien dieser Lauf als verschwunden markiert hat.</summary>
int Missing = 0);
/// <summary>Der Scan hat abgebrochen, statt den Bestand für verschwunden zu erklären.</summary>
public sealed class BestandVerschwundenException(string nachricht) : Exception(nachricht);
/// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary> /// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary>
/// ///
@@ -33,14 +38,42 @@ public sealed record ScanReport(
public sealed class LibraryScanner( public sealed class LibraryScanner(
FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log) FlaroContext db, IMediaProbe sonde, TimeProvider clock, ILogger<LibraryScanner> log)
{ {
public async Task<ScanReport> ScanAsync(Guid libraryId, CancellationToken ct = default) public async Task<ScanReport> ScanAsync(
Guid libraryId, IProgress<Scanschritt>? fortschritt = null, CancellationToken ct = default)
{ {
var bibliothek = await db.Libraries.FindAsync([libraryId], ct) var bibliothek = await db.Libraries.FindAsync([libraryId], ct)
?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}."); ?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}.");
var laufZeit = clock.GetUtcNow(); var laufZeit = clock.GetUtcNow();
_laufZeit = laufZeit;
_pruefungNoetig = bibliothek.ReviewNewItems;
// **Die Notbremse, und sie steht vor allem anderen.**
//
// Ist das Volume beim Start des NAS noch nicht eingehängt, liegt der
// Ordner leer oder gar nicht da. Ohne diese Prüfung erklärte der Lauf
// den gesamten Bestand für verschwunden — bei 458 Titeln samt
// Sehfortschritt aller Benutzer. Lieber gar kein Scan als der.
var warVorherDa = await db.MediaFiles.CountAsync(f => f.LibraryId == libraryId, ct);
if (!Directory.Exists(bibliothek.Path))
{
throw new BestandVerschwundenException(
$"Den Ordner {bibliothek.Path} gibt es nicht. Ist das Volume eingehängt? " +
"Es wurde nichts verändert.");
}
fortschritt?.Report(new Scanschritt(Scanphase.Suchen, bibliothek.Path));
var ergebnis = LibraryWalker.Walk(bibliothek.Path); var ergebnis = LibraryWalker.Walk(bibliothek.Path);
if (ergebnis.Files.Count == 0 && warVorherDa > 0)
{
throw new BestandVerschwundenException(
$"In {bibliothek.Path} liegt keine einzige Mediendatei mehr, vorher waren es " +
$"{warVorherDa}. Der Lauf wurde abgebrochen, damit nicht der ganze Bestand " +
"als verschwunden gilt. Es wurde nichts verändert.");
}
foreach (var problem in ergebnis.Problems) foreach (var problem in ergebnis.Problems)
log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason); log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason);
@@ -61,12 +94,59 @@ public sealed class LibraryScanner(
? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct) ? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct)
: []; : [];
int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0; int neu = 0, geändert = 0, unerkannt = 0, untersucht = 0, verschoben = 0;
var seitLetztemSchreiben = 0;
// **Dateien nach Grösse und Änderungszeit, für das Erkennen von
// Verschiebungen.**
//
// Seit verschwundene Dateien markiert statt gelöscht werden, hinterlässt
// jede Umbenennung eine Leiche: die alte Zeile bleibt als „vermisst"
// stehen, die alte Folge gilt damit nicht als verwaist, und die alte
// Serie bleibt in der Mediathek. Nach dem Umbenennen eines
// Serienordners stand alles doppelt da.
//
// Grösse **und** Zeitstempel müssen stimmen. Grösse allein reichte
// nicht: zwei gleich grosse Dateien gibt es, und die zu vertauschen
// wäre schlimmer als eine Leiche.
// Auf Sekunden gerundet: der Zeitstempel geht durch die Datenbank und
// kommt nicht auf den Tick genau zurück. Ein Vergleich auf Gleichheit
// schlug deshalb immer fehl, und keine Verschiebung wurde je erkannt.
static (long, long) Merkmal(long bytes, DateTimeOffset geaendert) =>
(bytes, geaendert.ToUnixTimeSeconds());
var nachMerkmal = bekannteDateien.Values
.GroupBy(f => Merkmal(f.SizeBytes, f.ModifiedAt))
.Where(g => g.Count() == 1) // Mehrdeutiges bleibt liegen.
.ToDictionary(g => g.Key, g => g.Single());
var durch = 0;
// **Was dieser Lauf schon in der Hand hatte.**
//
// Ein Pfad darf im selben Lauf nur einmal vorkommen. Kommt er zweimal
// — ein Ordner, der doppelt eingehängt ist, oder eine Verknüpfung, die
// in die Mediathek zurückzeigt —, entstand bisher eine zweite Zeile
// mit demselben `(LibraryId, Path)`. Der Eindeutigkeitsindex liess
// daraufhin den **ganzen** Speichervorgang scheitern: der Scan brach
// ab, die Zähler blieben stehen, und in der Oberfläche stand eine
// englische Meldung über „entity changes".
var gesehen = new HashSet<string>(StringComparer.Ordinal);
foreach (var datei in ergebnis.Files) foreach (var datei in ergebnis.Files)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (!gesehen.Add(datei.Path))
{
log.LogInformation(
"Scan: {Pfad} kam ein zweites Mal vorbei und wurde übersprungen.", datei.Path);
continue;
}
fortschritt?.Report(new Scanschritt(
Scanphase.Pruefen, datei.Path, ++durch, ergebnis.Files.Count));
Guid? filmId = null, folgeId = null; Guid? filmId = null, folgeId = null;
if (bibliothek.Kind == LibraryKind.Movies) if (bibliothek.Kind == LibraryKind.Movies)
@@ -76,18 +156,48 @@ public sealed class LibraryScanner(
} }
else else
{ {
var name = FileNames.ParseEpisode(datei.Path); // **Mit der Wurzel der Mediathek.** Erst damit kann der
// Scanner wissen, welcher Ordner die Serie benennt — und ohne
// das wurde aus jeder Folgendatei eine eigene Serie.
var name = FileNames.ParseEpisode(datei.Path, bibliothek.Path);
if (name is null) if (name is null)
{ {
// **Nicht raten.** Eine erfundene Staffel 1 landete unter // **Nicht raten — aber auch nicht verschweigen.**
// einer Serie, zu der die Datei nicht gehört, und dort //
// fände sie niemand je wieder. // Eine erfundene Staffel 1 landete unter einer Serie, zu
// der die Datei nicht gehört, und dort fände sie niemand je
// wieder. Die Datei einfach zu überspringen war aber die
// andere Hälfte des Fehlers: 3559 von 5884 Dateien
// verschwanden lautlos, und in der Mediathek stand eine
// Zahl, die keiner erklären konnte.
//
// Jetzt bekommt sie eine Zeile ohne Zuordnung. Damit ist
// sie zählbar, auflistbar und beim nächsten Lauf — oder
// nach einer besseren Regel — nachträglich zuzuordnen.
unerkannt++; unerkannt++;
log.LogInformation("Scan: {Pfad} trägt keine Folgennummer und bleibt liegen.", datei.Path); log.LogInformation("Scan: {Pfad} liess sich keiner Serie zuordnen.", datei.Path);
continue; fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
$"Nicht zugeordnet: {datei.Path} — {FileNames.WarumNicht(datei.Path, bibliothek.Path)}"));
} }
if (name is not null)
{
var vorher = serien.Count;
var serie = SerieFinden(serien, libraryId, name.SeriesTitle); var serie = SerieFinden(serien, libraryId, name.SeriesTitle);
// **Jede neue Serie wird begründet.** Ohne diese Zeile liess
// sich nicht feststellen, welcher der beiden Wege — Ordner oder
// Dateiname — die Geisterserien angelegt hatte, und jede
// Fehlersuche blieb Vermutung.
if (serien.Count > vorher)
{
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
$"Serie „{serie.Title}“ neu angelegt — "
+ (name.AusOrdner ? "aus dem Ordner" : "aus dem Dateinamen")
+ $", wegen {datei.Path}"));
}
folgeId = FolgeFinden(folgen, serie.Id, name).Id; folgeId = FolgeFinden(folgen, serie.Id, name).Id;
}
} }
MediaFile eintrag; MediaFile eintrag;
@@ -108,9 +218,40 @@ public sealed class LibraryScanner(
bekannt.MovieId = filmId; bekannt.MovieId = filmId;
bekannt.EpisodeId = folgeId; bekannt.EpisodeId = folgeId;
bekannt.SeenAt = laufZeit; bekannt.SeenAt = laufZeit;
// Wieder da — die Markierung fällt von selbst weg.
bekannt.MissingSince = null;
bekannt.VersionLabel = FileNames.Fassung(datei.Path) ?? bekannt.VersionLabel;
if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++;
eintrag = bekannt; eintrag = bekannt;
} }
else if (nachMerkmal.TryGetValue(Merkmal(datei.SizeBytes, datei.ModifiedAt), out var umgezogen)
&& umgezogen.SeenAt < laufZeit)
{
// **Dieselbe Datei, neuer Pfad.** Der Eintrag zieht um, statt
// dass ein neuer entsteht und der alte als vermisst
// zurückbleibt. Sonst stünde nach dem Umbenennen eines Ordners
// alles doppelt da — einmal richtig, einmal als Leiche.
nachMerkmal.Remove(Merkmal(datei.SizeBytes, datei.ModifiedAt));
bekannteDateien.Remove(umgezogen.Path);
umgezogen.Path = datei.Path;
// **Unter dem neuen Pfad wieder eintragen.** Der alte Schlüssel
// ist weg, der neue fehlte — und damit war der Eintrag für den
// Rest des Laufs unauffindbar. Trifft derselbe Pfad später noch
// einmal ein, entstünde eine zweite Zeile dafür.
bekannteDateien[datei.Path] = umgezogen;
umgezogen.MovieId = filmId;
umgezogen.EpisodeId = folgeId;
umgezogen.SeenAt = laufZeit;
umgezogen.MissingSince = null;
umgezogen.VersionLabel = FileNames.Fassung(datei.Path) ?? umgezogen.VersionLabel;
eintrag = umgezogen;
// Nicht erneut untersuchen: der Inhalt ist derselbe, nur der
// Name ist anders.
mussUntersucht = umgezogen.ProbedAt is null;
verschoben++;
}
else else
{ {
eintrag = new MediaFile eintrag = new MediaFile
@@ -122,31 +263,70 @@ public sealed class LibraryScanner(
SeenAt = laufZeit, SeenAt = laufZeit,
MovieId = filmId, MovieId = filmId,
EpisodeId = folgeId, EpisodeId = folgeId,
VersionLabel = FileNames.Fassung(datei.Path),
}; };
db.MediaFiles.Add(eintrag); db.MediaFiles.Add(eintrag);
// **Auch Neuzugänge gehören in die Liste.** Sie standen dort
// nie — ein Pfad, der im selben Lauf ein zweites Mal auftauchte,
// wurde deshalb ein zweites Mal angelegt.
bekannteDateien[datei.Path] = eintrag;
neu++; neu++;
mussUntersucht = true; mussUntersucht = true;
} }
if (mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++; // **Was keiner Serie gehört, wird nicht untersucht.** Ein
// ffprobe-Durchgang kostet rund fünfzig Millisekunden; bei 3559
// unzugeordneten Dateien ist das eine halbe Stunde für Angaben,
// die niemand zu sehen bekommt. Sobald die Zuordnung steht, holt
// der nächste Lauf es nach — `ProbedAt` ist dann noch leer.
var gehoertDazu = filmId is not null || folgeId is not null;
if (gehoertDazu && mussUntersucht && await UntersuchenAsync(eintrag, laufZeit, ct)) untersucht++;
// **Zwischendurch schreiben, nicht erst am Ende.**
//
// Gemessen an achtzig Dateien erscheinen die Titel ohnehin
// laufend — denn <see cref="UntersuchenAsync"/> speichert je
// Datei. Nur gilt das eben *nur* dort: scheitert die
// Untersuchung, kehrt sie vor dem Speichern um. Ein Bestand aus
// Dateien, die ffprobe nicht lesen kann, bliebe bis zum Schluss
// unsichtbar — und wer dabei zusieht, hält den Scan für hängend.
//
// Fünfzig, nicht jede: ein Schreibvorgang je Datei kostet bei
// vierzigtausend mehr, als die Sichtbarkeit wert ist.
//
// Für das Aufräumen weiter unten ist das unbedenklich. Es
// vergleicht gegen `laufZeit`, und die steht vor der Schleife
// fest; jede hier geschriebene Datei trägt genau diesen Wert.
if (++seitLetztemSchreiben >= 50)
{
await db.SaveChangesAsync(ct);
seitLetztemSchreiben = 0;
}
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// Was dieser Lauf nicht gesehen hat, liegt nicht mehr da. // **Was dieser Lauf nicht gesehen hat, wird markiert — nicht gelöscht.**
var entfernt = await db.MediaFiles // Eine umbenannte Festplatte, ein Tippfehler im Pfad, ein Laufwerk im
.Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit) // Ruhezustand: all das sah vorher aus wie „der Film ist weg", und mit
.ExecuteDeleteAsync(ct); // ihm ging der Sehfortschritt aller Benutzer.
var verschwunden = await db.MediaFiles
.Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit && f.MissingSince == null)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.MissingSince, laufZeit), ct);
fortschritt?.Report(new Scanschritt(Scanphase.Aufraeumen, ""));
await VerwaisteAufraeumen(libraryId, ct); await VerwaisteAufraeumen(libraryId, ct);
log.LogInformation( log.LogInformation(
"Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " + "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Verschoben} verschoben, " +
"{Entfernt} entfernt, {Unerkannt} unerkannt, {Untersucht} untersucht.", "{Geändert} geändert, {Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, " +
bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht); "{Untersucht} untersucht.",
bibliothek.Name, ergebnis.Files.Count, neu, verschoben, geändert, verschwunden,
unerkannt, untersucht);
return new ScanReport( return new ScanReport(
ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht, ergebnis.Problems); ergebnis.Files.Count, neu, geändert, 0, unerkannt, untersucht, ergebnis.Problems,
Missing: verschwunden);
} }
/// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary> /// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary>
@@ -171,6 +351,13 @@ public sealed class LibraryScanner(
datei.BitRate = gelesen.BitRate; datei.BitRate = gelesen.BitRate;
datei.ProbedAt = laufZeit; datei.ProbedAt = laufZeit;
// **Der Rückfall aus der Bildgrösse wird nicht mehr gespeichert.**
// Er stand in der Spalte und war damit das, was beim letzten Scan
// gegolten hat: als aus „576p" ein „SD" wurde, blieb in jeder
// bestehenden Zeile das alte Wort stehen. Jetzt rechnet die Antwort
// ihn aus den Spuren — siehe `LibraryContentEndpoints.Datei`. In der
// Spalte steht nur noch, was im Dateinamen stand.
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei // Die alten Spuren weg, bevor die neuen kommen: eine umkodierte Datei
@@ -221,18 +408,48 @@ public sealed class LibraryScanner(
/// <summary>Sucht den Film — oder legt ihn an.</summary> /// <summary>Sucht den Film — oder legt ihn an.</summary>
/// ///
/// <remarks> /// <remarks>
/// Der Titel **aus dem Dateinamen** und das Jahr entscheiden — nicht der /// <para>
/// angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle /// Der Titel <b>aus dem Dateinamen</b> und das Jahr entscheiden — nicht
/// der angezeigte Titel, der nach dem ersten Lauf von der Metadatenquelle
/// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen /// kommt und sich dabei ändert. Nur über den Titel ohne Jahr zu gehen
/// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu /// würfe die beiden „Dune" von 1984 und 2021 zusammen; über die Datei zu
/// gehen machte aus 4K- und 1080p-Fassung zwei Filme. /// gehen machte aus 4K- und 1080p-Fassung zwei Filme.
/// </para>
/// <para>
/// <b>Verglichen wird über <see cref="Titelschluessel"/>, nicht Zeichen
/// für Zeichen.</b> Vorher ergaben <c>Dr. Dolittle 2</c> und
/// <c>Dr Dolittle 2</c> zwei Einträge, die anschliessend beide demselben
/// TMDB-Eintrag zugeordnet wurden und ununterscheidbar nebeneinander
/// standen. Ebenso eine Datei mit Jahresangabe neben einer ohne.
/// </para>
/// </remarks> /// </remarks>
/// <summary>Ob neue Funde dieses Laufs erst geprüft werden sollen.</summary>
///
/// <remarks>
/// Als Feld und nicht als Parameter durch vier Methoden gereicht: es gilt
/// für den ganzen Lauf und ändert sich darin nicht.
/// </remarks>
private bool _pruefungNoetig;
private DateTimeOffset _laufZeit;
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name) private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
{ {
var treffer = bekannt.FirstOrDefault(m => var treffer = bekannt.FirstOrDefault(m =>
string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase) Titelschluessel.Gleich(m.ParsedTitle, m.ParsedYear, name.Title, name.Year));
&& m.ParsedYear == name.Year); if (treffer is not null)
if (treffer is not null) return treffer; {
// **Das Anzeigejahr nachtragen, wenn es bisher fehlte.** Trug die
// erste Datei keines im Namen und die zweite schon, stünde sonst
// ein Film ohne Jahr da, obwohl es bekannt ist.
//
// `ParsedYear` bleibt unangetastet — es ist der Vergleichsschlüssel
// und deshalb `init`. Das ist kein Mangel: ein fehlendes Jahr passt
// in `Titelschluessel.Gleich` ohnehin auf jedes, der Eintrag findet
// also weiterhin beide Schreibweisen.
treffer.Year ??= name.Year;
return treffer;
}
var neu = new Movie var neu = new Movie
{ {
@@ -242,16 +459,27 @@ public sealed class LibraryScanner(
Title = name.Title, Title = name.Title,
SortTitleValue = SortTitle.For(name.Title), SortTitleValue = SortTitle.For(name.Title),
Year = name.Year, Year = name.Year,
// **Sofort sichtbar, ausser die Mediathek verlangt eine Prüfung.**
// Die Vorgabe ist „erscheinen": wer eine Mediathek anlegt, will
// sie sehen.
PublishAt = _pruefungNoetig ? null : _laufZeit,
}; };
db.Movies.Add(neu); db.Movies.Add(neu);
bekannt.Add(neu); bekannt.Add(neu);
return neu; return neu;
} }
/// <summary>Sucht die Serie — oder legt sie an.</summary>
///
/// <remarks>
/// Über denselben Schlüssel wie beim Film: <c>Game of Thrones</c> und
/// <c>Game.of.Thrones</c> sind eine Serie, nicht zwei. Ein Jahr steht am
/// Serienordner selten, deshalb gibt es hier keines zu vergleichen.
/// </remarks>
private Series SerieFinden(List<Series> bekannt, Guid libraryId, string titel) private Series SerieFinden(List<Series> bekannt, Guid libraryId, string titel)
{ {
var treffer = bekannt.FirstOrDefault(s => var treffer = bekannt.FirstOrDefault(s =>
string.Equals(s.ParsedTitle, titel, StringComparison.OrdinalIgnoreCase)); Titelschluessel.Gleich(s.ParsedTitle, null, titel, null));
if (treffer is not null) return treffer; if (treffer is not null) return treffer;
var neu = new Series var neu = new Series
@@ -285,6 +513,7 @@ public sealed class LibraryScanner(
SeasonNumber = name.Season, SeasonNumber = name.Season,
EpisodeNumber = name.Episode, EpisodeNumber = name.Episode,
Title = name.EpisodeTitle, Title = name.EpisodeTitle,
PublishAt = _pruefungNoetig ? null : _laufZeit,
}; };
db.Episodes.Add(neu); db.Episodes.Add(neu);
bekannt.Add(neu); bekannt.Add(neu);
+278 -22
View File
@@ -30,6 +30,8 @@ public sealed class MetadataEnricher(
IImageStore bilder, IImageStore bilder,
IImageReader bildLeser, IImageReader bildLeser,
ISidecarStore beiwerk, ISidecarStore beiwerk,
Zusammenlegung zusammenlegung,
TimeProvider uhr,
ILogger<MetadataEnricher> log) ILogger<MetadataEnricher> log)
{ {
/// <param name="erneut"> /// <param name="erneut">
@@ -42,8 +44,22 @@ public sealed class MetadataEnricher(
/// <b>Nachgeholt wird über die bekannte Kennung, nicht über eine neue /// <b>Nachgeholt wird über die bekannte Kennung, nicht über eine neue
/// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen. /// Suche.</b> Eine Zuordnung von Hand bleibt damit stehen.
/// </param> /// </param>
/// <param name="nurSprache">
/// <b>Nur Titel anfassen, die nicht in dieser Sprache vorliegen.</b> Für
/// den Fall nach einer Sprachumstellung: zwölf abweichende Titel
/// aufzufrischen kostet zwölf Anfragen, der ganze Bestand viertausend.
/// <c>null</c> heisst: keine Einschränkung.
/// </param>
/// <param name="nurDiese">
/// <b>Nur diese Filme und Serien anfassen.</b> Für „Metadaten neu holen"
/// an einer Auswahl in der Mediathek: zehn ausgewählte Titel kosten zehn
/// Anfragen, der ganze Bestand viertausend. Folgen gehören zu ihrer Serie
/// — ist die ausgewählt, kommen sie mit. <c>null</c> heisst: alles.
/// </param>
public async Task<EnrichReport> EnrichAsync( public async Task<EnrichReport> EnrichAsync(
Guid libraryId, bool erneut = false, CancellationToken ct = default) Guid libraryId, bool erneut = false,
IProgress<Scanschritt>? fortschritt = null, CancellationToken ct = default,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
{ {
if (!await anbieter.IsConfiguredAsync(ct)) if (!await anbieter.IsConfiguredAsync(ct))
{ {
@@ -51,12 +67,19 @@ public sealed class MetadataEnricher(
return new EnrichReport(0, 0, 0, Skipped: true, null); return new EnrichReport(0, 0, 0, Skipped: true, null);
} }
// **Ob die Mediathek auf Prüfung steht.** Nur dann wird eine Änderung
// vorgemerkt — wer keine Prüfung will, soll auch keine Liste bekommen.
var pruefungNoetig = await db.Libraries.AsNoTracking()
.Where(l => l.Id == libraryId)
.Select(l => l.ReviewNewItems)
.FirstOrDefaultAsync(ct);
int filme = 0, serien = 0, folgen = 0; int filme = 0, serien = 0, folgen = 0;
try try
{ {
filme = await FilmeZuordnen(libraryId, erneut, ct); filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
serien = await SerienZuordnen(libraryId, erneut, ct); serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese, pruefungNoetig);
folgen = await FolgenZuordnen(libraryId, erneut, ct); folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese);
} }
catch (MetadataCredentialsException fehler) catch (MetadataCredentialsException fehler)
{ {
@@ -71,16 +94,43 @@ public sealed class MetadataEnricher(
return new EnrichReport(filme, serien, folgen, Skipped: false, null); return new EnrichReport(filme, serien, folgen, Skipped: false, null);
} }
private async Task<int> FilmeZuordnen(Guid libraryId, bool erneut, CancellationToken ct) private async Task<int> FilmeZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
{ {
var offen = await db.Movies var offen = await db.Movies
.Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)) .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)
// **Beim Sprachlauf nur Zugeordnetes.** Ein Titel ohne
// Kennung hat keine Sprache, weil er keine Metadaten hat —
// das ist ein Zuordnungsproblem. Ihn hier mitzunehmen hiesse,
// bei jedem Sprachlauf dieselben dreihundert vergeblichen
// Suchen erneut zu starten.
&& (nurSprache == null
|| (m.TmdbId != null && m.MetadataLanguage != nurSprache)))
.Where(m => nurDiese == null || nurDiese.Contains(m.Id))
.ToListAsync(ct); .ToListAsync(ct);
var zugeordnet = 0; var zugeordnet = 0;
var durch = 0;
// Wer welche Kennung schon hat — auch die, die dieser Lauf gerade
// vergeben hat und die deshalb noch nicht in der Datenbank stehen.
var vergeben = await db.Movies
.Where(m => m.LibraryId == libraryId && m.TmdbId != null)
.ToDictionaryAsync(m => m.TmdbId!.Value, m => m.Id, ct);
// Welches Jahr im Dateinamen des schon zugeordneten stand — daran
// entscheidet sich, ob ein zweiter Treffer wirklich derselbe Film ist.
var jahreNachId = await db.Movies
.Where(m => m.LibraryId == libraryId && m.TmdbId != null)
.ToDictionaryAsync(m => m.Id, m => m.ParsedYear, ct);
var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>();
foreach (var film in offen) foreach (var film in offen)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
fortschritt?.Report(new Scanschritt(
Scanphase.Nachschlagen, film.Title, ++durch, offen.Count));
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct); var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct); var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct);
@@ -96,15 +146,92 @@ public sealed class MetadataEnricher(
if (gefunden is null) continue; if (gefunden is null) continue;
// **Zwei Einträge, derselbe Film — und das fällt erst hier auf.**
//
// Beim Scan heisst der eine „Ich einfach unverbesserlich" und der
// andere „Despicable Me"; dass es derselbe ist, verrät allein die
// TMDB-Kennung. Die hier blind zu setzen verletzt den
// Eindeutigkeitsindex, und EF bricht die ganze Speicherung ab —
// mit „An error occurred while saving the entity changes", einer
// Meldung, die den Grund nicht nennt und den ganzen Lauf mitnimmt.
//
// Also nicht setzen, sondern vormerken: zusammengelegt wird nach
// der Schleife, wenn nichts mehr dazwischenkommt.
// **Sich selbst ist keine Dublette, und das war der teure Teil.**
//
// `vergeben` enthält jeden Film, der schon eine Kennung hat — beim
// Auffrischen also auch diesen hier. Ohne den Vergleich auf
// Gleichheit fand sich jeder zugeordnete Film als „Dublette seiner
// selbst", wurde übersprungen und nie fortgeschrieben. „Metadaten
// auffrischen" tat damit an allem, was schon zugeordnet war,
// schlicht nichts: Titel, Beschreibung und Sprache blieben für
// immer so, wie sie beim ersten Lauf hereinkamen. Genau das sah
// aus wie „die Spracheinstellung wirkt nicht".
if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != film.Id)
{
// **Verschiedene Jahre heissen: verschiedene Filme.**
//
// „…Heiligtümer des Todes Teil 1 (2010)" und „Teil 2 (2011)"
// landen bei TMDB auf demselben Treffer, wenn ohne Jahr
// gesucht werden musste — die Suche liefert für beide Titel
// Teil 2 zuerst. Zusammengelegt verschwand damit ein ganzer
// Film als „zweite Fassung" des anderen, und niemand erfuhr
// davon. Gemessen am echten Bestand: 448 Filme und 13 zweite
// Fassungen bei 461 Dateien.
//
// Das Jahr aus dem Dateinamen ändert sich nie und ist deshalb
// der verlässlichste Unterschied, den es hier gibt. Kennt nur
// einer von beiden eines, sagt das nichts — dann bleibt es
// beim Zusammenlegen, denn „Despicable Me" und „Ich einfach
// unverbesserlich" sind wirklich derselbe Film.
if (JahreWidersprechen(film.ParsedYear, jahreNachId.GetValueOrDefault(schonDa)))
{
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
$"„{film.ParsedTitle ?? film.Title}“ ({film.ParsedYear}) und ein anderer Titel "
+ $"führen beide auf {gefunden.ProviderId} — verschiedene Jahre, also nicht "
+ "zusammengelegt. Dieser bleibt ohne Zuordnung."));
durch++;
continue;
}
zusammenzulegen.Add((Sieger: schonDa, Verlierer: film.Id));
continue;
}
// **Was vorher dastand** — nur daran lässt sich sagen, ob ein
// Auffrischen wirklich etwas geändert hat. Ein Lauf, der denselben
// Titel noch einmal holt, ist nichts zum Abnicken.
var vorher = (film.TmdbId, film.Title, film.PosterImage, film.Year);
vergeben[gefunden.ProviderId] = film.Id;
// **Auch das Jahr, und zwar sofort.** Die beiden Tabellen müssen
// im Gleichschritt wachsen: wird nur `vergeben` fortgeschrieben,
// findet der zweite Film zwar den ersten, kennt aber dessen Jahr
// nicht — und legt wieder zusammen, was nicht zusammengehört.
jahreNachId[film.Id] = film.ParsedYear;
film.TmdbId = gefunden.ProviderId; film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId; film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title; // **Ein Titel von Hand bleibt stehen.** Wie bei den Bildern: ohne
film.SortTitleValue = SortTitle.For(gefunden.Title); // diese Prüfung tippt man ihn nach jedem Auffrischen neu.
if (!film.TitlePinned)
{
film.Title = gefunden.Title;
// **Der Sortiertitel hat seinen eigenen Merker.** Wer „Harry
// Potter 3" eingetragen hat, will den Titel trotzdem weiter
// automatisch bekommen — beides an einem Schalter hiesse, für
// die Reihenfolge auf die Pflege des Titels zu verzichten.
if (!film.SortTitlePinned) film.SortTitleValue = SortTitle.For(gefunden.Title);
}
film.OriginalTitle = gefunden.OriginalTitle;
film.MetadataLanguage = gefunden.Language;
film.Year = gefunden.Year ?? film.Year; film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview; film.Overview = gefunden.Overview;
film.PosterImage = await BildAblegen(gefunden.Poster, ct); // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); // überschreibt jedes Auffrischen die Auswahl wieder — und wer
film.LogoImage = await BildAblegen(gefunden.Logo, ct); // einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
if (!film.PosterPinned) film.PosterImage = await BildAblegen(gefunden.Poster, ct);
if (!film.BackdropPinned) film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct);
film.RuntimeMinutes = gefunden.RuntimeMinutes; film.RuntimeMinutes = gefunden.RuntimeMinutes;
film.Rating = gefunden.Rating; film.Rating = gefunden.Rating;
film.Tagline = gefunden.Tagline; film.Tagline = gefunden.Tagline;
@@ -115,10 +242,36 @@ public sealed class MetadataEnricher(
await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct); await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct);
zugeordnet++; zugeordnet++;
// **Eine Änderung an einem Titel, den es schon gab, wird vorgemerkt.**
// Bei einem neuen Fund nicht: der steht ohnehin schon in der
// Prüfliste, weil er auf Freigabe wartet.
if (pruefungNoetig && vorher.TmdbId is not null
&& vorher != (film.TmdbId, film.Title, film.PosterImage, film.Year))
{
film.ChangedSince ??= uhr.GetUtcNow();
}
if (datei is not null) await FilmBeiwerk(film, datei, ct); if (datei is not null) await FilmBeiwerk(film, datei, ct);
} }
if (zugeordnet > 0) await db.SaveChangesAsync(ct); if (zugeordnet > 0) await db.SaveChangesAsync(ct);
// **Erst speichern, dann zusammenlegen.** Das Verschmelzen löscht
// Zeilen am Änderungsverfolger vorbei; stünde es davor, schriebe das
// folgende Speichern sie wieder hin.
foreach (var (sieger, verlierer) in zusammenzulegen)
{
await zusammenlegung.FilmVerschmelzenAsync(sieger, verlierer, ct);
}
if (zusammenzulegen.Count > 0)
{
await db.SaveChangesAsync(ct);
log.LogInformation(
"{Anzahl} Film(e) waren derselbe Titel unter anderem Namen und wurden zusammengelegt.",
zusammenzulegen.Count);
}
return zugeordnet; return zugeordnet;
} }
@@ -142,16 +295,54 @@ public sealed class MetadataEnricher(
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo)); return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
} }
private async Task<int> SerienZuordnen(Guid libraryId, bool erneut, CancellationToken ct) /// <summary>Ob zwei Jahre einander widersprechen.</summary>
///
/// <remarks>
/// <para>
/// <b>Nur wenn beide bekannt sind.</b> Eine Lücke ist keine Aussage: wer
/// seine Dateien ohne Jahr benennt, bekäme sonst nie eine Dublette
/// erkannt — und „Despicable Me" und „Ich einfach unverbesserlich"
/// gehören wirklich zusammen.
/// </para>
/// <para>
/// <b>Dafür schon ab einem Jahr Abstand, und das ist gemessen.</b> Erst
/// stand hier eine Toleranz von einem Jahr — für den Fall, dass eine
/// Erstaufführung im Dezember hier im Januar läuft. Genau die Toleranz
/// verschluckte den Fall, um den es geht: „Heiligtümer des Todes Teil 1"
/// ist von 2010, „Teil 2" von 2011. Zwei Dateien, deren Namen
/// verschiedene Jahre nennen, sind verschiedene Filme — und wenn doch
/// nicht, steht der zweite in der Liste der nicht zugeordneten Titel und
/// lässt sich in zwei Klicks geradeziehen. Das ist allemal besser, als
/// ihn lautlos verschwinden zu lassen.
/// </para>
/// </remarks>
private static bool JahreWidersprechen(int? eines, int? anderes) =>
eines is { } a && anderes is { } b && a != b;
private async Task<int> SerienZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null, bool pruefungNoetig = false)
{ {
var offen = await db.Series var offen = await db.Series
.Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)) .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)
&& (nurSprache == null
|| (s.TmdbId != null && s.MetadataLanguage != nurSprache)))
.Where(s => nurDiese == null || nurDiese.Contains(s.Id))
.ToListAsync(ct); .ToListAsync(ct);
var zugeordnet = 0; var zugeordnet = 0;
var durch = 0;
var vergeben = await db.Series
.Where(x => x.LibraryId == libraryId && x.TmdbId != null)
.ToDictionaryAsync(x => x.TmdbId!.Value, x => x.Id, ct);
var zusammenzulegen = new List<(Guid Sieger, Guid Verlierer)>();
foreach (var serie in offen) foreach (var serie in offen)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
fortschritt?.Report(new Scanschritt(
Scanphase.Nachschlagen, serie.Title, ++durch, offen.Count));
// Bei einer Serie liegt die NFO neben der Folge, also im // Bei einer Serie liegt die NFO neben der Folge, also im
// Staffelordner — gesucht wird deshalb ab der ersten Folgendatei. // Staffelordner — gesucht wird deshalb ab der ersten Folgendatei.
@@ -168,15 +359,37 @@ public sealed class MetadataEnricher(
if (gefunden is null) continue; if (gefunden is null) continue;
// Wie beim Film: zwei Einträge, dieselbe Serie. Bei Serien ist das
// noch häufiger — derselbe Ordner unter zwei Schreibweisen, oder
// ein Staffelordner, der versehentlich als eigene Serie gelesen
// wurde.
// Siehe FilmeZuordnen: sich selbst ist keine Dublette.
if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != serie.Id)
{
zusammenzulegen.Add((Sieger: schonDa, Verlierer: serie.Id));
continue;
}
vergeben[gefunden.ProviderId] = serie.Id;
var vorher = (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year);
serie.TmdbId = gefunden.ProviderId; serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId; serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title; if (!serie.TitlePinned)
serie.SortTitleValue = SortTitle.For(gefunden.Title); {
serie.Title = gefunden.Title;
if (!serie.SortTitlePinned) serie.SortTitleValue = SortTitle.For(gefunden.Title);
}
serie.OriginalTitle = gefunden.OriginalTitle;
serie.MetadataLanguage = gefunden.Language;
serie.Year = gefunden.Year ?? serie.Year; serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview; serie.Overview = gefunden.Overview;
serie.PosterImage = await BildAblegen(gefunden.Poster, ct); // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct); // überschreibt jedes Auffrischen die Auswahl wieder — und wer
serie.LogoImage = await BildAblegen(gefunden.Logo, ct); // einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
if (!serie.PosterPinned) serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
if (!serie.BackdropPinned) serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
if (!serie.LogoPinned) serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
serie.RuntimeMinutes = gefunden.RuntimeMinutes; serie.RuntimeMinutes = gefunden.RuntimeMinutes;
serie.Rating = gefunden.Rating; serie.Rating = gefunden.Rating;
serie.Tagline = gefunden.Tagline; serie.Tagline = gefunden.Tagline;
@@ -184,6 +397,12 @@ public sealed class MetadataEnricher(
await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct); await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct);
await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct); await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct);
if (pruefungNoetig && vorher.TmdbId is not null
&& vorher != (serie.TmdbId, serie.Title, serie.PosterImage, serie.Year))
{
serie.ChangedSince ??= uhr.GetUtcNow();
}
serie.MatchLocked = festgelegt; serie.MatchLocked = festgelegt;
zugeordnet++; zugeordnet++;
@@ -191,6 +410,20 @@ public sealed class MetadataEnricher(
} }
if (zugeordnet > 0) await db.SaveChangesAsync(ct); if (zugeordnet > 0) await db.SaveChangesAsync(ct);
foreach (var (sieger, verlierer) in zusammenzulegen)
{
await zusammenlegung.SerieVerschmelzenAsync(sieger, verlierer, ct);
}
if (zusammenzulegen.Count > 0)
{
await db.SaveChangesAsync(ct);
log.LogInformation(
"{Anzahl} Serie(n) waren dieselbe unter anderem Namen und wurden zusammengelegt.",
zusammenzulegen.Count);
}
return zugeordnet; return zugeordnet;
} }
@@ -230,24 +463,46 @@ public sealed class MetadataEnricher(
} }
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary> /// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
private async Task<int> FolgenZuordnen(Guid libraryId, bool erneut, CancellationToken ct) private async Task<int> FolgenZuordnen(
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
string? nurSprache = null, IReadOnlySet<Guid>? nurDiese = null)
{ {
// **Mit Titel, nicht nur mit Kennung.** In der Fortschrittszeile stand
// „Hole Metadaten zu Folge 2×13" — und bei 3815 Folgen aus achtzig
// Serien sagt eine Folgennummer ohne Serie überhaupt nichts.
//
// **Eine Auswahl greift über die Serie, nicht über die Folge.** In der
// Mediathek wählt man Serienkacheln aus; dass damit deren Folgen
// gemeint sind, ist der ganze Sinn der Sache.
var serien = await db.Series var serien = await db.Series
.Where(s => s.LibraryId == libraryId && s.TmdbId != null) .Where(s => s.LibraryId == libraryId && s.TmdbId != null)
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value }) .Where(s => nurDiese == null || nurDiese.Contains(s.Id))
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value, s.Title })
.ToListAsync(ct); .ToListAsync(ct);
if (serien.Count == 0) return 0; if (serien.Count == 0) return 0;
var kennungen = serien.Select(s => s.Id).ToList(); var kennungen = serien.Select(s => s.Id).ToList();
var offen = await db.Episodes var offen = await db.Episodes
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)) .Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)
&& (nurSprache == null
|| (f.Overview != null && f.MetadataLanguage != nurSprache)))
.ToListAsync(ct); .ToListAsync(ct);
// Einmal nachschlagbar machen: `First` je Folge wäre bei 3815 Folgen
// und achtzig Serien eine Viertelmillion Vergleiche.
var nachKennung = serien.ToDictionary(s => s.Id);
var zugeordnet = 0; var zugeordnet = 0;
var durch = 0;
foreach (var folge in offen) foreach (var folge in offen)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
var serie = serien.First(s => s.Id == folge.SeriesId); if (!nachKennung.TryGetValue(folge.SeriesId, out var serie)) continue;
fortschritt?.Report(new Scanschritt(
Scanphase.Holen,
$"{serie.Title} {folge.SeasonNumber}x{folge.EpisodeNumber:00}",
++durch, offen.Count));
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct); var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
if (gefunden is null) continue; if (gefunden is null) continue;
@@ -255,6 +510,7 @@ public sealed class MetadataEnricher(
// Ein Titel aus dem Dateinamen wird ersetzt: TMDB kennt ihn besser. // Ein Titel aus dem Dateinamen wird ersetzt: TMDB kennt ihn besser.
// Ein leerer Fund überschreibt dagegen nichts. // Ein leerer Fund überschreibt dagegen nichts.
folge.Title = gefunden.Title ?? folge.Title; folge.Title = gefunden.Title ?? folge.Title;
folge.MetadataLanguage = gefunden.Language;
folge.Overview = gefunden.Overview; folge.Overview = gefunden.Overview;
folge.StillImage = await BildAblegen(gefunden.Still, ct); folge.StillImage = await BildAblegen(gefunden.Still, ct);
zugeordnet++; zugeordnet++;
@@ -0,0 +1,786 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004093243_Dubletten")]
partial class Dubletten
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,190 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Dubletten : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "VersionLabel",
table: "MediaFiles",
type: "TEXT",
maxLength: 64,
nullable: true);
// **Erst aufräumen, dann die Regel aufstellen.** Andersherum
// scheitert diese Migration auf jeder Datenbank, in der schon
// Dubletten stehen — und zwar beim Start im Container, wo die
// Meldung niemand sieht und der Server einfach nicht hochkommt.
//
// In SQL und nicht über die Anwendung, weil Migrationen vor allem
// anderen laufen: zu diesem Zeitpunkt gibt es noch keine Dienste.
//
// Der älteste Eintrag gewinnt. Die Kennungen sind Guid v7 und
// damit zeitlich geordnet, `MIN` liefert also den zuerst
// angelegten — den, auf den vorhandene Verläufe schon zeigen.
migrationBuilder.Sql("""
CREATE TEMP TABLE FilmDubletten AS
SELECT m.Id AS Verlierer,
(SELECT MIN(s.Id) FROM Movies s
WHERE s.LibraryId = m.LibraryId AND s.TmdbId = m.TmdbId) AS Sieger
FROM Movies m
WHERE m.TmdbId IS NOT NULL
AND m.Id <> (SELECT MIN(s.Id) FROM Movies s
WHERE s.LibraryId = m.LibraryId AND s.TmdbId = m.TmdbId);
""");
// Die Dateien zuerst — sie sind das Einzige, was sich nicht
// wiederherstellen lässt.
migrationBuilder.Sql("""
UPDATE MediaFiles
SET MovieId = (SELECT Sieger FROM FilmDubletten WHERE Verlierer = MediaFiles.MovieId)
WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);
""");
// **Sehfortschritt: erst die konfliktfreien umhängen.** Hat
// derselbe Benutzer beide Einträge angefangen, ginge das nicht —
// die Eindeutigkeitsregel auf UserItemStates verböte die zweite
// Zeile.
migrationBuilder.Sql("""
UPDATE UserItemStates
SET MovieId = (SELECT Sieger FROM FilmDubletten WHERE Verlierer = UserItemStates.MovieId)
WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten)
AND NOT EXISTS (
SELECT 1 FROM UserItemStates z
WHERE z.UserId = UserItemStates.UserId
AND z.MovieId = (SELECT Sieger FROM FilmDubletten
WHERE Verlierer = UserItemStates.MovieId));
""");
// Für die übrigen gewinnt der weitere Stand — niemand will
// Sehfortschritt verlieren, nur weil zwei Einträge verschmelzen.
migrationBuilder.Sql("""
UPDATE UserItemStates
SET Position = MAX(Position, COALESCE((
SELECT a.Position FROM UserItemStates a
JOIN FilmDubletten d ON a.MovieId = d.Verlierer
WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId
LIMIT 1), 0)),
Watched = MAX(Watched, COALESCE((
SELECT a.Watched FROM UserItemStates a
JOIN FilmDubletten d ON a.MovieId = d.Verlierer
WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId
LIMIT 1), 0)),
PlayCount = MAX(PlayCount, COALESCE((
SELECT a.PlayCount FROM UserItemStates a
JOIN FilmDubletten d ON a.MovieId = d.Verlierer
WHERE a.UserId = UserItemStates.UserId AND d.Sieger = UserItemStates.MovieId
LIMIT 1), 0))
WHERE MovieId IN (SELECT Sieger FROM FilmDubletten);
""");
migrationBuilder.Sql(
"DELETE FROM UserItemStates WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);");
// Genres und Mitwirkende sind abgeleitet — sie entstehen beim
// nächsten Auffrischen neu. Umhängen wäre mehr Aufwand für
// dasselbe Ergebnis.
migrationBuilder.Sql(
"DELETE FROM GenreLinks WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);");
migrationBuilder.Sql(
"DELETE FROM Credits WHERE MovieId IN (SELECT Verlierer FROM FilmDubletten);");
migrationBuilder.Sql(
"DELETE FROM Movies WHERE Id IN (SELECT Verlierer FROM FilmDubletten);");
migrationBuilder.Sql("DROP TABLE FilmDubletten;");
// --- Dasselbe für Serien ---------------------------------------
migrationBuilder.Sql("""
CREATE TEMP TABLE SerienDubletten AS
SELECT x.Id AS Verlierer,
(SELECT MIN(s.Id) FROM Series s
WHERE s.LibraryId = x.LibraryId AND s.TmdbId = x.TmdbId) AS Sieger
FROM Series x
WHERE x.TmdbId IS NOT NULL
AND x.Id <> (SELECT MIN(s.Id) FROM Series s
WHERE s.LibraryId = x.LibraryId AND s.TmdbId = x.TmdbId);
""");
// Folgen, deren Platz beim Sieger frei ist, wandern mit.
migrationBuilder.Sql("""
UPDATE Episodes
SET SeriesId = (SELECT Sieger FROM SerienDubletten WHERE Verlierer = Episodes.SeriesId)
WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten)
AND NOT EXISTS (
SELECT 1 FROM Episodes e
WHERE e.SeriesId = (SELECT Sieger FROM SerienDubletten
WHERE Verlierer = Episodes.SeriesId)
AND e.SeasonNumber = Episodes.SeasonNumber
AND e.EpisodeNumber = Episodes.EpisodeNumber);
""");
// Die übrigen sind doppelt belegt: die Datei wandert zur Folge des
// Siegers, die doppelte Folge fällt weg. Sonst stünde S01E01
// zweimal in der Staffelliste.
migrationBuilder.Sql("""
UPDATE MediaFiles
SET EpisodeId = (
SELECT e.Id FROM Episodes e
JOIN Episodes alt ON alt.Id = MediaFiles.EpisodeId
JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId
WHERE e.SeriesId = d.Sieger
AND e.SeasonNumber = alt.SeasonNumber
AND e.EpisodeNumber = alt.EpisodeNumber)
WHERE EpisodeId IN (
SELECT alt.Id FROM Episodes alt
JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId);
""");
migrationBuilder.Sql("""
DELETE FROM UserItemStates WHERE EpisodeId IN (
SELECT alt.Id FROM Episodes alt
JOIN SerienDubletten d ON d.Verlierer = alt.SeriesId);
""");
migrationBuilder.Sql("""
DELETE FROM Episodes WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);
""");
migrationBuilder.Sql(
"DELETE FROM GenreLinks WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);");
migrationBuilder.Sql(
"DELETE FROM Credits WHERE SeriesId IN (SELECT Verlierer FROM SerienDubletten);");
migrationBuilder.Sql(
"DELETE FROM Series WHERE Id IN (SELECT Verlierer FROM SerienDubletten);");
migrationBuilder.Sql("DROP TABLE SerienDubletten;");
migrationBuilder.CreateIndex(
name: "IX_Series_LibraryId_TmdbId",
table: "Series",
columns: new[] { "LibraryId", "TmdbId" },
unique: true,
filter: "\"TmdbId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Movies_LibraryId_TmdbId",
table: "Movies",
columns: new[] { "LibraryId", "TmdbId" },
unique: true,
filter: "\"TmdbId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Series_LibraryId_TmdbId",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_Movies_LibraryId_TmdbId",
table: "Movies");
migrationBuilder.DropColumn(
name: "VersionLabel",
table: "MediaFiles");
}
}
}
@@ -0,0 +1,789 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004094257_VerschwundeneDateien")]
partial class VerschwundeneDateien
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class VerschwundeneDateien : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "MissingSince",
table: "MediaFiles",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MissingSince",
table: "MediaFiles");
}
}
}
@@ -0,0 +1,852 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004102022_SpracheUndTitel")]
partial class SpracheUndTitel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class SpracheUndTitel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
table: "Users",
type: "TEXT",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "OriginalTitle",
table: "Series",
type: "TEXT",
maxLength: 512,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "OriginalTitle",
table: "Movies",
type: "TEXT",
maxLength: 512,
nullable: true);
migrationBuilder.CreateTable(
name: "Translations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
Language = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
Overview = table.Column<string>(type: "TEXT", nullable: true),
Tagline = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
FetchedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Translations", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Translations_EpisodeId_Language",
table: "Translations",
columns: new[] { "EpisodeId", "Language" },
unique: true,
filter: "\"EpisodeId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Translations_MovieId_Language",
table: "Translations",
columns: new[] { "MovieId", "Language" },
unique: true,
filter: "\"MovieId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Translations_SeriesId_Language",
table: "Translations",
columns: new[] { "SeriesId", "Language" },
unique: true,
filter: "\"SeriesId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Translations");
migrationBuilder.DropColumn(
name: "Language",
table: "Users");
migrationBuilder.DropColumn(
name: "OriginalTitle",
table: "Series");
migrationBuilder.DropColumn(
name: "OriginalTitle",
table: "Movies");
}
}
}
@@ -0,0 +1,870 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004103857_Bildauswahl")]
partial class Bildauswahl
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Bildauswahl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BackdropPinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LogoPinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "PosterPinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "BackdropPinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "LogoPinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "PosterPinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BackdropPinned",
table: "Series");
migrationBuilder.DropColumn(
name: "LogoPinned",
table: "Series");
migrationBuilder.DropColumn(
name: "PosterPinned",
table: "Series");
migrationBuilder.DropColumn(
name: "BackdropPinned",
table: "Movies");
migrationBuilder.DropColumn(
name: "LogoPinned",
table: "Movies");
migrationBuilder.DropColumn(
name: "PosterPinned",
table: "Movies");
}
}
}
@@ -0,0 +1,882 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004105814_Freigabe")]
partial class Freigabe
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Freigabe : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "PublishAt",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "PublishAt",
table: "Movies",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ReviewNewItems",
table: "Libraries",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<long>(
name: "PublishAt",
table: "Episodes",
type: "INTEGER",
nullable: true);
// **Alles Vorhandene gilt als veröffentlicht.**
//
// Ohne diese zwei Zeilen stünde nach der Migration jeder Titel auf
// `null` — und `null` heisst „wartet auf Prüfung". Der gesamte
// Bestand wäre für alle ausser Administratoren verschwunden, und
// zwar sofort beim Start im Container, ohne Meldung.
//
// Das Hinzufügedatum und nicht `now`: so bleibt „zuletzt
// hinzugefügt" eine sinnvolle Reihenfolge, statt dass vierhundert
// Filme auf dieselbe Sekunde fallen.
migrationBuilder.Sql("UPDATE Movies SET PublishAt = AddedAt WHERE PublishAt IS NULL;");
migrationBuilder.Sql("""
UPDATE Episodes SET PublishAt = (
SELECT COALESCE(MIN(f.SeenAt), '2000-01-01 00:00:00')
FROM MediaFiles f WHERE f.EpisodeId = Episodes.Id)
WHERE PublishAt IS NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PublishAt",
table: "Series");
migrationBuilder.DropColumn(
name: "PublishAt",
table: "Movies");
migrationBuilder.DropColumn(
name: "ReviewNewItems",
table: "Libraries");
migrationBuilder.DropColumn(
name: "PublishAt",
table: "Episodes");
}
}
}
@@ -0,0 +1,885 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004121630_Reihenfolge")]
partial class Reihenfolge
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Reihenfolge : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SortOrder",
table: "Libraries",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SortOrder",
table: "Libraries");
}
}
}
@@ -0,0 +1,907 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261004183828_Metadatensprache")]
partial class Metadatensprache
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Metadatensprache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MetadataLanguage",
table: "Series",
type: "TEXT",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TitlePinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "MetadataLanguage",
table: "Movies",
type: "TEXT",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TitlePinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "MetadataLanguage",
table: "Episodes",
type: "TEXT",
maxLength: 16,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Series_LibraryId_MetadataLanguage",
table: "Series",
columns: new[] { "LibraryId", "MetadataLanguage" });
migrationBuilder.CreateIndex(
name: "IX_Movies_LibraryId_MetadataLanguage",
table: "Movies",
columns: new[] { "LibraryId", "MetadataLanguage" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Series_LibraryId_MetadataLanguage",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_Movies_LibraryId_MetadataLanguage",
table: "Movies");
migrationBuilder.DropColumn(
name: "MetadataLanguage",
table: "Series");
migrationBuilder.DropColumn(
name: "TitlePinned",
table: "Series");
migrationBuilder.DropColumn(
name: "MetadataLanguage",
table: "Movies");
migrationBuilder.DropColumn(
name: "TitlePinned",
table: "Movies");
migrationBuilder.DropColumn(
name: "MetadataLanguage",
table: "Episodes");
}
}
}
@@ -0,0 +1,921 @@
// <auto-generated />
using System;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Flaro.Data.Migrations
{
[DbContext(typeof(FlaroContext))]
[Migration("20261005160301_Serienzustand")]
partial class Serienzustand
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("HandleFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("HandleFingerprint")
.IsUnique();
b.ToTable("AuthCodes");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenFingerprint")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("DeviceTokens");
});
modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("PersonId");
b.HasIndex("SeriesId");
b.ToTable("Credits");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
.IsUnique();
b.ToTable("Episodes");
});
modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Genres");
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("GenreId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("GenreId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("GenreLinks");
});
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId", "Language")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("MovieId", "Language")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("SeriesId", "Language")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.ToTable("Translations");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("VersionLabel")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("LibraryId", "Path")
.IsUnique();
b.ToTable("MediaFiles");
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("Watched")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeId");
b.HasIndex("MovieId");
b.HasIndex("SeriesId");
b.HasIndex("UserId", "EpisodeId")
.IsUnique()
.HasFilter("\"EpisodeId\" IS NOT NULL");
b.HasIndex("UserId", "MovieId")
.IsUnique()
.HasFilter("\"MovieId\" IS NOT NULL");
b.HasIndex("UserId", "SeriesId")
.IsUnique()
.HasFilter("\"SeriesId\" IS NOT NULL");
b.HasIndex("UserId", "InProgress", "UpdatedAt");
b.ToTable("UserItemStates");
});
modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT")
.UseCollation("NOCASE");
b.HasKey("Id");
b.HasIndex("UserName")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
{
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Person", null)
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
{
b.HasOne("Flaro.Core.Library.Genre", null)
.WithMany()
.HasForeignKey("GenreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
{
b.HasOne("Flaro.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
.WithMany()
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
.WithMany()
.HasForeignKey("EpisodeId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Movie", null)
.WithMany()
.HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("Flaro.Core.Users.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

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