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:
co-authored by
Claude Opus 5
parent
5dfc22bdce
commit
a609b2d087
@@ -128,6 +128,12 @@ builder.Services.AddScoped<IBewertungsquelle>(sp => sp.GetRequiredService<OmdbCl
|
|||||||
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
|
// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine
|
||||||
// Auskunft darüber, ob der Dienst antwortet.
|
// Auskunft darüber, ob der Dienst antwortet.
|
||||||
builder.Services.AddScoped<Quellenprobe>();
|
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.AddSingleton(new ImageCacheOptions(pfade.Cache));
|
||||||
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
||||||
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
||||||
@@ -376,6 +382,9 @@ if (Directory.Exists(oberfläche))
|
|||||||
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
|
_ = app.Services.GetRequiredService<Kratzplatzpflege>()
|
||||||
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
|
.LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping);
|
||||||
|
|
||||||
|
_ = app.Services.GetRequiredService<Sehstandpflege>()
|
||||||
|
.NachUpdateAsync(leben.ApplicationStopping);
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -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),
|
Assert.That((await Statistik(client))!.LoggedSeconds, Is.GreaterThan(0),
|
||||||
"und mitgezählt werden muss sie auch.");
|
"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
@@ -15,7 +15,7 @@ export type Kachel = {
|
|||||||
/** Welche Kennung den Sehstand trägt. Fehlt sie, ist es `id`.
|
/** Welche Kennung den Sehstand trägt. Fehlt sie, ist es `id`.
|
||||||
*
|
*
|
||||||
* **Nur dort verschieden, wo die Kachel woandershin führt als sie misst:**
|
* **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. */
|
* Folge. */
|
||||||
standId?: string;
|
standId?: string;
|
||||||
art: 'filme' | 'serien';
|
art: 'filme' | 'serien';
|
||||||
@@ -54,7 +54,7 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
|
|||||||
freigabe: e.publishAt,
|
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.
|
* dreizeilig, wie bei Plex.
|
||||||
*
|
*
|
||||||
* **Die Kachel führt zur Serie, der Sehstand bleibt bei der Folge.**
|
* **Die Kachel führt zur Serie, der Sehstand bleibt bei der Folge.**
|
||||||
@@ -310,7 +310,10 @@ function kachelpunkte(
|
|||||||
// schlechter als keiner.
|
// schlechter als keiner.
|
||||||
if (k.stand && !k.stand.watched && k.stand.positionSeconds > 0) {
|
if (k.stand && !k.stand.watched && k.stand.positionSeconds > 0) {
|
||||||
punkte.push({
|
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 () => {
|
tun: async () => {
|
||||||
await weglegen(k.standId ?? k.id);
|
await weglegen(k.standId ?? k.id);
|
||||||
if (k.stand) k.stand.dismissed = true;
|
if (k.stand) k.stand.dismissed = true;
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
|
async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); }));
|
||||||
|
|
||||||
if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) {
|
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(); }));
|
async () => { await weglegen(eintrag.id); await laden(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user