Erst fragen, ob die Datei den Abspann selbst benennt
Zweimal gemessen scheitert die Helligkeitssuche am selben: „Alice im Wunderland" legt den Abspann über verzierte Hintergründe, „(T)Raumschiff Surprise" zeigt darin Outtakes. Deine Zeile sagt es genau — Schwelle 49,4, letzte Probe 1, dunkle Strecke drei Sekunden: der Film endet schwarz, und davor ist volles Bild. Helligkeit kann dazu nichts sagen, und das ist kein Fehler, sondern ihre Grenze. Ein Kapitel sagt es dagegen genau. Die Suche fragt jetzt zuerst danach: ffprobe liest dafür nur den Kopf der Datei, Millisekunden gegen zwanzig Minuten Schlüsselbilder. Findet sie ein benanntes Kapitel am Ende, ist sie fertig; sonst geht es wie bisher über das Bild. **Nur bei einem Namen, der es hergibt** — „Chapter 12" ist keine Auskunft, und das letzte Kapitel ist nicht automatisch der Abspann: bei vielen Dateien ist es die Schlussszene. Lieber kein Fund als ein Knopf, der die letzten zehn Minuten Handlung überspringt. „Opening Credits" bei Minute zwei trägt dasselbe Wort und ist das Gegenteil, also zählt nur, was im letzten Drittel liegt. **Und wo nichts gefunden wird, steht jetzt die Kapitellage dabei** — „Keine Kapitel.", „14 Kapitel, alle ohne Namen." oder „12 Kapitel, letztes „Finale"." Ob sich an diesem Weg weiterbauen lohnt, sieht man erst an vielen Dateien, und genau das misst diese Zeile. Elf Prüfungen: das Lesen der ffprobe-Antwort ohne ffprobe, und die Regel, wann ein Kapitel zählt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f79ecf2757
commit
121ee62be9
@@ -390,6 +390,7 @@ public sealed class Markenerkennung(
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
||||
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
|
||||
|
||||
var film = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.Id == filmId)
|
||||
@@ -432,7 +433,8 @@ public sealed class Markenerkennung(
|
||||
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
|
||||
|
||||
var gefunden = await BildabspannAsync(
|
||||
db, abtaster, filmId, istFilm: true, titel, film.Path, laufzeit.TotalSeconds, ct);
|
||||
db, abtaster, kapitelleser, filmId, istFilm: true, titel, film.Path,
|
||||
laufzeit.TotalSeconds, ct);
|
||||
|
||||
if (!gefunden)
|
||||
{
|
||||
@@ -472,25 +474,46 @@ public sealed class Markenerkennung(
|
||||
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
|
||||
/// </param>
|
||||
private async Task<bool> BildabspannAsync(
|
||||
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm,
|
||||
string name, string pfad, double laufzeit, CancellationToken ct)
|
||||
FlaroContext db, Bildabtaster abtaster, Kapitelleser kapitelleser,
|
||||
Guid titelId, bool istFilm, string name, string pfad, double laufzeit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var regeln = Abspannregeln.Vorgabe;
|
||||
|
||||
// **Erst fragen, ob die Datei es selbst weiss.** Ein Kapitel kostet
|
||||
// Millisekunden — ffprobe liest nur den Kopf —, die Helligkeit
|
||||
// zwanzig Minuten Schlüsselbilder. Und es findet, woran die
|
||||
// Helligkeit scheitert: einen Abspann über laufendem Bild, wie bei
|
||||
// „Alice im Wunderland" oder den Outtakes in „(T)Raumschiff
|
||||
// Surprise".
|
||||
var kapitel = await kapitelleser.LesenAsync(pfad, ct);
|
||||
if (Abspannkapitel.Suchen(kapitel, laufzeit, regeln) is { } ausKapitel)
|
||||
{
|
||||
log.LogInformation("„{Titel}“: Abspann laut Kapitel ab {Stelle:h\\:mm\\:ss}.",
|
||||
name, TimeSpan.FromSeconds(ausKapitel));
|
||||
|
||||
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
|
||||
ausKapitel, laufzeit, ct);
|
||||
}
|
||||
|
||||
var ab = Math.Max(0, laufzeit - Suchbereich);
|
||||
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
|
||||
|
||||
var regeln = Abspannregeln.Vorgabe;
|
||||
|
||||
// **Ein Lauf ohne Fund soll sagen, warum.** „Kein Abspann erkannt"
|
||||
// allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder
|
||||
// hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel
|
||||
// steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht.
|
||||
if (Abspannkante.Suchen(proben, regeln, out var grund) is not { } stelle)
|
||||
{
|
||||
// **Mit der Kapitellage.** Ob eine Datei gar keine Kapitel hat
|
||||
// oder nur namenlose, entscheidet, ob sich an diesem Weg
|
||||
// weiterbauen lohnt — und das sieht man nur an vielen Dateien.
|
||||
log.LogInformation(
|
||||
"„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), "
|
||||
+ "Schwelle {Schwelle:0.#}, letzte Probe {Letzte:0.#}.",
|
||||
+ "Schwelle {Schwelle:0.#}, letzte Probe {Letzte:0.#}. {Kapitel}",
|
||||
name, grund, proben.Count, Abspannkante.Schwelle(proben, regeln),
|
||||
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN);
|
||||
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN,
|
||||
Kapitellage(kapitel));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -525,6 +548,7 @@ public sealed class Markenerkennung(
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
|
||||
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
||||
var kapitelleser = raum.ServiceProvider.GetRequiredService<Kapitelleser>();
|
||||
|
||||
var serienname = await db.Series.AsNoTracking()
|
||||
.Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct)
|
||||
@@ -713,8 +737,8 @@ public sealed class Markenerkennung(
|
||||
|
||||
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
|
||||
|
||||
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
|
||||
Name(i), folge.Path, dauer.TotalSeconds, ct))
|
||||
if (await BildabspannAsync(db, abtaster, kapitelleser, folge.Id,
|
||||
istFilm: false, Name(i), folge.Path, dauer.TotalSeconds, ct))
|
||||
{
|
||||
ueberBild.Add(folge.Id);
|
||||
gefundenAbspann++;
|
||||
@@ -795,6 +819,17 @@ public sealed class Markenerkennung(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Was die Datei an Kapiteln mitbringt — in einem Halbsatz.</summary>
|
||||
private static string Kapitellage(IReadOnlyList<Kapitel> kapitel)
|
||||
{
|
||||
if (kapitel.Count == 0) return "Keine Kapitel.";
|
||||
|
||||
var benannt = kapitel.Count(k => !string.IsNullOrWhiteSpace(k.Titel));
|
||||
return benannt == 0
|
||||
? $"{kapitel.Count} Kapitel, alle ohne Namen."
|
||||
: $"{kapitel.Count} Kapitel, letztes „{kapitel[^1].Titel}“.";
|
||||
}
|
||||
|
||||
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
|
||||
private static string Zeit(double sekunden)
|
||||
{
|
||||
|
||||
@@ -175,6 +175,7 @@ builder.Services.AddSingleton<ScanCoordinator>();
|
||||
builder.Services.AddSingleton<Tonabdruckleser>();
|
||||
// Bei Filmen führt kein Ton zum Abspann — nur das Bild.
|
||||
builder.Services.AddSingleton<Bildabtaster>();
|
||||
builder.Services.AddSingleton<Kapitelleser>();
|
||||
builder.Services.AddSingleton<Markenerkennung>();
|
||||
builder.Services.AddSingleton<PackageCoordinator>();
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace Flaro.Core.Media;
|
||||
|
||||
/// <summary>Ein Kapitel, wie es in der Datei steht.</summary>
|
||||
///
|
||||
/// <param name="Von">Anfang in Sekunden.</param>
|
||||
/// <param name="Bis">Ende in Sekunden.</param>
|
||||
/// <param name="Titel">Was draufsteht — oft leer oder „Chapter 12".</param>
|
||||
public readonly record struct Kapitel(double Von, double Bis, string Titel);
|
||||
|
||||
/// <summary>Findet den Abspann, wenn die Datei ihn selbst benennt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum neben der Helligkeit.</b> <see cref="Abspannkante"/> findet,
|
||||
/// was dunkel ist — und daran scheitert sie zweimal gemessen: „Alice im
|
||||
/// Wunderland" legt den Abspann über verzierte Hintergründe, „(T)Raumschiff
|
||||
/// Surprise" zeigt darin Outtakes. Beides ist volles Bild. Ein Kapitel
|
||||
/// sagt es dagegen genau, kostet nichts und ist nicht geraten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nur, wenn der Name es hergibt.</b> „Chapter 12" ist keine Auskunft,
|
||||
/// und das letzte Kapitel ist nicht automatisch der Abspann — bei vielen
|
||||
/// Dateien ist es die Schlussszene. Lieber kein Fund als ein Knopf, der
|
||||
/// die letzten zehn Minuten Handlung überspringt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Abspannkapitel
|
||||
{
|
||||
/// <summary>Woran ein Abspannkapitel zu erkennen ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Englisch und deutsch, weil beides vorkommt — oft in derselben
|
||||
/// Sammlung. „Ende" steht bewusst nicht dabei: „Das Ende" ist bei
|
||||
/// einem Film die Schlussszene, nicht der Abspann.
|
||||
/// </remarks>
|
||||
private static readonly string[] Woerter =
|
||||
[
|
||||
"abspann", "nachspann", "credit", "closing", "outro", "schlusstitel",
|
||||
];
|
||||
|
||||
/// <summary>Ab welchem Anteil der Laufzeit ein Kapitel in Frage kommt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein Abspann liegt am Ende. Ein Kapitel namens „Opening Credits" bei
|
||||
/// Minute zwei trägt dasselbe Wort und ist das Gegenteil.
|
||||
/// </remarks>
|
||||
private const double Spaetestens = 0.6;
|
||||
|
||||
/// <summary>Wo der Abspann laut Datei anfängt — oder <c>null</c>.</summary>
|
||||
public static double? Suchen(
|
||||
IReadOnlyList<Kapitel> kapitel, double laufzeit, Abspannregeln regeln)
|
||||
{
|
||||
if (laufzeit <= 0) return null;
|
||||
|
||||
var frühestens = laufzeit * Spaetestens;
|
||||
|
||||
return kapitel
|
||||
.Where(k => k.Von >= frühestens)
|
||||
.Where(k => laufzeit - k.Von >= regeln.Mindestlänge)
|
||||
.Where(k => Passt(k.Titel))
|
||||
// Der früheste Treffer: ein Abspann, der in „Credits" und
|
||||
// „Studio-Logo" zerfällt, fängt beim ersten an.
|
||||
.OrderBy(k => k.Von)
|
||||
.Select(k => (double?)k.Von)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static bool Passt(string titel) =>
|
||||
!string.IsNullOrWhiteSpace(titel)
|
||||
&& Array.Exists(Woerter,
|
||||
w => titel.Contains(w, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Flaro.Core.Media;
|
||||
|
||||
namespace Flaro.Media;
|
||||
|
||||
/// <summary>Liest die Kapitelmarken einer Datei.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum überhaupt.</b> Die Abspannsuche im Bild findet, was dunkel ist
|
||||
/// — und genau daran scheitert sie zweimal gemessen: „Alice im Wunderland"
|
||||
/// legt den Abspann über verzierte Hintergründe, „(T)Raumschiff Surprise"
|
||||
/// zeigt darin Outtakes. Beides ist volles Bild, und Helligkeit sagt dazu
|
||||
/// nichts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ein Kapitel sagt es dagegen genau</b> — wenn es eines gibt. Das ist
|
||||
/// die ganze Unsicherheit: viele Dateien haben keine Kapitel, und von
|
||||
/// denen, die welche haben, nennen viele sie nur „Chapter 12". Deshalb
|
||||
/// steht hier nur das Lesen; was daraus folgt, entscheidet der Aufrufer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Billig.</b> ffprobe liest dafür nur den Kopf der Datei, kein Bild.
|
||||
/// Millisekunden gegen zwanzig Minuten Schlüsselbilder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Kapitelleser(FfmpegOptions optionen, ILogger<Kapitelleser> log)
|
||||
{
|
||||
public async Task<IReadOnlyList<Kapitel>> LesenAsync(
|
||||
string pfad, CancellationToken ct = default)
|
||||
{
|
||||
var start = new ProcessStartInfo(optionen.Ffprobe)
|
||||
{
|
||||
ArgumentList =
|
||||
{
|
||||
"-v", "quiet", "-print_format", "json", "-show_chapters", "-i", pfad,
|
||||
},
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
|
||||
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
frist.CancelAfter(optionen.ProbeTimeout);
|
||||
|
||||
try
|
||||
{
|
||||
using var prozess = Process.Start(start);
|
||||
if (prozess is null) return [];
|
||||
|
||||
var lesen = prozess.StandardOutput.ReadToEndAsync(frist.Token);
|
||||
var fehlerLesen = prozess.StandardError.ReadToEndAsync(frist.Token);
|
||||
await prozess.WaitForExitAsync(frist.Token);
|
||||
var ausgabe = await lesen;
|
||||
await fehlerLesen;
|
||||
|
||||
return prozess.ExitCode == 0 ? Lesen(ausgabe) : [];
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Kapitel von {Pfad} liessen sich nicht lesen.", pfad);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Die Antwort von <c>-show_chapters</c> auswerten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Getrennt vom Aufruf, damit es sich ohne ffprobe prüfen lässt — wie
|
||||
/// bei <c>Bildabtaster.Lesen</c> und <c>Tonabdruckleser.Lesen</c>.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<Kapitel> Lesen(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
|
||||
try
|
||||
{
|
||||
using var papier = JsonDocument.Parse(json);
|
||||
if (!papier.RootElement.TryGetProperty("chapters", out var liste)
|
||||
|| liste.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var kapitel = new List<Kapitel>();
|
||||
foreach (var eintrag in liste.EnumerateArray())
|
||||
{
|
||||
if (!Zeit(eintrag, "start_time", out var von)) continue;
|
||||
if (!Zeit(eintrag, "end_time", out var bis)) bis = von;
|
||||
|
||||
var titel = eintrag.TryGetProperty("tags", out var marken)
|
||||
&& marken.TryGetProperty("title", out var name)
|
||||
? name.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
kapitel.Add(new Kapitel(von, bis, titel));
|
||||
}
|
||||
|
||||
return kapitel;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Zeit(JsonElement eintrag, string feld, out double wert)
|
||||
{
|
||||
wert = 0;
|
||||
return eintrag.TryGetProperty(feld, out var roh)
|
||||
&& roh.ValueKind == JsonValueKind.String
|
||||
&& double.TryParse(roh.GetString(), NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture, out wert);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Flaro.Core.Media;
|
||||
|
||||
namespace Flaro.Core.Tests;
|
||||
|
||||
/// <summary>Wann ein Kapitel der Abspann ist — und wann nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Anlass.</b> Die Helligkeitssuche findet, was dunkel ist, und
|
||||
/// scheitert zweimal gemessen an genau dem Gegenteil: „Alice im
|
||||
/// Wunderland" legt den Abspann über verzierte Hintergründe,
|
||||
/// „(T)Raumschiff Surprise" zeigt darin Outtakes — Schwelle 49,4, letzte
|
||||
/// Probe 1, und die dunkle Strecke war drei Sekunden lang. Ein Kapitel
|
||||
/// sagt es genau, wenn die Datei eines hat.
|
||||
/// </remarks>
|
||||
public class AbspannkapitelTests
|
||||
{
|
||||
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
|
||||
private const double Laufzeit = 7200;
|
||||
|
||||
[Test]
|
||||
public void Ein_benanntes_Kapitel_am_Ende_zaehlt()
|
||||
{
|
||||
var kapitel = new List<Kapitel>
|
||||
{
|
||||
new(0, 600, "Opening"),
|
||||
new(600, 6600, "Der Film"),
|
||||
new(6600, 7200, "End Credits"),
|
||||
};
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6600));
|
||||
}
|
||||
|
||||
/// <summary>Deutsch genauso — beides kommt in derselben Sammlung vor.</summary>
|
||||
[Test]
|
||||
public void Abspann_auf_Deutsch_auch()
|
||||
{
|
||||
var kapitel = new List<Kapitel> { new(6900, 7200, "Abspann") };
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6900));
|
||||
}
|
||||
|
||||
/// <summary>„Chapter 12" ist keine Auskunft.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Das letzte Kapitel ist nicht automatisch der Abspann — bei vielen
|
||||
/// Dateien ist es die Schlussszene. Lieber kein Fund als ein Knopf,
|
||||
/// der die letzten zehn Minuten Handlung überspringt.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public void Namenlose_Kapitel_zaehlen_nicht()
|
||||
{
|
||||
var kapitel = new List<Kapitel>
|
||||
{
|
||||
new(0, 3600, "Chapter 1"),
|
||||
new(3600, 7200, "Chapter 2"),
|
||||
};
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>„Opening Credits" trägt dasselbe Wort und ist das Gegenteil.</summary>
|
||||
[Test]
|
||||
public void Ein_Vorspannkapitel_zaehlt_nicht()
|
||||
{
|
||||
var kapitel = new List<Kapitel> { new(60, 180, "Opening Credits") };
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Ein Abspannkapitel von zehn Sekunden ist keins.</summary>
|
||||
[Test]
|
||||
public void Zu_kurz_zaehlt_nicht()
|
||||
{
|
||||
var kapitel = new List<Kapitel> { new(7190, 7200, "Credits") };
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Zerfällt er in zwei, gilt der erste.</summary>
|
||||
[Test]
|
||||
public void Der_frueheste_Treffer_gewinnt()
|
||||
{
|
||||
var kapitel = new List<Kapitel>
|
||||
{
|
||||
new(6600, 7100, "Closing Credits"),
|
||||
new(7100, 7200, "Studio-Logo mit Credits"),
|
||||
};
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6600));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ohne_Kapitel_kein_Fund()
|
||||
{
|
||||
Assert.That(Abspannkapitel.Suchen([], Laufzeit, Regeln), Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>Ohne Laufzeit lässt sich „am Ende" nicht beantworten.</summary>
|
||||
[Test]
|
||||
public void Ohne_Laufzeit_kein_Fund()
|
||||
{
|
||||
var kapitel = new List<Kapitel> { new(6600, 7200, "Credits") };
|
||||
|
||||
Assert.That(Abspannkapitel.Suchen(kapitel, 0, Regeln), Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Flaro.Core.Media;
|
||||
using Flaro.Media;
|
||||
|
||||
namespace Flaro.Media.Tests;
|
||||
|
||||
/// <summary>Die Antwort von <c>ffprobe -show_chapters</c> lesen — ohne ffprobe.</summary>
|
||||
public class KapitelleserTests
|
||||
{
|
||||
[Test]
|
||||
public void Kapitel_mit_Namen_und_Zeiten()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"chapters": [
|
||||
{ "id": 0, "start_time": "0.000000", "end_time": "600.000000",
|
||||
"tags": { "title": "Anfang" } },
|
||||
{ "id": 1, "start_time": "6600.000000", "end_time": "7200.000000",
|
||||
"tags": { "title": "End Credits" } }
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
var kapitel = Kapitelleser.Lesen(json);
|
||||
|
||||
Assert.That(kapitel, Is.EqualTo(new[]
|
||||
{
|
||||
new Kapitel(0, 600, "Anfang"),
|
||||
new Kapitel(6600, 7200, "End Credits"),
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Ohne <c>tags</c> bleibt der Name leer — und das ist kein Fehler.</summary>
|
||||
[Test]
|
||||
public void Ein_Kapitel_ohne_Namen_kommt_trotzdem_an()
|
||||
{
|
||||
var json = """
|
||||
{ "chapters": [ { "start_time": "10.5", "end_time": "20.25" } ] }
|
||||
""";
|
||||
|
||||
Assert.That(Kapitelleser.Lesen(json), Is.EqualTo(new[] { new Kapitel(10.5, 20.25, "") }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Eine_Datei_ohne_Kapitel_ergibt_nichts()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(Kapitelleser.Lesen("""{ "chapters": [] }"""), Is.Empty);
|
||||
Assert.That(Kapitelleser.Lesen("{}"), Is.Empty);
|
||||
Assert.That(Kapitelleser.Lesen(""), Is.Empty);
|
||||
Assert.That(Kapitelleser.Lesen("kein JSON"), Is.Empty);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user