Der Fortschrittsbalken ist an **einer** Stelle gebaut und wird in jeder
Kachel verwendet — Web wie App. Fehlte er, dann nicht an der Kachel,
sondern weil der Server `State: null` schickte. Drei Listen taten das,
obwohl die Stände in derselben Abfrage danebenlagen:
/home/suggestions — „Weil du Ähnliches gesehen hast"
/home/seasonal — die saisonale Reihe
/movies/{id}/similar — „Ähnliche Titel"
Ausgerechnet die erste entsteht aus dem Sehverhalten: ein halb gesehener
Film sah dort aus wie ein unangetasteter, während dieselbe Kachel in der
Mediathek einen Balken trug. `ZuständeZu` gab es längst, es war nur
privat.
**Und „Als Nächstes" lässt sich jetzt räumen.** Das vorhandene Weglegen
reichte nicht dorthin: die Reihe liest `Watched` und `InProgress`, nie
`Dismissed` — und bei einer nie gestarteten Folge ist `InProgress` ohnehin
`false`. Weggelegt wird deshalb die **Serie**, nicht die Folge: beim
nächsten Laden rückte sonst die übernächste nach, und man legte
zwanzigmal weg. Den Zustand dafür gibt es schon, er wurde nur nie
gelesen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
939 lines
44 KiB
C#
939 lines
44 KiB
C#
using System.Security.Claims;
|
|
using Flaro.Api.Auth;
|
|
using Flaro.Core.Playback;
|
|
using Flaro.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
/// <summary>Fortschritt, Gesehen-Status, Favoriten — alles je Benutzer.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Ein Titel wird über seine Kennung allein angesprochen</b>, 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class PlaybackEndpoints
|
|
{
|
|
private const int HöchstensJeSeite = 200;
|
|
|
|
/// <summary>Wie viele Titel eine Sammelanfrage höchstens trägt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Grosszügig, aber nicht offen.</b> 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.
|
|
/// </remarks>
|
|
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<Guid>();
|
|
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<ContinueEntry>(
|
|
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<ContinueEntry>([], 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<ContinueEntry>();
|
|
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<ContinueEntry>(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<MovieResponse>([], 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<MovieResponse>([], 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<MovieResponse>(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<MovieResponse>([], 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<MovieResponse>([], 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<MovieResponse>(saisonitems, saisonitems.Count)));
|
|
});
|
|
}
|
|
|
|
/// <summary>Der Zustand eines Titels, den noch niemand angefasst hat.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Lieber eine Antwort mit Nullen als <c>null</c>: die Reihe „Nächste
|
|
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
|
|
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
|
|
/// </remarks>
|
|
private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null);
|
|
|
|
/// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// In zwei Abfragen statt in einer je Zeile: zwanzig Einträge wären sonst
|
|
/// zwanzig Abfragen, und SQLite führt die nacheinander aus.
|
|
/// </remarks>
|
|
private static async Task<List<ContinueEntry>> Eintraege(FlaroContext db, List<UserItemState> 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<ContinueEntry>(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;
|
|
}
|
|
|
|
/// <summary>Je Titel die erste Datei — die, die abgespielt würde.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>In einer Abfrage für alle.</b> Zwanzig Zeilen auf der Startseite
|
|
/// wären sonst zwanzig Abfragen, und SQLite führt die nacheinander aus.
|
|
/// </remarks>
|
|
private static async Task<Dictionary<Guid, (Guid? Id, double? Sekunden)>> Erstdateien(
|
|
FlaroContext db, List<Guid> filme, List<Guid> 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
|
|
// ------------------------------------------------------------------
|
|
|
|
/// <summary>Hält fest, was die Regel zu dieser Zeile sagt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Nach jeder Änderung aufzurufen. Die einzige Stelle, die
|
|
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
|
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
|
/// </remarks>
|
|
/// <summary>Schreibt die tatsächlich gesehene Zeit ins Protokoll.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Der Zuwachs, gedeckelt auf die verstrichene Zeit.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Das Doppelte als Toleranz</b>, 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Rückwärts zählt nichts.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Schreibt die Sekunden an einer vorhandenen Zeile fort.</summary>
|
|
///
|
|
/// <remarks>Gibt zurück, wie viele Zeilen getroffen wurden — 0 oder 1.</remarks>
|
|
private static Task<int> 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);
|
|
|
|
/// <summary>Welches Gerät das ist — die Kennung seines Tokens.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// 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".
|
|
/// </remarks>
|
|
private static string Geraet(ClaimsPrincipal wer) =>
|
|
wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim) ?? "unbekannt";
|
|
|
|
/// <summary>Trägt die laufende Wiedergabe ins Dashboard ein.</summary>
|
|
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;
|
|
|
|
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Erst Film, dann Folge, dann Serie.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Dass die Serie fehlte, war ein stiller Fehler.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static async Task<UserItemState?> 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;
|
|
}
|
|
|
|
/// <summary>Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>„Serie gesehen" heisst: jede Folge gesehen.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Die Stelle bleibt, wo sie ist.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private static async Task<TimeSpan?> 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);
|
|
}
|