Files
Flaro/server/src/Flaro.Api/Endpoints/Contracts.cs
T
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

699 lines
29 KiB
C#

namespace Flaro.Api.Endpoints;
// Was über die Leitung geht. Bewusst eigene Typen und nicht die der Domäne:
// sonst wandert jede Umbenennung im Datenmodell ungefragt in die Schnittstelle
// und bricht Clients, die niemand mehr aktualisiert.
public sealed record SystemInfo(
string Version, string Build, string ServerName, bool NeedsSetup,
IReadOnlyList<AttributionResponse> Attributions);
/// <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 SetupRequest(string UserName, string Password);
/// <param name="StaySignedIn">
/// <b>Ob die Anmeldung einen geschlossenen Browser überlebt.</b> Vorgabe ist
/// ja — am eigenen Rechner will niemand sich täglich neu anmelden. Wer an
/// einem fremden sitzt, schaltet es ab, und das Cookie verfällt mit dem
/// Fenster.
///
/// Das Token selbst läuft in keinem Fall ab; es wird widerrufen, nicht
/// vergessen. Was hier entschieden wird, ist allein die Lebensdauer des
/// Cookies im Browser.
/// </param>
public sealed record LoginRequest(
string UserName, string Password, string DeviceName, string DeviceId,
bool StaySignedIn = true);
public sealed record AuthenticatedUser(
Guid Id, string UserName, bool IsAdministrator,
/// <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);
public sealed record LoginResponse(string Token, AuthenticatedUser User);
public sealed record CodeRequest(string DeviceName, string DeviceId);
/// <summary>Was der Fernseher nach dem Anfordern bekommt.</summary>
///
/// <remarks>
/// Der <c>Handle</c> ist das Geheimnis und wird <b>nicht</b> angezeigt — der
/// <c>Code</c> ist das, was auf dem Bildschirm steht.
/// </remarks>
public sealed record CodeResponse(string Code, string Handle, DateTimeOffset ExpiresAt);
public sealed record CodeState(string State, LoginResponse? Login);
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
/// <summary>Eine Bibliothek, wie die Oberfläche sie zeigt.</summary>
///
/// <remarks>
/// <c>ItemCount</c> ist <c>null</c>, wenn er nicht mitgezählt wurde — etwa
/// direkt nach dem Anlegen oder Umbenennen. Nicht <c>0</c>: „keine Titel" und
/// „nicht gezählt" sind verschiedene Auskünfte, und eine frisch angelegte
/// Bibliothek als leer auszugeben wäre die falsche.
/// </remarks>
public sealed record LibraryResponse(
Guid Id, string Name, string Kind, string Path, int? ItemCount,
/// <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 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 -------------------------------------------------
public sealed record ScanProblem(string Path, string Reason);
public sealed record ScanStatus(
string State,
DateTimeOffset? StartedAt,
DateTimeOffset? FinishedAt,
int? FilesFound,
int? Added,
int? Updated,
int? Removed,
int? Unrecognised,
int? Probed,
IReadOnlyList<ScanProblem>? Problems,
string? Error,
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>
public sealed record MetadataProgress(
int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error);
/// <summary>Eine Spur in einer Datei.</summary>
public sealed record MediaStreamResponse(
int Index, string Kind, string? Codec, string? Language, string? Title,
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 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>
///
/// <remarks>
/// <b>Nur der Dateiname, nicht der Pfad.</b> Der Pfad verriete jedem
/// Angemeldeten den Aufbau des Servers, und gebraucht wird er nirgends: die
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
/// Fassungen genügt der Name.
/// </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(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
IReadOnlyList<MediaStreamResponse> Streams, string? VersionLabel = null,
string? Folder = null);
public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage,
IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State,
/// <summary>Der Schriftzug des Titels — fehlt oft, und das ist in Ordnung.</summary>
string? LogoImage = null,
int? RuntimeMinutes = null,
double? Rating = null,
string? Tagline = null,
string? ContentRating = null,
/// <summary>Leer in Listen, gefüllt beim Einzelabruf.</summary>
///
/// <remarks>
/// <b>Eine Seite mit fünfzig Titeln braucht keine Besetzungslisten.</b>
/// Sie mitzuschicken hiesse, tausend Namen über die Leitung zu schicken,
/// von denen keiner angezeigt wird.
/// </remarks>
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null,
/// <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(
Guid Id, string Name, string Role, string? Character, string? Image);
public sealed record SeriesResponse(
Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage,
int EpisodeCount, IReadOnlyList<int> Seasons,
/// <summary>Wie viele Folgen dieser Benutzer noch nicht gesehen hat.</summary>
///
/// <remarks>
/// Statt eines Zustands je Serie: eine Serie wird nicht angeschaut,
/// ihre Folgen werden es. Was man an der Kachel sehen will, ist die Zahl
/// — bei Plex steht sie als Plakette in der Ecke.
/// </remarks>
int UnwatchedCount,
string? LogoImage = null,
int? RuntimeMinutes = null,
double? Rating = null,
string? Tagline = null,
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null,
/// <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(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files,
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 MetadataSettingsResponse(
bool HasApiKey, string Language, bool UsingProjectKey);
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
///
/// <remarks>
/// <b>Bedingung, nicht Höflichkeit.</b> TMDB verlangt in seinen
/// Nutzungsbedingungen, dass das Logo gezeigt wird und dieser Satz dasteht.
/// Der Server liefert beides, damit kein Client es abtippen muss — und keiner
/// es vergisst.
/// </remarks>
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
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 -------------------------------------------------
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
///
/// <remarks>
/// <b>Damit vorher feststeht, was man bekommt.</b> Drei Gigabyte
/// herunterzuladen und erst danach zu merken, dass die Untertitel fehlen oder
/// die falsche Tonspur drin ist, war der Anlass für dieses ganze Projekt.
/// </remarks>
public sealed record DeliveryInfo(
string Method,
IReadOnlyList<string> Reasons,
IReadOnlyList<int> AudioToEncode,
IReadOnlyList<int> SubtitlesDropped,
string PackageState,
double Progress,
string? Error);
// --- Zuordnung korrigieren -----------------------------------------------
/// <summary>Ein Vorschlag, aus dem gewählt werden kann.</summary>
public sealed record MatchCandidate(
int TmdbId, string Title, int? Year, string? Overview, string? PosterUrl);
public sealed record ApplyMatchRequest(int TmdbId);
/// <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(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year,
Guid Id = default, bool Merged = false);
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
/// <summary>Eine Fortschrittsmeldung.</summary>
///
/// <remarks>
/// <b>In Sekunden, nicht in Ticks.</b> Jellyfin rechnet in Hundertnanosekunden,
/// und jeder Client muss das wissen. Sekunden als Gleitkommazahl sind in jeder
/// Sprache dasselbe und in der Entwicklerkonsole lesbar.
///
/// <c>RuntimeSeconds</c> ist freiwillig: kennt der Client die Laufzeit der
/// Fassung, die er gerade abspielt, ist seine Angabe genauer als die des
/// Servers. Fehlt sie, nimmt der Server die aus der Datei.
/// </remarks>
public sealed record ProgressRequest(
double PositionSeconds, double? RuntimeSeconds,
/// <summary>Die letzte Meldung einer Wiedergabe.</summary>
///
/// <remarks>
/// Nimmt den Eintrag sofort aus „läuft gerade". Ohne sie stünde ein
/// beendeter Film dort noch eine Minute, bis das Lebenszeichen ausbleibt
/// — und wer gerade den Spieler geschlossen hat, sieht sich selbst noch
/// beim Schauen zu.
/// </remarks>
bool? Stopped = null);
public sealed record WatchedRequest(bool Watched);
public sealed record FavouriteRequest(bool Favourite);
/// <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>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);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
public sealed record ItemStateResponse(
double PositionSeconds, double? RuntimeSeconds,
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
DateTimeOffset? LastPlayedAt);
/// <summary>Eine Zeile in „Weiterschauen".</summary>
///
/// <remarks>
/// Film und Folge in einer Form, weil die Reihe beide mischt. Bei einer Folge
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
/// </remarks>
/// <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(
Guid Id, string Kind, string Title, string? SubTitle,
int? Season, int? Episode, int? Year,
string? PosterImage, string? BackdropImage,
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>Eine laufende Wiedergabe — für das Dashboard.</summary>
public sealed record SessionResponse(
string UserName, string DeviceName,
Guid ItemId, string Title, string? SubTitle, string? PosterImage,
double PositionSeconds, double? RuntimeSeconds);
/// <summary>Was der Server gerade tut.</summary>
public sealed record ServerStatusResponse(
int Playing, int Packaging, int Scanning, double Uptime);