Zweimal vermerkt, einmal gespeichert, Serie verloren

„Markensuche für ‚A Knight of the Seven Kingdoms' abgebrochen — An error
occurred while saving the entity changes." Das war meiner, und er hat
mehr gekostet als eine Fehlermeldung: der Lauf brach beim Speichern ab,
und damit war alles weg, was er für diese Serie gefunden hatte.

`VermerkenAsync` sah in der Datenbank nach, ob die Staffel schon
vermerkt ist — und eine Abfrage sieht nicht, was derselbe Lauf eben erst
hinzugefügt und noch nicht gespeichert hat. Seit der Bildweg dazukam,
wird für dieselbe Staffel zweimal vermerkt: einmal nach dem Tonabgleich,
einmal vor dem Bild. Die zweite Abfrage fand nichts, legte eine zweite
Zeile an, und der eindeutige Schlüssel über (Serie, Staffel, Art) warf
sie beim Speichern wieder hinaus. Jetzt wird erst im Änderungsspeicher
nachgesehen, dann in der Datenbank.

**Und die Meldung taugte nichts.** Das Tagebuch hängt nur `Message` an,
und EF Core schreibt dort „See the inner exception for details." — eine
Zeile, die ausdrücklich sagt, dass sie die Auskunft nicht enthält. Alle
vier Abbruchmeldungen nennen jetzt den innersten Grund über
`Fehlertext.FuerMenschen`.

**Dazu der zweite Fund aus demselben Protokoll.** „(T)Raumschiff
Surprise": kein Abspann, 1318 Proben, Schwelle 51,1, letzte Probe 17 —
das Ende ist also dunkel, und trotzdem kein Fund. 1318 Proben auf zwanzig
Minuten heissen eine alle 0,9 Sekunden, und die Nachsicht gegenüber
hellen Stellen mitten im Abspann war in *Proben* angegeben: zwei Stück,
dort also 1,8 Sekunden. Bei einer Datei mit einem Schlüsselbild alle zehn
Sekunden waren dieselben zwei Proben zwanzig Sekunden. Dasselbe
Zugeständnis, zehnfach verschieden — genau der Fehler, den ich eine Zeile
höher bei der Schlusstafel schon behoben hatte. `Nachsicht` zählt jetzt
Sekunden, zehn davon.

**Und die Suche sagt, woran es lag.** „kein Abspann — das Ende ist hell"
gegen „die dunkle Strecke ist mit 20 s zu kurz" sind zwei verschiedene
Ursachen mit zwei verschiedenen Antworten; bisher stand dort beides Mal
dasselbe.

