From 22be288fc31611addfc1095ff06f5fc0ac54dbc1 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 10 Oct 2026 21:38:19 +0200 Subject: [PATCH] =?UTF-8?q?Welche=20Sprachen=20eine=20Serie=20durchgehend?= =?UTF-8?q?=20f=C3=BChrt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Menü auf der Serienseite bot zwanzig Sprachen an, von denen die Serie zwei hatte. Um das zu ändern, muss jemand wissen, welche es wirklich gibt — und zwar in einer Abfrage: zwanzig Folgen einzeln zu holen wären zwanzig Rundreisen, und am Ende müsste der Client dieselbe Rechnung anstellen. `GET /api/v1/series/{id}/languages` nennt Ton- und Textsprachen als **Schnittmenge** über alle Folgen. Eine Sprache anzubieten, die es nur in Staffel 1 gibt, hiesse: eine Einstellung für die Serie, die ab Staffel 2 stillschweigend etwas anderes tut. Was dabei wegfällt, wird gezählt — damit die Oberfläche es sagen kann, statt es zu verschweigen. Zwei Dinge, die sonst falsche Ergebnisse gäben: Gruppiert wird nach dem **Namen**, nicht nach dem Kürzel. Eine gewachsene Sammlung trägt „ger" und „deu" nebeneinander; nach dem Kürzel gezählt fiele Deutsch aus der Schnittmenge, obwohl es überall liegt. Gezählt werden nur Folgen, die eine **Datei** haben. Eine angekündigte Folge kann keine Sprache beisteuern; sie als fehlend zu zählen liesse das Menü bei jeder laufenden Serie leer. Die Rechnung steht in `Flaro.Core`, nicht im Endpunkt: sie hängt an keiner Datenbank und lässt sich so prüfen, ohne eine Serie anzulegen. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 25 ++++ .../Endpoints/LibraryContentEndpoints.cs | 58 ++++++++ server/src/Flaro.Core/Media/Sprachschnitt.cs | 73 ++++++++++ .../Flaro.Core.Tests/SprachschnittTests.cs | 125 ++++++++++++++++++ 4 files changed, 281 insertions(+) create mode 100644 server/src/Flaro.Core/Media/Sprachschnitt.cs create mode 100644 server/tests/Flaro.Core.Tests/SprachschnittTests.cs diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 3c5e373..10d5d37 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -983,3 +983,28 @@ public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTi /// Namen und Passwort, die sich der Eingeladene selbst gibt. public sealed record EinloesenRequest(string UserName, string Password); + +/// Eine Sprache, wie sie in einem Menü steht. +/// +/// Das Kürzel, unter dem die Einstellung abgelegt wird. +/// Wie sie dasteht — „Deutsch", „English". +public sealed record Sprachangabe(string Code, string Name); + +/// Welche Sprachen eine Serie durchgehend führt. +/// +/// Tonsprachen, die in jeder Folge vorkommen. +/// Textsprachen, die in jeder Folge vorkommen. +/// +/// Wie viele Sprachen es nur in einem Teil der Folgen gibt. +/// +/// +/// Damit die Oberfläche es sagen kann, statt es zu verschweigen. Ohne +/// diese Zahl sähe eine gekürzte Liste aus wie die ganze Wahrheit — und wer +/// eine Sprache vermisst, die er in Staffel 1 gesehen hat, hielte es für +/// einen Fehler. +/// +/// +public sealed record Sprachauswahl( + IReadOnlyList Audio, + IReadOnlyList Subtitles, + int Partial); diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index c359bbb..359cd79 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -784,6 +784,64 @@ public static class LibraryContentEndpoints SeriesId: diese.SeriesId)); }).RequireAuthorization(); + // **Welche Sprachen eine Serie durchgehend hat.** + // + // Für die Vorwahl auf der Serienseite. Die Folgen einzeln abzufragen + // wären bei zwanzig Folgen zwanzig Rundreisen, und am Ende müsste + // der Client dieselbe Rechnung anstellen. + // + // **Die Schnittmenge, nicht die Vereinigung.** Eine Sprache + // anzubieten, die es nur in Staffel 1 gibt, hiesse: eine Einstellung + // für die Serie, die ab Staffel 2 stillschweigend etwas anderes tut. + // Was dabei wegfällt, wird gezählt, damit die Oberfläche es sagen + // kann statt es zu verschweigen. + app.MapGet("/api/v1/series/{id:guid}/languages", async (Guid id, FlaroContext db) => + { + if (!await db.Series.AnyAsync(x => x.Id == id)) return Results.NotFound(); + + var folgen = await db.Episodes.AsNoTracking() + .Where(f => f.SeriesId == id).Select(f => f.Id).ToListAsync(); + + var dateien = await db.MediaFiles.AsNoTracking() + .Where(f => f.EpisodeId != null && folgen.Contains(f.EpisodeId!.Value)) + .Select(f => new { f.Id, Folge = f.EpisodeId!.Value }) + .ToListAsync(); + + // **Nur Folgen, die überhaupt eine Datei haben.** Eine Folge, die + // noch nicht da ist, kann keine Sprache beisteuern — sie als + // fehlend zu zählen liesse die Schnittmenge leer werden. + var mitDatei = dateien.Select(d => d.Folge).Distinct().Count(); + if (mitDatei == 0) return Results.Ok(new Sprachauswahl([], [], 0)); + + var zuFolge = dateien.ToDictionary(d => d.Id, d => d.Folge); + var kennungen = dateien.Select(d => d.Id).ToList(); + + var spuren = await db.MediaStreams.AsNoTracking() + .Where(x => kennungen.Contains(x.MediaFileId)) + .Where(x => x.Kind == MediaStreamKind.Audio || x.Kind == MediaStreamKind.Subtitle) + .Select(x => new { x.MediaFileId, x.Kind, x.Language, x.Codec }) + .ToListAsync(); + + var (ton, tonFehlt) = Sprachschnitt.Von( + spuren.Where(x => x.Kind == MediaStreamKind.Audio) + .Select(x => (zuFolge[x.MediaFileId], x.Language)), + mitDatei); + + var (text, textFehlt) = Sprachschnitt.Von( + spuren.Where(x => x.Kind == MediaStreamKind.Subtitle) + // Bildbasierte Untertitel lassen sich nicht nach WebVTT + // wandeln; sie anzubieten wäre eine Zusage, die niemand + // einlösen kann. + .Where(x => x.Codec is null || !Untertitelvorrat.IstBild(x.Codec)) + .Select(x => (zuFolge[x.MediaFileId], x.Language)), + mitDatei); + + return Results.Ok(new Sprachauswahl( + [.. ton.Select(x => new Sprachangabe(x.Code, x.Name))], + [.. text.Select(x => new Sprachangabe(x.Code, x.Name))], + tonFehlt + textFehlt)); + }).RequireAuthorization(); + app.MapGet("/api/v1/series/{id:guid}/episodes", async ( Guid id, ClaimsPrincipal wer, FlaroContext db, int? season) => { diff --git a/server/src/Flaro.Core/Media/Sprachschnitt.cs b/server/src/Flaro.Core/Media/Sprachschnitt.cs new file mode 100644 index 0000000..1a40c51 --- /dev/null +++ b/server/src/Flaro.Core/Media/Sprachschnitt.cs @@ -0,0 +1,73 @@ +namespace Flaro.Core.Media; + +/// Eine Sprache, wie sie in einem Menü steht. +public sealed record Sprachfund(string Code, string Name); + +/// Welche Sprachen in jedem Teil einer Serie vorkommen. +/// +/// +/// +/// Die Schnittmenge, nicht die Vereinigung. Eine Sprache anzubieten, +/// die es nur in Staffel 1 gibt, hiesse: eine Einstellung für die Serie, die +/// ab Staffel 2 stillschweigend etwas anderes tut. Was dabei wegfällt, wird +/// gezählt — damit die Oberfläche es sagen kann, statt es zu verschweigen. +/// +/// +/// Eine reine Rechnung, deshalb hier und nicht im Endpunkt. Sie hängt +/// an keiner Datenbank: hinein gehen Paare aus Folge und Sprachkürzel, +/// heraus kommt eine Liste. So lässt sie sich prüfen, ohne eine Serie +/// anzulegen. +/// +/// +public static class Sprachschnitt +{ + /// Die durchgehenden Sprachen — und wie viele es nicht sind. + /// + /// Je Spur: zu welcher Folge sie gehört und ihr Kürzel. + /// + /// Wie viele Folgen überhaupt eine Datei haben. + /// + /// + /// Nicht wie viele Folgen die Serie hat. Eine Folge, die noch + /// nicht da ist, kann keine Sprache beisteuern; sie als fehlend zu + /// zählen liesse die Schnittmenge leer werden, und das Menü wäre bei + /// jeder laufenden Serie leer. + /// + /// + public static (IReadOnlyList Durchgehende, int Fehlende) Von( + IEnumerable<(Guid Folge, string? Kuerzel)> spuren, int folgen) + { + if (folgen <= 0) return ([], 0); + + // **Gruppiert wird nach dem Namen, nicht nach dem Kürzel.** Eine + // Sammlung trägt „ger" und „deu" nebeneinander, je nachdem, womit + // eine Datei erstellt wurde. Nach dem Kürzel gezählt fiele Deutsch + // aus der Schnittmenge, obwohl es in jeder Folge liegt. + var nachSprache = new Dictionary Folgen)>( + StringComparer.OrdinalIgnoreCase); + + foreach (var (folge, roh) in spuren) + { + if (Benennung.Sprache(roh) is not { Length: > 0 } name) continue; + + if (!nachSprache.TryGetValue(name, out var eintrag)) + { + nachSprache[name] = eintrag = (Blank(roh!), []); + } + + eintrag.Folgen.Add(folge); + } + + var durchgehend = nachSprache + .Where(x => x.Value.Folgen.Count >= folgen) + .OrderBy(x => x.Key, StringComparer.CurrentCulture) + .Select(x => new Sprachfund(x.Value.Code, x.Key)) + .ToList(); + + return (durchgehend, nachSprache.Count - durchgehend.Count); + } + + /// „de-DE" wird zu „de" — gespeichert wird nur die Sprache. + private static string Blank(string kuerzel) => + kuerzel.Trim().Split('-', '_')[0].ToLowerInvariant(); +} diff --git a/server/tests/Flaro.Core.Tests/SprachschnittTests.cs b/server/tests/Flaro.Core.Tests/SprachschnittTests.cs new file mode 100644 index 0000000..27647c8 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/SprachschnittTests.cs @@ -0,0 +1,125 @@ +using Flaro.Core.Media; + +namespace Flaro.Core.Tests; + +/// Welche Sprachen eine Serie durchgehend führt. +/// +/// +/// Der Anlass. Auf der Serienseite stand ein Menü mit zwanzig +/// Sprachen, von denen die Serie zwei hatte. Die Frage dahinter — welche +/// gilt für die ganze Serie — beantwortet die Schnittmenge: eine Sprache, +/// die es nur in Staffel 1 gibt, taugt nicht als Einstellung für die Serie. +/// +public class SprachschnittTests +{ + private static readonly Guid Eins = Guid.NewGuid(); + private static readonly Guid Zwei = Guid.NewGuid(); + private static readonly Guid Drei = Guid.NewGuid(); + + [Test] + public void Was_in_jeder_Folge_liegt_kommt_durch() + { + var (durch, fehlt) = Sprachschnitt.Von( + [(Eins, "deu"), (Eins, "eng"), (Zwei, "deu"), (Zwei, "eng")], folgen: 2); + + Assert.Multiple(() => + { + Assert.That(durch.Select(x => x.Code), Is.EquivalentTo(new[] { "deu", "eng" })); + Assert.That(fehlt, Is.Zero); + }); + } + + /// Eine Sprache aus nur einer Staffel fällt weg — und wird gezählt. + /// + /// + /// Gezählt, nicht verschwiegen. Wer Japanisch in Staffel 1 gesehen + /// hat und es im Menü vermisst, hielte das sonst für einen Fehler. + /// + [Test] + public void Was_nur_in_einer_Folge_liegt_faellt_weg() + { + var (durch, fehlt) = Sprachschnitt.Von( + [(Eins, "deu"), (Eins, "jpn"), (Zwei, "deu")], folgen: 2); + + Assert.Multiple(() => + { + Assert.That(durch.Select(x => x.Code), Is.EquivalentTo(new[] { "deu" })); + Assert.That(fehlt, Is.EqualTo(1), "Japanisch gibt es, aber nicht überall."); + }); + } + + /// „ger" und „deu" sind dieselbe Sprache. + /// + /// + /// Sonst fiele Deutsch aus der Schnittmenge, obwohl es in jeder + /// Folge liegt — nur mit zwei Kürzeln, je nachdem, womit die Datei + /// erstellt wurde. Genau so sieht eine gewachsene Sammlung aus. + /// + [Test] + public void Zwei_Kuerzel_fuer_dieselbe_Sprache_zaehlen_als_eine() + { + var (durch, fehlt) = Sprachschnitt.Von( + [(Eins, "ger"), (Zwei, "deu"), (Drei, "de-DE")], folgen: 3); + + Assert.Multiple(() => + { + Assert.That(durch, Has.Count.EqualTo(1)); + Assert.That(durch[0].Name, Is.EqualTo("Deutsch")); + Assert.That(fehlt, Is.Zero); + }); + } + + /// „und" ist keine Sprache, sondern das Fehlen einer Angabe. + [Test] + public void Eine_unbekannte_Angabe_zaehlt_nicht_mit() + { + var (durch, fehlt) = Sprachschnitt.Von( + [(Eins, "und"), (Eins, "deu"), (Zwei, "deu"), (Zwei, null)], folgen: 2); + + Assert.Multiple(() => + { + Assert.That(durch.Select(x => x.Code), Is.EquivalentTo(new[] { "deu" })); + Assert.That(fehlt, Is.Zero); + }); + } + + /// Ohne Folgen mit Datei gibt es nichts zu schneiden. + /// + /// + /// Und keine Division durch die Erwartung. Eine angekündigte + /// Serie ohne eine einzige Datei darf kein leeres Menü mit einem Hinweis + /// ergeben, sondern gar keines. + /// + [Test] + public void Ohne_Folgen_kommt_nichts() + { + var (durch, fehlt) = Sprachschnitt.Von([(Eins, "deu")], folgen: 0); + + Assert.Multiple(() => + { + Assert.That(durch, Is.Empty); + Assert.That(fehlt, Is.Zero); + }); + } + + /// Dieselbe Sprache zweimal in einer Folge zählt einmal. + /// + /// + /// Zwei deutsche Tonspuren — Original und Audiodeskription — kommen vor. + /// Gezählt wird die Folge, nicht die Spur; sonst käme eine Serie mit + /// zwei Spuren in Folge 1 und keiner in Folge 2 auf dieselbe Zahl wie + /// eine durchgehende. + /// + [Test] + public void Zwei_Spuren_derselben_Sprache_in_einer_Folge_zaehlen_einmal() + { + var (durch, fehlt) = Sprachschnitt.Von( + [(Eins, "deu"), (Eins, "deu")], folgen: 2); + + Assert.Multiple(() => + { + Assert.That(durch, Is.Empty, "Folge 2 hat keine deutsche Spur."); + Assert.That(fehlt, Is.EqualTo(1)); + }); + } +}