diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index dfa3cb3..451bcc1 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -460,6 +460,30 @@ public sealed record AttributionResponse(string Text, string LogoUrl, string Url public sealed record MetadataSettingsRequest(string? ApiKey, string? Language); +/// Was der Server über die Wiedergabe entscheidet. +/// +/// +/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht. +/// +/// Ab welchem Anteil er als gesehen gilt, 0 bis 1. +/// „qualitaet", „ausgewogen" oder „tempo". +public sealed record PlaybackSettingsResponse( + double ResumeAfterSeconds, double WatchedFraction, string Encoder); + +/// Was geändert werden soll — null heisst: unverändert. +public sealed record PlaybackSettingsRequest( + double? ResumeAfterSeconds, double? WatchedFraction, string? Encoder); + +/// Was danach gilt, und wie viele Zeilen das umgeworfen hat. +/// +/// +/// Die Zahl gehört in die Antwort. Eine neue Schwelle wirkt rückwirkend +/// auf alles, was schon in „Weiterschauen" steht; ohne diese Rückmeldung +/// änderte sich die Startseite scheinbar von selbst. +/// +public sealed record PlaybackSettingsApplied( + double ResumeAfterSeconds, double WatchedFraction, string Encoder, int Reevaluated); + // --- Stufe 3: Ausliefern ------------------------------------------------- /// Was mit einer Datei geschehen wird, bevor sie hinausgeht. diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 3caa54f..f730127 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -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 /// schreibt — gäbe es eine zweite, /// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen. /// - 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); /// Welches Gerät das ist — die Kennung seines Tokens. /// @@ -642,7 +654,8 @@ public static class PlaybackEndpoints /// /// 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); } } diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 998daf9..8885fbc 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -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); + } + + /// Rechnet „steht in Weiterschauen" für alle Zeilen neu. + /// + /// + /// 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. + /// + private static async Task 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) diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index cfd71fa..beb8f1e 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -89,6 +89,7 @@ builder.Services.AddScoped(); // Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was // ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann. builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(c => { c.BaseAddress = new Uri(TmdbClient.BaseAddress); diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs index 2dab9ed..83a5877 100644 --- a/server/src/Flaro.Api/Streamkoordinator.cs +++ b/server/src/Flaro.Api/Streamkoordinator.cs @@ -129,8 +129,13 @@ public sealed class Streamkoordinator( var ordner = Path.Combine(pfade.Transcode, "stroeme", kennung.ToString("N")); Directory.CreateDirectory(ordner); + // Die eingestellte Stufe aus demselben Bereich — ein Lesevorgang, und + // er steht hier, weil nur hier feststeht, dass überhaupt kodiert wird. + var stufe = await bereich.ServiceProvider + .GetRequiredService().StufeAsync(ct); + var anfrage = new Stromanfrage( - datei.Path, ordner, gelesen, plan, tonspur, Math.Max(0, ab), Hardware()); + datei.Path, ordner, gelesen, plan, tonspur, Math.Max(0, ab), Hardware(), stufe); // **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst // stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und diff --git a/server/src/Flaro.Api/Wiedergabeeinstellungen.cs b/server/src/Flaro.Api/Wiedergabeeinstellungen.cs new file mode 100644 index 0000000..f754e12 --- /dev/null +++ b/server/src/Flaro.Api/Wiedergabeeinstellungen.cs @@ -0,0 +1,83 @@ +using Flaro.Core.Delivery; +using Flaro.Core.Playback; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Holt die Wiedergabe-Einstellungen aus der Datenbank. +/// +/// +/// Dasselbe Muster wie : je Anfrage +/// einmal gelesen und dann gemerkt. Eine Fortschrittsmeldung kommt alle zehn +/// Sekunden und von jedem Gerät; dreimal dieselbe Zeile je Meldung zu lesen +/// wäre Arbeit für nichts. +/// +/// Serverweit, nicht je Nutzer. Der Transcoder gehört der Maschine — +/// es gibt eine CPU und eine Entscheidung. Die beiden Schwellen könnten je +/// Nutzer gelten; sie tun es nicht, weil zwei Wahrheiten darüber, was +/// „angefangen" heisst, mehr kosten als sie einbringen. +/// +public sealed class Wiedergabeeinstellungen(FlaroContext db) +{ + public const string FortsetzenAbName = "wiedergabe.fortsetzen.abSekunden"; + public const string GesehenAnteilName = "wiedergabe.gesehen.anteil"; + public const string TranscoderName = "wiedergabe.transcoder.stufe"; + + private Wiedergaberegeln? _regeln; + private Kodierstufe? _stufe; + + /// Wann ein Titel angefangen ist und wann er gesehen ist. + public async Task RegelnAsync(CancellationToken ct = default) + { + if (_regeln is { } fertig) return fertig; + await LesenAsync(ct); + return _regeln!.Value; + } + + /// Wie der Transcoder zwischen Bild und Tempo abwägt. + public async Task StufeAsync(CancellationToken ct = default) + { + if (_stufe is { } fertig) return fertig; + await LesenAsync(ct); + return _stufe!.Value; + } + + private async Task LesenAsync(CancellationToken ct) + { + var zeilen = await db.Settings + .AsNoTracking() + .Where(s => s.Key == FortsetzenAbName + || s.Key == GesehenAnteilName + || s.Key == TranscoderName) + .ToDictionaryAsync(s => s.Key, s => s.Value, ct); + + _regeln = Lesen(zeilen.GetValueOrDefault(FortsetzenAbName), + zeilen.GetValueOrDefault(GesehenAnteilName)); + _stufe = Kodierstufen.Lesen(zeilen.GetValueOrDefault(TranscoderName)); + } + + /// Macht aus zwei gespeicherten Zeichenketten geprüfte Regeln. + /// + /// + /// Ohne Ortsangabe gerechnet — mit : in der + /// Datenbank steht ein Punkt, und auf einem Server mit deutscher + /// Spracheinstellung läse ein Komma-Parser aus „0.9" eine Neun. + /// + public static Wiedergaberegeln Lesen(string? abSekunden, string? anteil) + { + var regeln = Wiedergaberegeln.Vorgabe; + + if (double.TryParse(abSekunden, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var sekunden)) + regeln = regeln with { FortsetzenAb = TimeSpan.FromSeconds(sekunden) }; + + if (double.TryParse(anteil, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var wert)) + regeln = regeln with { GesehenAnteil = wert }; + + return regeln.Geprueft(); + } +} diff --git a/server/src/Flaro.Core/Delivery/Kodierstufe.cs b/server/src/Flaro.Core/Delivery/Kodierstufe.cs new file mode 100644 index 0000000..9c11ca2 --- /dev/null +++ b/server/src/Flaro.Core/Delivery/Kodierstufe.cs @@ -0,0 +1,48 @@ +namespace Flaro.Core.Delivery; + +/// Wie der Transcoder zwischen Bild und Tempo abwägt. +/// +/// +/// +/// Drei Stufen, keine freie Zahl — wie bei +/// . „crf 19" sagt niemandem etwas, der +/// nicht ffmpeg kennt; „Qualität" und „Tempo" sagen genau das, worum es geht. +/// +/// +/// Und es ist eine echte Abwägung. Was in Echtzeit laufen muss, darf +/// nicht langsamer rechnen als es abspielt. Auf einer schwachen Maschine kann +/// „Qualität" dazu führen, dass das Bild stockt — das ist kein Fehler, das ist +/// die Stufe. Deshalb steht der Satz auch in der Oberfläche. +/// +/// +public enum Kodierstufe +{ + /// Schöneres Bild, langsamere Rechnung. + Qualitaet, + + /// Was Flaro immer getan hat. + Ausgewogen, + + /// Startet schneller, kostet Bild. + Tempo, +} + +/// Hin und zurück zwischen Stufe und gespeichertem Wort. +public static class Kodierstufen +{ + /// Unbekanntes wird ausgewogen — das war schon immer das Verhalten. + public static Kodierstufe Lesen(string? wert) => wert?.Trim().ToLowerInvariant() switch + { + "qualitaet" or "qualität" => Kodierstufe.Qualitaet, + "tempo" => Kodierstufe.Tempo, + _ => Kodierstufe.Ausgewogen, + }; + + /// Wie die Stufe in der Datenbank steht. + public static string AlsWort(this Kodierstufe stufe) => stufe switch + { + Kodierstufe.Qualitaet => "qualitaet", + Kodierstufe.Tempo => "tempo", + _ => "ausgewogen", + }; +} diff --git a/server/src/Flaro.Core/Playback/ProgressRule.cs b/server/src/Flaro.Core/Playback/ProgressRule.cs index 0e5b0af..b376453 100644 --- a/server/src/Flaro.Core/Playback/ProgressRule.cs +++ b/server/src/Flaro.Core/Playback/ProgressRule.cs @@ -25,23 +25,15 @@ namespace Flaro.Core.Playback; /// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus — /// wer einen Film ein zweites Mal anfängt, soll beides haben. /// +/// +/// Was einstellbar ist, steht in . Es +/// gibt inzwischen eine Untergrenze fürs Weiterschauen — aber sie entscheidet +/// nur, ob der Titel in der Reihe steht, nicht ob die Stelle überlebt. Das +/// bleibt der Unterschied. +/// /// public static class ProgressRule { - /// Ab welchem Anteil ein Titel als gesehen gilt. - /// - /// - /// Eine Schwelle ist hier unvermeidlich, und das ist kein - /// Widerspruch zum Obigen: Niemand sieht einen Film bis zur letzten - /// Sekunde des Abspanns. Ohne diese Regel stünde jeder zu Ende geschaute - /// Film für immer bei 98 % in „Weiterschauen". - /// - /// Der Unterschied zu Jellyfin ist nicht, dass es diese Grenze - /// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort - /// zusätzlich die Position weggeworfen. - /// - public const double WatchedFraction = 0.90; - /// Was aus einer Meldung folgt. /// Die Stelle, die abgelegt wird — nie null gesetzt. /// Ob der Titel damit als gesehen gilt. @@ -57,7 +49,8 @@ public static class ProgressRule /// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne /// es zu merken. /// - public static Outcome Apply(TimeSpan position, TimeSpan? runtime) + /// Ab welchem Anteil ein Titel als gesehen gilt. + public static Outcome Apply(TimeSpan position, TimeSpan? runtime, Wiedergaberegeln regeln) { // Eine negative Stelle gibt es nicht, und eine jenseits des Endes // auch nicht. Beides kommt vor: der eine Player meldet beim Spulen @@ -69,7 +62,7 @@ public static class ProgressRule if (stelle > laenge) stelle = laenge; - return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction); + return new Outcome(stelle, Watched: stelle >= laenge * regeln.GesehenAnteil); } /// Ob ein Zustand in „Weiterschauen" gehört. @@ -80,9 +73,15 @@ public static class ProgressRule /// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander, /// fällt es hier auf und nicht beim Benutzer. /// - public static bool BelongsInContinueWatching(UserItemState zustand) => + public static bool BelongsInContinueWatching(UserItemState zustand, Wiedergaberegeln regeln) => zustand is { Dismissed: false, Position.Ticks: > 0 } + // **Die Untergrenze ist einstellbar und war es nicht.** Ein Tick + // genügte: wer im Aufmacher eine Sekunde hineinschaute, fand den + // Titel danach in „Weiterschauen". Was oben über Jellyfin steht, + // gilt weiter — die Stelle wird nicht weggeworfen, der Titel steht + // nur nicht in der Reihe. + && zustand.Position >= regeln.FortsetzenAb && (zustand.Runtime is not { } laenge || laenge <= TimeSpan.Zero - || zustand.Position < laenge * WatchedFraction); + || zustand.Position < laenge * regeln.GesehenAnteil); } diff --git a/server/src/Flaro.Core/Playback/Wiedergaberegeln.cs b/server/src/Flaro.Core/Playback/Wiedergaberegeln.cs new file mode 100644 index 0000000..2278441 --- /dev/null +++ b/server/src/Flaro.Core/Playback/Wiedergaberegeln.cs @@ -0,0 +1,47 @@ +namespace Flaro.Core.Playback; + +/// Wann ein Titel angefangen ist und wann er als gesehen gilt. +/// +/// +/// +/// Zwei Zahlen, die niemand raten kann. Wie lange man hineinschauen +/// muss, damit es zählt, und ab welchem Anteil ein Film durch ist — das hängt +/// davon ab, wie jemand schaut, nicht davon, was technisch richtig wäre. Beide +/// standen als Konstante im Quelltext; jetzt stehen sie in den Einstellungen. +/// +/// +/// Als Wert, nicht als Abfrage. bleibt damit +/// ein reiner Typ: er rechnet, er liest nichts. Prüfbar ohne Datenbank, und +/// derselbe Satz Regeln gilt für eine Meldung und für die Neubewertung aller +/// vorhandenen Zeilen. +/// +/// +/// +/// +/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht. +/// heisst: jede angefangene Sekunde zählt. +/// +/// Ab welchem Anteil der Laufzeit ein Titel gesehen ist. +public readonly record struct Wiedergaberegeln(TimeSpan FortsetzenAb, double GesehenAnteil) +{ + /// Was gilt, solange niemand etwas anderes eingestellt hat. + /// + /// + /// Neunzig Sekunden, nicht null. Ohne Untergrenze genügte ein + /// einziger Tick: wer im Aufmacher eine Sekunde hineinschaute, fand den + /// Titel danach in „Weiterschauen" wieder. Eineinhalb Minuten sind lang + /// genug, dass niemand aus Versehen hineinfällt, und kurz genug, dass eine + /// abgebrochene Folge noch zählt. + /// + public static readonly Wiedergaberegeln Vorgabe = new(TimeSpan.FromSeconds(90), 0.90); + + /// Dieselben Regeln mit geprüften Werten. + /// + /// + /// Was aus der Datenbank kommt, hat dort irgendwann jemand hineingeschrieben. + /// Ein Anteil von 0 hiesse „alles ist gesehen", einer von 2 „nichts". + /// + public Wiedergaberegeln Geprueft() => new( + FortsetzenAb < TimeSpan.Zero ? TimeSpan.Zero : FortsetzenAb, + Math.Clamp(GesehenAnteil, 0.5, 1.0)); +} diff --git a/server/src/Flaro.Media/HlsArgumente.cs b/server/src/Flaro.Media/HlsArgumente.cs index eb22f87..3407a98 100644 --- a/server/src/Flaro.Media/HlsArgumente.cs +++ b/server/src/Flaro.Media/HlsArgumente.cs @@ -31,7 +31,8 @@ public sealed record Stromanfrage( Streamplan Plan, int? Tonspur = null, double Ab = 0, - Beschleunigung Hardware = Beschleunigung.Software); + Beschleunigung Hardware = Beschleunigung.Software, + Kodierstufe Stufe = Kodierstufe.Ausgewogen); /// Baut die ffmpeg-Zeile für einen laufenden Strom. /// @@ -189,7 +190,7 @@ public static class HlsArgumente return [ "-vf", kette, "-c:v", "h264_vaapi", - "-qp", "23", + "-qp", Qp(anfrage.Stufe).ToString(CultureInfo.InvariantCulture), "-g", schluesselabstand, "-bf", "0", ]; @@ -203,14 +204,17 @@ public static class HlsArgumente weich.AddRange(["-vf", $"scale=-2:{hoehe}"]); } + var (preset, crf) = Weichkodierung(anfrage.Stufe); weich.AddRange([ "-c:v", "libx264", - // **`veryfast`, nicht `medium`.** Was in Echtzeit laufen muss, - // darf nicht langsamer rechnen als es abspielt — ein schöneres - // Bild, das stockt, ist kein schöneres Bild. - "-preset", "veryfast", - "-crf", "21", + // **Voreingestellt `veryfast`, nicht `medium`.** Was in Echtzeit + // laufen muss, darf nicht langsamer rechnen als es abspielt — ein + // schöneres Bild, das stockt, ist kein schöneres Bild. Wer eine + // Maschine hat, die mehr kann, stellt es um; wer eine schwache + // hat, stellt auf Tempo. + "-preset", preset, + "-crf", crf.ToString(CultureInfo.InvariantCulture), "-profile:v", "high", "-level", "4.1", "-pix_fmt", "yuv420p", @@ -225,6 +229,35 @@ public static class HlsArgumente return weich; } + /// Was die Stufe für libx264 bedeutet. + /// + /// + /// Preset und CRF zusammen, nicht einzeln. Ein langsameres Preset + /// rechnet länger an demselben CRF und holt dabei Bild heraus; ein + /// schnelleres braucht einen höheren CRF, sonst wächst nur die Datei. Die + /// drei Paare sind deshalb Paare und keine zwei Regler. + /// + private static (string Preset, int Crf) Weichkodierung(Kodierstufe stufe) => stufe switch + { + Kodierstufe.Qualitaet => ("medium", 19), + Kodierstufe.Tempo => ("ultrafast", 23), + _ => ("veryfast", 21), + }; + + /// Dasselbe für die Grafikeinheit. + /// + /// + /// VAAPI kennt kein Preset — die Festfunktionseinheit rechnet, wie sie + /// rechnet. Es bleibt der Quantisierer, und der wirkt in dieselbe + /// Richtung: kleiner ist schöner und grösser. + /// + private static int Qp(Kodierstufe stufe) => stufe switch + { + Kodierstufe.Qualitaet => 21, + Kodierstufe.Tempo => 26, + _ => 23, + }; + private static IEnumerable Tonargumente(Stromanfrage anfrage, ProbedStream? ton) { if (ton is null) return ["-an"]; diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index be628f9..1590a47 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -818,11 +818,55 @@ public class PlaybackTests MovieId = film, Position = TimeSpan.FromSeconds(stelle), Runtime = laenge is { } l ? TimeSpan.FromSeconds(l) : null, - }); + }, Wiedergaberegeln.Vorgabe); var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); Assert.That(reihe!.Items.Count == 1, Is.EqualTo(erwartet), $"bei {stelle}s von {laenge}s"); } } + + /// Eine neue Schwelle wirkt auch auf das, was schon dasteht. + /// + /// + /// Der Grund, aus dem der Endpunkt eine Zahl zurückgibt. + /// InProgress ist eine gespeicherte Spalte — die Bruchrechnung + /// lässt sich nicht nach SQL übersetzen. Ohne Durchlauf stünde der Titel, + /// in den man gestern eine Sekunde hineingeschaut hat, weiter in + /// „Weiterschauen", und die Einstellung sähe aus, als täte sie nichts. + /// + [Test] + public async Task Eine_neue_Schwelle_raeumt_Weiterschauen_auf() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + // Erst ohne Schwelle: eine Sekunde genügt. + await client.PutAsJsonAsync("/api/v1/settings/playback", + new PlaybackSettingsRequest(0, null, null)); + await Melden(client, film, 1, 7200); + + var vorher = await client.GetFromJsonAsync>("/api/v1/home/continue"); + Assert.That(vorher!.Items, Has.Count.EqualTo(1), "ohne Schwelle steht er da"); + + // Jetzt neunzig Sekunden — und zwar rückwirkend. + var antwort = await client.PutAsJsonAsync("/api/v1/settings/playback", + new PlaybackSettingsRequest(90, null, null)); + var ergebnis = (await antwort.Content.ReadFromJsonAsync())!; + + var nachher = await client.GetFromJsonAsync>("/api/v1/home/continue"); + + Assert.Multiple(() => + { + Assert.That(ergebnis.ResumeAfterSeconds, Is.EqualTo(90)); + Assert.That(ergebnis.Reevaluated, Is.EqualTo(1), "eine Zeile hat sich geändert"); + Assert.That(nachher!.Items, Is.Empty, "und die Reihe ist leer"); + }); + + // Die Stelle selbst überlebt — das ist der Punkt des ganzen Servers. + var eintrag = await client.GetFromJsonAsync($"/api/v1/movies/{film}"); + Assert.That(eintrag?.State?.PositionSeconds, Is.EqualTo(1).Within(0.001)); + } } diff --git a/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs b/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs index 7f56601..867d67b 100644 --- a/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs +++ b/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs @@ -8,6 +8,13 @@ public sealed class ProgressRuleTests { private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2); + /// Die Vorgabe des Servers — was gilt, wenn niemand etwas einstellt. + private static readonly Wiedergaberegeln Regeln = Wiedergaberegeln.Vorgabe; + + /// Kurzschreibweise, damit die Prüfungen lesbar bleiben. + private static bool BelongsInContinueWatching(UserItemState zustand) => + ProgressRule.BelongsInContinueWatching(zustand, Regeln); + /// Der Fall, der den Ausschlag gab. /// /// @@ -18,7 +25,7 @@ public sealed class ProgressRuleTests [Test] public void Die_ersten_Minuten_zaehlen_auch() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder); + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder, Regeln); Assert.Multiple(() => { @@ -30,7 +37,7 @@ public sealed class ProgressRuleTests [Test] public void Eine_einzige_Sekunde_zaehlt_auch() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder); + var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder, Regeln); Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(1))); } @@ -44,7 +51,7 @@ public sealed class ProgressRuleTests [Test] public void Ein_kurzer_Titel_gilt_nicht_sofort_als_gesehen() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4)); + var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4), Regeln); Assert.Multiple(() => { @@ -56,7 +63,7 @@ public sealed class ProgressRuleTests [Test] public void Kurz_vor_Schluss_gilt_er_als_gesehen() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder); + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder, Regeln); Assert.That(ergebnis.Watched, Is.True); } @@ -70,7 +77,7 @@ public sealed class ProgressRuleTests [Test] public void Gesehen_wirft_die_Stelle_nicht_weg() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder); + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder, Regeln); Assert.Multiple(() => { @@ -82,7 +89,7 @@ public sealed class ProgressRuleTests [Test] public void Genau_an_der_Grenze_gilt_er_als_gesehen() { - var ergebnis = ProgressRule.Apply(Zweistünder * ProgressRule.WatchedFraction, Zweistünder); + var ergebnis = ProgressRule.Apply(Zweistünder * Regeln.GesehenAnteil, Zweistünder, Regeln); Assert.That(ergebnis.Watched, Is.True); } @@ -91,7 +98,7 @@ public sealed class ProgressRuleTests public void Eine_Sekunde_davor_noch_nicht() { var ergebnis = ProgressRule.Apply( - Zweistünder * ProgressRule.WatchedFraction - TimeSpan.FromSeconds(1), Zweistünder); + Zweistünder * Regeln.GesehenAnteil - TimeSpan.FromSeconds(1), Zweistünder, Regeln); Assert.That(ergebnis.Watched, Is.False); } @@ -106,7 +113,7 @@ public sealed class ProgressRuleTests [Test] public void Ohne_Laufzeit_wird_nichts_vermutet() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null); + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null, Regeln); Assert.Multiple(() => { @@ -118,7 +125,7 @@ public sealed class ProgressRuleTests [Test] public void Eine_Laufzeit_von_null_ist_keine() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero); + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero, Regeln); Assert.That(ergebnis.Watched, Is.False); } @@ -127,7 +134,7 @@ public sealed class ProgressRuleTests [Test] public void Eine_negative_Stelle_wird_zu_null() { - var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder); + var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder, Regeln); Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.Zero)); } @@ -135,7 +142,7 @@ public sealed class ProgressRuleTests [Test] public void Jenseits_des_Endes_wird_auf_das_Ende_gekuerzt() { - var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder); + var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder, Regeln); Assert.Multiple(() => { @@ -149,21 +156,21 @@ public sealed class ProgressRuleTests [Test] public void Angefangen_gehoert_in_Weiterschauen() { - Assert.That(ProgressRule.BelongsInContinueWatching( + Assert.That(BelongsInContinueWatching( Zustand(TimeSpan.FromMinutes(20), Zweistünder)), Is.True); } [Test] public void Nie_angefangen_gehoert_nicht_hinein() { - Assert.That(ProgressRule.BelongsInContinueWatching( + Assert.That(BelongsInContinueWatching( Zustand(TimeSpan.Zero, Zweistünder)), Is.False); } [Test] public void Durchgeschaut_gehoert_nicht_hinein() { - Assert.That(ProgressRule.BelongsInContinueWatching( + Assert.That(BelongsInContinueWatching( Zustand(TimeSpan.FromMinutes(118), Zweistünder)), Is.False); } @@ -173,7 +180,7 @@ public sealed class ProgressRuleTests var zustand = Zustand(TimeSpan.FromMinutes(20), Zweistünder); zustand.Dismissed = true; - Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.False); + Assert.That(BelongsInContinueWatching(zustand), Is.False); } /// Ein zweites Mal angefangen — dann steht er wieder da. @@ -188,7 +195,75 @@ public sealed class ProgressRuleTests var zustand = Zustand(TimeSpan.FromMinutes(10), Zweistünder); zustand.Watched = true; - Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.True); + Assert.That(BelongsInContinueWatching(zustand), Is.True); + } + + /* ---------- Die einstellbare Untergrenze ---------- */ + + /// Hineingeschaut ist noch nicht angefangen. + /// + /// + /// Der Anlass: eine Sekunde im Aufmacher, und der Titel stand danach in + /// „Weiterschauen". Die Stelle wird trotzdem abgelegt — nur die Reihe + /// bleibt frei. Das ist der Unterschied zu Jellyfin, der oben steht. + /// + [Test] + public void Unter_der_Schwelle_gehoert_er_nicht_hinein() + { + var zustand = Zustand(TimeSpan.FromSeconds(1), Zweistünder); + + Assert.Multiple(() => + { + Assert.That(ProgressRule.BelongsInContinueWatching(zustand, Regeln), Is.False); + Assert.That(zustand.Position, Is.EqualTo(TimeSpan.FromSeconds(1)), + "die Stelle bleibt — weggeworfen wird sie nie"); + }); + } + + [Test] + public void Genau_auf_der_Schwelle_gehoert_er_hinein() + { + Assert.That(ProgressRule.BelongsInContinueWatching( + Zustand(Regeln.FortsetzenAb, Zweistünder), Regeln), Is.True); + } + + /// „Sofort" ist eine gültige Einstellung — dann zählt jede Sekunde. + [Test] + public void Ohne_Schwelle_zaehlt_die_erste_Sekunde() + { + var ohne = Regeln with { FortsetzenAb = TimeSpan.Zero }; + + Assert.That(ProgressRule.BelongsInContinueWatching( + Zustand(TimeSpan.FromSeconds(1), Zweistünder), ohne), Is.True); + } + + /// Und der Anteil wirkt auch. + [Test] + public void Ein_hoeherer_Anteil_laesst_ihn_laenger_stehen() + { + var zustand = Zustand(TimeSpan.FromMinutes(112), Zweistünder); // 93,3 % + + Assert.Multiple(() => + { + Assert.That(ProgressRule.BelongsInContinueWatching(zustand, Regeln), Is.False, + "bei 90 % ist er durch"); + Assert.That(ProgressRule.BelongsInContinueWatching( + zustand, Regeln with { GesehenAnteil = 0.95 }), Is.True, + "bei 95 % noch nicht"); + }); + } + + /// Unsinn aus der Datenbank wird eingefangen, nicht übernommen. + [Test] + public void Geprueft_bindet_den_Anteil_ein() + { + Assert.Multiple(() => + { + Assert.That(new Wiedergaberegeln(TimeSpan.FromSeconds(-5), 0).Geprueft(), + Is.EqualTo(new Wiedergaberegeln(TimeSpan.Zero, 0.5))); + Assert.That(new Wiedergaberegeln(TimeSpan.Zero, 7).Geprueft().GesehenAnteil, + Is.EqualTo(1.0)); + }); } private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) => diff --git a/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs b/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs index 3855c4c..90c417e 100644 --- a/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs +++ b/server/tests/Flaro.Media.Tests/HlsArgumenteTests.cs @@ -24,11 +24,12 @@ public class HlsArgumenteTests private static string Zeile( IReadOnlyList spuren, string schachtel = "mkv", int? hoehe = null, int? tonspur = null, double ab = 0, - Beschleunigung hardware = Beschleunigung.Software) + Beschleunigung hardware = Beschleunigung.Software, + Kodierstufe stufe = Kodierstufe.Ausgewogen) { var plan = Stromentscheid.Fuer(schachtel, spuren, Browser, hoehe); return string.Join(' ', HlsArgumente.Fuer(new Stromanfrage( - "/medien/film.mkv", "/kratz/sitzung", spuren, plan, tonspur, ab, hardware))); + "/medien/film.mkv", "/kratz/sitzung", spuren, plan, tonspur, ab, hardware, stufe))); } // ------------------------------------------------------------------ @@ -264,4 +265,65 @@ public class HlsArgumenteTests Assert.That(zeile, Does.Not.Contain("-c:a aac")); }); } + + // ------------------------------------------------------------------ + // Die Stufe: Bild gegen Tempo + // ------------------------------------------------------------------ + + /// Jede Stufe rechnet anders — und „ausgewogen" wie bisher. + /// + /// + /// Preset und CRF gehören zusammen. Ein langsameres Preset holt bei + /// demselben CRF Bild heraus; ein schnelleres braucht einen höheren, sonst + /// wächst nur die Datei. Deshalb wird hier beides geprüft und nicht eines. + /// + [TestCase(Kodierstufe.Qualitaet, "-preset medium", "-crf 19")] + [TestCase(Kodierstufe.Ausgewogen, "-preset veryfast", "-crf 21")] + [TestCase(Kodierstufe.Tempo, "-preset ultrafast", "-crf 23")] + public void Die_Stufe_entscheidet_ueber_Preset_und_Crf( + Kodierstufe stufe, string preset, string crf) + { + var zeile = Zeile([Bild("hevc"), Ton(1)], stufe: stufe); + + Assert.Multiple(() => + { + Assert.That(zeile, Does.Contain(preset)); + Assert.That(zeile, Does.Contain(crf)); + }); + } + + /// Die Grafikeinheit kennt kein Preset, nur den Quantisierer. + [TestCase(Kodierstufe.Qualitaet, "-qp 21")] + [TestCase(Kodierstufe.Ausgewogen, "-qp 23")] + [TestCase(Kodierstufe.Tempo, "-qp 26")] + public void Die_Stufe_entscheidet_auch_bei_Vaapi(Kodierstufe stufe, string qp) + { + var zeile = Zeile([Bild("hevc"), Ton(1)], + hardware: Beschleunigung.Vaapi, stufe: stufe); + + Assert.Multiple(() => + { + Assert.That(zeile, Does.Contain("-c:v h264_vaapi")); + Assert.That(zeile, Does.Contain(qp)); + }); + } + + /// Beim Umpacken ist die Stufe gegenstandslos. + /// + /// + /// Wird das Bild kopiert, gibt es nichts zu kodieren — dann darf auch kein + /// Preset in der Zeile stehen, sonst lehnt ffmpeg sie ab. + /// + [Test] + public void Wer_nur_umpackt_bekommt_kein_Preset() + { + var zeile = Zeile([Bild(), Ton(1)], stufe: Kodierstufe.Qualitaet); + + Assert.Multiple(() => + { + Assert.That(zeile, Does.Contain("-c:v copy")); + Assert.That(zeile, Does.Not.Contain("-preset")); + Assert.That(zeile, Does.Not.Contain("-crf")); + }); + } } diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 24efd95..d2a86c7 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -787,6 +787,34 @@ export const abweichendeAuffrischen = () => anfrage<{ started: number; language: string }>( '/settings/metadata/languages/refresh', { method: 'POST' }); +/** Was der Server über die Wiedergabe entscheidet — serverweit, nur Admin. */ +export type Wiedergabeeinstellung = { + /** Ab wann ein Titel in „Weiterschauen" steht. 0 heisst: ab der ersten Sekunde. */ + resumeAfterSeconds: number; + /** Ab welchem Anteil der Laufzeit er als gesehen gilt, 0 bis 1. */ + watchedFraction: number; + /** „qualitaet", „ausgewogen" oder „tempo". */ + encoder: string; +}; + +export const wiedergabeEinstellung = () => + anfrage('/settings/playback'); + +/** Setzt, was angegeben ist — ausgelassene Felder bleiben. + * + * Die Antwort nennt, wie viele vorhandene Einträge die neue Schwelle + * umgeworfen hat. Ohne diese Zahl änderte sich die Startseite scheinbar von + * selbst. */ +export const wiedergabeSetzen = (werte: Partial) => + anfrage('/settings/playback', { + method: 'PUT', + body: JSON.stringify({ + resumeAfterSeconds: werte.resumeAfterSeconds ?? null, + watchedFraction: werte.watchedFraction ?? null, + encoder: werte.encoder ?? null, + }), + }); + export type Speicherort = { name: string; variable: string; pfad: string; belegt: number; frei: number | null; bilder: number | null; diff --git a/web/app/src/views/verwaltung/index.ts b/web/app/src/views/verwaltung/index.ts index 50b203d..86a8240 100644 --- a/web/app/src/views/verwaltung/index.ts +++ b/web/app/src/views/verwaltung/index.ts @@ -4,6 +4,7 @@ import { dashboard, serverbereich, speicherbereich } from './uebersicht'; import { bibliotheksbereich, vermisstenbereich } from './mediatheken'; import { pruefbereich } from './pruefung'; import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung'; +import { wiedergabebereich } from './wiedergabe'; import { sprachbereich } from './metadaten'; import { benutzerbereich } from './benutzer'; import { protokollbereich } from './protokolle'; @@ -11,13 +12,14 @@ import { protokollbereich } from './protokolle'; /** Welcher Teil der Verwaltung offen ist. */ export type Bereich = | 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung' - | 'metadaten' | 'benutzer' | 'protokolle'; + | 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle'; const BEREICHE: { wert: Bereich; text: string; bild: IconName }[] = [ { wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' }, { wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' }, { wert: 'pruefung', text: 'Zu prüfen', bild: 'hakenKreis' }, { wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' }, + { wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' }, { wert: 'metadaten', text: 'Metadaten', bild: 'stift' }, { wert: 'benutzer', text: 'Benutzer', bild: 'person' }, { wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' }, @@ -89,6 +91,8 @@ export function verwaltung( } case 'zuordnung': return [ohneZuordnungBereich(), unzugeordnetbereich()]; + case 'wiedergabe': + return [wiedergabebereich()]; case 'metadaten': return [sprachbereich()]; case 'benutzer': diff --git a/web/app/src/views/verwaltung/metadaten.ts b/web/app/src/views/verwaltung/metadaten.ts index 2f14a4d..a8f8cb4 100644 --- a/web/app/src/views/verwaltung/metadaten.ts +++ b/web/app/src/views/verwaltung/metadaten.ts @@ -2,11 +2,10 @@ import { sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, metadatenEinstellung, metadatenSetzen, sprachverteilung, abweichendeAuffrischen, } from '../../api'; -import { esc, knopf, fehlerzeile, meldung } from '../../util'; +import { knopf, fehlerzeile, meldung } from '../../util'; import { dialog } from '../../dialog'; -import { icon } from '../../icons'; -import { abschnitt } from './teile'; +import { abschnitt, zeile } from './teile'; /** Sprache und Herkunft der Metadaten. */ @@ -184,35 +183,5 @@ export function sprachbereich(): HTMLElement { return bereich; - function zeile( - name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, - ): HTMLElement { - const d = document.createElement('div'); - d.className = 'einstellung'; - - const oben = document.createElement('div'); - oben.className = 'knopfreihe'; - const text = document.createElement('div'); - text.className = 'zeile'; - text.style.flex = '1'; - text.innerHTML = `${esc(name)}${esc(wert)}`; - oben.appendChild(text); - - if (bearbeiten) { - const b = knopf('Ändern', 'btn'); - b.prepend(icon('stift')); - b.onclick = bearbeiten; - oben.appendChild(b); - } - d.appendChild(oben); - - if (hinweis) { - const h = document.createElement('div'); - h.className = 'hinweis'; - h.textContent = hinweis; - d.appendChild(h); - } - return d; - } } diff --git a/web/app/src/views/verwaltung/teile.ts b/web/app/src/views/verwaltung/teile.ts index 6dc0db4..165c55d 100644 --- a/web/app/src/views/verwaltung/teile.ts +++ b/web/app/src/views/verwaltung/teile.ts @@ -1,3 +1,6 @@ +import { esc, knopf } from '../../util'; +import { icon } from '../../icons'; + /** Gemeinsame Bausteine der Verwaltung. */ @@ -18,3 +21,40 @@ export function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLEleme return kopf; } + +/** Eine Zeile „Name — Wert" mit einem Ändern-Knopf. + * + * **Eine Stelle, nicht zwei.** Diesen Bauer gab es wortgleich zweimal — in + * der Sprach- und in der Serverauskunft. Zwei Kopien derselben Zeile sind + * genau dann ein Problem, wenn eine davon gepflegt wird. + */ +export function zeile( + name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, +): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + text.innerHTML = `${esc(name)}${esc(wert)}`; + oben.appendChild(text); + + if (bearbeiten) { + const b = knopf('Ändern', 'btn'); + b.prepend(icon('stift')); + b.onclick = bearbeiten; + oben.appendChild(b); + } + d.appendChild(oben); + + if (hinweis) { + const h = document.createElement('div'); + h.className = 'hinweis'; + h.textContent = hinweis; + d.appendChild(h); + } + return d; +} diff --git a/web/app/src/views/verwaltung/uebersicht.ts b/web/app/src/views/verwaltung/uebersicht.ts index 0e61331..3b52e2e 100644 --- a/web/app/src/views/verwaltung/uebersicht.ts +++ b/web/app/src/views/verwaltung/uebersicht.ts @@ -5,9 +5,8 @@ import { } from '../../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util'; import { dialog } from '../../dialog'; -import { icon } from '../../icons'; -import { abschnitt } from './teile'; +import { abschnitt, zeile } from './teile'; /** Übersicht: was läuft, was der Server meldet, was er belegt. */ @@ -218,36 +217,6 @@ export function serverbereich(geaendert: () => void): HTMLElement { } })(); - function zeile( - name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, - ): HTMLElement { - const d = document.createElement('div'); - d.className = 'einstellung'; - - const oben = document.createElement('div'); - oben.className = 'knopfreihe'; - const text = document.createElement('div'); - text.className = 'zeile'; - text.style.flex = '1'; - text.innerHTML = `${esc(name)}${esc(wert)}`; - oben.appendChild(text); - - if (bearbeiten) { - const b = knopf('Ändern', 'btn'); - b.prepend(icon('stift')); - b.onclick = bearbeiten; - oben.appendChild(b); - } - d.appendChild(oben); - - if (hinweis) { - const h = document.createElement('div'); - h.className = 'hinweis'; - h.textContent = hinweis; - d.appendChild(h); - } - return d; - } liste.innerHTML = '
'; return bereich; diff --git a/web/app/src/views/verwaltung/wiedergabe.ts b/web/app/src/views/verwaltung/wiedergabe.ts new file mode 100644 index 0000000..8b37ed3 --- /dev/null +++ b/web/app/src/views/verwaltung/wiedergabe.ts @@ -0,0 +1,157 @@ +import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api'; +import { fehlerzeile, meldung, hinweisZeigen } from '../../util'; +import { dialog } from '../../dialog'; + +import { abschnitt, zeile } from './teile'; + +/** Wie der Server mit Fortschritt umgeht und wie er umkodiert. */ + +/** Die Stufen fürs Weiterschauen — lieber eine Auswahl als ein Zahlenfeld. + * + * **„87 Sekunden" will niemand eintippen.** Eine freie Zahl sieht nach + * Genauigkeit aus, die es hier nicht gibt: ob 90 oder 100 Sekunden, merkt + * kein Mensch. Fünf Stufen beantworten die Frage, um die es geht — zählt + * Hineinschauen oder nicht. + */ +const SCHWELLEN: { wert: string; text: string }[] = [ + { wert: '0', text: 'Sofort — jede angefangene Sekunde' }, + { wert: '30', text: 'Nach 30 Sekunden' }, + { wert: '90', text: 'Nach 1½ Minuten' }, + { wert: '180', text: 'Nach 3 Minuten' }, + { wert: '300', text: 'Nach 5 Minuten' }, +]; + +const ANTEILE: { wert: string; text: string }[] = [ + { wert: '0.85', text: '85 % — früher abgehakt' }, + { wert: '0.9', text: '90 %' }, + { wert: '0.95', text: '95 % — erst im Abspann' }, +]; + +const STUFEN: { wert: string; text: string }[] = [ + { wert: 'qualitaet', text: 'Qualität — schöneres Bild, rechnet länger' }, + { wert: 'ausgewogen', text: 'Ausgewogen' }, + { wert: 'tempo', text: 'Tempo — startet schneller, kostet Bild' }, +]; + +export function wiedergabebereich(): HTMLElement { + const bereich = document.createElement('section'); + bereich.appendChild(abschnitt('Wiedergabe')); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + liste.innerHTML = '
'; + bereich.appendChild(liste); + + void laden(); + return bereich; + + async function laden(): Promise { + let stand; + try { + stand = await wiedergabeEinstellung(); + } catch (fehler) { + liste.innerHTML = ''; + liste.appendChild(fehlerzeile(meldung(fehler))); + return; + } + + liste.innerHTML = ''; + + liste.appendChild(zeile( + 'Weiterschauen ab', + textVon(SCHWELLEN, String(stand.resumeAfterSeconds)), + 'Wer kürzer hineinschaut, findet den Titel nicht in „Fortsetzen" wieder. ' + + 'Die Stelle wird trotzdem gemerkt — wer ihn später öffnet, macht dort weiter.', + () => fragen({ + titel: 'Weiterschauen ab', + text: 'Ohne Untergrenze genügt eine Sekunde: ein Blick in den Aufmacher, ' + + 'und der Titel steht in „Fortsetzen". Die Umstellung wirkt rückwirkend — ' + + 'was schon in der Reihe steht, wird neu bewertet.', + name: 'schwelle', + beschriftung: 'Erst ab', + auswahl: SCHWELLEN, + wert: naechsteStufe(SCHWELLEN, stand.resumeAfterSeconds), + setzen: v => wiedergabeSetzen({ resumeAfterSeconds: Number(v) }), + }))); + + liste.appendChild(zeile( + 'Gesehen ab', + `${Math.round(stand.watchedFraction * 100)} %`, + 'Niemand schaut bis zur letzten Sekunde des Abspanns. Ab diesem Anteil ' + + 'bekommt ein Titel sein Häkchen und verlässt „Fortsetzen".', + () => fragen({ + titel: 'Gesehen ab', + text: 'Höher heisst: der Titel bleibt länger in „Fortsetzen" stehen. ' + + 'Auch das wirkt rückwirkend.', + name: 'anteil', + beschriftung: 'Anteil der Laufzeit', + auswahl: ANTEILE, + wert: naechsteStufe(ANTEILE, stand.watchedFraction), + setzen: v => wiedergabeSetzen({ watchedFraction: Number(v) }), + }))); + + liste.appendChild(zeile( + 'Umkodieren', + textVon(STUFEN, stand.encoder), + 'Gilt nur, wenn wirklich gerechnet werden muss — wird eine Datei bloss ' + + 'umgepackt, bleibt das Bild unberührt. Vorsicht bei „Qualität": was in ' + + 'Echtzeit laufen muss, darf nicht langsamer rechnen als es abspielt. ' + + 'Auf einer schwachen Maschine kann das Bild dadurch stocken.', + () => fragen({ + titel: 'Umkodieren', + text: 'Die Stufe wirkt auf die nächste Wiedergabe, nicht auf eine laufende.', + name: 'stufe', + beschriftung: 'Abwägung', + auswahl: STUFEN, + wert: stand.encoder, + setzen: v => wiedergabeSetzen({ encoder: v }), + }))); + } + + /** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */ + function fragen(o: { + titel: string; text: string; name: string; beschriftung: string; + auswahl: { wert: string; text: string }[]; wert: string; + setzen: (wert: string) => Promise<{ reevaluated: number }>; + }): void { + dialog({ + titel: o.titel, + text: o.text, + felder: [{ name: o.name, beschriftung: o.beschriftung, auswahl: o.auswahl, wert: o.wert }], + bestaetigen: 'Übernehmen', + tun: async wert => { + const ergebnis = await o.setzen(wert(o.name)); + // **Die Zahl gehört in die Rückmeldung.** Eine neue Schwelle räumt + // „Fortsetzen" auf; ohne diesen Satz änderte sich die Startseite + // scheinbar von selbst. + hinweisZeigen(ergebnis.reevaluated === 0 + ? 'Übernommen.' + : ergebnis.reevaluated === 1 + ? 'Übernommen — ein Eintrag wurde neu bewertet.' + : `Übernommen — ${ergebnis.reevaluated} Einträge wurden neu bewertet.`); + await laden(); + }, + }); + } +} + +/** Der Text zur gespeicherten Zahl — oder die Zahl selbst, wenn sie nicht passt. */ +function textVon(stufen: { wert: string; text: string }[], wert: string): string { + return stufen.find(s => s.wert === wert)?.text ?? wert; +} + +/** Welche Stufe ein gespeicherter Wert am ehesten meint. + * + * In der Datenbank steht eine Zahl, keine Stufe — sie kann aus einer älteren + * Fassung stammen oder von Hand gesetzt sein. Dann soll der Dialog nicht leer + * aufgehen, sondern bei der nächstgelegenen stehen. + */ +function naechsteStufe(stufen: { wert: string; text: string }[], wert: number): string { + let beste = stufen[0]!.wert; + let abstand = Infinity; + for (const s of stufen) { + const d = Math.abs(Number(s.wert) - wert); + if (d < abstand) { abstand = d; beste = s.wert; } + } + return beste; +}