Vier neue Prüfungen, darunter dieselbe Szene bei 0,9 und bei 10 Sekunden
Abtastung. Mit der alten Probenzählung ist die dichte rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-15 13:31:48 +02:00
co-authored by Claude Opus 5
parent 767491ef03
commit 259996c5ad
3 changed files with 192 additions and 34 deletions
+33 -12
View File
@@ -117,7 +117,8 @@ public sealed class Markenerkennung(
} }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serieId); log.LogWarning(fehler, "Markensuche für {Serie} abgebrochen: {Grund}",
serieId, Fehlertext.FuerMenschen(fehler));
} }
finally finally
{ {
@@ -233,7 +234,12 @@ public sealed class Markenerkennung(
} }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Markensuche für „{Serie}“ abgebrochen.", titel); // **Mit dem innersten Text.** Das Tagebuch hängt nur
// `Message` an, und bei EF Core steht dort „See the inner
// exception for details." — eine Zeile, die ausdrücklich
// sagt, dass sie die Auskunft nicht enthält.
log.LogWarning(fehler, "Markensuche für „{Serie}“ abgebrochen: {Grund}",
titel, Fehlertext.FuerMenschen(fehler));
} }
finally { _laufend.TryRemove(id, out _); } finally { _laufend.TryRemove(id, out _); }
} }
@@ -260,8 +266,8 @@ public sealed class Markenerkennung(
try { await NachScanAsync(mediathek, erneut, CancellationToken.None); } try { await NachScanAsync(mediathek, erneut, CancellationToken.None); }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, log.LogWarning(fehler, "Markenlauf für Mediathek {Kennung} abgebrochen: {Grund}",
"Markenlauf für Mediathek {Kennung} abgebrochen.", mediathek); mediathek, Fehlertext.FuerMenschen(fehler));
} }
}); });
} }
@@ -303,7 +309,8 @@ public sealed class Markenerkennung(
try { if (await FilmAsync(film.Id, erneut, ct)) gefunden++; } try { if (await FilmAsync(film.Id, erneut, ct)) gefunden++; }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen.", titel); log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen: {Grund}",
titel, Fehlertext.FuerMenschen(fehler));
} }
} }
@@ -427,12 +434,12 @@ public sealed class Markenerkennung(
// allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder // allein ist keine Auskunft: ob die Datei zu wenige Schlüsselbilder
// hat, ob der Abspann zu hell war oder ob am Schluss eine Tafel // 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. // steht, sieht man nur an diesen drei Zahlen — und sonst gar nicht.
if (Abspannkante.Suchen(proben, regeln) is not { } stelle) if (Abspannkante.Suchen(proben, regeln, out var grund) is not { } stelle)
{ {
log.LogInformation( log.LogInformation(
"„{Titel}“: kein Abspann — {Proben} Probe(n), Schwelle {Schwelle:0.#}, " "„{Titel}“: kein Abspann — {Grund}. {Proben} Probe(n), "
+ "letzte Probe {Letzte:0.#}.", + "Schwelle {Schwelle:0.#}, letzte Probe {Letzte:0.#}.",
name, proben.Count, Abspannkante.Schwelle(proben, regeln), name, grund, proben.Count, Abspannkante.Schwelle(proben, regeln),
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN); proben.Count > 0 ? proben[^1].Helligkeit : double.NaN);
return false; return false;
} }
@@ -741,7 +748,9 @@ public sealed class Markenerkennung(
private async Task VermerkenAsync( private async Task VermerkenAsync(
FlaroContext db, Guid filmId, Markenart art, CancellationToken ct) FlaroContext db, Guid filmId, Markenart art, CancellationToken ct)
{ {
var alt = await db.Markensuchen var alt = db.Markensuchen.Local
.FirstOrDefault(x => x.MovieId == filmId && x.Art == art)
?? await db.Markensuchen
.FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct); .FirstOrDefaultAsync(x => x.MovieId == filmId && x.Art == art, ct);
if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; } if (alt is not null) { alt.GelaufenAm = clock.GetUtcNow(); return; }
@@ -759,8 +768,20 @@ public sealed class Markenerkennung(
private async Task VermerkenAsync( private async Task VermerkenAsync(
FlaroContext db, Guid serieId, int staffel, Markenart art, int folgen, CancellationToken ct) FlaroContext db, Guid serieId, int staffel, Markenart art, int folgen, CancellationToken ct)
{ {
var alt = await db.Markensuchen // **Erst im Änderungsspeicher nachsehen, dann in der Datenbank.**
.FirstOrDefaultAsync(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct); //
// Eine Abfrage sieht nicht, was derselbe Lauf eben erst hinzugefügt
// und noch nicht gespeichert hat. Seit der Bildweg dazukam, wird
// für dieselbe Staffel zweimal vermerkt — einmal nach dem
// Tonabgleich, einmal vor dem Bild —, und die zweite Abfrage fand
// nichts, legte eine zweite Zeile an und brach beim Speichern am
// eindeutigen Schlüssel ab. Die Serie verlor dabei alles, was in
// diesem Lauf gefunden worden war.
var alt = db.Markensuchen.Local
.FirstOrDefault(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art)
?? await db.Markensuchen
.FirstOrDefaultAsync(
x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct);
if (alt is not null) if (alt is not null)
{ {
+59 -20
View File
@@ -46,14 +46,26 @@ public readonly record struct Abspannregeln
/// </remarks> /// </remarks>
public double Anteil { get; init; } public double Anteil { get; init; }
/// <summary>Wie viele helle Proben am Stück eine Strecke nicht brechen.</summary> /// <summary>Wie viele Sekunden hell eine dunkle Strecke nicht brechen.</summary>
/// ///
/// <remarks> /// <remarks>
/// <para>
/// Eine Schrifttafel blitzt hell auf, ein Logo steht zwei Sekunden im /// Eine Schrifttafel blitzt hell auf, ein Logo steht zwei Sekunden im
/// Bild. Ohne Nachsicht zerfiele ein Abspann in fünf kurze Strecken, /// Bild. Ohne Nachsicht zerfiele ein Abspann in fünf kurze Strecken,
/// von denen keine lang genug wäre. /// von denen keine lang genug wäre.
/// </para>
/// <para>
/// <b>In Sekunden, nicht in Proben</b> — dieselbe Lehre wie bei
/// <see cref="Schlusstafel"/>, hier eine Zeile höher übersehen. Als
/// Probenzahl hing die Nachsicht daran, wie dicht die Datei ihre
/// Schlüsselbilder setzt: gemessen an „(T)Raumschiff Surprise" kommen
/// 1318 Proben auf zwanzig Minuten, also eine alle 0,9 Sekunden —
/// zwei Proben waren dort 1,8 Sekunden Nachsicht. Bei einer Datei mit
/// einem Schlüsselbild alle zehn Sekunden waren es zwanzig. Dasselbe
/// Zugeständnis, zehnfach verschieden.
/// </para>
/// </remarks> /// </remarks>
public int Lücke { get; init; } public double Nachsicht { get; init; }
/// <summary>Wie viele Sekunden hell hinter dem Abspann erlaubt sind.</summary> /// <summary>Wie viele Sekunden hell hinter dem Abspann erlaubt sind.</summary>
/// ///
@@ -73,7 +85,7 @@ public readonly record struct Abspannregeln
{ {
Dunkelschwelle = 35, Dunkelschwelle = 35,
Anteil = 0.55, Anteil = 0.55,
Lücke = 2, Nachsicht = 10,
Schlusstafel = 20, Schlusstafel = 20,
Mindestlänge = 45, Mindestlänge = 45,
Höchstlänge = 12 * 60, Höchstlänge = 12 * 60,
@@ -137,9 +149,25 @@ public static class Abspannkante
return Math.Max(regeln.Dunkelschwelle, grundton * regeln.Anteil); return Math.Max(regeln.Dunkelschwelle, grundton * regeln.Anteil);
} }
public static double? Suchen(IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) public static double? Suchen(IReadOnlyList<Bildprobe> proben, Abspannregeln regeln) =>
Suchen(proben, regeln, out _);
/// <param name="grund">
/// Woran es lag — für das Protokoll. „Kein Abspann gefunden" ist keine
/// Auskunft: ob das Ende zu hell war, die Strecke zu kurz oder zu lang,
/// sind drei verschiedene Ursachen mit drei verschiedenen Antworten,
/// und ohne diese Zeile beginnt jede Nachfrage wieder beim Raten.
/// </param>
public static double? Suchen(
IReadOnlyList<Bildprobe> proben, Abspannregeln regeln, out string grund)
{ {
if (proben.Count < 2) return null; grund = "gefunden";
if (proben.Count < 2)
{
grund = "zu wenige Proben";
return null;
}
var schwelle = Schwelle(proben, regeln); var schwelle = Schwelle(proben, regeln);
// **Von hinten.** Der Abspann endet am Dateiende; alles, was davor // **Von hinten.** Der Abspann endet am Dateiende; alles, was davor
@@ -150,33 +178,44 @@ public static class Abspannkante
// Sender, Studio, Förderer —, oft hell und ein paar Sekunden lang. // Sender, Studio, Förderer —, oft hell und ein paar Sekunden lang.
// Darauf zu bestehen, dass die letzte Probe dunkel ist, hiess: ein // Darauf zu bestehen, dass die letzte Probe dunkel ist, hiess: ein
// einziges weisses Schlussbild macht den ganzen Abspann unsichtbar. // einziges weisses Schlussbild macht den ganzen Abspann unsichtbar.
// Dieselbe Nachsicht wie innerhalb der Strecke, also `Lücke`.
var letzte = proben.Count - 1; var letzte = proben.Count - 1;
var ende = proben[letzte].Sekunde; var ende = proben[letzte].Sekunde;
while (letzte > 0 && proben[letzte].Helligkeit >= schwelle) while (letzte > 0 && proben[letzte].Helligkeit >= schwelle)
{ {
if (ende - proben[letzte].Sekunde >= regeln.Schlusstafel) return null; if (ende - proben[letzte].Sekunde >= regeln.Schlusstafel)
{
grund = "das Ende ist zu lange hell";
return null;
}
letzte--; letzte--;
} }
if (proben[letzte].Helligkeit >= schwelle) return null; if (proben[letzte].Helligkeit >= schwelle)
var anfang = letzte;
var hellAmStück = 0;
for (var i = letzte - 1; i >= 0; i--)
{ {
if (proben[i].Helligkeit < schwelle) grund = "das Ende ist hell";
{ return null;
anfang = i;
hellAmStück = 0;
continue;
} }
if (++hellAmStück > regeln.Lücke) break; // **Rückwärts, und helle Stellen in Sekunden verzeihen.** Gezählt
// wurden hier früher Proben, und was das bedeutet, hängt allein an
// der Schlüsselbilddichte der Datei — siehe `Nachsicht`.
var anfang = letzte;
for (var i = letzte - 1; i >= 0; i--)
{
if (proben[i].Helligkeit < schwelle) { anfang = i; continue; }
if (proben[anfang].Sekunde - proben[i].Sekunde > regeln.Nachsicht) break;
} }
var länge = proben[letzte].Sekunde - proben[anfang].Sekunde; var länge = proben[letzte].Sekunde - proben[anfang].Sekunde;
if (länge < regeln.Mindestlänge || länge > regeln.Höchstlänge) return null; if (länge < regeln.Mindestlänge)
{
grund = $"die dunkle Strecke ist mit {länge:0} s zu kurz";
return null;
}
if (länge > regeln.Höchstlänge)
{
grund = $"die dunkle Strecke ist mit {länge:0} s zu lang";
return null;
}
// **Die erste dunkle Probe ist nicht der Anfang.** Zwischen ihr und // **Die erste dunkle Probe ist nicht der Anfang.** Zwischen ihr und
// der letzten hellen davor liegt die Kante irgendwo; wo genau, sagt // der letzten hellen davor liegt die Kante irgendwo; wo genau, sagt
@@ -72,13 +72,20 @@ public class AbspannkanteTests
} }
/// <summary>Eine helle Schrifttafel mittendrin bricht die Strecke nicht.</summary> /// <summary>Eine helle Schrifttafel mittendrin bricht die Strecke nicht.</summary>
///
/// <remarks>
/// <b>Eine Probe, also zehn Sekunden.</b> Hier standen zwei, und das
/// war unter der alten Zählweise gedacht: nachgesehen wurde, wie viele
/// <i>Proben</i> am Stück hell sind, nicht wie lange. Zwanzig Sekunden
/// helles Bild sind keine Schrifttafel mehr, sondern eine Szene — dass
/// die Suche dort abbricht, ist richtig und steht in
/// <c>AbspannNachsichtTests</c>.
/// </remarks>
[Test] [Test]
public void Eine_kurze_helle_Tafel_bricht_nichts() public void Eine_kurze_helle_Tafel_bricht_nichts()
{ {
var proben = Reihe(0, 600, abspannAb: 420); var proben = Reihe(0, 600, abspannAb: 420);
// Zwei Proben, also zwanzig Sekunden, sind hell.
proben[50] = proben[50] with { Helligkeit = 140 }; proben[50] = proben[50] with { Helligkeit = 140 };
proben[51] = proben[51] with { Helligkeit = 140 };
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420)); Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(420));
} }
@@ -310,3 +317,94 @@ public class AbspannSchlusstafelTests
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null); Assert.That(Abspannkante.Suchen(proben, Regeln), Is.Null);
} }
} }
/// <summary>Dieselbe Szene, zweierlei Schlüsselbilddichte.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Fehler, für den diese Prüfungen stehen.</b> Die Nachsicht
/// gegenüber hellen Stellen mitten im Abspann war in <i>Proben</i>
/// angegeben, nicht in Sekunden. Was das bedeutet, hängt allein daran,
/// wie dicht eine Datei ihre Schlüsselbilder setzt — und das schwankt um
/// eine Zehnerpotenz.
/// </para>
/// <para>
/// Gemessen am Bestand: „(T)Raumschiff Surprise" liefert 1318 Proben auf
/// zwanzig Minuten, also eine alle 0,9 Sekunden. Zwei Proben Nachsicht
/// waren dort 1,8 Sekunden; bei einer Datei mit einem Schlüsselbild alle
/// zehn Sekunden waren dieselben zwei Proben zwanzig Sekunden. Ein
/// fünfsekündiges Logo mitten im Abspann zerriss die Strecke deshalb nur
/// bei der dicht abgetasteten Datei — und genau die fiel durch.
/// </para>
/// </remarks>
public class AbspannNachsichtTests
{
private static readonly Abspannregeln Regeln = Abspannregeln.Vorgabe;
/// <summary>Abspann ab 2340 s, darin fünf Sekunden helles Logo.</summary>
private static List<Bildprobe> Fenster(double takt)
{
var proben = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += takt)
{
var imLogo = t >= 2500 && t < 2505;
proben.Add(new Bildprobe(t, t < 2340 ? 126 : imLogo ? 180 : 32));
}
return proben;
}
/// <summary>Dicht abgetastet — der Fall, der im Bestand durchfiel.</summary>
[Test]
public void Ein_Logo_zerreisst_den_Abspann_nicht_bei_dichten_Proben()
{
Assert.That(Abspannkante.Suchen(Fenster(takt: 0.9), Regeln),
Is.EqualTo(2340.0).Within(2));
}
/// <summary>Dünn abgetastet — derselbe Film, dieselbe Antwort.</summary>
[Test]
public void Und_auch_nicht_bei_duennen_Proben()
{
Assert.That(Abspannkante.Suchen(Fenster(takt: 10), Regeln),
Is.EqualTo(2340.0).Within(11));
}
/// <summary>Eine lange helle Strecke bricht weiterhin ab.</summary>
///
/// <remarks>
/// Sonst liefe die Suche rückwärts durch jede Nachtszene und meldete
/// den halben Film als Abspann.
/// </remarks>
[Test]
public void Eine_lange_helle_Strecke_bricht_die_Suche()
{
var proben = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += 1)
{
// Dunkel ab 2340, aber von 2400 bis 2460 eine Minute hell.
var hell = t < 2340 || (t >= 2400 && t < 2460);
proben.Add(new Bildprobe(t, hell ? 126 : 32));
}
// Gefunden wird nur das Stück hinter der hellen Minute.
Assert.That(Abspannkante.Suchen(proben, Regeln), Is.EqualTo(2460.0).Within(2));
}
/// <summary>Und der Grund steht für das Protokoll bereit.</summary>
[Test]
public void Der_Grund_wird_benannt()
{
var hell = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += 5) hell.Add(new Bildprobe(t, 126));
Abspannkante.Suchen(hell, Regeln, out var grund);
Assert.That(grund, Does.Contain("hell"));
var kurz = new List<Bildprobe>();
for (var t = 1500.0; t <= 2700; t += 5)
kurz.Add(new Bildprobe(t, t < 2680 ? 126 : 32));
Abspannkante.Suchen(kurz, Regeln, out var zuKurz);
Assert.That(zuKurz, Does.Contain("kurz"));
}
}