Stufe 4: Fortschritt ab der ersten Sekunde, ohne Schwelle

Der Anlass für diesen Server, jetzt als Code. Fortschritt, Gesehen-Status,
Weiterschauen, Nächste Folge, Favoriten und Bewertungen — alles je Benutzer.

**Die Jellyfin-Werte sind jetzt belegt statt behauptet.** Das Entwurfsdokument
hatte das ausdrücklich offengelassen („aus dem Gedächtnis"); hier stehen sie
aus `MediaBrowser.Model/Configuration/ServerConfiguration.cs` und
`Emby.Server.Implementations/Library/UserDataManager.cs`:

| | Vorgabe | Was passiert |
|---|---|---|
| `MinResumePct` | 5 | darunter `positionTicks = 0` — Kommentar im Quelltext: „ignore progress during the beginning" |
| `MaxResumePct` | 90 | darüber gesehen, Position **auf 0** |
| `MinResumeDurationSeconds` | 300 | alles unter fünf Minuten gilt nach dem ersten Lebenszeichen als gesehen |

Bei einem Zweistünder sind die ersten sechs Minuten damit weg. Eine
vierminütige Folge gilt nach dreissig Sekunden als vollständig gesehen. Und
fehlt die Laufzeit, nimmt Jellyfin an, der Titel sei durch — wörtlich:
„we'll just have to assume it was fully played".

**Flaro legt jede Meldung ab und wirft nie eine Position weg.** Eine Grenze
fürs Häkchen gibt es trotzdem — 90 %, sonst stünde jeder zu Ende geschaute
Film für immer bei 98 % in Weiterschauen. Der Unterschied ist nicht, *dass*
es die Grenze gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt,
dort zusätzlich die Stelle vernichtet. Wer einen gesehenen Film noch einmal
anfängt, hat beides — das Häkchen und den Punkt, an dem er steht.

Ohne bekannte Laufzeit wird nichts vermutet. Das ist der Fehler, den man
nicht zurücknehmen kann, ohne ihn zu bemerken.

---

**Eine Spalte statt einer zweiten Regel.** „Gehört in Weiterschauen" vergleicht
die Stelle mit einem Anteil der Laufzeit, und diese Rechnung lässt sich nicht
nach SQL übersetzen. Die Regel daneben noch einmal als Abfrage zu schreiben
wäre die Variante, bei der beide irgendwann auseinanderlaufen — unbemerkt,
weil keine von beiden dabei falsch aussieht. Stattdessen hält `InProgress`
das Ergebnis fest, geschrieben an genau einer Stelle. Eine Prüfung hält Reihe
und Regel über vier Stände gegeneinander.

**„Weiterschauen" und „Nächste Folge" überschneiden sich nicht.** Was
angefangen ist, steht links; was ansteht, rechts. Jellyfin zeigt beides
doppelt, und auf einer Startseite mit zwei Reihen nebeneinander sieht das
nach einem Fehler aus.

Dazu, weil die App sie braucht: Einzelabruf für Film und Serie, die nächste
Folge nach einer gegebenen, und der Zustand hängt an jeder Liste mit dran —
in einer Abfrage je Seite, nicht in einer je Titel.

Ein Titel wird über seine Kennung allein angesprochen, ohne dass der Aufrufer
sagen muss, ob es ein Film oder eine Folge ist. Die App hat diese Auskunft an
der Stelle nicht.

348 Tests grün. Im Container nachgewiesen: fünf Minuten „Der Untergang" von
2:36 bleiben stehen, bei Minute 150 kommt das Häkchen und die Stelle bleibt,
beim zweiten Anlauf steht der Film mit beidem wieder in der Reihe — und nach
Folge 1 von Dark zeigt „Nächste Folge" S01E02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 21:25:55 +02:00
co-authored by Claude Opus 5
parent 705ec87416
commit c4081ff251
12 changed files with 2185 additions and 10 deletions
@@ -0,0 +1,88 @@
namespace Flaro.Core.Playback;
/// <summary>Was eine Fortschrittsmeldung am Zustand ändert.</summary>
///
/// <remarks>
/// <para>
/// <b>Keine untere Schwelle. Keine Mindestlaufzeit. Keine verworfene
/// Position.</b> Das ist der Anlass für diesen Server, und es lohnt, den
/// Vergleich festzuhalten, damit später niemand „das macht Jellyfin doch
/// genauso" denkt.
/// </para>
/// <para>
/// Jellyfin rechnet in <c>UserDataManager.UpdatePlayState</c> mit drei Werten
/// (Vorgaben aus <c>ServerConfiguration</c>): <c>MinResumePct</c> 5,
/// <c>MaxResumePct</c> 90, <c>MinResumeDurationSeconds</c> 300. Unterhalb von
/// fünf Prozent setzt es die Position auf null — der Kommentar im Quelltext
/// lautet wörtlich „ignore progress during the beginning". Bei einem
/// Zweistünder sind das die ersten sechs Minuten. Oberhalb von neunzig
/// Prozent gilt der Titel als gesehen, und auch dann fällt die Position weg.
/// Und alles, was kürzer als fünf Minuten ist, gilt nach dem ersten
/// Lebenszeichen als vollständig gesehen, egal an welcher Stelle.
/// </para>
/// <para>
/// Flaro behält in allen drei Fällen die Position. Ein gesehener Titel und
/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus —
/// wer einen Film ein zweites Mal anfängt, soll beides haben.
/// </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>
public readonly record struct Outcome(TimeSpan Position, bool Watched);
/// <summary>Rechnet eine gemeldete Stelle in einen Zustand um.</summary>
///
/// <param name="position">Wo der Client steht.</param>
/// <param name="runtime">
/// Wie lang der Titel ist, falls bekannt. Ohne Laufzeit gibt es keinen
/// Anteil zu rechnen — dann wird die Stelle abgelegt und sonst nichts.
/// Zu raten wäre hier schlimmer als nichts zu tun: „gesehen", weil die
/// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne
/// es zu merken.
/// </param>
public static Outcome Apply(TimeSpan position, TimeSpan? runtime)
{
// Eine negative Stelle gibt es nicht, und eine jenseits des Endes
// auch nicht. Beides kommt vor: der eine Player meldet beim Spulen
// kurz Unsinn, der andere beim Beenden die Laufzeit plus Rundung.
var stelle = position < TimeSpan.Zero ? TimeSpan.Zero : position;
if (runtime is not { } laenge || laenge <= TimeSpan.Zero)
return new Outcome(stelle, Watched: false);
if (stelle > laenge) stelle = laenge;
return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction);
}
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
///
/// <remarks>
/// <b>Dieselbe Regel wie in der Abfrage, an einer Stelle.</b> Die
/// Serverabfrage kann sie nicht aufrufen — sie wird zu SQL übersetzt —,
/// 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) =>
zustand is { Dismissed: false, Position.Ticks: > 0 }
&& (zustand.Runtime is not { } laenge
|| laenge <= TimeSpan.Zero
|| zustand.Position < laenge * WatchedFraction);
}
@@ -0,0 +1,84 @@
namespace Flaro.Core.Playback;
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
///
/// <remarks>
/// <para>
/// <b>Je Benutzer, nicht je Server.</b> Zwei Leute im selben Haushalt stehen
/// an verschiedenen Stellen desselben Films, und wessen Stand gilt, darf
/// nicht davon abhängen, wer zuletzt geschaut hat.
/// </para>
/// <para>
/// <b>Der Verweis zeigt auf Film <i>oder</i> Folge</b>, wie bei
/// <c>MediaFile</c>. Eine gemeinsame Kennung mit einem Typfeld daneben wäre
/// kürzer, aber die Datenbank könnte dann nicht mehr dafür einstehen, dass
/// der Verweis irgendwohin zeigt.
/// </para>
/// </remarks>
public sealed class UserItemState
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid UserId { get; init; }
public Guid? MovieId { get; init; }
public Guid? EpisodeId { get; init; }
/// <summary>Wo der Benutzer steht.</summary>
///
/// <remarks>
/// <b>Ab der ersten Sekunde, ohne untere Schwelle.</b> Das ist der
/// Unterschied, um dessentwillen dieser Server existiert — siehe
/// <see cref="ProgressRule"/>.
/// </remarks>
public TimeSpan Position { get; set; }
/// <summary>Wie lang der Titel ist, so wie der Client es gemeldet hat.</summary>
///
/// <remarks>
/// Hier abgelegt, damit „wie weit ist er" ohne einen Blick in die Dateien
/// zu beantworten ist. Bei zwei Fassungen eines Films — 4K und 1080p —
/// können sich die Laufzeiten um Sekunden unterscheiden; was zählt, ist
/// die, gegen die gemessen wurde.
/// </remarks>
public TimeSpan? Runtime { get; set; }
public bool Watched { get; set; }
public int PlayCount { get; set; }
public DateTimeOffset? LastPlayedAt { get; set; }
/// <summary>Aus „Weiterschauen" genommen, ohne als gesehen zu gelten.</summary>
///
/// <remarks>
/// <b>Der Fall, für den es sonst nichts gibt.</b> Nach einer halben Stunde
/// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll
/// nur nicht weiter oben stehen. „Als gesehen" wäre eine Lüge, „als
/// ungesehen" täte nichts: die Position bliebe.
///
/// Die Position bleibt auch hier erhalten. Wer doch weitermacht, fängt
/// nicht von vorn an.
/// </remarks>
public bool Dismissed { get; set; }
public bool Favourite { get; set; }
/// <summary>Eine eigene Bewertung von 1 bis 10, oder keine.</summary>
public int? Rating { get; set; }
/// <summary>Ob dieser Titel in „Weiterschauen" gehört.</summary>
///
/// <remarks>
/// <b>Kein zweites Urteil, sondern das festgehaltene Ergebnis des
/// ersten.</b> Geschrieben wird es ausschliesslich aus
/// <see cref="ProgressRule.BelongsInContinueWatching"/>, bei jeder
/// Änderung an dieser Zeile.
///
/// Warum überhaupt eine Spalte: die Regel vergleicht die Stelle mit einem
/// Anteil der Laufzeit, und diese Rechnung lässt sich nicht nach SQL
/// übersetzen. Die Regel daneben ein zweites Mal als Abfrage zu schreiben
/// wäre die Variante, bei der beide irgendwann auseinanderlaufen — und
/// zwar unbemerkt, weil keine von beiden dabei falsch aussieht.
/// </remarks>
public bool InProgress { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}