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,
});
}