using System.Security.Claims; using Flaro.Api.Auth; using Flaro.Core.Playback; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api.Endpoints; /// Fortschritt, Gesehen-Status, Favoriten — alles je Benutzer. /// /// /// /// Ein Titel wird über seine Kennung allein angesprochen, ohne dass /// der Aufrufer sagen muss, ob es ein Film oder eine Folge ist. Die App hat /// diese Auskunft an der Stelle nicht — sie reicht durch, was eine Liste ihr /// gegeben hat. Der Preis ist eine zusätzliche Abfrage, wenn es eine Folge /// ist; der Gegenwert ist eine Schnittstelle, die man nicht falsch bedienen /// kann. /// /// public static class PlaybackEndpoints { private const int HöchstensJeSeite = 200; /// Wie viele Titel eine Sammelanfrage höchstens trägt. /// /// /// Grosszügig, aber nicht offen. Eine Mediathek mit 500 Filmen /// lässt sich damit in einem Zug abhaken; eine Anfrage mit 50 000 /// Kennungen ist keine Bedienung mehr, sondern ein Angriff. /// private const int HöchstensJeZug = 1000; public static void MapPlayback(this IEndpointRouteBuilder app) { MapZustand(app); MapStartseite(app); } // ------------------------------------------------------------------ // Melden // ------------------------------------------------------------------ private static void MapZustand(IEndpointRouteBuilder app) { var titel = app.MapGroup("/api/v1/items/{id:guid}").RequireAuthorization(); // Jede Meldung wird abgelegt — es gibt keine, die zu klein wäre. titel.MapPost("/progress", async ( Guid id, ProgressRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, PlaybackSessions laufende, Wiedergabeeinstellungen einstellungen) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); var zustand = await Zustand(db, benutzer, id); if (zustand is null) return Results.NotFound(); // **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; // **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. await Mitzaehlen(db, benutzer, zustand, ergebnis.Position, clock); // **Das Häkchen wird gesetzt, nicht genommen.** Wer einen // gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und // soll beides haben: das Häkchen und die neue Stelle. if (ergebnis.Watched && !zustand.Watched) { zustand.Watched = true; zustand.PlayCount++; } zustand.Position = ergebnis.Position; zustand.Runtime = laenge; zustand.LastPlayedAt = clock.GetUtcNow(); zustand.UpdatedAt = clock.GetUtcNow(); // Wer weitersieht, hat es sich offenbar anders überlegt. if (ergebnis.Position > TimeSpan.Zero) zustand.Dismissed = false; Nachfuehren(zustand, regeln); await db.SaveChangesAsync(); // **Die Meldung ist zugleich das Lebenszeichen.** Jeder Client // meldet beim Abspielen ohnehin alle paar Sekunden; daraus das // Dashboard zu speisen braucht kein zweites Verfahren, das ein // Client vergessen könnte. Bleibt die Meldung aus, läuft der // Eintrag von selbst ab. // // `stopped` nimmt ihn sofort heraus — sonst stünde ein beendeter // Film noch eine Minute als „läuft". if (anfrage.Stopped == true) laufende.Beenden(Geraet(wer)); else await Mitschreiben(db, laufende, wer, benutzer, zustand, clock); return Results.Ok(Abbild(zustand)); }); titel.MapPut("/watched", async ( Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, Wiedergabeeinstellungen einstellungen) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); var zustand = await Zustand(db, benutzer, id); if (zustand is null) return Results.NotFound(); if (anfrage.Watched) { if (!zustand.Watched) zustand.PlayCount++; zustand.Watched = true; zustand.LastPlayedAt = clock.GetUtcNow(); // **Von Hand als gesehen markiert heisst: fertig damit.** Hier // wäre eine stehengebliebene Stelle keine Hilfe, sondern eine // Zeile in „Weiterschauen", die niemand bestellt hat. zustand.Position = TimeSpan.Zero; } else { zustand.Watched = false; zustand.Position = TimeSpan.Zero; zustand.Dismissed = false; } // **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an // der Serie und daneben „noch 42 offen". if (zustand.SeriesId is { } serie) await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln); zustand.UpdatedAt = clock.GetUtcNow(); Nachfuehren(zustand, regeln); await db.SaveChangesAsync(); return Results.Ok(Abbild(zustand)); }); // **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung // im Pfad trägt und eine Liste keine hat. app.MapPut("/api/v1/items/watched", async ( BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, Wiedergabeeinstellungen einstellungen) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); if (anfrage.Ids.Count == 0) return Results.Ok(new BulkWatchedResponse(0, [])); if (anfrage.Ids.Count > HöchstensJeZug) return Results.Problem( $"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. " + "Nimm weniger und wiederhole es.", statusCode: StatusCodes.Status400BadRequest); var jetzt = clock.GetUtcNow(); var unbekannt = new List(); var geaendert = 0; // **Nacheinander, nicht in einem Rutsch.** Eine Serie zieht ihre // Folgen mit, und dafür gibt es keine Mengenoperation, die das // ebenso richtig täte. Gespeichert wird einmal am Ende. foreach (var id in anfrage.Ids.Distinct()) { var zustand = await Zustand(db, benutzer, id); if (zustand is null) { unbekannt.Add(id); continue; } if (zustand.Watched == anfrage.Watched && zustand.Position == TimeSpan.Zero) continue; if (anfrage.Watched && !zustand.Watched) { zustand.PlayCount++; zustand.LastPlayedAt = jetzt; } zustand.Watched = anfrage.Watched; zustand.Position = TimeSpan.Zero; zustand.Dismissed = false; zustand.UpdatedAt = jetzt; Nachfuehren(zustand, regeln); if (zustand.SeriesId is { } serie) await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock, regeln); geaendert++; } await db.SaveChangesAsync(); return Results.Ok(new BulkWatchedResponse(geaendert, unbekannt)); }).RequireAuthorization(); // Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. titel.MapPost("/dismiss", async ( Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, Wiedergabeeinstellungen einstellungen) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); var zustand = await Zustand(db, benutzer, id); if (zustand is null) return Results.NotFound(); zustand.Dismissed = true; zustand.UpdatedAt = clock.GetUtcNow(); Nachfuehren(zustand, regeln); await db.SaveChangesAsync(); return Results.Ok(Abbild(zustand)); }); titel.MapPut("/favourite", async ( Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, Wiedergabeeinstellungen einstellungen) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); var zustand = await Zustand(db, benutzer, id); if (zustand is null) return Results.NotFound(); zustand.Favourite = anfrage.Favourite; zustand.UpdatedAt = clock.GetUtcNow(); Nachfuehren(zustand, regeln); await db.SaveChangesAsync(); return Results.Ok(Abbild(zustand)); }); titel.MapPut("/rating", async ( Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock, Wiedergabeeinstellungen einstellungen) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var regeln = await einstellungen.RegelnAsync(); if (anfrage.Rating is { } wert && wert is < 1 or > 10) return Einwand.Bitte("Eine Bewertung liegt zwischen 1 und 10."); var zustand = await Zustand(db, benutzer, id); if (zustand is null) return Results.NotFound(); zustand.Rating = anfrage.Rating; zustand.UpdatedAt = clock.GetUtcNow(); Nachfuehren(zustand, regeln); await db.SaveChangesAsync(); return Results.Ok(Abbild(zustand)); }); } // ------------------------------------------------------------------ // Startseite // ------------------------------------------------------------------ private static void MapStartseite(IEndpointRouteBuilder app) { var start = app.MapGroup("/api/v1/home").RequireAuthorization(); start.MapGet("/continue", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); // Die Regel steht in `ProgressRule`; was hier abgefragt wird, ist // ihr festgehaltenes Ergebnis. Siehe `UserItemState.InProgress`. var zustände = await db.UserItemStates.AsNoTracking() .Where(z => z.UserId == benutzer && z.InProgress) .OrderByDescending(z => z.UpdatedAt) .Take(Math.Clamp(take, 1, HöchstensJeSeite)) .ToListAsync(); return Results.Ok(new Page( await Eintraege(db, zustände), zustände.Count)); }); // **Die erste ungesehene Folge je angefangener Serie.** // // Ohne Überschneidung mit „Weiterschauen": was dort schon steht, weil // es mitten drin liegt, taucht hier nicht noch einmal auf. Jellyfin // zeigt beides doppelt; auf einer Startseite mit zwei Reihen // nebeneinander sieht das nach einem Fehler aus. start.MapGet("/next", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); // Welche Serien der Benutzer überhaupt angefasst hat. Ohne diese // Einschränkung stünde die erste Folge jeder Serie der Mediathek // hier — eine Reihe, die nichts über den Benutzer aussagt. var angefasst = await db.UserItemStates.AsNoTracking() .Where(z => z.UserId == benutzer && z.EpisodeId != null) .Join(db.Episodes.AsNoTracking(), z => z.EpisodeId, f => f.Id, (z, f) => new { f.SeriesId, z.UpdatedAt }) .GroupBy(x => x.SeriesId) .Select(g => new { SeriesId = g.Key, Zuletzt = g.Max(x => x.UpdatedAt) }) .OrderByDescending(x => x.Zuletzt) .Take(Math.Clamp(take, 1, HöchstensJeSeite)) .ToListAsync(); if (angefasst.Count == 0) return Results.Ok(new Page([], 0)); var serienIds = angefasst.Select(x => x.SeriesId).ToList(); var folgen = await db.Episodes.AsNoTracking() .Where(f => serienIds.Contains(f.SeriesId)) .Select(f => new { f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.Title, f.StillImage, f.PublishAt }) .ToListAsync(); var folgenIds = folgen.Select(f => f.Id).ToList(); var zustände = await db.UserItemStates.AsNoTracking() .Where(z => z.UserId == benutzer && z.EpisodeId != null && folgenIds.Contains(z.EpisodeId!.Value)) .ToDictionaryAsync(z => z.EpisodeId!.Value); var serien = await db.Series.AsNoTracking() .Where(x => serienIds.Contains(x.Id)) .Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage }) .ToDictionaryAsync(x => x.Id); // **Weggelegt gilt hier für die ganze Serie.** // // Das Weglegen einer einzelnen Folge nützt in dieser Reihe // nichts: beim nächsten Laden rückt die übernächste nach, und // man legt zwanzigmal weg. Gemeint ist ohnehin „diese Serie // nicht vorschlagen". Der Zustand dafür gibt es schon — eine // Zeile mit `SeriesId` —, er wurde hier nur nie gelesen. var serienzustaende = await db.UserItemStates.AsNoTracking() .Where(z => z.UserId == benutzer && z.SeriesId != null && serienIds.Contains(z.SeriesId!.Value)) .ToDictionaryAsync(z => z.SeriesId!.Value); var liste = new List(); foreach (var x in angefasst) { if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue; var naechste = folgen .Where(f => f.SeriesId == x.SeriesId) .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) .FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true }); // Nichts offen — die Serie ist durch. if (naechste is null) continue; // Mittendrin: steht bereits in „Weiterschauen". if (zustände.GetValueOrDefault(naechste.Id) is { InProgress: true }) continue; if (!serien.TryGetValue(x.SeriesId, out var serie)) continue; var zustand = zustände.GetValueOrDefault(naechste.Id); liste.Add(new ContinueEntry( naechste.Id, "episode", serie.Title, naechste.Title, naechste.SeasonNumber, naechste.EpisodeNumber, null, serie.PosterImage, naechste.StillImage ?? serie.PosterImage, zustand is null ? Leer() : Abbild(zustand), SeriesId: x.SeriesId, PublishAt: naechste.PublishAt, SeriesBackdropImage: serie.BackdropImage)); } // Die Dateien erst jetzt, für die ausgewählten Folgen — nicht für // alle Folgen aller angefassten Serien. var erste = await Erstdateien(db, [], liste.Select(e => e.Id).ToList()); liste = liste.Select(e => e with { FileId = erste.GetValueOrDefault(e.Id).Id, DurationSeconds = erste.GetValueOrDefault(e.Id).Sekunden, }).ToList(); return Results.Ok(new Page(liste, liste.Count)); }); // **Ähnliches — „weil du X gesehen hast".** // // Über die Genres der zuletzt gesehenen Titel, nicht über eine // Empfehlungsmaschine. Ein Haushaltsserver hat keine Millionen // Sehverläufe, aus denen sich etwas lernen liesse; was er hat, sind // die Genres dessen, was dieser eine Mensch angefangen hat. Das ist // eine schlichte Regel — und sie ist nachvollziehbar, was eine // undurchschaubare Empfehlung nie ist. start.MapGet("/suggestions", async (ClaimsPrincipal wer, FlaroContext db, int take = 20) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); // Woran dieser Mensch zuletzt dran war — Filme wie Folgen. var zuletzt = await db.UserItemStates.AsNoTracking() .Where(z => z.UserId == benutzer && (z.Watched || z.Position > TimeSpan.Zero)) .OrderByDescending(z => z.UpdatedAt) .Take(30) .ToListAsync(); if (zuletzt.Count == 0) return Results.Ok(new Page([], 0)); var filmIds = zuletzt.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList(); var folgenIds = zuletzt.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); var serienIds = await db.Episodes.AsNoTracking() .Where(f => folgenIds.Contains(f.Id)).Select(f => f.SeriesId).Distinct().ToListAsync(); // Die Genres daraus, nach Häufigkeit: wer dreimal Science Fiction // angefangen hat, meint Science Fiction. var genres = await db.GenreLinks.AsNoTracking() .Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value)) || (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value))) .GroupBy(l => l.GenreId) .OrderByDescending(g => g.Count()) .Select(g => g.Key) .Take(5) .ToListAsync(); if (genres.Count == 0) return Results.Ok(new Page([], 0)); // **Nichts vorschlagen, was schon angefasst wurde.** Ein // Vorschlag, den man gestern gesehen hat, ist kein Vorschlag. var beruehrt = filmIds.ToHashSet(); var treffer = await db.Movies.AsNoTracking() .Where(m => !beruehrt.Contains(m.Id)) .Where(m => db.GenreLinks.Any(l => l.MovieId == m.Id && genres.Contains(l.GenreId))) // Je mehr gemeinsame Genres, desto besser der Vorschlag. .Select(m => new { Film = m, Treffer = db.GenreLinks.Count(l => l.MovieId == m.Id && genres.Contains(l.GenreId)), }) .OrderByDescending(x => x.Treffer) .ThenByDescending(x => x.Film.Rating ?? 0) .Take(Math.Clamp(take, 1, HöchstensJeSeite)) .Select(x => x.Film) .ToListAsync(); // **Mit Datei und Termin.** Ein Vorschlag ohne Datei liesse den // Aufmacher „Abspielen" anbieten und dann doch nur blättern; ein // Vorschlag ohne Termin verschwiege, dass es ihn noch nicht gibt. var vorschlagIds = treffer.Select(m => m.Id).ToList(); var vorschlagsdateien = await db.MediaFiles.AsNoTracking() .Where(d => d.MovieId != null && vorschlagIds.Contains(d.MovieId!.Value)) .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel }) .ToListAsync(); // **Mit Stand.** Hier stand `null`, und damit fehlte der // Fortschrittsbalken ausgerechnet in einer Reihe, die aus dem // Sehverhalten gebaut wird — ein halb gesehener Film sah dort // aus wie ein unangetasteter. var vorschlagsstände = await LibraryContentEndpoints.ZuständeZu( db, wer, filme: vorschlagIds); var items = treffer.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, vorschlagsdateien.Where(d => d.MovieId == m.Id) .Select(d => new MediaFileResponse( d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container, d.Duration?.TotalSeconds, [], d.VersionLabel)) .ToList(), vorschlagsstände.GetValueOrDefault(m.Id), m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating, PublishAt: m.PublishAt)).ToList(); return Results.Ok(new Page(items, items.Count)); }); /* ---------- Was gerade Saison hat ---------- */ // **Eine Reihe, die nur zu ihrer Zeit dasteht.** // // Nach Stichwörtern der Metadatenquelle, nicht nach Genres: „Stirb // langsam" ist kein Familienfilm und trägt trotzdem „christmas". // Eine Weihnachtsreihe nach Genres enthielte jeden // Zeichentrickfilm und diesen Film nicht. // // **Leer heisst leer.** Findet der Anlass nichts, kommt eine leere // Seite ohne Namen zurück — der Client zeigt dann gar keine Reihe, // statt einer mit Überschrift und nichts darunter. start.MapGet("/seasonal", async ( ClaimsPrincipal wer, FlaroContext db, Saisoneinstellungen einstellungen, TimeProvider uhr, CancellationToken ct, int take = 24) => { if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); var heute = uhr.GetUtcNow(); var anlass = (await einstellungen.AnlaesseAsync(ct)).FirstOrDefault(a => a.Gilt(heute)); if (anlass is null) return Results.Ok(new Saisonreihe(null, new Page([], 0))); var stichworte = anlass.Stichworte.Select(w => w.ToLowerInvariant()).ToList(); var genres = anlass.Genres; var treffer = await db.Movies.AsNoTracking() .Where(m => db.Stichwortlinks.Any(l => l.MovieId == m.Id && db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name))) || db.GenreLinks.Any(l => l.MovieId == m.Id && db.Genres.Any(g => g.Id == l.GenreId && genres.Contains(g.Name)))) // **Stichworttreffer vor Genretreffer.** Ein Film, der // „christmas" trägt, gehört sicherer in die Reihe als einer, // der nur im passenden Genre liegt. .Select(m => new { Film = m, Sicher = db.Stichwortlinks.Count(l => l.MovieId == m.Id && db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name))), }) .OrderByDescending(x => x.Sicher) .ThenByDescending(x => x.Film.Rating ?? 0) .Take(Math.Clamp(take, 1, HöchstensJeSeite)) .Select(x => x.Film) .ToListAsync(ct); if (treffer.Count == 0) return Results.Ok(new Saisonreihe(null, new Page([], 0))); var saisonIds = treffer.Select(m => m.Id).ToList(); var saisondateien = await db.MediaFiles.AsNoTracking() .Where(d => d.MovieId != null && saisonIds.Contains(d.MovieId!.Value)) .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel }) .ToListAsync(ct); var saisonstände = await LibraryContentEndpoints.ZuständeZu( db, wer, filme: saisonIds); var saisonitems = treffer.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, saisondateien.Where(d => d.MovieId == m.Id) .Select(d => new MediaFileResponse( d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container, d.Duration?.TotalSeconds, [], d.VersionLabel)) .ToList(), saisonstände.GetValueOrDefault(m.Id), m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating, PublishAt: m.PublishAt)).ToList(); return Results.Ok(new Saisonreihe( anlass.Name, new Page(saisonitems, saisonitems.Count))); }); } /// Der Zustand eines Titels, den noch niemand angefasst hat. /// /// /// Lieber eine Antwort mit Nullen als null: die Reihe „Nächste /// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für /// jede davon einen Sonderfall braucht, vergisst ihn irgendwo. /// private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null); /// Macht aus Zuständen Einträge mit Titel und Bild. /// /// /// In zwei Abfragen statt in einer je Zeile: zwanzig Einträge wären sonst /// zwanzig Abfragen, und SQLite führt die nacheinander aus. /// private static async Task> Eintraege(FlaroContext db, List zustände) { var filme = zustände.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList(); var folgen = zustände.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList(); var filmdaten = await db.Movies.AsNoTracking().Where(m => filme.Contains(m.Id)) .Select(m => new { m.Id, m.Title, m.Year, m.PosterImage, m.BackdropImage, m.PublishAt }) .ToDictionaryAsync(m => m.Id); var folgendaten = await db.Episodes.AsNoTracking().Where(f => folgen.Contains(f.Id)) .Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, f.StillImage, SerienId = s.Id, SerienTitel = s.Title, s.PosterImage, f.PublishAt, SerienBild = s.BackdropImage, }) .ToDictionaryAsync(f => f.Id); var dateien = await Erstdateien(db, filme, folgen); var liste = new List(zustände.Count); foreach (var z in zustände) { if (z.MovieId is { } film && filmdaten.TryGetValue(film, out var m)) liste.Add(new ContinueEntry( m.Id, "movie", m.Title, null, null, null, m.Year, m.PosterImage, m.BackdropImage ?? m.PosterImage, Abbild(z), FileId: dateien.GetValueOrDefault(m.Id).Id, DurationSeconds: dateien.GetValueOrDefault(m.Id).Sekunden, PublishAt: m.PublishAt)); else if (z.EpisodeId is { } folge && folgendaten.TryGetValue(folge, out var f)) liste.Add(new ContinueEntry( f.Id, "episode", f.SerienTitel, f.FolgenTitel, f.SeasonNumber, f.EpisodeNumber, null, f.PosterImage, f.StillImage ?? f.PosterImage, Abbild(z), SeriesId: f.SerienId, FileId: dateien.GetValueOrDefault(f.Id).Id, DurationSeconds: dateien.GetValueOrDefault(f.Id).Sekunden, PublishAt: f.PublishAt, SeriesBackdropImage: f.SerienBild)); } return liste; } /// Je Titel die erste Datei — die, die abgespielt würde. /// /// /// In einer Abfrage für alle. Zwanzig Zeilen auf der Startseite /// wären sonst zwanzig Abfragen, und SQLite führt die nacheinander aus. /// private static async Task> Erstdateien( FlaroContext db, List filme, List folgen) { if (filme.Count == 0 && folgen.Count == 0) return []; var roh = await db.MediaFiles.AsNoTracking() .Where(d => (d.MovieId != null && filme.Contains(d.MovieId!.Value)) || (d.EpisodeId != null && folgen.Contains(d.EpisodeId!.Value))) .Select(d => new { d.Id, d.MovieId, d.EpisodeId, d.Duration }) .ToListAsync(); return roh .GroupBy(d => d.MovieId ?? d.EpisodeId!.Value) .ToDictionary(g => g.Key, g => { var erste = g.First(); return ((Guid?)erste.Id, erste.Duration?.TotalSeconds); }); } // ------------------------------------------------------------------ // Gemeinsames // ------------------------------------------------------------------ /// Hält fest, was die Regel zu dieser Zeile sagt. /// /// /// Nach jeder Änderung aufzurufen. Die einzige Stelle, die /// schreibt — gäbe es eine zweite, /// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen. /// /// Schreibt die tatsächlich gesehene Zeit ins Protokoll. /// /// /// /// Der Zuwachs, gedeckelt auf die verstrichene Zeit. Zwischen zwei /// Meldungen liegen zehn Sekunden; wer in dieser Zeit von Minute 5 auf /// Minute 90 springt, hat nicht 85 Minuten gesehen. Ohne Deckel erfände /// jedes Vorspulen Sehzeit — und ein Film, den man einmal durchgespult /// hat, stünde in der Statistik wie zweimal gesehen. /// /// /// Das Doppelte als Toleranz, nicht genau die verstrichene Zeit: /// eine ausgefallene Meldung, ein kurzer Aussetzer im Netz, doppelte /// Abspielgeschwindigkeit — all das ist echtes Schauen und soll zählen. /// Erst darüber wird gekappt. /// /// /// Rückwärts zählt nichts. Wer zurückspringt und dieselbe Stelle /// noch einmal sieht, bekommt sie nicht abgezogen und auch nicht doppelt /// gutgeschrieben: der Zuwachs ist dann null, bis er die alte Stelle /// wieder erreicht. Das ist die freundlichere Ungenauigkeit von beiden. /// /// private static async Task Mitzaehlen( FlaroContext db, Guid benutzer, UserItemState zustand, TimeSpan neueStelle, TimeProvider clock, CancellationToken ct = default) { if (zustand.MovieId is null && zustand.EpisodeId is null) return; var jetzt = clock.GetUtcNow(); var verstrichen = jetzt - zustand.UpdatedAt; var zuwachs = (neueStelle - zustand.Position).TotalSeconds; // **Ohne verstrichene Zeit gibt es nichts gutzuschreiben.** Das ist // die erste Meldung einer Zeile — sie legt den Nullpunkt, mehr nicht. // Stünde hier stattdessen „kein Deckel", zählte eine frisch angelegte // Zeile ihre Anfangsstelle voll mit: wer einen Film öffnet und sofort // auf Minute fünf springt, hätte fünf Minuten gesehen. if (zuwachs <= 0 || verstrichen <= TimeSpan.Zero) return; zuwachs = Math.Min(zuwachs, verstrichen.TotalSeconds * 2); if (zuwachs <= 0) return; // Ortszeit des Servers: wer um halb eins zu Ende sieht, hat das am // Abend getan. var tag = DateOnly.FromDateTime(jetzt.ToLocalTime().DateTime); // **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; db.Sehtage.Add(new Sehtag { UserId = benutzer, MovieId = zustand.MovieId, EpisodeId = zustand.EpisodeId, Tag = tag, Sekunden = zuwachs, }); try { await db.SaveChangesAsync(ct); } catch (DbUpdateException) { // 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); /// Welches Gerät das ist — die Kennung seines Tokens. /// /// /// Nicht der Benutzer: derselbe Mensch kann auf dem Fernseher und auf /// dem Telefon gleichzeitig etwas laufen lassen, und beides gehört ins /// Dashboard. Nicht der Gerätename: zwei iPhones heissen beide „iPhone". /// private static string Geraet(ClaimsPrincipal wer) => wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim) ?? "unbekannt"; /// Trägt die laufende Wiedergabe ins Dashboard ein. private static async Task Mitschreiben( FlaroContext db, PlaybackSessions laufende, ClaimsPrincipal wer, Guid benutzer, UserItemState zustand, TimeProvider clock) { string titel; string? untertitel = null; string? plakat; if (zustand.MovieId is { } film) { var eintrag = await db.Movies.AsNoTracking() .Where(m => m.Id == film) .Select(m => new { m.Title, m.PosterImage }) .FirstOrDefaultAsync(); if (eintrag is null) return; titel = eintrag.Title; plakat = eintrag.PosterImage; } else if (zustand.EpisodeId is { } folge) { var eintrag = await db.Episodes.AsNoTracking() .Where(f => f.Id == folge) .Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new { SerienTitel = s.Title, s.PosterImage, f.SeasonNumber, f.EpisodeNumber, FolgenTitel = f.Title, }) .FirstOrDefaultAsync(); if (eintrag is null) return; titel = eintrag.SerienTitel; untertitel = $"S{eintrag.SeasonNumber} · F{eintrag.EpisodeNumber}" + (eintrag.FolgenTitel is { Length: > 0 } n ? $" · {n}" : ""); plakat = eintrag.PosterImage; } else return; laufende.Melden(new PlaybackSessions.Wiedergabe( DeviceKey: Geraet(wer), UserId: benutzer, UserName: wer.FindFirstValue(ClaimTypes.Name) ?? "", ItemId: zustand.MovieId ?? zustand.EpisodeId ?? Guid.Empty, Title: titel, SubTitle: untertitel, PosterImage: plakat, Position: zustand.Position, Runtime: zustand.Runtime, DeviceName: wer.FindFirstValue(TokenAuthenticationHandler.DeviceNameClaim) ?? "Unbekannt", LastSeenAt: clock.GetUtcNow())); } internal static Guid? Benutzer(ClaimsPrincipal wer) => Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; /// Holt den Stand oder legt ihn an — null, wenn es den Titel nicht gibt. /// /// /// /// Erst Film, dann Folge, dann Serie. Drei Abfragen im schlechtesten /// Fall. Eine gemeinsame Tabelle für alles würde zwei sparen und dafür an /// jeder anderen Stelle im Modell kosten. /// /// /// Dass die Serie fehlte, war ein stiller Fehler. Jede Aktion auf /// einer Serienkachel — als gesehen markieren, zu den Favoriten — bekam /// eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah /// sichtbar gar nichts. /// /// private static async Task Zustand(FlaroContext db, Guid benutzer, Guid titel) { var vorhanden = await db.UserItemStates .FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel || z.SeriesId == titel)); if (vorhanden is not null) return vorhanden; if (await db.Movies.AnyAsync(m => m.Id == titel)) { var neu = new UserItemState { UserId = benutzer, MovieId = titel }; db.UserItemStates.Add(neu); return neu; } if (await db.Episodes.AnyAsync(f => f.Id == titel)) { var neu = new UserItemState { UserId = benutzer, EpisodeId = titel }; db.UserItemStates.Add(neu); return neu; } if (await db.Series.AnyAsync(s => s.Id == titel)) { var neu = new UserItemState { UserId = benutzer, SeriesId = titel }; db.UserItemStates.Add(neu); return neu; } return null; } /// Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg. /// /// /// /// „Serie gesehen" heisst: jede Folge gesehen. Nur die Zeile der /// Serie zu setzen liesse die Folgenliste unverändert und den Zähler /// „noch 42 offen" daneben stehen — zwei Angaben, die sich widersprechen. /// /// /// Die Stelle bleibt, wo sie ist. Wer eine Serie von Hand abhakt, /// will sie aus dem Weg haben; eine halb gesehene Folge soll dadurch nicht /// ihren Fortschritt verlieren, falls er doch zurückkommt. Nur /// „Weiterschauen" räumt sich auf, weil ein Abgehaktes dort nichts zu /// suchen hat. /// /// private static async Task FolgenSetzen( FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock, Wiedergaberegeln regeln) { var folgen = await db.Episodes.AsNoTracking() .Where(f => f.SeriesId == serie) .Select(f => f.Id) .ToListAsync(); if (folgen.Count == 0) return; var staende = await db.UserItemStates .Where(z => z.UserId == benutzer && z.EpisodeId != null && folgen.Contains(z.EpisodeId.Value)) .ToListAsync(); var bekannt = staende.ToDictionary(z => z.EpisodeId!.Value); var jetzt = clock.GetUtcNow(); foreach (var folge in folgen) { if (!bekannt.TryGetValue(folge, out var zustand)) { // Ungesehen ist der Normalfall — dafür braucht es keine Zeile. if (!gesehen) continue; zustand = new UserItemState { UserId = benutzer, EpisodeId = folge }; db.UserItemStates.Add(zustand); } if (gesehen && !zustand.Watched) zustand.PlayCount++; zustand.Watched = gesehen; zustand.Position = TimeSpan.Zero; zustand.Dismissed = false; if (gesehen) zustand.LastPlayedAt = jetzt; zustand.UpdatedAt = jetzt; Nachfuehren(zustand, regeln); } } /// Die Laufzeit aus der längsten Datei hinter dem Titel. /// /// /// Die längste, weil mehrere Fassungen vorliegen können und eine davon /// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen /// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat. /// private static async Task Dateilaufzeit(FlaroContext db, UserItemState zustand) { // Eine Serie hat keine eigene Datei. Ohne diese Zeile verglich die // Abfrage `EpisodeId == null` und nahm die längste herrenlose Datei. if (zustand.MovieId is null && zustand.EpisodeId is null) return null; return await db.MediaFiles.AsNoTracking() .Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId) .MaxAsync(d => (TimeSpan?)d.Duration); } internal static ItemStateResponse Abbild(UserItemState z) => new( z.Position.TotalSeconds, z.Runtime?.TotalSeconds, z.Watched, z.PlayCount, z.Favourite, z.Rating, z.Dismissed, z.LastPlayedAt, z.InProgress); }