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:
co-authored by
Claude Opus 5
parent
1e4a231d1b
commit
9622da0284
@@ -460,6 +460,30 @@ public sealed record AttributionResponse(string Text, string LogoUrl, string Url
|
||||
|
||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
||||
|
||||
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
|
||||
///
|
||||
/// <param name="ResumeAfterSeconds">
|
||||
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
|
||||
/// </param>
|
||||
/// <param name="WatchedFraction">Ab welchem Anteil er als gesehen gilt, 0 bis 1.</param>
|
||||
/// <param name="Encoder">„qualitaet", „ausgewogen" oder „tempo".</param>
|
||||
public sealed record PlaybackSettingsResponse(
|
||||
double ResumeAfterSeconds, double WatchedFraction, string Encoder);
|
||||
|
||||
/// <summary>Was geändert werden soll — <c>null</c> heisst: unverändert.</summary>
|
||||
public sealed record PlaybackSettingsRequest(
|
||||
double? ResumeAfterSeconds, double? WatchedFraction, string? Encoder);
|
||||
|
||||
/// <summary>Was danach gilt, und wie viele Zeilen das umgeworfen hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Zahl gehört in die Antwort.</b> Eine neue Schwelle wirkt rückwirkend
|
||||
/// auf alles, was schon in „Weiterschauen" steht; ohne diese Rückmeldung
|
||||
/// änderte sich die Startseite scheinbar von selbst.
|
||||
/// </remarks>
|
||||
public sealed record PlaybackSettingsApplied(
|
||||
double ResumeAfterSeconds, double WatchedFraction, string Encoder, int Reevaluated);
|
||||
|
||||
// --- Stufe 3: Ausliefern -------------------------------------------------
|
||||
|
||||
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
|
||||
|
||||
@@ -48,9 +48,10 @@ public static class PlaybackEndpoints
|
||||
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
|
||||
titel.MapPost("/progress", async (
|
||||
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||
TimeProvider clock, PlaybackSessions laufende) =>
|
||||
TimeProvider clock, PlaybackSessions laufende, Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
@@ -62,7 +63,8 @@ public static class PlaybackEndpoints
|
||||
? TimeSpan.FromSeconds(sekunden)
|
||||
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
|
||||
|
||||
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge);
|
||||
var ergebnis = ProgressRule.Apply(
|
||||
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
|
||||
|
||||
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
|
||||
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
|
||||
@@ -81,7 +83,7 @@ public static class PlaybackEndpoints
|
||||
// Wer weitersieht, hat es sich offenbar anders überlegt.
|
||||
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
|
||||
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
|
||||
@@ -99,9 +101,11 @@ public static class PlaybackEndpoints
|
||||
});
|
||||
|
||||
titel.MapPut("/watched", async (
|
||||
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
@@ -126,10 +130,10 @@ public static class PlaybackEndpoints
|
||||
// **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an
|
||||
// der Serie und daneben „noch 42 offen".
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
|
||||
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
@@ -137,9 +141,11 @@ public static class PlaybackEndpoints
|
||||
// **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung
|
||||
// im Pfad trägt und eine Liste keine hat.
|
||||
app.MapPut("/api/v1/items/watched", async (
|
||||
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
if (anfrage.Ids.Count == 0)
|
||||
return Results.Ok(new BulkWatchedResponse(0, []));
|
||||
if (anfrage.Ids.Count > HöchstensJeZug)
|
||||
@@ -171,10 +177,10 @@ public static class PlaybackEndpoints
|
||||
zustand.Position = TimeSpan.Zero;
|
||||
zustand.Dismissed = false;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
|
||||
|
||||
geaendert++;
|
||||
}
|
||||
@@ -185,39 +191,45 @@ public static class PlaybackEndpoints
|
||||
|
||||
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
||||
titel.MapPost("/dismiss", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
|
||||
zustand.Dismissed = true;
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
|
||||
titel.MapPut("/favourite", async (
|
||||
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
|
||||
zustand.Favourite = anfrage.Favourite;
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
|
||||
titel.MapPut("/rating", async (
|
||||
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
|
||||
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
@@ -227,7 +239,7 @@ public static class PlaybackEndpoints
|
||||
|
||||
zustand.Rating = anfrage.Rating;
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
@@ -512,8 +524,8 @@ public static class PlaybackEndpoints
|
||||
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
||||
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
||||
/// </remarks>
|
||||
private static void Nachfuehren(UserItemState zustand) =>
|
||||
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand);
|
||||
private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) =>
|
||||
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln);
|
||||
|
||||
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
||||
///
|
||||
@@ -642,7 +654,8 @@ public static class PlaybackEndpoints
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task FolgenSetzen(
|
||||
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock)
|
||||
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock,
|
||||
Wiedergaberegeln regeln)
|
||||
{
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == serie)
|
||||
@@ -672,7 +685,7 @@ public static class PlaybackEndpoints
|
||||
zustand.Dismissed = false;
|
||||
if (gesehen) zustand.LastPlayedAt = jetzt;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user