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));
+ });
+ }
+}