diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs index a32266e..6f6734a 100644 --- a/server/src/Flaro.Api/Markenerkennung.cs +++ b/server/src/Flaro.Api/Markenerkennung.cs @@ -390,6 +390,7 @@ public sealed class Markenerkennung( using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var abtaster = raum.ServiceProvider.GetRequiredService(); + var kapitelleser = raum.ServiceProvider.GetRequiredService(); 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. /// private async Task 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(); var leser = raum.ServiceProvider.GetRequiredService(); var abtaster = raum.ServiceProvider.GetRequiredService(); + var kapitelleser = raum.ServiceProvider.GetRequiredService(); 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( } } + /// Was die Datei an Kapiteln mitbringt — in einem Halbsatz. + private static string Kapitellage(IReadOnlyList 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}“."; + } + /// „41:12" oder „1:52:10" — kurz genug für eine Protokollzeile. private static string Zeit(double sekunden) { diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 87356a1..379f9d6 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -175,6 +175,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Bei Filmen führt kein Ton zum Abspann — nur das Bild. builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/server/src/Flaro.Core/Media/Abspannkapitel.cs b/server/src/Flaro.Core/Media/Abspannkapitel.cs new file mode 100644 index 0000000..d7fee59 --- /dev/null +++ b/server/src/Flaro.Core/Media/Abspannkapitel.cs @@ -0,0 +1,72 @@ +namespace Flaro.Core.Media; + +/// Ein Kapitel, wie es in der Datei steht. +/// +/// Anfang in Sekunden. +/// Ende in Sekunden. +/// Was draufsteht — oft leer oder „Chapter 12". +public readonly record struct Kapitel(double Von, double Bis, string Titel); + +/// Findet den Abspann, wenn die Datei ihn selbst benennt. +/// +/// +/// +/// Warum neben der Helligkeit. 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. +/// +/// +/// Nur, wenn der Name 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. +/// +/// +public static class Abspannkapitel +{ + /// Woran ein Abspannkapitel zu erkennen ist. + /// + /// + /// 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. + /// + private static readonly string[] Woerter = + [ + "abspann", "nachspann", "credit", "closing", "outro", "schlusstitel", + ]; + + /// Ab welchem Anteil der Laufzeit ein Kapitel in Frage kommt. + /// + /// + /// Ein Abspann liegt am Ende. Ein Kapitel namens „Opening Credits" bei + /// Minute zwei trägt dasselbe Wort und ist das Gegenteil. + /// + private const double Spaetestens = 0.6; + + /// Wo der Abspann laut Datei anfängt — oder null. + public static double? Suchen( + IReadOnlyList 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)); +} diff --git a/server/src/Flaro.Media/Kapitelleser.cs b/server/src/Flaro.Media/Kapitelleser.cs new file mode 100644 index 0000000..bbb9cba --- /dev/null +++ b/server/src/Flaro.Media/Kapitelleser.cs @@ -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; + +/// Liest die Kapitelmarken einer Datei. +/// +/// +/// +/// Warum überhaupt. 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. +/// +/// +/// Ein Kapitel sagt es dagegen genau — 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. +/// +/// +/// Billig. ffprobe liest dafür nur den Kopf der Datei, kein Bild. +/// Millisekunden gegen zwanzig Minuten Schlüsselbilder. +/// +/// +public sealed class Kapitelleser(FfmpegOptions optionen, ILogger log) +{ + public async Task> 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 []; + } + } + + /// Die Antwort von -show_chapters auswerten. + /// + /// + /// Getrennt vom Aufruf, damit es sich ohne ffprobe prüfen lässt — wie + /// bei Bildabtaster.Lesen und Tonabdruckleser.Lesen. + /// + public static IReadOnlyList 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(); + 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); + } +} diff --git a/server/tests/Flaro.Core.Tests/AbspannkapitelTests.cs b/server/tests/Flaro.Core.Tests/AbspannkapitelTests.cs new file mode 100644 index 0000000..6162cfa --- /dev/null +++ b/server/tests/Flaro.Core.Tests/AbspannkapitelTests.cs @@ -0,0 +1,106 @@ +using Flaro.Core.Media; + +namespace Flaro.Core.Tests; + +/// Wann ein Kapitel der Abspann ist — und wann nicht. +/// +/// +/// Der Anlass. 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. +/// +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 + { + new(0, 600, "Opening"), + new(600, 6600, "Der Film"), + new(6600, 7200, "End Credits"), + }; + + Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6600)); + } + + /// Deutsch genauso — beides kommt in derselben Sammlung vor. + [Test] + public void Abspann_auf_Deutsch_auch() + { + var kapitel = new List { new(6900, 7200, "Abspann") }; + + Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.EqualTo(6900)); + } + + /// „Chapter 12" ist keine Auskunft. + /// + /// + /// 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. + /// + [Test] + public void Namenlose_Kapitel_zaehlen_nicht() + { + var kapitel = new List + { + new(0, 3600, "Chapter 1"), + new(3600, 7200, "Chapter 2"), + }; + + Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null); + } + + /// „Opening Credits" trägt dasselbe Wort und ist das Gegenteil. + [Test] + public void Ein_Vorspannkapitel_zaehlt_nicht() + { + var kapitel = new List { new(60, 180, "Opening Credits") }; + + Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null); + } + + /// Ein Abspannkapitel von zehn Sekunden ist keins. + [Test] + public void Zu_kurz_zaehlt_nicht() + { + var kapitel = new List { new(7190, 7200, "Credits") }; + + Assert.That(Abspannkapitel.Suchen(kapitel, Laufzeit, Regeln), Is.Null); + } + + /// Zerfällt er in zwei, gilt der erste. + [Test] + public void Der_frueheste_Treffer_gewinnt() + { + var kapitel = new List + { + 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); + } + + /// Ohne Laufzeit lässt sich „am Ende" nicht beantworten. + [Test] + public void Ohne_Laufzeit_kein_Fund() + { + var kapitel = new List { new(6600, 7200, "Credits") }; + + Assert.That(Abspannkapitel.Suchen(kapitel, 0, Regeln), Is.Null); + } +} diff --git a/server/tests/Flaro.Media.Tests/KapitelleserTests.cs b/server/tests/Flaro.Media.Tests/KapitelleserTests.cs new file mode 100644 index 0000000..6dea38b --- /dev/null +++ b/server/tests/Flaro.Media.Tests/KapitelleserTests.cs @@ -0,0 +1,54 @@ +using Flaro.Core.Media; +using Flaro.Media; + +namespace Flaro.Media.Tests; + +/// Die Antwort von ffprobe -show_chapters lesen — ohne ffprobe. +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"), + })); + } + + /// Ohne tags bleibt der Name leer — und das ist kein Fehler. + [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); + }); + } +}