Ein Client darf einen Stand fortschreiben, nicht zerstören

Ein Film stand bei 46 Minuten; nach einem Neuladen bot die Detailseite nur
noch „Abspielen" an. Gemessen am laufenden Server, was eine einzige Meldung
„Stelle 500 s, Laufzeit 60 s" anrichtet:

    Stelle        500 s  ->   60 s
    Laufzeit      600 s  ->   60 s
    gesehen        nein  ->   ja
    Weiterschauen    ja  ->   nein

Der Server beschnitt die Stelle auf die gemeldete Laufzeit, ersetzte die
richtige Laufzeit durch sie und setzte das Häkchen. Ein Film war damit weg.

Und 60 ist keine erfundene Zahl. Wer einen gerechneten Strom abspielt, misst
an einer Playlist, die noch wächst, und sieht anfangs nur einen Stummel.
Genau den meldete die App als Laufzeit — und sie meldet beim Öffnen
ausserdem die Zeit null, bevor sie überhaupt gesprungen ist.

Drei Stellen, dieselbe Regel:

- Server: die gemessene Laufzeit schlägt die gemeldete. Die Datei zuerst,
  nicht der gemerkte Wert — stünde dort schon ein Stummel, bliebe er sonst
  für immer stehen. So heilt sich ein verdorbener Eintrag von selbst.
- Server: eine gemeldete Null löscht keine gemerkte Stelle. Wer wirklich
  von vorn anfängt, verliert nichts: zehn Sekunden später meldet derselbe
  Spieler die 10, und die zählt. Zurückspulen bleibt unberührt.
- App: dieselbe Rangfolge bei der Laufzeit, und beim Öffnen wird die
  Stelle gemeldet, an der der Spieler einsteigen will, statt der Null.

Im Webspieler kam eine eigene Null dazu: bei Direktwiedergabe gibt der
Server `startSeconds: 0` zurück und der Browser springt selbst, sobald die
Metadaten da sind. Kommen sie nie, weil er mit der Datei nicht zurechtkommt,
blieb `filmzeit()` auf null — und der Rückfall aufs Umkodieren, der
ausdrücklich „an der Stelle weiter, an der es hing" meint, fing von vorn an.
Der ausstehende Sprung zählt jetzt mit.

