diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 8e1f02b..22f2541 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -552,7 +552,7 @@ public static class PlaybackEndpoints /// private static async Task Mitzaehlen( FlaroContext db, Guid benutzer, UserItemState zustand, - TimeSpan neueStelle, TimeProvider clock) + TimeSpan neueStelle, TimeProvider clock, CancellationToken ct = default) { if (zustand.MovieId is null && zustand.EpisodeId is null) return; @@ -574,27 +574,55 @@ public static class PlaybackEndpoints // Abend getan. var tag = DateOnly.FromDateTime(jetzt.ToLocalTime().DateTime); - var zeile = await db.Sehtage.FirstOrDefaultAsync(z => - z.UserId == benutzer && z.Tag == tag - && z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId); + // **Erhöhen, und nur wenn es die Zeile nicht gibt, anlegen.** + // + // Vorher stand hier „nachsehen, dann anlegen oder erhöhen" — und das + // ist ein Wettlauf: der Spieler meldet alle zehn Sekunden, ein Sprung + // meldet sofort, und zwei Meldungen können sich überholen. Dann fanden + // beide keine Zeile, beide legten eine an, und die zweite lief in + // „UNIQUE constraint failed: Sehtage.UserId, Sehtage.Tag, + // Sehtage.MovieId". Gemeldet wurde das als Fehler 500 auf einen + // Fortschrittsbericht — für den Zuschauer unsichtbar, im Protokoll ein + // Stapelabzug. + // + // `ExecuteUpdateAsync` rechnet in der Datenbank statt im Speicher und + // kann deshalb nicht danebengreifen. Trifft es nichts, wird angelegt; + // kommt dabei jemand zuvor, fängt der zweite Versuch es auf. + if (await Erhoehen(db, benutzer, tag, zustand, zuwachs, ct) > 0) return; - if (zeile is null) + db.Sehtage.Add(new Sehtag { - db.Sehtage.Add(new Sehtag - { - UserId = benutzer, - MovieId = zustand.MovieId, - EpisodeId = zustand.EpisodeId, - Tag = tag, - Sekunden = zuwachs, - }); + UserId = benutzer, + MovieId = zustand.MovieId, + EpisodeId = zustand.EpisodeId, + Tag = tag, + Sekunden = zuwachs, + }); + + try + { + await db.SaveChangesAsync(ct); } - else + catch (DbUpdateException) { - zeile.Sekunden += zuwachs; + // Jemand war schneller. Die angefangene Zeile wieder vergessen, + // sonst versucht der Aufrufer sie beim nächsten Speichern erneut. + db.ChangeTracker.Clear(); + await Erhoehen(db, benutzer, tag, zustand, zuwachs, ct); } } + /// Schreibt die Sekunden an einer vorhandenen Zeile fort. + /// + /// Gibt zurück, wie viele Zeilen getroffen wurden — 0 oder 1. + private static Task Erhoehen( + FlaroContext db, Guid benutzer, DateOnly tag, UserItemState zustand, + double zuwachs, CancellationToken ct) => + db.Sehtage + .Where(z => z.UserId == benutzer && z.Tag == tag + && z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId) + .ExecuteUpdateAsync(s => s.SetProperty(z => z.Sekunden, z => z.Sekunden + zuwachs), ct); + private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) => zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln); diff --git a/server/tests/Flaro.Api.Tests/StatistikTests.cs b/server/tests/Flaro.Api.Tests/StatistikTests.cs index 20d70a5..2acf544 100644 --- a/server/tests/Flaro.Api.Tests/StatistikTests.cs +++ b/server/tests/Flaro.Api.Tests/StatistikTests.cs @@ -230,4 +230,37 @@ public sealed class StatistikTests Assert.That(fremde.Recent, Is.Empty); }); } + + /// Zwei Fortschrittsmeldungen gleichzeitig stürzen nicht ab. + /// + /// + /// Aus dem Protokoll eines laufenden Servers. Der Spieler meldet + /// alle zehn Sekunden, ein Sprung meldet sofort — zwei Meldungen können + /// sich überholen. Vorher fanden dann beide keine Zeile im Sehprotokoll, + /// beide legten eine an, und die zweite lief in „UNIQUE constraint failed: + /// Sehtage.UserId, Sehtage.Tag, Sehtage.MovieId". Der Zuschauer merkte + /// nichts, im Protokoll stand ein Stapelabzug. + /// + [Test] + public async Task Zwei_Meldungen_gleichzeitig_zaehlen_beide() + { + Datei("Gleichzeitig (2024)/Gleichzeitig (2024).mkv"); + using var server = MitUhr(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + // Den Nullpunkt setzen, dann Zeit vergehen lassen — sonst greift der + // Deckel und es gäbe ohnehin nichts gutzuschreiben. + await Melden(client, film, 0, 7200); + _uhr.Weiter(TimeSpan.FromMinutes(5)); + + var beide = await Task.WhenAll( + client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(60, 7200, null)), + client.PostAsJsonAsync($"/api/v1/items/{film}/progress", new ProgressRequest(90, 7200, null))); + + Assert.That(beide.Select(a => (int)a.StatusCode), Has.All.LessThan(400), + "Eine Fortschrittsmeldung darf nie an einem Wettlauf scheitern."); + Assert.That((await Statistik(client))!.LoggedSeconds, Is.GreaterThan(0), + "und mitgezählt werden muss sie auch."); + } } diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts index d6afc55..eea0446 100644 --- a/web/app/src/spieler.ts +++ b/web/app/src/spieler.ts @@ -116,6 +116,22 @@ function versatzMerken(datei: string, wert: number): void { } catch { /* Privates Fenster — dann gilt er eben nur für diesen Film. */ } } +/** Welche Tonsprache zuletzt gewählt wurde. + * + * **Eine Sprache, keine Spurnummer.** Welche Nummer die englische Spur trägt, + * ist von Film zu Film verschieden; dass jemand Englisch will, ist es nicht. + * Hier steht deshalb eine Sprache, und die Detailseite sucht damit die + * passende Spur. */ +const SPRACHSCHLUESSEL = 'flaro.tonsprache'; + +export function tonsprache(): string | null { + try { return localStorage.getItem(SPRACHSCHLUESSEL); } catch { return null; } +} + +export function tonspracheMerken(sprache: string): void { + try { localStorage.setItem(SPRACHSCHLUESSEL, sprache); } catch { /* Privates Fenster. */ } +} + /** „läuft mit", „200 ms später", „300 ms früher". */ function versatzWort(ms: number): string { if (ms === 0) return 'läuft mit dem Bild'; @@ -141,6 +157,8 @@ export type Spieleranfrage = { * kennt nur den Rest ab dem Einstiegspunkt und machte aus einem * Zweistünder ab Minute 90 einen Halbstünder. */ laenge: number | null; + /** Mit welcher Tonspur gestartet wird — gewählt auf der Detailseite. */ + tonspur?: number | null; danach: () => void; }; @@ -187,7 +205,7 @@ export function spieler(anfrage: Spieleranfrage): void { /** Die gewählte Stufe und Spuren — sie überleben einen Neustart des Stroms. */ let stufe: number | null = null; - let tonspur: number | null = null; + let tonspur: number | null = anfrage.tonspur ?? null; let untertitel: number | null = null; /** Ob der Server umkodieren soll, egal was das Profil sagt. @@ -620,7 +638,12 @@ export function spieler(anfrage: Spieleranfrage): void { const spuren = toene.length > 0 ? toene.map(s => ({ text: s.label, gewaehlt: s.index === tonspur, - tun: () => { tonspur = s.index; void stromAufbauen(filmzeit()); }, + tun: () => { + tonspur = s.index; + // Wer hier wechselt, meint es auch beim nächsten Film so. + if (s.language) tonspracheMerken(s.language); + void stromAufbauen(filmzeit()); + }, })) : [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }]; diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 47e96a6..c316a7a 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -2,7 +2,7 @@ import { ApiFehler, film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse, vorschlaege, zuordnen, bildAdresse, istAdmin, gesehen, favorit, weglegen, type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand, - type Mitwirkender, aehnliche, + type Mitwirkender, type Spur, aehnliche, } from '../api'; import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, @@ -10,7 +10,8 @@ import { import { icons, icon } from '../icons'; import { kachel, alsKachel, reihe } from '../card'; import { vergessen } from '../ansichten'; -import { spieler } from '../spieler'; +import { spieler, tonsprache, tonspracheMerken } from '../spieler'; +import { menueAnZiel } from '../menue'; import { meldedialog } from '../melden'; type Art = 'filme' | 'serien'; @@ -303,6 +304,15 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem reihe.appendChild(vonVorn); } + // **Die Sprache vor dem Start, nicht erst mitten im Film.** + // + // Im Spieler umzuschalten heisst, den Strom neu aufzubauen — und bei + // einem Film, den man immer auf Englisch sehen will, jedes Mal von + // Neuem. Welche Spuren es gibt, steht hier ohnehin schon; gewählt wird + // also, bevor etwas läuft. + const tonwahl = tonspurwahl(datei); + if (tonwahl) reihe.appendChild(tonwahl); + const laden = knopf('Herunterladen', 'btn'); laden.prepend(icon('download')); laden.onclick = () => void herunterladen(datei, laden, hinweisfeld); @@ -348,6 +358,50 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem return huelle; } + /** Die zuletzt gewählte Tonspur dieser Datei — oder keine Wahl. */ + let gewaehlteSpur: number | null = null; + + /** Ein Knopf, der die Tonspur zeigt und ein Menü dazu öffnet. + * + * **Nur bei mehr als einer.** Bei einer einzigen gäbe es nichts zu wählen, + * und ein Knopf, der nur bestätigt, was ohnehin läuft, ist einer zu viel. */ + function tonspurwahl(datei: Datei): HTMLElement | null { + const spuren = datei.streams.filter(s => s.kind === 'Audio'); + if (spuren.length < 2) return null; + + // **Die gemerkte Sprache schlägt die Reihenfolge der Datei.** Sonst + // gewinnt immer die erste Spur — und das ist bei deutschen Veröffent- + // lichungen fast immer die deutsche. + const gemerkt = tonsprache(); + const vorgewaehlt = gewaehlteSpur + ?? spuren.find(s => s.language && s.language === gemerkt)?.index + ?? spuren[0]!.index; + gewaehlteSpur = vorgewaehlt; + + const name = (s: Spur): string => + s.languageName ?? s.display?.split(' · ')[0] ?? s.language ?? `Spur ${s.index}`; + + const k = knopf('', 'btn'); + const zeichnen = (): void => { + const jetzt = spuren.find(s => s.index === gewaehlteSpur) ?? spuren[0]!; + k.textContent = `Ton: ${name(jetzt)}`; + k.prepend(icon('tonspur')); + }; + zeichnen(); + + k.onclick = () => menueAnZiel(k, spuren.map(s => ({ + text: name(s), + icon: s.index === gewaehlteSpur ? ('haken' as const) : undefined, + tun: () => { + gewaehlteSpur = s.index; + if (s.language) tonspracheMerken(s.language); + zeichnen(); + }, + }))); + + return k; + } + function rundKnopf( bild: keyof typeof icons, an: boolean, titel: string, tun: () => Promise, ): HTMLButtonElement { @@ -737,6 +791,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem name, ab, laenge: datei.durationSeconds, + tonspur: gewaehlteSpur, danach: laden, }); }