Welche Sprachen eine Serie durchgehend führt
Das Menü auf der Serienseite bot zwanzig Sprachen an, von denen die Serie
zwei hatte. Um das zu ändern, muss jemand wissen, welche es wirklich
gibt — und zwar in einer Abfrage: zwanzig Folgen einzeln zu holen wären
zwanzig Rundreisen, und am Ende müsste der Client dieselbe Rechnung
anstellen.
`GET /api/v1/series/{id}/languages` nennt Ton- und Textsprachen als
**Schnittmenge** über alle Folgen. Eine Sprache anzubieten, die es nur in
Staffel 1 gibt, hiesse: eine Einstellung für die Serie, die ab Staffel 2
stillschweigend etwas anderes tut. Was dabei wegfällt, wird gezählt —
damit die Oberfläche es sagen kann, statt es zu verschweigen.
Zwei Dinge, die sonst falsche Ergebnisse gäben:
Gruppiert wird nach dem **Namen**, nicht nach dem Kürzel. Eine gewachsene
Sammlung trägt „ger" und „deu" nebeneinander; nach dem Kürzel gezählt
fiele Deutsch aus der Schnittmenge, obwohl es überall liegt.
Gezählt werden nur Folgen, die eine **Datei** haben. Eine angekündigte
Folge kann keine Sprache beisteuern; sie als fehlend zu zählen liesse das
Menü bei jeder laufenden Serie leer.
Die Rechnung steht in `Flaro.Core`, nicht im Endpunkt: sie hängt an keiner
Datenbank und lässt sich so prüfen, ohne eine Serie anzulegen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2c51f639be
commit
22be288fc3
@@ -983,3 +983,28 @@ public sealed record Einladungsschau(string ServerName, string InvitedBy, DateTi
|
||||
|
||||
/// <summary>Namen und Passwort, die sich der Eingeladene selbst gibt.</summary>
|
||||
public sealed record EinloesenRequest(string UserName, string Password);
|
||||
|
||||
/// <summary>Eine Sprache, wie sie in einem Menü steht.</summary>
|
||||
///
|
||||
/// <param name="Code">Das Kürzel, unter dem die Einstellung abgelegt wird.</param>
|
||||
/// <param name="Name">Wie sie dasteht — „Deutsch", „English".</param>
|
||||
public sealed record Sprachangabe(string Code, string Name);
|
||||
|
||||
/// <summary>Welche Sprachen eine Serie durchgehend führt.</summary>
|
||||
///
|
||||
/// <param name="Audio">Tonsprachen, die in jeder Folge vorkommen.</param>
|
||||
/// <param name="Subtitles">Textsprachen, die in jeder Folge vorkommen.</param>
|
||||
/// <param name="Partial">
|
||||
/// Wie viele Sprachen es nur in einem Teil der Folgen gibt.
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Oberfläche es sagen kann, statt es zu verschweigen.</b> Ohne
|
||||
/// diese Zahl sähe eine gekürzte Liste aus wie die ganze Wahrheit — und wer
|
||||
/// eine Sprache vermisst, die er in Staffel 1 gesehen hat, hielte es für
|
||||
/// einen Fehler.
|
||||
/// </remarks>
|
||||
/// </param>
|
||||
public sealed record Sprachauswahl(
|
||||
IReadOnlyList<Sprachangabe> Audio,
|
||||
IReadOnlyList<Sprachangabe> Subtitles,
|
||||
int Partial);
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace Flaro.Core.Media;
|
||||
|
||||
/// <summary>Eine Sprache, wie sie in einem Menü steht.</summary>
|
||||
public sealed record Sprachfund(string Code, string Name);
|
||||
|
||||
/// <summary>Welche Sprachen in <b>jedem</b> Teil einer Serie vorkommen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Die Schnittmenge, nicht die Vereinigung.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Eine reine Rechnung, deshalb hier und nicht im Endpunkt.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Sprachschnitt
|
||||
{
|
||||
/// <summary>Die durchgehenden Sprachen — und wie viele es nicht sind.</summary>
|
||||
///
|
||||
/// <param name="spuren">Je Spur: zu welcher Folge sie gehört und ihr Kürzel.</param>
|
||||
/// <param name="folgen">
|
||||
/// Wie viele Folgen überhaupt eine Datei haben.
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nicht wie viele Folgen die Serie hat.</b> 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.
|
||||
/// </remarks>
|
||||
/// </param>
|
||||
public static (IReadOnlyList<Sprachfund> 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<string, (string Code, HashSet<Guid> 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);
|
||||
}
|
||||
|
||||
/// <summary>„de-DE" wird zu „de" — gespeichert wird nur die Sprache.</summary>
|
||||
private static string Blank(string kuerzel) =>
|
||||
kuerzel.Trim().Split('-', '_')[0].ToLowerInvariant();
|
||||
}
|
||||
Reference in New Issue
Block a user