Fünf neue Prüfungen halten die gemessenen Zahlen fest, damit die Regel nicht
wieder umkippt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 21:36:35 +02:00
co-authored by Claude Opus 5
parent 51d1db4641
commit 339a6c07d0
9 changed files with 279 additions and 19 deletions
@@ -56,15 +56,47 @@ public static class PlaybackEndpoints
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
// Die Laufzeit vom Client hat Vorrang: er misst an der Fassung,
// die er gerade abspielt. Fehlt sie, hilft die Datei weiter —
// ohne Laufzeit gäbe es keinen Anteil und damit kein „gesehen".
var laenge = anfrage.RuntimeSeconds is { } sekunden and > 0
? TimeSpan.FromSeconds(sekunden)
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
// **Die gemessene Laufzeit schlägt die gemeldete.**
//
// Vorher war es umgekehrt, begründet damit, der Client messe an
// der Fassung, die er gerade abspielt. Was das anrichtet, ist
// gemessen: eine einzige Meldung „Stelle 500 s, Laufzeit 60 s"
// machte aus der Stelle 500 die Stelle 60, aus der Laufzeit 600
// die Laufzeit 60, setzte das Häkchen „gesehen" und nahm den
// Titel aus „Weiterschauen". Der Film war damit weg, und auf der
// Detailseite stand nur noch „Abspielen".
//
// Und 60 ist keine erfundene Zahl. Wer einen gerechneten Strom
// abspielt, misst an einer Playlist, die noch wächst, und sieht
// nur, was bisher erzeugt wurde. Scheitert die Wiedergabe gleich
// darauf, bleibt genau dieser Stummel als „Laufzeit" stehen.
//
// Die Datei zuerst, nicht der gemerkte Wert: stünde dort schon
// ein verdorbener Stummel, bliebe er sonst für immer stehen. So
// heilt sich der Eintrag bei der nächsten Meldung von selbst.
var ausDatei = await Dateilaufzeit(db, zustand);
var laenge =
ausDatei is { Ticks: > 0 } ? ausDatei
: zustand.Runtime is { Ticks: > 0 } ? zustand.Runtime
: anfrage.RuntimeSeconds is { } sekunden and > 0
? TimeSpan.FromSeconds(sekunden)
: null;
var ergebnis = ProgressRule.Apply(
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
// **Eine gemeldete Null löscht keine gemerkte Stelle.**
//
// Kein Spieler steht je wirklich bei null, ausser im Augenblick
// des Öffnens — und da hat er die Stelle, an der er anfangen
// will, oft noch gar nicht gesetzt. Die App meldet beim Start
// ausdrücklich Zeit null; der Browser tut es im Fenster zwischen
// „Datei geöffnet" und „Sprung ausgeführt". Beides hat bisher
// eine Stelle von 46 Minuten gelöscht.
//
// Wer wirklich von vorn anfängt, verliert dadurch nichts: zehn
// Sekunden später meldet derselbe Spieler die 10, und die zählt.
var gemeldet = TimeSpan.FromSeconds(anfrage.PositionSeconds);
if (gemeldet <= TimeSpan.Zero) gemeldet = zustand.Position;
var ergebnis = ProgressRule.Apply(gemeldet, laenge, regeln);
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
@@ -0,0 +1,185 @@
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
namespace Flaro.Api.Tests;
/// <summary>Was eine Fortschrittsmeldung am gemerkten Stand ändern darf.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Anlass.</b> Ein Film stand bei 46 Minuten, und nach einem Neuladen
/// bot die Detailseite nur noch „Abspielen" an. Gemessen, woran es lag: eine
/// einzige Meldung „Stelle 500 s, Laufzeit 60 s" machte aus der Stelle 500
/// die Stelle 60, aus der Laufzeit 600 die Laufzeit 60, setzte das Häkchen
/// „gesehen" und nahm den Titel aus „Weiterschauen".
/// </para>
/// <para>
/// Woher so eine Meldung kommt: wer einen gerechneten Strom abspielt, misst
/// an einer Playlist, die noch wächst, und sieht nur das bisher Erzeugte.
/// Scheitert die Wiedergabe gleich darauf — auf dem iPad tat sie das —,
/// bleibt dieser Stummel als „Laufzeit" stehen. Dazu meldet jeder Spieler
/// beim Öffnen einmal die Zeit null, bevor er überhaupt gesprungen ist.
/// </para>
/// <para>
/// <b>Die Regel.</b> Ein Client darf einen Stand fortschreiben, aber nicht
/// zerstören. Die Laufzeit, die der Server kennt, schlägt die gemeldete, und
/// eine gemeldete Null löscht nichts.
/// </para>
/// </remarks>
[TestFixture]
public sealed class FortschrittTests
{
private string _bestand = "";
[SetUp]
public void Anlegen() => _bestand = Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), "flaro-fort-" + Guid.CreateVersion7().ToString("N"))).FullName;
[TearDown]
public void Aufraeumen()
{
try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
}
/// <summary>Ein Server mit einem eingelesenen Film, angemeldet als Administrator.</summary>
private async Task<(HttpClient Client, Guid Film)> MitEinemFilm(FlaroServer server)
{
var voll = Path.Combine(_bestand, "Langprobe (2026)", "Langprobe (2026).mkv");
Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
await File.WriteAllBytesAsync(voll, new byte[2048]);
var admin = await server.SetUpAndSignIn();
var client = server.AlsAngemeldeter(admin.Token);
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
new CreateLibraryRequest("Prüfbestand", "Movies", _bestand));
angelegt.EnsureSuccessStatusCode();
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
if (stand!.State is "finished") break;
if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
await Task.Delay(50);
}
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
return (client, seite!.Items[0].Id);
}
private static async Task<ItemStateResponse> Melden(
HttpClient client, Guid film, double stelle, double? laufzeit)
{
var antwort = await client.PostAsJsonAsync($"/api/v1/items/{film}/progress",
new ProgressRequest(stelle, laufzeit, null));
antwort.EnsureSuccessStatusCode();
return (await antwort.Content.ReadFromJsonAsync<ItemStateResponse>())!;
}
/// <summary>Eine zu kurz gemeldete Laufzeit nimmt dem Film nicht die Stelle.</summary>
///
/// <remarks>
/// Das ist der gemeldete Fehler, Zahl für Zahl. Vorher kam hier Stelle 60
/// heraus, Laufzeit 60 und „gesehen".
/// </remarks>
[Test]
public async Task Eine_zu_kurze_Laufzeit_zerstoert_den_Stand_nicht()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 500, 600);
var danach = await Melden(client, film, 500, 60);
Assert.Multiple(() =>
{
Assert.That(danach.PositionSeconds, Is.EqualTo(500).Within(0.001),
"die Stelle darf nicht auf den Stummel zusammengestaucht werden");
Assert.That(danach.RuntimeSeconds, Is.EqualTo(600).Within(0.001),
"und die bekannte Laufzeit nicht durch ihn ersetzt");
Assert.That(danach.Watched, Is.False,
"bei 500 von 600 Sekunden ist nichts zu Ende gesehen");
Assert.That(danach.InProgress, Is.True,
"der Film gehört weiter in die Reihe Weiterschauen");
});
}
/// <summary>Eine gemeldete Null löscht keine gemerkte Stelle.</summary>
///
/// <remarks>
/// <b>Beide Clients tun das.</b> Die App meldet beim Öffnen ausdrücklich
/// die Zeit null — dort legt erst diese Meldung die Sitzung an. Der
/// Browser tut es im Fenster zwischen „Datei geöffnet" und „Sprung
/// ausgeführt", und wenn er die Datei nicht entschlüsseln kann, kommt er
/// aus diesem Fenster nie heraus.
/// </remarks>
[Test]
public async Task Eine_gemeldete_Null_loescht_die_Stelle_nicht()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 2771, 11468);
var danach = await Melden(client, film, 0, 11468);
Assert.That(danach.PositionSeconds, Is.EqualTo(2771).Within(0.001));
}
/// <summary>Wer wirklich von vorn anfängt, setzt die Stelle trotzdem zurück.</summary>
///
/// <remarks>
/// Das ist der Preis der Regel darüber, und er ist klein: zehn Sekunden
/// später meldet derselbe Spieler die 10, und die zählt. „Von vorn"
/// bleibt damit „von vorn", nur eben ab der ersten gesehenen Sekunde.
/// </remarks>
[Test]
public async Task Von_vorn_setzt_die_Stelle_mit_der_ersten_echten_Meldung_zurueck()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 2771, 11468);
await Melden(client, film, 0, 11468);
var danach = await Melden(client, film, 10, 11468);
Assert.That(danach.PositionSeconds, Is.EqualTo(10).Within(0.001));
}
/// <summary>Zurückspulen bleibt möglich.</summary>
///
/// <remarks>
/// Die Regel gilt der Null, nicht dem Rückwärtsgehen. Wer eine Szene
/// noch einmal sehen will, soll danach auch dort weitermachen.
/// </remarks>
[Test]
public async Task Zurueckspulen_setzt_die_Stelle_weiter_zurueck()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
await Melden(client, film, 500, 600);
var danach = await Melden(client, film, 120, 600);
Assert.That(danach.PositionSeconds, Is.EqualTo(120).Within(0.001));
}
/// <summary>Ohne jede bekannte Laufzeit zählt weiter, was der Client sagt.</summary>
///
/// <remarks>
/// Sonst gäbe es für einen frisch angefangenen Titel keinen Anteil und
/// damit nie ein „gesehen". Die neue Rangfolge nimmt dem Client nur den
/// Vorrang, nicht das Wort.
/// </remarks>
[Test]
public async Task Ohne_bekannte_Laufzeit_gilt_die_gemeldete()
{
using var server = new FlaroServer();
var (client, film) = await MitEinemFilm(server);
var danach = await Melden(client, film, 100, 1000);
Assert.That(danach.RuntimeSeconds, Is.EqualTo(1000).Within(0.001));
}
}