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;
}
}
@@ -263,4 +263,66 @@ public sealed class StatistikTests
Assert.That((await Statistik(client))!.LoggedSeconds, Is.GreaterThan(0),
"und mitgezählt werden muss sie auch.");
}
/// <summary>Ein Stummel als Laufzeit nimmt den Titel nicht dauerhaft aus „Fortsetzen".</summary>
///
/// <remarks>
/// <para>
/// <b>Der gemeldete Fall.</b> Ein Film stand bei 1:32, und in
/// „Fortsetzen" tauchte er trotzdem nicht auf — bis er einmal neu
/// gestartet wurde. Die Reihe fragt nicht die Regel, sondern deren
/// festgehaltenes Ergebnis: die Spalte <c>InProgress</c>. Berechnet
/// wird sie nur, wenn eine Meldung eintrifft.
/// </para>
/// <para>
/// Hier wird geprüft, dass eine Meldung mit zu kurzer Laufzeit den
/// Eintrag gar nicht erst herauswirft — und dass er danach weiter in
/// „Fortsetzen" steht, ohne dass jemand den Film anfassen muss.
/// </para>
/// </remarks>
[Test]
public async Task Eine_zu_kurze_Laufzeit_wirft_nicht_aus_Fortsetzen()
{
Datei("Spinnenmann (2026)/Spinnenmann (2026).mkv");
using var server = MitUhr();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 5520, 11460);
var davor = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(davor!.Items.Select(e => e.Id), Has.Member(film),
"bei 1:32 von 3:11 gehört er dort hin");
// Die Meldung, die früher alles zerstörte: ein Client, der an einer
// wachsenden Playlist misst und nur einen Stummel sieht.
await Melden(client, film, 5520, 60);
var danach = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(danach!.Items.Select(e => e.Id), Has.Member(film),
"und er bleibt dort, auch nach einer unsinnig kurzen Laufzeit");
}
/// <summary>„Entfernen" nimmt ihn heraus — das soll weiter gehen.</summary>
///
/// <remarks>
/// Die Reparatur darf nicht so weit gehen, dass sie eine Entscheidung
/// des Zuschauers überschreibt.
/// </remarks>
[Test]
public async Task Wer_entfernt_wird_bleibt_draussen()
{
Datei("Spinnenmann (2026)/Spinnenmann (2026).mkv");
using var server = MitUhr();
var client = await Eingelesen(server);
var film = await ErsterFilm(client);
await Melden(client, film, 5520, 11460);
(await client.PostAsync($"/api/v1/items/{film}/dismiss", null)).EnsureSuccessStatusCode();
var danach = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(danach!.Items, Is.Empty);
}
}
+6 -3
View File
@@ -15,7 +15,7 @@ export type Kachel = {
/** Welche Kennung den Sehstand trägt. Fehlt sie, ist es `id`.
*
* **Nur dort verschieden, wo die Kachel woandershin führt als sie misst:**
* eine Folge aus „Weiterschauen" öffnet die Serie, abgehakt wird aber die
* eine Folge aus „Fortsetzen" öffnet die Serie, abgehakt wird aber die
* Folge. */
standId?: string;
art: 'filme' | 'serien';
@@ -54,7 +54,7 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
freigabe: e.publishAt,
});
/** Eine Kachel aus „Weiterschauen" oder „Nächste Folge" — bei Folgen
/** Eine Kachel aus „Fortsetzen" oder „Nächste Folge" — bei Folgen
* dreizeilig, wie bei Plex.
*
* **Die Kachel führt zur Serie, der Sehstand bleibt bei der Folge.**
@@ -310,7 +310,10 @@ function kachelpunkte(
// schlechter als keiner.
if (k.stand && !k.stand.watched && k.stand.positionSeconds > 0) {
punkte.push({
text: 'Aus Weiterschauen nehmen',
// **Dieselbe Reihe, derselbe Name.** Oben stand „Fortsetzen", hier
// „Weiterschauen" — zwei Wörter für eine Sache, und der Knopf sah
// danach aus, als meinte er etwas anderes.
text: 'Aus Fortsetzen entfernen',
tun: async () => {
await weglegen(k.standId ?? k.id);
if (k.stand) k.stand.dismissed = true;
+1 -1
View File
@@ -367,7 +367,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen',
reihe.appendChild(rundKnopf('mehr', false, 'Aus „Fortsetzen" entfernen',
async () => { await weglegen(eintrag.id); await laden(); }));
}