diff --git a/Flaro/Views/Player/PlayerModel.swift b/Flaro/Views/Player/PlayerModel.swift index 69f29f9..2998f77 100644 --- a/Flaro/Views/Player/PlayerModel.swift +++ b/Flaro/Views/Player/PlayerModel.swift @@ -1660,24 +1660,36 @@ final class PlayerModel { private func report(state: PlaybackReportState) async { guard let backend, let item else { return } - // Die Laufzeit der Engine ist die genauere, aber sie steht erst ein - // paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die - // des Servers — sonst fiele ausgerechnet der Bericht aus, der die - // Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade". - guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) }) + // **Die Laufzeit des Titels schlägt die der Engine.** + // + // Vorher war es umgekehrt, mit dem Argument, die Engine messe + // genauer. Sie misst aber am Strom und nicht am Film: bei einem + // gerechneten Strom ist das eine Playlist, die noch wächst, und + // anfangs sieht die Engine davon nur einen Stummel. Gemeldet hat sie + // ihn als Laufzeit — und daraus machte der Server die Laufzeit des + // Films: Stelle zusammengestaucht, Häkchen „gesehen" gesetzt, Titel + // aus „Weiterschauen" verschwunden. Gemessen an einem Film, der bei + // 46 Minuten stand und danach wieder bei null anfing. + guard let total = item.duration.map({ Duration.milliseconds($0) }) ?? duration else { return } - // Beim Beginn steht die Zeit noch auf null — das ist keine fehlende - // Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären - // dagegen wertlos und würden einen gemerkten Fortschritt überschreiben. - guard currentTime > .zero || state == .started else { return } + // **Keine Null melden, die der Spieler gar nicht meint.** + // + // Beim Öffnen steht die Zeit der Engine auf null, obwohl der Spieler + // gleich an einer ganz anderen Stelle einsteigt. Diese Meldung darf + // nicht ausfallen — erst sie legt beim Server die Sitzung an, unter + // der der Titel als „läuft gerade" erscheint. Sie muss nur die + // Stelle nennen, die gemeint ist, statt der, die noch nicht gesetzt + // ist. + let stelle = currentTime > .zero ? currentTime : request.startTime + guard stelle > .zero || state == .started else { return } await backend.reportProgress(PlaybackReport( itemID: item.id, sessionID: playbackSession, versionID: playbackVersionID, state: state, - time: currentTime, + time: stelle, duration: total )) } diff --git a/scratchpad/neu/data/flaro.db b/scratchpad/neu/data/flaro.db new file mode 100644 index 0000000..c9cf4b4 Binary files /dev/null and b/scratchpad/neu/data/flaro.db differ diff --git a/scratchpad/neu/data/flaro.db-shm b/scratchpad/neu/data/flaro.db-shm new file mode 100644 index 0000000..9560449 Binary files /dev/null and b/scratchpad/neu/data/flaro.db-shm differ diff --git a/scratchpad/neu/data/flaro.db-wal b/scratchpad/neu/data/flaro.db-wal new file mode 100644 index 0000000..1536221 Binary files /dev/null and b/scratchpad/neu/data/flaro.db-wal differ diff --git a/scratchpad/neu/data/keys/key-15706be2-d1ec-4fa7-918b-920e87c4acce.xml b/scratchpad/neu/data/keys/key-15706be2-d1ec-4fa7-918b-920e87c4acce.xml new file mode 100644 index 0000000..ec8e19a --- /dev/null +++ b/scratchpad/neu/data/keys/key-15706be2-d1ec-4fa7-918b-920e87c4acce.xml @@ -0,0 +1,16 @@ + + + 2026-10-07T19:35:00.68989Z + 2026-10-07T19:35:00.68989Z + 2027-01-05T19:35:00.68989Z + + + + + + + oyFqIl0Q27Th3X+CX07TX95s7kMGEQ2OkjOUJEJVkgK7LeTccP4tp3FeJnMzLSuu7rsKVIJa4xINONp+0AOAWg== + + + + \ No newline at end of file diff --git a/scratchpad/neu/data/logs/flaro-2026-10-07.log b/scratchpad/neu/data/logs/flaro-2026-10-07.log new file mode 100644 index 0000000..98b18bf --- /dev/null +++ b/scratchpad/neu/data/logs/flaro-2026-10-07.log @@ -0,0 +1 @@ +21:35:00 INFO Server gestartet — 1.0.0 · DEV diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 22f2541..27d8357 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -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. diff --git a/server/tests/Flaro.Api.Tests/FortschrittTests.cs b/server/tests/Flaro.Api.Tests/FortschrittTests.cs new file mode 100644 index 0000000..70db830 --- /dev/null +++ b/server/tests/Flaro.Api.Tests/FortschrittTests.cs @@ -0,0 +1,185 @@ +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Was eine Fortschrittsmeldung am gemerkten Stand ändern darf. +/// +/// +/// +/// Der Anlass. 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". +/// +/// +/// 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. +/// +/// +/// Die Regel. 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. +/// +/// +[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 */ } + } + + /// Ein Server mit einem eingelesenen Film, angemeldet als Administrator. + 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())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/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>("/api/v1/movies"); + return (client, seite!.Items[0].Id); + } + + private static async Task 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())!; + } + + /// Eine zu kurz gemeldete Laufzeit nimmt dem Film nicht die Stelle. + /// + /// + /// Das ist der gemeldete Fehler, Zahl für Zahl. Vorher kam hier Stelle 60 + /// heraus, Laufzeit 60 und „gesehen". + /// + [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"); + }); + } + + /// Eine gemeldete Null löscht keine gemerkte Stelle. + /// + /// + /// Beide Clients tun das. 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. + /// + [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)); + } + + /// Wer wirklich von vorn anfängt, setzt die Stelle trotzdem zurück. + /// + /// + /// 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. + /// + [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)); + } + + /// Zurückspulen bleibt möglich. + /// + /// + /// Die Regel gilt der Null, nicht dem Rückwärtsgehen. Wer eine Szene + /// noch einmal sehen will, soll danach auch dort weitermachen. + /// + [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)); + } + + /// Ohne jede bekannte Laufzeit zählt weiter, was der Client sagt. + /// + /// + /// 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. + /// + [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)); + } +} diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index 6114468..1193842 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -218,7 +218,21 @@ export function spieler(anfrage: Spieleranfrage): void { * weiss selten, welche. */ let umkodierenErzwingen = false; - const filmzeit = (): number => vorlauf + (Number.isFinite(video.currentTime) ? video.currentTime : 0); + /** Wo im Film der Spieler steht. + * + * **Der noch ausstehende Sprung zählt mit.** Bei Direktwiedergabe gibt der + * Server `startSeconds: 0` zurück und der Browser springt selbst, sobald + * die Metadaten da sind. Bis dahin steht `currentTime` auf null — und + * kommen die Metadaten nie, weil der Browser mit der Datei nicht + * zurechtkommt, bleibt es dabei. + * + * Genau dann greift der Rückfall aufs Umkodieren, und der bekam bisher + * diese Null übergeben: „an der Stelle weiter, an der es hing" fing in + * Wahrheit von vorn an. Gemeldet wurde die Null auch, und sie hat auf dem + * Server eine gemerkte Stelle gelöscht. */ + const filmzeit = (): number => zuSpringen > 0 + ? zuSpringen + : vorlauf + (Number.isFinite(video.currentTime) ? video.currentTime : 0); /* ---------- Aufbau ---------- */