diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs index 835a127..da9c3fc 100644 --- a/server/src/Flaro.Api/Markenerkennung.cs +++ b/server/src/Flaro.Api/Markenerkennung.cs @@ -227,25 +227,12 @@ public sealed class Markenerkennung( .AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct)) return false; - var ab = Math.Max(0, laufzeit.TotalSeconds - Suchbereich); - var proben = await abtaster.UeberblickAsync( - film.Path, ab, laufzeit.TotalSeconds - ab, ct); - await VermerkenAsync(db, filmId, Markenart.Abspann, ct); - var regeln = Abspannregeln.Vorgabe; - var grob = Abspannkante.Suchen(proben, regeln); + var gefunden = await BildabspannAsync( + db, abtaster, filmId, istFilm: true, film.Path, laufzeit.TotalSeconds, ct); - if (grob is { } stelle) - { - var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab; - var kante = await Abspannkante.VerfeinernAsync( - hell, stelle, regeln, wo => abtaster.HelligkeitAsync(film.Path, wo, ct)); - - await SchreibenAsync(db, filmId, istFilm: true, Markenart.Abspann, - kante, laufzeit.TotalSeconds, ct); - } - else + if (!gefunden) { // Nichts gefunden ist ein Ergebnis. Eine alte Marke, die jetzt // nicht mehr bestätigt wird, gehört weg — sonst bliebe ein @@ -256,7 +243,43 @@ public sealed class Markenerkennung( } await db.SaveChangesAsync(ct); - return grob is not null; + return gefunden; + } + + /// Sucht den Abspann im Bild — bei einem Film wie bei einer Folge. + /// + /// + /// + /// Dunkel und bis zum Ende durchhalten — mehr braucht es nicht, + /// und mehr hat ein Abspann auch nicht gemeinsam. Erst ein grober + /// Überblick über die letzten zwanzig Minuten aus Schlüsselbildern + /// (billig, aber auf fünf bis zehn Sekunden ungenau), dann eine Handvoll + /// kurzer Sonden für die Kante. + /// + /// + /// Warum das auch bei Folgen gebraucht wird. Der Tonabgleich lebt + /// davon, dass der Abspann in jeder Folge derselbe ist — und genau das + /// stimmt beim Staffelfinale oft nicht: anderer Schluss, andere Musik, + /// kein Treffer. Das Bild weiss davon nichts; dort sieht das Finale aus + /// wie jede andere Folge. + /// + /// + private async Task BildabspannAsync( + FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm, + string pfad, double laufzeit, CancellationToken ct) + { + var ab = Math.Max(0, laufzeit - Suchbereich); + var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct); + + var regeln = Abspannregeln.Vorgabe; + if (Abspannkante.Suchen(proben, regeln) is not { } stelle) return false; + + var hell = Abspannkante.HellDavor(proben, stelle, regeln) ?? ab; + var kante = await Abspannkante.VerfeinernAsync( + hell, stelle, regeln, wo => abtaster.HelligkeitAsync(pfad, wo, ct)); + + return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann, + kante, laufzeit, ct); } private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct) @@ -264,6 +287,7 @@ public sealed class Markenerkennung( using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); var leser = raum.ServiceProvider.GetRequiredService(); + var abtaster = raum.ServiceProvider.GetRequiredService(); if (!await leser.VorhandenAsync(ct)) { @@ -356,6 +380,42 @@ public sealed class Markenerkennung( gefunden++; } } + + if (!fehlt.Contains(Markenart.Abspann)) continue; + + // **Was der Ton nicht fand, sucht das Bild.** + // + // Der Abgleich lebt davon, dass der Abspann in jeder Folge + // derselbe ist — und beim Staffelfinale stimmt das oft nicht: + // anderer Schluss, andere Musik, kein Treffer. Genau dort fiel + // die letzte Folge jeder Staffel durch, und nur dort. Das Bild + // weiss davon nichts; dort sieht das Finale aus wie jede + // andere Folge. + // + // **Nur für die Lücken.** Der Überblick dekodiert zwanzig + // Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft + // er gar nicht, mit Lücke ein- bis zweimal. Erst speichern, + // sonst sieht die Abfrage die eben geschriebenen Marken nicht. + await db.SaveChangesAsync(ct); + + var nummern = geordnet.Select(f => f.Id).ToList(); + var mitAbspann = await db.Marken.AsNoTracking() + .Where(m => m.Art == Markenart.Abspann + && m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value)) + .Select(m => m.EpisodeId!.Value) + .ToListAsync(ct); + + foreach (var folge in geordnet) + { + if (mitAbspann.Contains(folge.Id)) continue; + if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue; + + if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false, + folge.Path, dauer.TotalSeconds, ct)) + { + gefunden++; + } + } } // Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke, diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 625f00a..b4d5ad4 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -455,9 +455,20 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten', async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); })); + // **Drei Punkte heissen „mehr davon", nicht „tu etwas".** + // + // Hier taten sie etwas: wegräumen aus „Fortsetzen", ohne Rückfrage. + // Jeder, der sie sah, hat ein Menü erwartet, draufgetippt — und dann + // baute die Seite sich neu auf und die Punkte waren weg. Von aussen + // sah das aus wie ein Fehler, und ohne Rückmeldung war nicht einmal + // zu erkennen, was passiert war. if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) { - reihe.appendChild(rundKnopf('mehr', false, 'Aus „Fortsetzen" entfernen', - async () => { await weglegen(eintrag.id); await laden(); })); + reihe.appendChild(rundKnopf('schliessen', false, 'Aus „Fortsetzen" entfernen', + async () => { + await weglegen(eintrag.id); + hinweisZeigen('Aus „Fortsetzen" entfernt.'); + await laden(); + })); } // **Diesen einen Titel neu holen, nicht den ganzen Bestand.** @@ -1094,9 +1105,14 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem function markenwort(marken: Folge['markers']): string { if (!istAdmin()) return ''; if (!marken || marken.length === 0) return 'keine Marken'; + // **Mit Zeiten, nicht bloss mit Namen.** Dass eine Marke da ist, sagt + // noch nicht, dass sie an der richtigen Stelle steht — und genau das + // ist der Unterschied zwischen „die Erkennung hat nichts gefunden" + // und „der Knopf kommt zur falschen Minute". return marken - .map(m => m.kind === 'intro' ? 'Vorspann' : 'Abspann') - .join(' + '); + .map(m => `${m.kind === 'intro' ? 'Vorspann' : 'Abspann'} ` + + `${dauer(m.startSeconds)}–${dauer(m.endSeconds)}`) + .join(' · '); } function folgenblock(folge: Folge): HTMLElement {