diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index eef070d..2ff68e2 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -128,6 +128,12 @@ builder.Services.AddScoped(sp => sp.GetRequiredService(); + +// **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(); builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache)); builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddScoped(sp => sp.GetRequiredService()); @@ -376,6 +382,9 @@ if (Directory.Exists(oberfläche)) _ = app.Services.GetRequiredService() .LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping); + _ = app.Services.GetRequiredService() + .NachUpdateAsync(leben.ApplicationStopping); + _ = Task.Run(async () => { try diff --git a/server/src/Flaro.Api/Sehstandpflege.cs b/server/src/Flaro.Api/Sehstandpflege.cs new file mode 100644 index 0000000..b1a640c --- /dev/null +++ b/server/src/Flaro.Api/Sehstandpflege.cs @@ -0,0 +1,135 @@ +using Flaro.Core.Playback; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Bringt die gemerkten Sehstände wieder mit der Wirklichkeit in Einklang. +/// +/// +/// +/// Der Anlass. 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 InProgress. Die Bruchrechnung „Stelle kleiner als +/// Anteil der Laufzeit" lässt sich nicht nach SQL übersetzen, deshalb steht +/// sie als Wahrheitswert in der Zeile. +/// +/// +/// Und berechnet wird sie nur, wenn eine Meldung eintrifft. Die +/// zerstörerische Meldung von vorgestern — zu kurze Laufzeit, Stelle +/// beschnitten, Häkchen gesetzt — hat dort false 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. +/// +/// +/// Zwei Schritte, und die Reihenfolge zählt. Erst die Laufzeit aus +/// der Datei nachtragen, dann neu bewerten — eine Neubewertung auf Basis +/// eines Stummels käme wieder zu false. +/// +/// +public sealed class Sehstandpflege( + IServiceScopeFactory scopes, + TimeProvider uhr, + ILogger log) +{ + /// Unter diesem Schlüssel steht, für welchen Stand es schon lief. + /// + /// + /// Einmal je Abbild. 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. + /// + public const string Schluessel = "wartung.sehstaende.stand"; + + public async Task NachUpdateAsync(CancellationToken ct = default) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + + 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; + } + + /// Trägt fehlende Laufzeiten nach und bewertet neu. + public async Task LaufenAsync(CancellationToken ct = default) + { + using var raum = scopes.CreateScope(); + var db = raum.ServiceProvider.GetRequiredService(); + var regeln = await raum.ServiceProvider + .GetRequiredService().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; + } +} diff --git a/server/tests/Flaro.Api.Tests/StatistikTests.cs b/server/tests/Flaro.Api.Tests/StatistikTests.cs index 2acf544..039beca 100644 --- a/server/tests/Flaro.Api.Tests/StatistikTests.cs +++ b/server/tests/Flaro.Api.Tests/StatistikTests.cs @@ -263,4 +263,66 @@ public sealed class StatistikTests Assert.That((await Statistik(client))!.LoggedSeconds, Is.GreaterThan(0), "und mitgezählt werden muss sie auch."); } + + /// Ein Stummel als Laufzeit nimmt den Titel nicht dauerhaft aus „Fortsetzen". + /// + /// + /// + /// Der gemeldete Fall. 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 InProgress. Berechnet + /// wird sie nur, wenn eine Meldung eintrifft. + /// + /// + /// 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. + /// + /// + [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>("/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>("/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"); + } + + /// „Entfernen" nimmt ihn heraus — das soll weiter gehen. + /// + /// + /// Die Reparatur darf nicht so weit gehen, dass sie eine Entscheidung + /// des Zuschauers überschreibt. + /// + [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>("/api/v1/home/continue"); + + Assert.That(danach!.Items, Is.Empty); + } } diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 37615a2..3ee3872 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -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; diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 182f6de..ac55153 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -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(); })); }