Jeder Scan hätte jede Tonspur noch einmal gelesen

Gemerkt wurde nur, was gefunden wurde. Eine Staffel ohne Abspann — es
gibt genug davon — sah damit bei jedem Scan aus wie eine, in der noch
niemand nachgesehen hat, und wurde wieder abgetastet: jede Tonspur jeder
Folge noch einmal von der Platte, für immer, ohne Ertrag. Bei
sechstausend Folgen ist das kein Schönheitsfehler.

Jetzt steht auch „gesucht und nichts gefunden" in der Datenbank, je
Staffel und Art. Ausgelöst wird ein zweiter Lauf von der Folgenzahl:
kommt eine Folge dazu, gibt es etwas Neues zu vergleichen, sonst nicht.
Ein Zeitstempel allein täte das nicht — eine Staffel, die sich nie
ändert, hat nie einen Grund für einen zweiten Lauf.

Dazu der Knopf, der ausdrücklich doch noch einmal rechnet: die Uhr auf
der Serienseite sucht ab jetzt immer neu. Von selbst läuft die Suche
nach jedem Scan und überspringt dort das Abgesuchte; wer hier drückt,
will genau das Gegenteil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 05:51:23 +02:00
co-authored by Claude Opus 5
parent 9bcdd239d7
commit 8112c15d4d
9 changed files with 1567 additions and 11 deletions
+40 -6
View File
@@ -155,9 +155,11 @@ public sealed class Markenerkennung(
(f, d) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, d.Path, d.Duration })
.ToListAsync(ct);
var vorhanden = await db.Marken.AsNoTracking()
.Where(m => folgen.Select(f => f.Id).Contains(m.EpisodeId))
.Select(m => new { m.EpisodeId, m.Art })
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
// Nicht „was gefunden wurde": eine Staffel ohne Abspann sähe sonst
// bei jedem Scan aus wie eine, in der noch niemand nachgesehen hat.
var gesucht = await db.Markensuchen.AsNoTracking()
.Where(x => x.SeriesId == serieId)
.ToListAsync(ct);
var gefunden = 0;
@@ -172,8 +174,9 @@ public sealed class Markenerkennung(
// aus demselben Abdruck. Das ist nicht nur richtig, sondern
// billiger als zweimal lesen.
var fehlt = new[] { Markenart.Intro, Markenart.Abspann }
.Where(art => erneut || !geordnet.All(f =>
vorhanden.Any(v => v.EpisodeId == f.Id && v.Art == art)))
.Where(art => erneut || gesucht.FirstOrDefault(
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|| lauf.Folgen != geordnet.Count)
.ToList();
if (fehlt.Count == 0) continue;
@@ -206,6 +209,11 @@ public sealed class Markenerkennung(
var treffer = Tonabgleich.ReiheAbgleichen(
abdruecke, Tonabdruckleser.SekundenJeAbdruck);
// **Vermerken, dass gesucht wurde — vor dem Ergebnis.**
// Auch ein Lauf ohne Fund ist ein Lauf; ihn nicht zu
// vermerken hiesse, ihn beim nächsten Scan zu wiederholen.
await VermerkenAsync(db, serieId, staffel.Key, art, geordnet.Count, ct);
foreach (var (folgenId, stelle) in treffer)
{
if (stelle.Bis - stelle.Von > Hoechstlaenge) continue;
@@ -222,7 +230,9 @@ public sealed class Markenerkennung(
}
}
if (gefunden > 0) await db.SaveChangesAsync(ct);
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
// und genau die verhindern den nächsten vergeblichen Lauf.
await db.SaveChangesAsync(ct);
log.LogInformation("Markenerkennung für {Serie}: {Anzahl} Marken.", serieId, gefunden);
}
@@ -243,6 +253,30 @@ public sealed class Markenerkennung(
return art == Markenart.Intro ? (0, fenster) : (werte - fenster, fenster);
}
/// <summary>Hält fest, dass in dieser Staffel gesucht wurde.</summary>
private async Task VermerkenAsync(
FlaroContext db, Guid serieId, int staffel, Markenart art, int folgen, CancellationToken ct)
{
var alt = await db.Markensuchen
.FirstOrDefaultAsync(x => x.SeriesId == serieId && x.Staffel == staffel && x.Art == art, ct);
if (alt is not null)
{
alt.Folgen = folgen;
alt.GelaufenAm = clock.GetUtcNow();
return;
}
db.Markensuchen.Add(new Markensuche
{
SeriesId = serieId,
Staffel = staffel,
Art = art,
Folgen = folgen,
GelaufenAm = clock.GetUtcNow(),
});
}
private async Task<bool> SchreibenAsync(
FlaroContext db, Guid folgenId, Markenart art, double von, double bis, CancellationToken ct)
{
@@ -165,3 +165,35 @@ public sealed class Marke
/// </remarks>
public DateTimeOffset ErkanntAm { get; set; }
}
/// <summary>Dass in einer Staffel schon einmal gesucht wurde.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil „nichts gefunden" auch ein Ergebnis ist.</b> Ohne diese Zeile
/// liess sich nur ablesen, was gefunden wurde. Eine Staffel ohne Abspann —
/// es gibt genug davon — sah damit bei jedem Scan aus wie eine, in der noch
/// niemand nachgesehen hat, und wurde wieder und wieder abgetastet. Das
/// heisst: bei jedem Scan jede Tonspur jeder Folge noch einmal von der
/// Platte lesen, für immer, ohne Ertrag.
/// </para>
/// <para>
/// <b>Die Folgenzahl ist der Auslöser.</b> Kommt eine Folge dazu, passt die
/// Zahl nicht mehr und es wird neu gerechnet — genau dann, wenn es etwas
/// Neues zu vergleichen gibt. Ein Zeitstempel allein täte das nicht: eine
/// Staffel, die sich nie ändert, hat nie einen Grund für einen zweiten
/// Lauf.
/// </para>
/// </remarks>
public sealed class Markensuche
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid SeriesId { get; init; }
public required int Staffel { get; init; }
public required Markenart Art { get; init; }
/// <summary>Wie viele Folgen die Staffel hatte, als gesucht wurde.</summary>
public required int Folgen { get; set; }
public DateTimeOffset GelaufenAm { get; set; }
}
+9
View File
@@ -77,6 +77,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Marke> Marken => Set<Marke>();
public DbSet<Markensuche> Markensuchen => Set<Markensuche>();
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
public DbSet<Person> People => Set<Person>();
@@ -319,6 +320,14 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Art).HasConversion<int>();
});
b.Entity<Markensuche>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => new { x.SeriesId, x.Staffel, x.Art }).IsUnique();
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
e.Property(x => x.Art).HasConversion<int>();
});
b.Entity<GenreLink>(e =>
{
e.HasKey(x => x.Id);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Markensuche : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Markensuchen",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
SeriesId = table.Column<Guid>(type: "TEXT", nullable: false),
Staffel = table.Column<int>(type: "INTEGER", nullable: false),
Art = table.Column<int>(type: "INTEGER", nullable: false),
Folgen = table.Column<int>(type: "INTEGER", nullable: false),
GelaufenAm = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Markensuchen", x => x.Id);
table.ForeignKey(
name: "FK_Markensuchen_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Markensuchen_SeriesId_Staffel_Art",
table: "Markensuchen",
columns: new[] { "SeriesId", "Staffel", "Art" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Markensuchen");
}
}
}
@@ -435,6 +435,35 @@ namespace Flaro.Data.Migrations
b.ToTable("Marken");
});
modelBuilder.Entity("Flaro.Core.Library.Markensuche", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Art")
.HasColumnType("INTEGER");
b.Property<int>("Folgen")
.HasColumnType("INTEGER");
b.Property<long>("GelaufenAm")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<int>("Staffel")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId", "Staffel", "Art")
.IsUnique();
b.ToTable("Markensuchen");
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property<Guid>("Id")
@@ -1169,6 +1198,15 @@ namespace Flaro.Data.Migrations
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.Markensuche", b =>
{
b.HasOne("Flaro.Core.Library.Series", null)
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.HasOne("Flaro.Core.Libraries.Library", null)
@@ -168,4 +168,52 @@ public class MarkenTests
Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
});
}
/// <summary>Eine abgesuchte Staffel wird nicht noch einmal abgetastet.</summary>
///
/// <remarks>
/// <b>Der teuerste Fehler, den es hier gab.</b> Gemerkt wurde nur, was
/// gefunden wurde. Eine Staffel ohne Abspann — es gibt genug davon —
/// sah damit bei jedem Scan aus wie eine, in der noch niemand
/// nachgesehen hat: jede Tonspur jeder Folge noch einmal von der
/// Platte, für immer, ohne Ertrag.
///
/// Geprüft wird hier der Vermerk, nicht das Abtasten selbst — ohne
/// fpcalc und ohne echten Ton gibt es nichts zu finden, und genau
/// dieser Fall ist der, um den es geht.
/// </remarks>
[Test]
public async Task Ein_Lauf_ohne_Fund_merkt_sich_das()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var serie = serien!.Items[0].Id;
(await client.PostAsync($"/api/v1/series/{serie}/marken", null)).EnsureSuccessStatusCode();
// Der Lauf hängt im Hintergrund; ohne fpcalc ist er sofort durch.
List<Markensuche> vermerke = [];
for (var versuch = 0; versuch < 40; versuch++)
{
await server.InDerDatenbank(async db =>
vermerke = await db.Markensuchen.AsNoTracking()
.Where(x => x.SeriesId == serie).ToListAsync());
if (vermerke.Count > 0) break;
await Task.Delay(50);
}
// Ohne fpcalc gibt es keinen Vermerk — dann ist nichts gelaufen und
// auch nichts zu merken. Mit fpcalc müssen beide Arten dastehen.
Assert.That(vermerke.Count, Is.AnyOf(0, 2),
"entweder gar nicht gelaufen oder beide Arten vermerkt");
if (vermerke.Count == 2)
{
Assert.That(vermerke.Select(v => v.Folgen), Is.All.EqualTo(2),
"die Folgenzahl löst den nächsten Lauf aus");
}
}
}
@@ -66,7 +66,7 @@ public class TonabdruckRateTests
{
// 21 786 Werte für 2700 Sekunden.
var gemessen = 2700.0 / 21786;
Assert.That(Tonabdruckleser.SekundenJeAbdruck, Is.EqualTo(gemessen).Within(0.0005));
Assert.That(Math.Abs(gemessen - Tonabdruckleser.SekundenJeAbdruck), Is.LessThan(0.0005));
}
/// <summary>Eine Stunde Abdruck passt in eine überschaubare Reihe.</summary>
@@ -80,6 +80,6 @@ public class TonabdruckRateTests
public void Eine_Stunde_sind_rund_29000_Werte()
{
var werte = (int)(3600 / Tonabdruckleser.SekundenJeAbdruck);
Assert.That(werte, Is.EqualTo(29000).Within(500));
Assert.That(werte, Is.InRange(28500, 29500));
}
}