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 ---------- */