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;
+}