„Die Anfrage war so nicht zu gebrauchen" — das ist der Ersatzsatz des
Browsers für eine Ablehnung ohne Begründung. Dabei stand die Begründung
da: „Benutzername darf nicht leer sein, das Passwort braucht mindestens 8
Zeichen." Nur eben in `errors`, und beide Clients lesen `detail`.
Zwei Formen waren betroffen. `Results.ValidationProblem` legt den Satz
unter `errors` ab, `Results.BadRequest(new { fehler = … })` unter
`fehler` — in beiden Fällen kommt er nirgends an. Zusammen 36 Stellen,
darunter jede Zeile, die ich gestern für die Meldungen geschrieben habe.
Dass die Clients `title` und `errors` auslassen, ist dabei richtig: dort
stehen sonst die englischen Prüftexte des Rahmenwerks, und „Not Found" ist
keine Auskunft.
Jetzt eine Form, hinter `Einwand.Bitte` / `.Fehlt` / `.Belegt`. Dass es
drei benannte Türen sind statt eines Aufrufs mit Zahl, hat einen Grund:
man sieht am Wort, ob der Server um eine bessere Eingabe bittet, etwas
nicht findet oder auf etwas Bestehendes stösst.
**Auffallen konnte es nicht**, und das ist der eigentliche Fund. Die
vorhandenen Tests prüften den Rumpf als Zeichenkette — der Satz *stand* ja
drin, nur im falschen Feld. Der neue Test prüft deshalb, in welchem Feld,
und ein Wächter am Quelltext schlägt an, wenn eine der beiden alten Formen
zurückkommt. Er hat beim ersten Lauf gleich eine Stelle gefunden, die mein
Suchmuster übersehen hatte.
Dazu drei Kleinigkeiten, die beim Lesen auffielen:
**Passwörter wurden getrimmt.** Der Dialog schnitt jedem Feld die Ränder
ab, auch dem Passwort — `login.ts` sendet es ungeschoren. Ein Leerzeichen
am Ende, und das angelegte Konto war für immer unbetretbar.
**Beim Einrichten stapelten sich die Fehlerzeilen**, weil `.error`
weggeräumt wurde und `.fehler` entsteht. Drei Fehlversuche, drei rote
Kästen.
**Der Benutzerdialog versprach acht Zeichen und prüfte nichts.** Jetzt
sagt er es selbst, ohne den Umweg über die Leitung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
786 lines
35 KiB
C#
786 lines
35 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 Laufzeit vom Client hat Vorrang: er misst an der Fassung,
|
|
// die er gerade abspielt. Fehlt sie, hilft die Datei weiter —
|
|
// ohne Laufzeit gäbe es keinen Anteil und damit kein „gesehen".
|
|
var laenge = anfrage.RuntimeSeconds is { } sekunden and > 0
|
|
? TimeSpan.FromSeconds(sekunden)
|
|
: zustand.Runtime ?? await Dateilaufzeit(db, zustand);
|
|
|
|
var ergebnis = ProgressRule.Apply(
|
|
TimeSpan.FromSeconds(anfrage.PositionSeconds), 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);
|
|
|
|
var liste = new List<ContinueEntry>();
|
|
foreach (var x in angefasst)
|
|
{
|
|
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();
|
|
|
|
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(),
|
|
null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
|
PublishAt: m.PublishAt)).ToList();
|
|
|
|
return Results.Ok(new Page<MovieResponse>(items, items.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)
|
|
{
|
|
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);
|
|
|
|
var zeile = await db.Sehtage.FirstOrDefaultAsync(z =>
|
|
z.UserId == benutzer && z.Tag == tag
|
|
&& z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId);
|
|
|
|
if (zeile is null)
|
|
{
|
|
db.Sehtage.Add(new Sehtag
|
|
{
|
|
UserId = benutzer,
|
|
MovieId = zustand.MovieId,
|
|
EpisodeId = zustand.EpisodeId,
|
|
Tag = tag,
|
|
Sekunden = zuwachs,
|
|
});
|
|
}
|
|
else
|
|
{
|
|
zeile.Sekunden += zuwachs;
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|