Vier Stellen, an denen eine Angabe unterwegs verlorenging

**Der Aufmacher wusste nur die Serie.** `Heroeintrag.titel` ist bei einer
angefangenen Folge der Name der Serie — richtig, denn der Aufmacher zeigt
die Serie. Wer von dort startete, las über dem Spieler „Biohackers" und
nicht, welche Folge läuft. Staffel, Nummer und Folgentitel reisen jetzt
mit. Dasselbe im Aufmacher selbst: „Staffel 1 · Folge 2" statt „S1 · F2".

**Der Abspann ist kein Angebot, sondern eine Frage.** Die Acht-Sekunden-
Frist von gestern war für das Intro gedacht und traf den Abspann mit:
„Nächste Folge" verschwand genau dort, wo man hinsieht. Ein Intro-Knopf
geht, wenn man ihn nicht nimmt; der Abspann-Knopf bleibt, bis die Folge
zu Ende ist. Web und App.

**„deu" stand da, weil es in der Datei steht.** Der Server reichte einen
vorhandenen Spurnamen unbesehen durch — und viele Dateien tragen als
Titel schlicht das Sprachkürzel. Wiederholt er bloss die Sprache, gilt er
nicht, und die Tabelle darunter macht „Deutsch" daraus.

**Und zweimal „Deutsch" ist keine Wahl.** Zwei deutsche Untertitelspuren
sind der Normalfall — Vollfassung und Nur-Fremdsprachiges. Gleichnamige
bekommen jetzt ihren Codec dazu, und wenn der auch gleich ist, die
Nummer. Das kann keine Spur für sich beantworten: ob ein Name eindeutig
ist, hängt an den anderen. Deshalb steht es in `Benennung` und hat fünf
Prüfungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 17:34:11 +02:00
co-authored by Claude Opus 5
parent d152ac3795
commit a360bed02b
7 changed files with 197 additions and 25 deletions
+10 -1
View File
@@ -817,8 +817,17 @@ struct PlayerView: View {
.animation(.easeIn(duration: 0.28), value: zeigtMarken) .animation(.easeIn(duration: 0.28), value: zeigtMarken)
} }
/// **Ein Intro ist ein Angebot, ein Abspann eine Frage.**
///
/// „Intro überspringen" kommt von selbst und geht nach acht Sekunden
/// wieder: wer es nicht genommen hat, will zusehen. Im Abspann ist es
/// umgekehrt — „Nächste Folge" ist das, wofür man dort überhaupt
/// hinsieht, und der bleibt stehen.
private func markenSichtbar(_ model: PlayerModel) -> Bool { private func markenSichtbar(_ model: PlayerModel) -> Bool {
model.hasReachedEnd || showsControls || zeigtMarken model.hasReachedEnd
|| model.activeMarker?.isCredits == true
|| showsControls
|| zeigtMarken
} }
/// Eine neue Marke zeigt ihre Knöpfe von selbst — für acht Sekunden. /// Eine neue Marke zeigt ihre Knöpfe von selbst — für acht Sekunden.
@@ -478,13 +478,10 @@ public static class StreamEndpoints
StartSeconds: 0, StartSeconds: 0,
Height: null, Height: null,
AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index, AudioStream: spuren.FirstOrDefault(s => s.Kind == MediaStreamKind.Audio)?.Index,
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio) AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) SubtitleStreams: Spurliste(spuren
.ToList(), .Where(s => s.Kind == MediaStreamKind.Subtitle)
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle) .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
.Where(s => s.Codec is null || !Untertitelvorrat.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> /// <summary>Das Geräteprofil aus der Anfrage — oder, wenn keines kam, Apples.</summary>
@@ -549,13 +546,10 @@ public static class StreamEndpoints
StartSeconds: sitzung.Ab, StartSeconds: sitzung.Ab,
Height: sitzung.Hoehe, Height: sitzung.Hoehe,
AudioStream: sitzung.Tonspur, AudioStream: sitzung.Tonspur,
AudioStreams: spuren.Where(s => s.Kind == MediaStreamKind.Audio) AudioStreams: Spurliste(spuren.Where(s => s.Kind == MediaStreamKind.Audio)),
.Select(s => new SpurAuskunft(s.Index, Benennung(s), s.Language, s.IsDefault)) SubtitleStreams: Spurliste(spuren
.ToList(), .Where(s => s.Kind == MediaStreamKind.Subtitle)
SubtitleStreams: spuren.Where(s => s.Kind == MediaStreamKind.Subtitle) .Where(s => s.Codec is null || !Untertitelvorrat.IstBild(s.Codec))));
.Where(s => s.Codec is null || !Untertitelvorrat.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> /// <summary>Bildbasierte Untertitel lassen sich nicht nach WebVTT wandeln.</summary>
@@ -566,10 +560,36 @@ public static class StreamEndpoints
/// nichts führt, ist schlechter als kein Eintrag. /// nichts führt, ist schlechter als kein Eintrag.
/// </remarks> /// </remarks>
/// <summary>Beschriftet eine Spurliste — unterscheidbar.</summary>
///
/// <remarks>
/// Das Auftrennen gleicher Namen steht in <c>Benennung</c>: die Apps
/// zeigen dieselben Listen, und zweimal formuliert driften sie
/// auseinander.
/// </remarks>
private static List<SpurAuskunft> Spurliste(IEnumerable<MediaStream> spuren)
{
var liste = spuren.ToList();
var namen = Flaro.Core.Media.Benennung.Unterscheiden(
liste.Select(s => new Flaro.Core.Media.Benennung.Spurzeile(
Benennung(s), s.Codec, s.Index)).ToList());
return liste
.Select((s, i) => new SpurAuskunft(s.Index, namen[i], s.Language, s.IsDefault))
.ToList();
}
/// <summary>Wie eine Spur im Menü heisst.</summary> /// <summary>Wie eine Spur im Menü heisst.</summary>
private static string Benennung(MediaStream spur) private static string Benennung(MediaStream spur)
{ {
if (spur.Title is { Length: > 0 } eigen) return eigen; // **Ein Spurname, der nur die Sprache wiederholt, ist keiner.**
// Viele Dateien tragen als Titel schlicht „deu" — durchgereicht
// stand genau das in der Auswahl, roh und dreibuchstabig, obwohl
// die Tabelle darunter „Deutsch" daraus gemacht hätte.
var istKuerzel = spur.Language is { Length: > 0 } kennung
&& string.Equals(spur.Title?.Trim(), kennung.Trim(), StringComparison.OrdinalIgnoreCase);
if (!istKuerzel && spur.Title is { Length: > 0 } eigen) return eigen;
var sprache = Sprache(spur.Language); var sprache = Sprache(spur.Language);
var teile = new List<string> { sprache }; var teile = new List<string> { sprache };
+49
View File
@@ -52,6 +52,55 @@ public static class Benennung
return Sprachnamen.GetValueOrDefault(blank, blank.ToUpperInvariant()); return Sprachnamen.GetValueOrDefault(blank, blank.ToUpperInvariant());
} }
/// <summary>Eine Spur, wie sie im Menü stünde — vor dem Vergleich.</summary>
public readonly record struct Spurzeile(string Name, string? Codec, int Nummer);
/// <summary>Macht gleichnamige Spuren unterscheidbar.</summary>
///
/// <remarks>
/// <para>
/// <b>Zweimal „Deutsch" ist keine Wahl.</b> Viele Dateien führen zwei
/// deutsche Untertitelspuren — Vollfassung und Nur-Fremdsprachiges.
/// Standen beide gleich da, musste man raten und es danach wieder
/// umstellen.
/// </para>
/// <para>
/// <b>Warum das keine Spur für sich beantworten kann.</b> Ob ein Name
/// eindeutig ist, hängt an den anderen — einzeln benannt weiss keine
/// Spur, dass es eine zweite mit demselben Namen gibt. Deshalb steht
/// das hier und nicht in <see cref="Sprache"/>.
/// </para>
/// <para>
/// Erst der Codec: „Deutsch · SRT" gegen „Deutsch · ASS" sagt etwas.
/// Hilft er nicht, hilft die Nummer — nichtssagend, aber sie trennt,
/// und das ist hier die Aufgabe.
/// </para>
/// </remarks>
public static IReadOnlyList<string> Unterscheiden(IReadOnlyList<Spurzeile> zeilen)
{
var namen = zeilen.Select(z => z.Name).ToList();
Entwirren(i => Codec(zeilen[i].Codec));
Entwirren(i => $"Spur {zeilen[i].Nummer}");
return namen;
void Entwirren(Func<int, string?> zusatz)
{
var doppelt = namen
.Select((name, i) => (name, i))
.GroupBy(x => x.name)
.Where(g => g.Count() > 1)
.SelectMany(g => g.Select(x => x.i))
.ToList();
foreach (var i in doppelt)
{
if (zusatz(i) is { Length: > 0 } text) namen[i] = $"{namen[i]} · {text}";
}
}
}
/// <summary>Die übliche Schreibweise für eine Kanalzahl.</summary> /// <summary>Die übliche Schreibweise für eine Kanalzahl.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -0,0 +1,69 @@
using Flaro.Core.Media;
namespace Flaro.Core.Tests;
/// <summary>Lassen sich zwei gleichnamige Spuren auseinanderhalten?</summary>
///
/// <remarks>
/// <b>Der Fehler, für den diese Prüfung steht.</b> In der Spurwahl standen
/// zweimal „deu" untereinander — gleicher Name, gleiches Aussehen, und die
/// einzige Möglichkeit herauszufinden, welche gemeint war, bestand darin,
/// eine zu nehmen und es danach wieder umzustellen.
/// </remarks>
public class SpurnamenTests
{
private static Benennung.Spurzeile Zeile(string name, string? codec = null, int nummer = 0)
=> new(name, codec, nummer);
[Test]
public void Eindeutige_Namen_bleiben_wie_sie_sind()
{
var namen = Benennung.Unterscheiden([Zeile("Deutsch"), Zeile("Englisch")]);
Assert.That(namen, Is.EqualTo(new[] { "Deutsch", "Englisch" }));
}
[Test]
public void Gleiche_Namen_bekommen_ihren_Codec()
{
var namen = Benennung.Unterscheiden(
[Zeile("Deutsch", "subrip", 2), Zeile("Deutsch", "ass", 3)]);
Assert.That(namen, Is.EqualTo(new[] { "Deutsch · SRT", "Deutsch · ASS" }));
}
/// <summary>Hilft der Codec nicht, hilft die Nummer.</summary>
///
/// <remarks>
/// Zwei deutsche SRT-Spuren in derselben Datei sind der Normalfall:
/// Vollfassung und Nur-Fremdsprachiges. „Spur 3" sagt nichts über den
/// Inhalt — aber es trennt, und darum geht es hier.
/// </remarks>
[Test]
public void Hilft_der_Codec_nicht_hilft_die_Nummer()
{
var namen = Benennung.Unterscheiden(
[Zeile("Deutsch", "subrip", 2), Zeile("Deutsch", "subrip", 3)]);
Assert.That(namen, Is.EqualTo(new[] { "Deutsch · SRT · Spur 2", "Deutsch · SRT · Spur 3" }));
}
/// <summary>Ohne Codec geht es gleich über die Nummer.</summary>
[Test]
public void Ohne_Codec_trennt_die_Nummer()
{
var namen = Benennung.Unterscheiden([Zeile("Deutsch", null, 1), Zeile("Deutsch", null, 4)]);
Assert.That(namen, Is.EqualTo(new[] { "Deutsch · Spur 1", "Deutsch · Spur 4" }));
}
/// <summary>Drei gleiche auch.</summary>
[Test]
public void Drei_gleiche_Namen_werden_alle_drei_getrennt()
{
var namen = Benennung.Unterscheiden(
[Zeile("Deutsch", "subrip", 1), Zeile("Deutsch", "subrip", 2), Zeile("Deutsch", "ass", 3)]);
Assert.That(namen, Is.Unique);
}
}
+19 -2
View File
@@ -1,6 +1,6 @@
import { bildAdresse, type Angefangen, type Film, type Serie } from './api'; import { bildAdresse, type Angefangen, type Film, type Serie } from './api';
import { icons } from './icons'; import { icons } from './icons';
import { dauer, knopf } from './util'; import { dauer, knopf, folgenname } from './util';
/** Was im Hero stehen kann — Film, Serie oder eine angefangene Folge. */ /** Was im Hero stehen kann — Film, Serie oder eine angefangene Folge. */
export type Heroeintrag = { export type Heroeintrag = {
@@ -29,6 +29,15 @@ export type Heroeintrag = {
angefangen: boolean; angefangen: boolean;
/** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */ /** Wem der Fortschritt gehört: bei einer Folge der Folge, nicht der Serie. */
standId: string; standId: string;
/** Bei einer Folge ihre Teile — für die Zeile über dem Spieler.
*
* **`titel` reicht dafür nicht.** Dort steht bei einer Folge der Name der
* *Serie*, weil der Aufmacher die Serie zeigt. Wer von hier aus startete,
* las über dem Spieler nur „Biohackers" und nicht, welche Folge läuft. */
staffel: number | null;
folge: number | null;
folgentitel: string | null;
}; };
const WECHSEL_MS = 9000; const WECHSEL_MS = 9000;
@@ -461,6 +470,9 @@ export function alsHero(eintrag: Film | Serie, art: 'filme' | 'serien'): Heroein
bewertung: eintrag.rating, bewertung: eintrag.rating,
beschreibung: eintrag.overview, beschreibung: eintrag.overview,
verfuegbarAb: eintrag.publishAt, verfuegbarAb: eintrag.publishAt,
staffel: null,
folge: null,
folgentitel: null,
dateiId: datei?.id ?? null, dateiId: datei?.id ?? null,
laenge: datei?.durationSeconds ?? null, laenge: datei?.durationSeconds ?? null,
stelle: eintrag.state?.positionSeconds ?? 0, stelle: eintrag.state?.positionSeconds ?? 0,
@@ -492,10 +504,15 @@ export function angefangenAlsHero(eintrag: Angefangen): Heroeintrag {
laufzeit: null, laufzeit: null,
freigabe: null, freigabe: null,
bewertung: null, bewertung: null,
// Ausgeschrieben, nicht „S1 · F2": der Aufmacher hat den Platz dafür,
// und zwei Buchstaben mit Zahl liest niemand gern.
beschreibung: folge && eintrag.season !== null beschreibung: folge && eintrag.season !== null
? `S${eintrag.season} · F${eintrag.episode}${eintrag.subTitle ? ` — ${eintrag.subTitle}` : ''}` ? folgenname(null, eintrag.season, eintrag.episode, eintrag.subTitle)
: null, : null,
verfuegbarAb: eintrag.publishAt ?? null, verfuegbarAb: eintrag.publishAt ?? null,
staffel: folge ? eintrag.season : null,
folge: folge ? eintrag.episode : null,
folgentitel: folge ? eintrag.subTitle : null,
dateiId: eintrag.fileId ?? null, dateiId: eintrag.fileId ?? null,
laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds, laenge: eintrag.durationSeconds ?? eintrag.state.runtimeSeconds,
stelle: eintrag.state.positionSeconds, stelle: eintrag.state.positionSeconds,
+9 -7
View File
@@ -852,15 +852,17 @@ export function spieler(anfrage: Spieleranfrage): void {
if (kennung !== markenstandJetzt) markenAb = performance.now(); if (kennung !== markenstandJetzt) markenAb = performance.now();
// **Eine Marke dauert Minuten, ein Angebot nicht.** Die Knöpfe kommen // **Ein Intro ist ein Angebot, ein Abspann eine Frage.**
// von selbst, ohne dass man den Film antippen muss — aber ein Abspann //
// läuft sechs Minuten, und so lange gehört kein Knopf über das Bild. // „Intro überspringen" kommt von selbst und geht nach acht Sekunden
// Acht Sekunden von allein, danach nur noch zusammen mit der übrigen // wieder: wer es nicht genommen hat, will zusehen, und dann gehört
// Bedienung: wer später doch überspringen will, bewegt die Maus. // kein Knopf über das Bild. Im Abspann ist es umgekehrt. „Nächste
const frisch = performance.now() - markenAb < 8000; // Folge" ist das, wofür man dort überhaupt hinsieht — der bleibt
// stehen, bis die Folge zu Ende ist.
const bleibt = abspann || performance.now() - markenAb < 8000;
markeLaeuft = drin != null; markeLaeuft = drin != null;
markenreihe.classList.toggle( markenreihe.classList.toggle(
'hidden', !drin || (!frisch && schicht.classList.contains('pl-still'))); 'hidden', !drin || (!bleibt && schicht.classList.contains('pl-still')));
if (!drin || kennung === markenstandJetzt) return; if (!drin || kennung === markenstandJetzt) return;
markenstandJetzt = kennung; markenstandJetzt = kennung;
+6
View File
@@ -106,6 +106,12 @@ export function startseite(
dateiId: e.dateiId, dateiId: e.dateiId,
titelId: e.standId, titelId: e.standId,
name: e.titel, name: e.titel,
// Bei einer Folge steht in `titel` der Name der Serie — der
// Aufmacher zeigt die Serie. Die Folge selbst kommt daneben.
serie: e.staffel != null ? e.titel : null,
staffel: e.staffel,
folge: e.folge,
folgentitel: e.folgentitel,
ab: e.stelle, ab: e.stelle,
laenge: e.laenge, laenge: e.laenge,
danach: () => void aufbauen(), danach: () => void aufbauen(),