Eine Sekunde ist kein Anfang
Drei Zahlen standen als Konstante im Quelltext und bestimmten, wie sich der Server anfühlt. Jetzt stehen sie in der Verwaltung, unter „Wiedergabe". **Weiterschauen ab.** Gemessen: `BelongsInContinueWatching` verlangte nur `Position.Ticks > 0` — ein Tick genügte. Wer im Aufmacher eine Sekunde hineinschaute, fand den Titel danach in „Fortsetzen". Jetzt gibt es eine Untergrenze, voreingestellt auf 1½ Minuten. Was sich dabei **nicht** ändert: die Stelle wird weiter gemerkt. Das ist der Unterschied zu Jellyfin, um dessentwillen es diesen Server gibt — die Schwelle entscheidet, ob der Titel in der Reihe steht, nicht ob man ihn wiederfindet. Nachgemessen: nach dem Umstellen ist „Heat" aus der Reihe verschwunden und steht weiterhin bei Sekunde 1. **Gesehen ab.** War 90 % als `const`, ist jetzt 85/90/95. **Umkodieren.** `-preset veryfast -crf 21` und VAAPI `-qp 23` waren fest verdrahtet, ohne Einstellung und ohne Umgebungsvariable. Drei Stufen: Qualität (medium/19, qp 21), Ausgewogen (wie bisher), Tempo (ultrafast/23, qp 26). Mit dem Warnsatz daneben, und der ist ernst gemeint: was in Echtzeit laufen muss, darf nicht langsamer rechnen als es abspielt. **Und das Vorhandene wird neu bewertet.** `InProgress` ist eine gespeicherte Spalte — die Bruchrechnung lässt sich nicht nach SQL übersetzen. Eine neue Schwelle wirkte sonst nur auf künftige Meldungen. Der Endpunkt läuft deshalb einmal in Stapeln durch und meldet zurück, wie viele Zeilen das umgeworfen hat: „Übernommen — 2 Einträge wurden neu bewertet." Ohne diesen Satz änderte sich die Startseite scheinbar von selbst. `ProgressRule` bleibt ein reiner Typ: die Regeln kommen als Wert herein. Nebenbei zieht der doppelte `zeile()`-Bauer nach `teile.ts` — er stand zweimal wortgleich da. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1e4a231d1b
commit
9622da0284
@@ -460,6 +460,30 @@ public sealed record AttributionResponse(string Text, string LogoUrl, string Url
|
||||
|
||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
||||
|
||||
/// <summary>Was der Server über die Wiedergabe entscheidet.</summary>
|
||||
///
|
||||
/// <param name="ResumeAfterSeconds">
|
||||
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
|
||||
/// </param>
|
||||
/// <param name="WatchedFraction">Ab welchem Anteil er als gesehen gilt, 0 bis 1.</param>
|
||||
/// <param name="Encoder">„qualitaet", „ausgewogen" oder „tempo".</param>
|
||||
public sealed record PlaybackSettingsResponse(
|
||||
double ResumeAfterSeconds, double WatchedFraction, string Encoder);
|
||||
|
||||
/// <summary>Was geändert werden soll — <c>null</c> heisst: unverändert.</summary>
|
||||
public sealed record PlaybackSettingsRequest(
|
||||
double? ResumeAfterSeconds, double? WatchedFraction, string? Encoder);
|
||||
|
||||
/// <summary>Was danach gilt, und wie viele Zeilen das umgeworfen hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Zahl gehört in die Antwort.</b> Eine neue Schwelle wirkt rückwirkend
|
||||
/// auf alles, was schon in „Weiterschauen" steht; ohne diese Rückmeldung
|
||||
/// änderte sich die Startseite scheinbar von selbst.
|
||||
/// </remarks>
|
||||
public sealed record PlaybackSettingsApplied(
|
||||
double ResumeAfterSeconds, double WatchedFraction, string Encoder, int Reevaluated);
|
||||
|
||||
// --- Stufe 3: Ausliefern -------------------------------------------------
|
||||
|
||||
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
|
||||
|
||||
@@ -48,9 +48,10 @@ public static class PlaybackEndpoints
|
||||
// Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre.
|
||||
titel.MapPost("/progress", async (
|
||||
Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||
TimeProvider clock, PlaybackSessions laufende) =>
|
||||
TimeProvider clock, PlaybackSessions laufende, Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
@@ -62,7 +63,8 @@ public static class PlaybackEndpoints
|
||||
? TimeSpan.FromSeconds(sekunden)
|
||||
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
|
||||
|
||||
var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge);
|
||||
var ergebnis = ProgressRule.Apply(
|
||||
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
|
||||
|
||||
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
|
||||
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
|
||||
@@ -81,7 +83,7 @@ public static class PlaybackEndpoints
|
||||
// Wer weitersieht, hat es sich offenbar anders überlegt.
|
||||
if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false;
|
||||
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client
|
||||
@@ -99,9 +101,11 @@ public static class PlaybackEndpoints
|
||||
});
|
||||
|
||||
titel.MapPut("/watched", async (
|
||||
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
@@ -126,10 +130,10 @@ public static class PlaybackEndpoints
|
||||
// **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an
|
||||
// der Serie und daneben „noch 42 offen".
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
|
||||
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
@@ -137,9 +141,11 @@ public static class PlaybackEndpoints
|
||||
// **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung
|
||||
// im Pfad trägt und eine Liste keine hat.
|
||||
app.MapPut("/api/v1/items/watched", async (
|
||||
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
if (anfrage.Ids.Count == 0)
|
||||
return Results.Ok(new BulkWatchedResponse(0, []));
|
||||
if (anfrage.Ids.Count > HöchstensJeZug)
|
||||
@@ -171,10 +177,10 @@ public static class PlaybackEndpoints
|
||||
zustand.Position = TimeSpan.Zero;
|
||||
zustand.Dismissed = false;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln);
|
||||
|
||||
geaendert++;
|
||||
}
|
||||
@@ -185,39 +191,45 @@ public static class PlaybackEndpoints
|
||||
|
||||
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
||||
titel.MapPost("/dismiss", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
|
||||
zustand.Dismissed = true;
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
|
||||
titel.MapPut("/favourite", async (
|
||||
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) return Results.NotFound();
|
||||
|
||||
zustand.Favourite = anfrage.Favourite;
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
|
||||
titel.MapPut("/rating", async (
|
||||
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
|
||||
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
@@ -227,7 +239,7 @@ public static class PlaybackEndpoints
|
||||
|
||||
zustand.Rating = anfrage.Rating;
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
@@ -512,8 +524,8 @@ public static class PlaybackEndpoints
|
||||
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
||||
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
||||
/// </remarks>
|
||||
private static void Nachfuehren(UserItemState zustand) =>
|
||||
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand);
|
||||
private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) =>
|
||||
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln);
|
||||
|
||||
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
||||
///
|
||||
@@ -642,7 +654,8 @@ public static class PlaybackEndpoints
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task FolgenSetzen(
|
||||
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock)
|
||||
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock,
|
||||
Wiedergaberegeln regeln)
|
||||
{
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == serie)
|
||||
@@ -672,7 +685,7 @@ public static class PlaybackEndpoints
|
||||
zustand.Dismissed = false;
|
||||
if (gesehen) zustand.LastPlayedAt = jetzt;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
Nachfuehren(zustand, regeln);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core;
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -103,6 +106,92 @@ public static class SettingsEndpoints
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Wiedergabe
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
app.MapGet("/api/v1/settings/playback", async (Wiedergabeeinstellungen einstellungen) =>
|
||||
{
|
||||
var regeln = await einstellungen.RegelnAsync();
|
||||
var stufe = await einstellungen.StufeAsync();
|
||||
return Results.Ok(new PlaybackSettingsResponse(
|
||||
regeln.FortsetzenAb.TotalSeconds, regeln.GesehenAnteil, stufe.AlsWort()));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/settings/playback", async (
|
||||
PlaybackSettingsRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var vorher = Wiedergabeeinstellungen.Lesen(
|
||||
(await db.Settings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.FortsetzenAbName))?.Value,
|
||||
(await db.Settings.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.GesehenAnteilName))?.Value);
|
||||
|
||||
var nachher = Wiedergabeeinstellungen.Lesen(
|
||||
anfrage.ResumeAfterSeconds?.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
?? vorher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
anfrage.WatchedFraction?.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
?? vorher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
await Setzen(db, Wiedergabeeinstellungen.FortsetzenAbName,
|
||||
nachher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
await Setzen(db, Wiedergabeeinstellungen.GesehenAnteilName,
|
||||
nachher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
if (anfrage.Encoder is not null)
|
||||
await Setzen(db, Wiedergabeeinstellungen.TranscoderName, Kodierstufen.Lesen(anfrage.Encoder).AlsWort());
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// **Was schon dasteht, muss neu bewertet werden.**
|
||||
//
|
||||
// `UserItemState.InProgress` ist eine gespeicherte Spalte — die
|
||||
// Bruchrechnung lässt sich nicht nach SQL übersetzen. Eine neue
|
||||
// Schwelle wirkte deshalb nur auf künftige Meldungen: wer gestern
|
||||
// eine Sekunde hineingeschaut hat, stünde bis in alle Ewigkeit in
|
||||
// „Weiterschauen". Also einmal durch, in Stapeln.
|
||||
var geaendert = nachher == vorher ? 0 : await NeuBewerten(db, nachher, clock);
|
||||
return Results.Ok(new PlaybackSettingsApplied(
|
||||
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
|
||||
Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
|
||||
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// In Stapeln zu fünfhundert: ein Haushalt mit drei Leuten und
|
||||
/// fünftausend Titeln hat höchstens ein paar tausend Zeilen, aber alle
|
||||
/// auf einmal zu laden ist eine Zahl, die man nicht kennt.
|
||||
/// </remarks>
|
||||
private static async Task<int> NeuBewerten(
|
||||
FlaroContext db, Wiedergaberegeln regeln, TimeProvider clock)
|
||||
{
|
||||
const int Stapel = 500;
|
||||
var geaendert = 0;
|
||||
var jetzt = clock.GetUtcNow();
|
||||
|
||||
for (var sprung = 0; ; sprung += Stapel)
|
||||
{
|
||||
var zeilen = await db.UserItemStates
|
||||
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync();
|
||||
if (zeilen.Count == 0) break;
|
||||
|
||||
foreach (var z in zeilen)
|
||||
{
|
||||
var soll = ProgressRule.BelongsInContinueWatching(z, regeln);
|
||||
if (soll == z.InProgress) continue;
|
||||
z.InProgress = soll;
|
||||
z.UpdatedAt = jetzt;
|
||||
geaendert++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
if (zeilen.Count < Stapel) break;
|
||||
}
|
||||
|
||||
return geaendert;
|
||||
}
|
||||
|
||||
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
|
||||
|
||||
@@ -89,6 +89,7 @@ builder.Services.AddScoped<Uebersetzer>();
|
||||
// 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<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
||||
builder.Services.AddScoped<Wiedergabeeinstellungen>();
|
||||
builder.Services.AddHttpClient<TmdbClient>(c =>
|
||||
{
|
||||
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
||||
|
||||
@@ -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<Wiedergabeeinstellungen>().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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Playback;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Holt die Wiedergabe-Einstellungen aus der Datenbank.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Dasselbe Muster wie <see cref="DatabaseTmdbSettings"/>:</b> 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.
|
||||
///
|
||||
/// <b>Serverweit, nicht je Nutzer.</b> 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>Wann ein Titel angefangen ist und wann er gesehen ist.</summary>
|
||||
public async Task<Wiedergaberegeln> RegelnAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_regeln is { } fertig) return fertig;
|
||||
await LesenAsync(ct);
|
||||
return _regeln!.Value;
|
||||
}
|
||||
|
||||
/// <summary>Wie der Transcoder zwischen Bild und Tempo abwägt.</summary>
|
||||
public async Task<Kodierstufe> 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));
|
||||
}
|
||||
|
||||
/// <summary>Macht aus zwei gespeicherten Zeichenketten geprüfte Regeln.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ohne Ortsangabe gerechnet</b> — <see cref="double.TryParse(string?,
|
||||
/// IFormatProvider?, out double)"/> mit <see
|
||||
/// cref="System.Globalization.CultureInfo.InvariantCulture"/>: in der
|
||||
/// Datenbank steht ein Punkt, und auf einem Server mit deutscher
|
||||
/// Spracheinstellung läse ein Komma-Parser aus „0.9" eine Neun.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace Flaro.Core.Delivery;
|
||||
|
||||
/// <summary>Wie der Transcoder zwischen Bild und Tempo abwägt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Drei Stufen, keine freie Zahl</b> — wie bei
|
||||
/// <see cref="Stromentscheid.Stufen"/>. „crf 19" sagt niemandem etwas, der
|
||||
/// nicht ffmpeg kennt; „Qualität" und „Tempo" sagen genau das, worum es geht.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Und es ist eine echte Abwägung.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum Kodierstufe
|
||||
{
|
||||
/// <summary>Schöneres Bild, langsamere Rechnung.</summary>
|
||||
Qualitaet,
|
||||
|
||||
/// <summary>Was Flaro immer getan hat.</summary>
|
||||
Ausgewogen,
|
||||
|
||||
/// <summary>Startet schneller, kostet Bild.</summary>
|
||||
Tempo,
|
||||
}
|
||||
|
||||
/// <summary>Hin und zurück zwischen Stufe und gespeichertem Wort.</summary>
|
||||
public static class Kodierstufen
|
||||
{
|
||||
/// <summary>Unbekanntes wird ausgewogen — das war schon immer das Verhalten.</summary>
|
||||
public static Kodierstufe Lesen(string? wert) => wert?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"qualitaet" or "qualität" => Kodierstufe.Qualitaet,
|
||||
"tempo" => Kodierstufe.Tempo,
|
||||
_ => Kodierstufe.Ausgewogen,
|
||||
};
|
||||
|
||||
/// <summary>Wie die Stufe in der Datenbank steht.</summary>
|
||||
public static string AlsWort(this Kodierstufe stufe) => stufe switch
|
||||
{
|
||||
Kodierstufe.Qualitaet => "qualitaet",
|
||||
Kodierstufe.Tempo => "tempo",
|
||||
_ => "ausgewogen",
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Was einstellbar ist, steht in <see cref="Wiedergaberegeln"/>.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ProgressRule
|
||||
{
|
||||
/// <summary>Ab welchem Anteil ein Titel als gesehen gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Schwelle ist hier unvermeidlich</b>, 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, <i>dass</i> es diese Grenze
|
||||
/// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort
|
||||
/// zusätzlich die Position weggeworfen.
|
||||
/// </remarks>
|
||||
public const double WatchedFraction = 0.90;
|
||||
|
||||
/// <summary>Was aus einer Meldung folgt.</summary>
|
||||
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
|
||||
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
|
||||
@@ -57,7 +49,8 @@ public static class ProgressRule
|
||||
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
|
||||
/// es zu merken.
|
||||
/// </param>
|
||||
public static Outcome Apply(TimeSpan position, TimeSpan? runtime)
|
||||
/// <param name="regeln">Ab welchem Anteil ein Titel als gesehen gilt.</param>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
|
||||
@@ -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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace Flaro.Core.Playback;
|
||||
|
||||
/// <summary>Wann ein Titel angefangen ist und wann er als gesehen gilt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Zwei Zahlen, die niemand raten kann.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Als Wert, nicht als Abfrage.</b> <see cref="ProgressRule"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
///
|
||||
/// <param name="FortsetzenAb">
|
||||
/// Wie weit man gekommen sein muss, damit ein Titel in „Weiterschauen" steht.
|
||||
/// <see cref="TimeSpan.Zero"/> heisst: jede angefangene Sekunde zählt.
|
||||
/// </param>
|
||||
/// <param name="GesehenAnteil">Ab welchem Anteil der Laufzeit ein Titel gesehen ist.</param>
|
||||
public readonly record struct Wiedergaberegeln(TimeSpan FortsetzenAb, double GesehenAnteil)
|
||||
{
|
||||
/// <summary>Was gilt, solange niemand etwas anderes eingestellt hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neunzig Sekunden, nicht null.</b> 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.
|
||||
/// </remarks>
|
||||
public static readonly Wiedergaberegeln Vorgabe = new(TimeSpan.FromSeconds(90), 0.90);
|
||||
|
||||
/// <summary>Dieselben Regeln mit geprüften Werten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Was aus der Datenbank kommt, hat dort irgendwann jemand hineingeschrieben.
|
||||
/// Ein Anteil von 0 hiesse „alles ist gesehen", einer von 2 „nichts".
|
||||
/// </remarks>
|
||||
public Wiedergaberegeln Geprueft() => new(
|
||||
FortsetzenAb < TimeSpan.Zero ? TimeSpan.Zero : FortsetzenAb,
|
||||
Math.Clamp(GesehenAnteil, 0.5, 1.0));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Baut die ffmpeg-Zeile für einen laufenden Strom.</summary>
|
||||
///
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Was die Stufe für libx264 bedeutet.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Preset und CRF zusammen, nicht einzeln.</b> 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.
|
||||
/// </remarks>
|
||||
private static (string Preset, int Crf) Weichkodierung(Kodierstufe stufe) => stufe switch
|
||||
{
|
||||
Kodierstufe.Qualitaet => ("medium", 19),
|
||||
Kodierstufe.Tempo => ("ultrafast", 23),
|
||||
_ => ("veryfast", 21),
|
||||
};
|
||||
|
||||
/// <summary>Dasselbe für die Grafikeinheit.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private static int Qp(Kodierstufe stufe) => stufe switch
|
||||
{
|
||||
Kodierstufe.Qualitaet => 21,
|
||||
Kodierstufe.Tempo => 26,
|
||||
_ => 23,
|
||||
};
|
||||
|
||||
private static IEnumerable<string> Tonargumente(Stromanfrage anfrage, ProbedStream? ton)
|
||||
{
|
||||
if (ton is null) return ["-an"];
|
||||
|
||||
@@ -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<Page<ContinueEntry>>("/api/v1/home/continue");
|
||||
Assert.That(reihe!.Items.Count == 1, Is.EqualTo(erwartet),
|
||||
$"bei {stelle}s von {laenge}s");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Eine neue Schwelle wirkt auch auf das, was schon dasteht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Grund, aus dem der Endpunkt eine Zahl zurückgibt.</b>
|
||||
/// <c>InProgress</c> 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.
|
||||
/// </remarks>
|
||||
[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<Page<ContinueEntry>>("/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<PlaybackSettingsApplied>())!;
|
||||
|
||||
var nachher = await client.GetFromJsonAsync<Page<ContinueEntry>>("/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<MovieResponse>($"/api/v1/movies/{film}");
|
||||
Assert.That(eintrag?.State?.PositionSeconds, Is.EqualTo(1).Within(0.001));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ public sealed class ProgressRuleTests
|
||||
{
|
||||
private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2);
|
||||
|
||||
/// <summary>Die Vorgabe des Servers — was gilt, wenn niemand etwas einstellt.</summary>
|
||||
private static readonly Wiedergaberegeln Regeln = Wiedergaberegeln.Vorgabe;
|
||||
|
||||
/// <summary>Kurzschreibweise, damit die Prüfungen lesbar bleiben.</summary>
|
||||
private static bool BelongsInContinueWatching(UserItemState zustand) =>
|
||||
ProgressRule.BelongsInContinueWatching(zustand, Regeln);
|
||||
|
||||
/// <summary>Der Fall, der den Ausschlag gab.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Ein zweites Mal angefangen — dann steht er wieder da.</summary>
|
||||
@@ -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 ---------- */
|
||||
|
||||
/// <summary>Hineingeschaut ist noch nicht angefangen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>„Sofort" ist eine gültige Einstellung — dann zählt jede Sekunde.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Und der Anteil wirkt auch.</summary>
|
||||
[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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Unsinn aus der Datenbank wird eingefangen, nicht übernommen.</summary>
|
||||
[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) =>
|
||||
|
||||
@@ -24,11 +24,12 @@ public class HlsArgumenteTests
|
||||
private static string Zeile(
|
||||
IReadOnlyList<ProbedStream> 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
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>Jede Stufe rechnet anders — und „ausgewogen" wie bisher.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Preset und CRF gehören zusammen.</b> 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.
|
||||
/// </remarks>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Die Grafikeinheit kennt kein Preset, nur den Quantisierer.</summary>
|
||||
[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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Beim Umpacken ist die Stufe gegenstandslos.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Wird das Bild kopiert, gibt es nichts zu kodieren — dann darf auch kein
|
||||
/// Preset in der Zeile stehen, sonst lehnt ffmpeg sie ab.
|
||||
/// </remarks>
|
||||
[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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user