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