Eine Sekunde ist kein Anfang

Drei Zahlen standen als Konstante im Quelltext und bestimmten, wie sich
der Server anfühlt. Jetzt stehen sie in der Verwaltung, unter
„Wiedergabe".

**Weiterschauen ab.** Gemessen: `BelongsInContinueWatching` verlangte nur
`Position.Ticks > 0` — ein Tick genügte. Wer im Aufmacher eine Sekunde
hineinschaute, fand den Titel danach in „Fortsetzen". Jetzt gibt es eine
Untergrenze, voreingestellt auf 1½ Minuten.

Was sich dabei **nicht** ändert: die Stelle wird weiter gemerkt. Das ist
der Unterschied zu Jellyfin, um dessentwillen es diesen Server gibt — die
Schwelle entscheidet, ob der Titel in der Reihe steht, nicht ob man ihn
wiederfindet. Nachgemessen: nach dem Umstellen ist „Heat" aus der Reihe
verschwunden und steht weiterhin bei Sekunde 1.

**Gesehen ab.** War 90 % als `const`, ist jetzt 85/90/95.

**Umkodieren.** `-preset veryfast -crf 21` und VAAPI `-qp 23` waren fest
verdrahtet, ohne Einstellung und ohne Umgebungsvariable. Drei Stufen:
Qualität (medium/19, qp 21), Ausgewogen (wie bisher), Tempo
(ultrafast/23, qp 26). Mit dem Warnsatz daneben, und der ist ernst
gemeint: was in Echtzeit laufen muss, darf nicht langsamer rechnen als es
abspielt.

**Und das Vorhandene wird neu bewertet.** `InProgress` ist eine
gespeicherte Spalte — die Bruchrechnung lässt sich nicht nach SQL
übersetzen. Eine neue Schwelle wirkte sonst nur auf künftige Meldungen.
Der Endpunkt läuft deshalb einmal in Stapeln durch und meldet zurück, wie
viele Zeilen das umgeworfen hat: „Übernommen — 2 Einträge wurden neu
bewertet." Ohne diesen Satz änderte sich die Startseite scheinbar von
selbst.

`ProgressRule` bleibt ein reiner Typ: die Regeln kommen als Wert herein.
Nebenbei zieht der doppelte `zeile()`-Bauer nach `teile.ts` — er stand
zweimal wortgleich da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 16:52:53 +02:00
co-authored by Claude Opus 5
parent 1e4a231d1b
commit 9622da0284
19 changed files with 820 additions and 130 deletions
@@ -1,4 +1,7 @@
using Flaro.Api.Auth;
using Flaro.Core;
using Flaro.Core.Delivery;
using Flaro.Core.Playback;
using Flaro.Data;
using Flaro.Metadata;
using Microsoft.EntityFrameworkCore;
@@ -103,6 +106,92 @@ public static class SettingsEndpoints
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
// ------------------------------------------------------------------
// Wiedergabe
// ------------------------------------------------------------------
app.MapGet("/api/v1/settings/playback", async (Wiedergabeeinstellungen einstellungen) =>
{
var regeln = await einstellungen.RegelnAsync();
var stufe = await einstellungen.StufeAsync();
return Results.Ok(new PlaybackSettingsResponse(
regeln.FortsetzenAb.TotalSeconds, regeln.GesehenAnteil, stufe.AlsWort()));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/playback", async (
PlaybackSettingsRequest anfrage, FlaroContext db, TimeProvider clock) =>
{
var vorher = Wiedergabeeinstellungen.Lesen(
(await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.FortsetzenAbName))?.Value,
(await db.Settings.AsNoTracking()
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.GesehenAnteilName))?.Value);
var nachher = Wiedergabeeinstellungen.Lesen(
anfrage.ResumeAfterSeconds?.ToString(System.Globalization.CultureInfo.InvariantCulture)
?? vorher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
anfrage.WatchedFraction?.ToString(System.Globalization.CultureInfo.InvariantCulture)
?? vorher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
await Setzen(db, Wiedergabeeinstellungen.FortsetzenAbName,
nachher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
await Setzen(db, Wiedergabeeinstellungen.GesehenAnteilName,
nachher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
if (anfrage.Encoder is not null)
await Setzen(db, Wiedergabeeinstellungen.TranscoderName, Kodierstufen.Lesen(anfrage.Encoder).AlsWort());
await db.SaveChangesAsync();
// **Was schon dasteht, muss neu bewertet werden.**
//
// `UserItemState.InProgress` ist eine gespeicherte Spalte — die
// Bruchrechnung lässt sich nicht nach SQL übersetzen. Eine neue
// Schwelle wirkte deshalb nur auf künftige Meldungen: wer gestern
// eine Sekunde hineingeschaut hat, stünde bis in alle Ewigkeit in
// „Weiterschauen". Also einmal durch, in Stapeln.
var geaendert = nachher == vorher ? 0 : await NeuBewerten(db, nachher, clock);
return Results.Ok(new PlaybackSettingsApplied(
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
///
/// <remarks>
/// In Stapeln zu fünfhundert: ein Haushalt mit drei Leuten und
/// fünftausend Titeln hat höchstens ein paar tausend Zeilen, aber alle
/// auf einmal zu laden ist eine Zahl, die man nicht kennt.
/// </remarks>
private static async Task<int> NeuBewerten(
FlaroContext db, Wiedergaberegeln regeln, TimeProvider clock)
{
const int Stapel = 500;
var geaendert = 0;
var jetzt = clock.GetUtcNow();
for (var sprung = 0; ; sprung += Stapel)
{
var zeilen = await db.UserItemStates
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync();
if (zeilen.Count == 0) break;
foreach (var z in zeilen)
{
var soll = ProgressRule.BelongsInContinueWatching(z, regeln);
if (soll == z.InProgress) continue;
z.InProgress = soll;
z.UpdatedAt = jetzt;
geaendert++;
}
await db.SaveChangesAsync();
if (zeilen.Count < Stapel) break;
}
return geaendert;
}
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)