Ein Haken, der stehen blieb, als die Rechnung sich änderte

Ein Film stand bei 1:32 und tauchte in „Fortsetzen" trotzdem nicht auf.
Die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis:
die Spalte `InProgress`. Die Bruchrechnung „Stelle kleiner als Anteil der
Laufzeit" lässt sich nicht nach SQL übersetzen, also steht sie als
Wahrheitswert in der Zeile — und berechnet wird sie nur, wenn eine
Fortschrittsmeldung eintrifft. Die kaputte Meldung von vorgestern hatte
dort `false` hinterlassen; die Korrektur am Server verhinderte neuen
Schaden, räumte den alten aber nicht weg.

`Sehstandpflege` rechnet nach jedem Update einmal durch: erst die
Laufzeit aus der Datei nachtragen, dann neu bewerten — in dieser
Reihenfolge, denn eine Neubewertung auf Basis eines Stummels käme wieder
zu `false`. `UpdatedAt` bleibt, wo es war; danach ist die Reihe sortiert.

Und der Knopf heisst jetzt so wie die Reihe, über der er steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 22:41:22 +02:00
co-authored by Claude Opus 5
parent 5dfc22bdce
commit a609b2d087
5 changed files with 213 additions and 4 deletions
+9
View File
@@ -128,6 +128,12 @@ builder.Services.AddScoped<IBewertungsquelle>(sp => sp.GetRequiredService<OmdbCl
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
// Auskunft darüber, ob der Dienst antwortet.
builder.Services.AddScoped<Quellenprobe>();
// **Damit ein festgehaltenes Ergebnis nicht ewig falsch bleibt.** Ob ein
// Titel in „Fortsetzen" steht, ist eine gespeicherte Spalte — berechnet
// wird sie nur, wenn eine Fortschrittsmeldung eintrifft. Nach einem Update
// einmal durchrechnen fängt genau das ab.
builder.Services.AddSingleton<Sehstandpflege>();
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
@@ -376,6 +382,9 @@ if (Directory.Exists(oberfläche))
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
_ = app.Services.GetRequiredService<Sehstandpflege>()
.NachUpdateAsync(leben.ApplicationStopping);
_ = Task.Run(async () =>
{
try
+135
View File
@@ -0,0 +1,135 @@
using Flaro.Core.Playback;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Bringt die gemerkten Sehstände wieder mit der Wirklichkeit in Einklang.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> Ein Film stand bei 1:32, und in „Fortsetzen" tauchte
/// er trotzdem nicht auf — bis er einmal neu gestartet wurde. Der Grund:
/// die Reihe fragt nicht die Regel, sondern deren festgehaltenes Ergebnis,
/// die Spalte <c>InProgress</c>. Die Bruchrechnung „Stelle kleiner als
/// Anteil der Laufzeit" lässt sich nicht nach SQL übersetzen, deshalb steht
/// sie als Wahrheitswert in der Zeile.
/// </para>
/// <para>
/// <b>Und berechnet wird sie nur, wenn eine Meldung eintrifft.</b> Die
/// zerstörerische Meldung von vorgestern — zu kurze Laufzeit, Stelle
/// beschnitten, Häkchen gesetzt — hat dort <c>false</c> hinterlassen. Die
/// Korrektur am Server verhindert neuen Schaden, räumt den alten aber
/// nicht weg: dafür müsste jemand jeden betroffenen Titel einmal anspielen.
/// </para>
/// <para>
/// <b>Zwei Schritte, und die Reihenfolge zählt.</b> Erst die Laufzeit aus
/// der Datei nachtragen, dann neu bewerten — eine Neubewertung auf Basis
/// eines Stummels käme wieder zu <c>false</c>.
/// </para>
/// </remarks>
public sealed class Sehstandpflege(
IServiceScopeFactory scopes,
TimeProvider uhr,
ILogger<Sehstandpflege> log)
{
/// <summary>Unter diesem Schlüssel steht, für welchen Stand es schon lief.</summary>
///
/// <remarks>
/// <b>Einmal je Abbild.</b> Nach jedem Update einmal durchrechnen ist
/// billig — es ist ein Durchlauf über eine Tabelle mit ein paar tausend
/// Zeilen — und fängt genau diese Art Fehler ab: eine Regel ändert sich,
/// und das Festgehaltene bleibt stehen.
/// </remarks>
public const string Schluessel = "wartung.sehstaende.stand";
public async Task<int> NachUpdateAsync(CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var stand = await db.Settings.AsNoTracking().FirstOrDefaultAsync(s => s.Key == Schluessel, ct);
if (stand?.Value == Buildkennung.Build) return 0;
var geaendert = await LaufenAsync(ct);
var zeile = await db.Settings.FindAsync([Schluessel], ct);
if (zeile is null) db.Settings.Add(new Core.ServerSetting { Key = Schluessel, Value = Buildkennung.Build });
else zeile.Value = Buildkennung.Build;
await db.SaveChangesAsync(ct);
if (geaendert > 0)
{
log.LogInformation(
"Sehstände nachgerechnet: {Anzahl} Eintrag/Einträge stehen jetzt richtig.", geaendert);
}
return geaendert;
}
/// <summary>Trägt fehlende Laufzeiten nach und bewertet neu.</summary>
public async Task<int> LaufenAsync(CancellationToken ct = default)
{
using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
var regeln = await raum.ServiceProvider
.GetRequiredService<Wiedergabeeinstellungen>().RegelnAsync();
var jetzt = uhr.GetUtcNow();
const int Stapel = 500;
var geaendert = 0;
for (var sprung = 0; ; sprung += Stapel)
{
var zeilen = await db.UserItemStates
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync(ct);
if (zeilen.Count == 0) break;
// **Die Laufzeiten in einem Zug, nicht je Zeile.** Fünfhundert
// Einzelabfragen für fünfhundert Zeilen wären bei achttausend
// Sehständen sechzehntausend Rundreisen.
var filme = zeilen.Where(z => z.MovieId is not null).Select(z => z.MovieId!.Value).ToList();
var folgen = zeilen.Where(z => z.EpisodeId is not null).Select(z => z.EpisodeId!.Value).ToList();
var filmlaufzeit = await db.MediaFiles.AsNoTracking()
.Where(d => d.MovieId != null && filme.Contains(d.MovieId!.Value))
.GroupBy(d => d.MovieId!.Value)
.Select(g => new { Id = g.Key, Dauer = g.Max(d => d.Duration) })
.ToDictionaryAsync(x => x.Id, x => x.Dauer, ct);
var folgenlaufzeit = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value))
.GroupBy(d => d.EpisodeId!.Value)
.Select(g => new { Id = g.Key, Dauer = g.Max(d => d.Duration) })
.ToDictionaryAsync(x => x.Id, x => x.Dauer, ct);
foreach (var z in zeilen)
{
var gemessen = z.MovieId is { } f
? filmlaufzeit.GetValueOrDefault(f)
: z.EpisodeId is { } e ? folgenlaufzeit.GetValueOrDefault(e) : null;
var vorher = (z.Runtime, z.InProgress);
// **Die gemessene Laufzeit gewinnt.** Steht in der Zeile ein
// Stummel, weil ein Client ihn einmal gemeldet hat, bleibt
// der sonst für immer stehen.
if (gemessen is { Ticks: > 0 }) z.Runtime = gemessen;
z.InProgress = ProgressRule.BelongsInContinueWatching(z, regeln);
if (vorher == (z.Runtime, z.InProgress)) continue;
// **`UpdatedAt` bleibt, wo es war.** Danach ist „Fortsetzen"
// sortiert; eine Reparatur, die alles auf heute setzt,
// mischte die Reihenfolge neu und sähe aus wie ein Fehler.
geaendert++;
}
await db.SaveChangesAsync(ct);
if (zeilen.Count < Stapel) break;
}
return geaendert;
}
}