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 {