diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 0575885..01cad0a 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -75,16 +75,26 @@ public sealed record MediaFileResponse( public sealed record MovieResponse( Guid Id, string Title, int? Year, string? Overview, string? PosterImage, string? BackdropImage, - IReadOnlyList Files); + IReadOnlyList Files, + ItemStateResponse? State); public sealed record SeriesResponse( Guid Id, string Title, int? Year, string? Overview, string? PosterImage, string? BackdropImage, - int EpisodeCount, IReadOnlyList Seasons); + int EpisodeCount, IReadOnlyList Seasons, + /// Wie viele Folgen dieser Benutzer noch nicht gesehen hat. + /// + /// + /// Statt eines Zustands je Serie: eine Serie wird nicht angeschaut, + /// ihre Folgen werden es. Was man an der Kachel sehen will, ist die Zahl + /// — bei Plex steht sie als Plakette in der Ecke. + /// + int UnwatchedCount); public sealed record EpisodeResponse( Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, - IReadOnlyList Files); + IReadOnlyList Files, + ItemStateResponse? State); public sealed record Page(IReadOnlyList Items, int Total); @@ -132,3 +142,41 @@ public sealed record ApplyMatchRequest(int TmdbId); /// Wie die Zuordnung jetzt aussieht. public sealed record MatchState( int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year); + +// --- Stufe 4: Fortschritt und Zustand ------------------------------------- + +/// Eine Fortschrittsmeldung. +/// +/// +/// In Sekunden, nicht in Ticks. Jellyfin rechnet in Hundertnanosekunden, +/// und jeder Client muss das wissen. Sekunden als Gleitkommazahl sind in jeder +/// Sprache dasselbe und in der Entwicklerkonsole lesbar. +/// +/// RuntimeSeconds ist freiwillig: kennt der Client die Laufzeit der +/// Fassung, die er gerade abspielt, ist seine Angabe genauer als die des +/// Servers. Fehlt sie, nimmt der Server die aus der Datei. +/// +public sealed record ProgressRequest(double PositionSeconds, double? RuntimeSeconds); + +public sealed record WatchedRequest(bool Watched); +public sealed record FavouriteRequest(bool Favourite); +public sealed record RatingRequest(int? Rating); + +/// Was ein Benutzer mit einem Titel zu tun hatte. +public sealed record ItemStateResponse( + double PositionSeconds, double? RuntimeSeconds, + bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed, + DateTimeOffset? LastPlayedAt); + +/// Eine Zeile in „Weiterschauen". +/// +/// +/// Film und Folge in einer Form, weil die Reihe beide mischt. Bei einer Folge +/// steht in Title die Serie und in SubTitle die Folge — so liest +/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist. +/// +public sealed record ContinueEntry( + Guid Id, string Kind, string Title, string? SubTitle, + int? Season, int? Episode, int? Year, + string? PosterImage, string? BackdropImage, + ItemStateResponse State); diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 11a1472..a8a7f7d 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -1,3 +1,5 @@ +using System.Security.Claims; +using Flaro.Core.Playback; using Flaro.Data; using Microsoft.EntityFrameworkCore; @@ -45,6 +47,48 @@ public static class LibraryContentEndpoints ILookup spuren) => new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList()); + /// Die Stände dieses Benutzers zu den genannten Titeln. + /// + /// + /// In einer Abfrage für die ganze Seite. Ein Titel ohne Zeile hat keinen + /// Stand — das ist kein Fehler, sondern der Normalfall für alles, was + /// noch niemand angefasst hat, und die Antwort trägt dort null. + /// + private static async Task> ZuständeZu( + FlaroContext db, ClaimsPrincipal wer, List? filme = null, List? folgen = null) + { + if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return []; + + var gesucht = (filme ?? folgen)!; + if (gesucht.Count == 0) return []; + + var zeilen = await db.UserItemStates.AsNoTracking() + .Where(z => z.UserId == benutzer) + .Where(z => filme != null + ? z.MovieId != null && gesucht.Contains(z.MovieId!.Value) + : z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)) + .ToListAsync(); + + return zeilen.ToDictionary( + z => (filme != null ? z.MovieId : z.EpisodeId)!.Value, + PlaybackEndpoints.Abbild); + } + + /// Welche der genannten Folgen dieser Benutzer gesehen hat. + private static async Task> GeseheneFolgen( + FlaroContext db, ClaimsPrincipal wer, List folgen) + { + if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer || folgen.Count == 0) return []; + + var ids = await db.UserItemStates.AsNoTracking() + .Where(z => z.UserId == benutzer && z.Watched + && z.EpisodeId != null && folgen.Contains(z.EpisodeId!.Value)) + .Select(z => z.EpisodeId!.Value) + .ToListAsync(); + + return [.. ids]; + } + private static void MapScan(IEndpointRouteBuilder app) { app.MapPost("/api/v1/libraries/{id:guid}/scan", async ( @@ -82,7 +126,7 @@ public static class LibraryContentEndpoints private static void MapBrowse(IEndpointRouteBuilder app) { app.MapGet("/api/v1/movies", async ( - FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) => + ClaimsPrincipal wer, FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) => { var abfrage = db.Movies.AsNoTracking(); if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung); @@ -105,17 +149,19 @@ public static class LibraryContentEndpoints .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); + var stände = await ZuständeZu(db, wer, filme: ids); var items = kennungen.Select(m => new MovieResponse( m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, dateien.Where(f => f.MovieId == m.Id) - .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).ToList(); + .Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), + stände.GetValueOrDefault(m.Id))).ToList(); return Results.Ok(new Page(items, gesamt)); }).RequireAuthorization(); app.MapGet("/api/v1/series", async ( - FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) => + ClaimsPrincipal wer, FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) => { var abfrage = db.Series.AsNoTracking(); if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung); @@ -132,21 +178,111 @@ public static class LibraryContentEndpoints var ids = serien.Select(s => s.Id).ToList(); var folgen = await db.Episodes.AsNoTracking() .Where(f => ids.Contains(f.SeriesId)) - .Select(f => new { f.SeriesId, f.SeasonNumber }) + .Select(f => new { f.Id, f.SeriesId, f.SeasonNumber }) .ToListAsync(); + // Gesehene Folgen in **einer** Abfrage, für alle Serien der Seite + // zusammen. Je Serie zu fragen wäre bei fünfzig Serien + // einundfünfzig Abfragen. + var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList()); + var items = serien.Select(s => { var meine = folgen.Where(f => f.SeriesId == s.Id).ToList(); return new SeriesResponse( s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count, - meine.Select(f => f.SeasonNumber).Distinct().Order().ToList()); + meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(), + meine.Count(f => !gesehen.Contains(f.Id))); }).ToList(); return Results.Ok(new Page(items, gesamt)); }).RequireAuthorization(); - app.MapGet("/api/v1/series/{id:guid}/episodes", async (Guid id, FlaroContext db, int? season) => + // **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.** + // Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus + // „Weiterschauen" — in all diesen Fällen hat er nur die Kennung. + app.MapGet("/api/v1/movies/{id:guid}", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db) => + { + var film = await db.Movies.AsNoTracking() + .Where(m => m.Id == id) + .Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage }) + .FirstOrDefaultAsync(); + if (film is null) return Results.NotFound(); + + var dateien = await db.MediaFiles.AsNoTracking() + .Where(f => f.MovieId == id) + .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration }) + .ToListAsync(); + var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); + var stände = await ZuständeZu(db, wer, filme: [id]); + + return Results.Ok(new MovieResponse( + film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage, + dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(), + stände.GetValueOrDefault(id))); + }).RequireAuthorization(); + + app.MapGet("/api/v1/series/{id:guid}", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db) => + { + var serie = await db.Series.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => new { x.Id, x.Title, x.Year, x.Overview, x.PosterImage, x.BackdropImage }) + .FirstOrDefaultAsync(); + if (serie is null) return Results.NotFound(); + + var folgen = await db.Episodes.AsNoTracking() + .Where(f => f.SeriesId == id) + .Select(f => new { f.Id, f.SeasonNumber }) + .ToListAsync(); + var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList()); + + return Results.Ok(new SeriesResponse( + serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage, + folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(), + folgen.Count(f => !gesehen.Contains(f.Id)))); + }).RequireAuthorization(); + + // **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann + // Folge — nicht die Kennung: die ist zeitlich geordnet und sagt, wann + // der Scan sie fand, nicht wann sie läuft. + app.MapGet("/api/v1/episodes/{id:guid}/next", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db) => + { + var diese = await db.Episodes.AsNoTracking() + .Where(f => f.Id == id) + .Select(f => new { f.SeriesId, f.SeasonNumber, f.EpisodeNumber }) + .FirstOrDefaultAsync(); + if (diese is null) return Results.NotFound(); + + var naechste = await db.Episodes.AsNoTracking() + .Where(f => f.SeriesId == diese.SeriesId) + .Where(f => f.SeasonNumber > diese.SeasonNumber + || (f.SeasonNumber == diese.SeasonNumber && f.EpisodeNumber > diese.EpisodeNumber)) + .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage }) + .FirstOrDefaultAsync(); + + // Keine weitere Folge ist kein Fehler — es war die letzte. + if (naechste is null) return Results.NoContent(); + + var dateien = await db.MediaFiles.AsNoTracking() + .Where(d => d.EpisodeId == naechste.Id) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration }) + .ToListAsync(); + var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); + var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]); + + return Results.Ok(new EpisodeResponse( + naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber, + naechste.Title, naechste.Overview, naechste.StillImage, + dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), + stände.GetValueOrDefault(naechste.Id))); + }).RequireAuthorization(); + + app.MapGet("/api/v1/series/{id:guid}/episodes", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db, int? season) => { if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound(); @@ -164,11 +300,13 @@ public static class LibraryContentEndpoints .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId }) .ToListAsync(); var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); + var stände = await ZuständeZu(db, wer, folgen: ids); var items = folgen.Select(f => new EpisodeResponse( f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, dateien.Where(d => d.EpisodeId == f.Id) - .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList())).ToList(); + .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), + stände.GetValueOrDefault(f.Id))).ToList(); return Results.Ok(new Page(items, items.Count)); }).RequireAuthorization(); diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs new file mode 100644 index 0000000..1051d2f --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -0,0 +1,360 @@ +using System.Security.Claims; +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; + + 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) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + + 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); + + // **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); + await db.SaveChangesAsync(); + return Results.Ok(Abbild(zustand)); + }); + + titel.MapPut("/watched", async ( + Guid id, WatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + + 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; + } + + zustand.UpdatedAt = clock.GetUtcNow(); + Nachfuehren(zustand); + await db.SaveChangesAsync(); + return Results.Ok(Abbild(zustand)); + }); + + // Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. + titel.MapPost("/dismiss", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + + var zustand = await Zustand(db, benutzer, id); + if (zustand is null) return Results.NotFound(); + + zustand.Dismissed = true; + zustand.UpdatedAt = clock.GetUtcNow(); + Nachfuehren(zustand); + await db.SaveChangesAsync(); + return Results.Ok(Abbild(zustand)); + }); + + titel.MapPut("/favourite", async ( + Guid id, FavouriteRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + + var zustand = await Zustand(db, benutzer, id); + if (zustand is null) return Results.NotFound(); + + zustand.Favourite = anfrage.Favourite; + zustand.UpdatedAt = clock.GetUtcNow(); + Nachfuehren(zustand); + await db.SaveChangesAsync(); + return Results.Ok(Abbild(zustand)); + }); + + titel.MapPut("/rating", async ( + Guid id, RatingRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + if (anfrage.Rating is { } wert && wert is < 1 or > 10) + return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.", + statusCode: StatusCodes.Status400BadRequest); + + var zustand = await Zustand(db, benutzer, id); + if (zustand is null) return Results.NotFound(); + + zustand.Rating = anfrage.Rating; + zustand.UpdatedAt = clock.GetUtcNow(); + Nachfuehren(zustand); + 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 }) + .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 }) + .ToDictionaryAsync(x => x.Id); + + var liste = new List(); + 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))); + } + + return Results.Ok(new Page(liste, liste.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 }) + .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, + }) + .ToDictionaryAsync(f => f.Id); + + 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))); + 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))); + } + return liste; + } + + // ------------------------------------------------------------------ + // 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. + /// + private static void Nachfuehren(UserItemState zustand) => + zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand); + + 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. Zwei Abfragen im schlechteren Fall. Eine + /// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder + /// anderen Stelle im Modell kosten. + /// + 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)); + 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; + } + + return null; + } + + /// 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) => + 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); +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 225db8b..2929f84 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -137,6 +137,7 @@ app.MapLibraryContent(); app.MapSettings(); app.MapImages(); app.MapDelivery(); +app.MapPlayback(); app.MapMatching(); // Alles, was keine Schnittstelle ist, bekommt die Seite — der Router läuft diff --git a/server/src/Flaro.Core/Playback/ProgressRule.cs b/server/src/Flaro.Core/Playback/ProgressRule.cs new file mode 100644 index 0000000..0e5b0af --- /dev/null +++ b/server/src/Flaro.Core/Playback/ProgressRule.cs @@ -0,0 +1,88 @@ +namespace Flaro.Core.Playback; + +/// Was eine Fortschrittsmeldung am Zustand ändert. +/// +/// +/// +/// Keine untere Schwelle. Keine Mindestlaufzeit. Keine verworfene +/// Position. Das ist der Anlass für diesen Server, und es lohnt, den +/// Vergleich festzuhalten, damit später niemand „das macht Jellyfin doch +/// genauso" denkt. +/// +/// +/// Jellyfin rechnet in UserDataManager.UpdatePlayState mit drei Werten +/// (Vorgaben aus ServerConfiguration): MinResumePct 5, +/// MaxResumePct 90, MinResumeDurationSeconds 300. Unterhalb von +/// fünf Prozent setzt es die Position auf null — der Kommentar im Quelltext +/// lautet wörtlich „ignore progress during the beginning". Bei einem +/// Zweistünder sind das die ersten sechs Minuten. Oberhalb von neunzig +/// Prozent gilt der Titel als gesehen, und auch dann fällt die Position weg. +/// Und alles, was kürzer als fünf Minuten ist, gilt nach dem ersten +/// Lebenszeichen als vollständig gesehen, egal an welcher Stelle. +/// +/// +/// Flaro behält in allen drei Fällen die Position. Ein gesehener Titel und +/// eine Stelle, an der man weitermachen kann, schliessen einander nicht aus — +/// wer einen Film ein zweites Mal anfängt, soll beides haben. +/// +/// +public static class ProgressRule +{ + /// Ab welchem Anteil ein Titel als gesehen gilt. + /// + /// + /// Eine Schwelle ist hier unvermeidlich, und das ist kein + /// Widerspruch zum Obigen: Niemand sieht einen Film bis zur letzten + /// Sekunde des Abspanns. Ohne diese Regel stünde jeder zu Ende geschaute + /// Film für immer bei 98 % in „Weiterschauen". + /// + /// Der Unterschied zu Jellyfin ist nicht, dass es diese Grenze + /// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort + /// zusätzlich die Position weggeworfen. + /// + public const double WatchedFraction = 0.90; + + /// Was aus einer Meldung folgt. + /// Die Stelle, die abgelegt wird — nie null gesetzt. + /// Ob der Titel damit als gesehen gilt. + public readonly record struct Outcome(TimeSpan Position, bool Watched); + + /// Rechnet eine gemeldete Stelle in einen Zustand um. + /// + /// Wo der Client steht. + /// + /// Wie lang der Titel ist, falls bekannt. Ohne Laufzeit gibt es keinen + /// Anteil zu rechnen — dann wird die Stelle abgelegt und sonst nichts. + /// Zu raten wäre hier schlimmer als nichts zu tun: „gesehen", weil die + /// Laufzeit fehlt, ist der Fehler, den man nicht zurücknehmen kann, ohne + /// es zu merken. + /// + public static Outcome Apply(TimeSpan position, TimeSpan? runtime) + { + // Eine negative Stelle gibt es nicht, und eine jenseits des Endes + // auch nicht. Beides kommt vor: der eine Player meldet beim Spulen + // kurz Unsinn, der andere beim Beenden die Laufzeit plus Rundung. + var stelle = position < TimeSpan.Zero ? TimeSpan.Zero : position; + + if (runtime is not { } laenge || laenge <= TimeSpan.Zero) + return new Outcome(stelle, Watched: false); + + if (stelle > laenge) stelle = laenge; + + return new Outcome(stelle, Watched: stelle >= laenge * WatchedFraction); + } + + /// Ob ein Zustand in „Weiterschauen" gehört. + /// + /// + /// Dieselbe Regel wie in der Abfrage, an einer Stelle. Die + /// Serverabfrage kann sie nicht aufrufen — sie wird zu SQL übersetzt —, + /// aber die Prüfung kann beides gegeneinanderhalten. Läuft das auseinander, + /// fällt es hier auf und nicht beim Benutzer. + /// + public static bool BelongsInContinueWatching(UserItemState zustand) => + zustand is { Dismissed: false, Position.Ticks: > 0 } + && (zustand.Runtime is not { } laenge + || laenge <= TimeSpan.Zero + || zustand.Position < laenge * WatchedFraction); +} diff --git a/server/src/Flaro.Core/Playback/UserItemState.cs b/server/src/Flaro.Core/Playback/UserItemState.cs new file mode 100644 index 0000000..3db2cfe --- /dev/null +++ b/server/src/Flaro.Core/Playback/UserItemState.cs @@ -0,0 +1,84 @@ +namespace Flaro.Core.Playback; + +/// Was ein Benutzer mit einem Titel zu tun hatte. +/// +/// +/// +/// Je Benutzer, nicht je Server. Zwei Leute im selben Haushalt stehen +/// an verschiedenen Stellen desselben Films, und wessen Stand gilt, darf +/// nicht davon abhängen, wer zuletzt geschaut hat. +/// +/// +/// Der Verweis zeigt auf Film oder Folge, wie bei +/// MediaFile. Eine gemeinsame Kennung mit einem Typfeld daneben wäre +/// kürzer, aber die Datenbank könnte dann nicht mehr dafür einstehen, dass +/// der Verweis irgendwohin zeigt. +/// +/// +public sealed class UserItemState +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid UserId { get; init; } + + public Guid? MovieId { get; init; } + public Guid? EpisodeId { get; init; } + + /// Wo der Benutzer steht. + /// + /// + /// Ab der ersten Sekunde, ohne untere Schwelle. Das ist der + /// Unterschied, um dessentwillen dieser Server existiert — siehe + /// . + /// + public TimeSpan Position { get; set; } + + /// Wie lang der Titel ist, so wie der Client es gemeldet hat. + /// + /// + /// Hier abgelegt, damit „wie weit ist er" ohne einen Blick in die Dateien + /// zu beantworten ist. Bei zwei Fassungen eines Films — 4K und 1080p — + /// können sich die Laufzeiten um Sekunden unterscheiden; was zählt, ist + /// die, gegen die gemessen wurde. + /// + public TimeSpan? Runtime { get; set; } + + public bool Watched { get; set; } + public int PlayCount { get; set; } + public DateTimeOffset? LastPlayedAt { get; set; } + + /// Aus „Weiterschauen" genommen, ohne als gesehen zu gelten. + /// + /// + /// Der Fall, für den es sonst nichts gibt. Nach einer halben Stunde + /// keine Lust mehr — der Film ist weder gesehen noch ungesehen, er soll + /// nur nicht weiter oben stehen. „Als gesehen" wäre eine Lüge, „als + /// ungesehen" täte nichts: die Position bliebe. + /// + /// Die Position bleibt auch hier erhalten. Wer doch weitermacht, fängt + /// nicht von vorn an. + /// + public bool Dismissed { get; set; } + + public bool Favourite { get; set; } + + /// Eine eigene Bewertung von 1 bis 10, oder keine. + public int? Rating { get; set; } + + /// Ob dieser Titel in „Weiterschauen" gehört. + /// + /// + /// Kein zweites Urteil, sondern das festgehaltene Ergebnis des + /// ersten. Geschrieben wird es ausschliesslich aus + /// , bei jeder + /// Änderung an dieser Zeile. + /// + /// Warum überhaupt eine Spalte: die Regel vergleicht die Stelle mit einem + /// Anteil der Laufzeit, und diese Rechnung lässt sich nicht nach SQL + /// übersetzen. Die Regel daneben ein zweites Mal als Abfrage zu schreiben + /// wäre die Variante, bei der beide irgendwann auseinanderlaufen — und + /// zwar unbemerkt, weil keine von beiden dabei falsch aussieht. + /// + public bool InProgress { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 40248a3..b3260a7 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -2,6 +2,7 @@ using Flaro.Core; using Flaro.Core.Auth; using Flaro.Core.Libraries; using Flaro.Core.Library; +using Flaro.Core.Playback; using Flaro.Core.Users; using Microsoft.EntityFrameworkCore; @@ -32,6 +33,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet Series => Set(); public DbSet Episodes => Set(); public DbSet MediaStreams => Set(); + public DbSet UserItemStates => Set(); /// Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen. /// @@ -150,6 +152,37 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.HasOne().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + + // **Je Benutzer und Titel genau eine Zeile.** Ohne diese Regel + // legen zwei Fortschrittsmeldungen, die sich überholen, zwei + // Zeilen an — und welche davon später gelesen wird, entscheidet + // der Zufall. + // + // Gefiltert, weil die jeweils andere Spalte leer ist: eine + // ungefilterte Regel über (UserId, MovieId) liesse zwar beliebig + // viele Folgen-Zeilen zu (NULL gilt als verschieden), aber das + // wäre Verlass auf eine Eigenheit statt auf eine Aussage. + e.HasIndex(x => new { x.UserId, x.MovieId }).IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + // Der Weg von „Weiterschauen": die offenen Zeilen eines + // Benutzers, neueste zuerst. Ohne Index ein vollständiger + // Durchlauf je Aufruf der Startseite. + e.HasIndex(x => new { x.UserId, x.InProgress, x.UpdatedAt }); + + e.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + // Verschwindet ein Film aus der Mediathek, verschwindet auch der + // Stand dazu. Ein Fortschritt zu einem Titel, den es nicht mehr + // gibt, ist keine Erinnerung, sondern Datenmüll. + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/Migrations/20261003192048_Sehfortschritt.Designer.cs b/server/src/Flaro.Data/Migrations/20261003192048_Sehfortschritt.Designer.cs new file mode 100644 index 0000000..26bb17a --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261003192048_Sehfortschritt.Designer.cs @@ -0,0 +1,587 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261003192048_Sehfortschritt")] + partial class Sehfortschritt + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261003192048_Sehfortschritt.cs b/server/src/Flaro.Data/Migrations/20261003192048_Sehfortschritt.cs new file mode 100644 index 0000000..c34394e --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261003192048_Sehfortschritt.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Sehfortschritt : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserItemStates", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + EpisodeId = table.Column(type: "TEXT", nullable: true), + Position = table.Column(type: "INTEGER", nullable: false), + Runtime = table.Column(type: "INTEGER", nullable: true), + Watched = table.Column(type: "INTEGER", nullable: false), + PlayCount = table.Column(type: "INTEGER", nullable: false), + LastPlayedAt = table.Column(type: "INTEGER", nullable: true), + Dismissed = table.Column(type: "INTEGER", nullable: false), + Favourite = table.Column(type: "INTEGER", nullable: false), + Rating = table.Column(type: "INTEGER", nullable: true), + InProgress = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserItemStates", x => x.Id); + table.ForeignKey( + name: "FK_UserItemStates_Episodes_EpisodeId", + column: x => x.EpisodeId, + principalTable: "Episodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserItemStates_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserItemStates_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_EpisodeId", + table: "UserItemStates", + column: "EpisodeId"); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_MovieId", + table: "UserItemStates", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_UserId_EpisodeId", + table: "UserItemStates", + columns: new[] { "UserId", "EpisodeId" }, + unique: true, + filter: "\"EpisodeId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_UserId_InProgress_UpdatedAt", + table: "UserItemStates", + columns: new[] { "UserId", "InProgress", "UpdatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_UserId_MovieId", + table: "UserItemStates", + columns: new[] { "UserId", "MovieId" }, + unique: true, + filter: "\"MovieId\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserItemStates"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 2959b7d..2ee792f 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -395,6 +395,70 @@ namespace Flaro.Data.Migrations b.ToTable("Series"); }); + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + modelBuilder.Entity("Flaro.Core.ServerSetting", b => { b.Property("Key") @@ -495,6 +559,25 @@ namespace Flaro.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs new file mode 100644 index 0000000..89767aa --- /dev/null +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -0,0 +1,464 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; +using Flaro.Core.Playback; + +namespace Flaro.Api.Tests; + +/// Fortschritt, Gesehen-Status und die Startseite — je Benutzer. +public class PlaybackTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-medien-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[2048]); + } + + /// Legt eine Bibliothek an, scannt sie und meldet den Client. + private async Task Eingelesen(FlaroServer server, string art = "Movies") + { + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", art, _bestand)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode(); + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan"); + if (stand!.State is "finished") return client; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig."); + return null!; + } + + private static async Task ErsterFilm(HttpClient client) + { + var seite = await client.GetFromJsonAsync>("/api/v1/movies"); + return seite!.Items[0].Id; + } + + private static Task Melden(HttpClient c, Guid id, double stelle, double? laenge) => + c.PostAsJsonAsync($"/api/v1/items/{id}/progress", new ProgressRequest(stelle, laenge)); + + // ------------------------------------------------------------------ + + /// Der Fall, um dessentwillen der Server existiert. + /// + /// + /// Jellyfin verwirft bei zwei Stunden alles unterhalb von sechs Minuten — + /// MinResumePct steht auf 5. Hier kommt die Stelle zurück. + /// + [Test] + public async Task Fuenf_Minuten_von_zwei_Stunden_bleiben_erhalten() + { + Datei("Der Untergang (2004)/Der Untergang (2004).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + var antwort = await Melden(client, film, 300, 7200); + var stand = (await antwort.Content.ReadFromJsonAsync())!; + + Assert.Multiple(() => + { + Assert.That(stand.PositionSeconds, Is.EqualTo(300)); + Assert.That(stand.Watched, Is.False); + }); + } + + [Test] + public async Task Eine_Sekunde_wird_schon_gemerkt() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 1, 7200); + + var seite = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(seite!.Items[0].State!.PositionSeconds, Is.EqualTo(1)); + } + + [Test] + public async Task Kurz_vor_Schluss_gilt_er_als_gesehen_und_behaelt_die_Stelle() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + var antwort = await Melden(client, film, 7000, 7200); + var stand = (await antwort.Content.ReadFromJsonAsync())!; + + Assert.Multiple(() => + { + Assert.That(stand.Watched, Is.True); + Assert.That(stand.PositionSeconds, Is.EqualTo(7000)); + Assert.That(stand.PlayCount, Is.EqualTo(1)); + }); + } + + [Test] + public async Task Angefangenes_steht_in_Weiterschauen() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 600, 7200); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + + Assert.Multiple(() => + { + Assert.That(reihe!.Items, Has.Count.EqualTo(1)); + Assert.That(reihe.Items[0].Title, Is.EqualTo("Matrix")); + Assert.That(reihe.Items[0].Kind, Is.EqualTo("movie")); + Assert.That(reihe.Items[0].State.PositionSeconds, Is.EqualTo(600)); + }); + } + + [Test] + public async Task Durchgeschautes_verlaesst_Weiterschauen() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 600, 7200); + await Melden(client, film, 7000, 7200); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + Assert.That(reihe!.Items, Is.Empty); + } + + /// Ein zweites Mal angefangen — mit Häkchen und Stelle zugleich. + [Test] + public async Task Ein_gesehener_Film_kommt_beim_zweiten_Anlauf_zurueck() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 7000, 7200); + await Melden(client, film, 120, 7200); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + + Assert.Multiple(() => + { + Assert.That(reihe!.Items, Has.Count.EqualTo(1)); + Assert.That(reihe.Items[0].State.Watched, Is.True, "gesehen hat er ihn ja"); + Assert.That(reihe.Items[0].State.PositionSeconds, Is.EqualTo(120)); + }); + } + + [Test] + public async Task Weglegen_nimmt_aus_der_Reihe_ohne_gesehen_zu_behaupten() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 1800, 7200); + (await client.PostAsync($"/api/v1/items/{film}/dismiss", null)).EnsureSuccessStatusCode(); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + var seite = await client.GetFromJsonAsync>("/api/v1/movies"); + + Assert.Multiple(() => + { + Assert.That(reihe!.Items, Is.Empty); + Assert.That(seite!.Items[0].State!.Watched, Is.False, "weggelegt heisst nicht gesehen"); + Assert.That(seite.Items[0].State!.PositionSeconds, Is.EqualTo(1800), "die Stelle bleibt"); + }); + } + + [Test] + public async Task Wer_doch_weiterschaut_holt_es_zurueck() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 1800, 7200); + await client.PostAsync($"/api/v1/items/{film}/dismiss", null); + await Melden(client, film, 1900, 7200); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + Assert.That(reihe!.Items, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Von_Hand_als_gesehen_raeumt_die_Stelle_weg() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await Melden(client, film, 1800, 7200); + var antwort = await client.PutAsJsonAsync($"/api/v1/items/{film}/watched", new WatchedRequest(true)); + var stand = (await antwort.Content.ReadFromJsonAsync())!; + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + + Assert.Multiple(() => + { + Assert.That(stand.Watched, Is.True); + Assert.That(stand.PositionSeconds, Is.Zero); + Assert.That(reihe!.Items, Is.Empty); + }); + } + + [Test] + public async Task Favorit_und_Bewertung_bleiben_stehen() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + await client.PutAsJsonAsync($"/api/v1/items/{film}/favourite", new FavouriteRequest(true)); + await client.PutAsJsonAsync($"/api/v1/items/{film}/rating", new RatingRequest(9)); + + var einzeln = await client.GetFromJsonAsync($"/api/v1/movies/{film}"); + + Assert.Multiple(() => + { + Assert.That(einzeln!.State!.Favourite, Is.True); + Assert.That(einzeln.State!.Rating, Is.EqualTo(9)); + }); + } + + [Test] + public async Task Eine_Bewertung_ausserhalb_von_eins_bis_zehn_wird_abgelehnt() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + var antwort = await client.PutAsJsonAsync($"/api/v1/items/{film}/rating", new RatingRequest(11)); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + /// Zwei Leute im selben Haushalt stehen an verschiedenen Stellen. + [Test] + public async Task Der_Stand_gehoert_dem_Benutzer_nicht_dem_Server() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var admin = await Eingelesen(server); + var film = await ErsterFilm(admin); + + (await admin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode(); + var zweiter = server.AlsAngemeldeter( + (await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token); + + await Melden(admin, film, 600, 7200); + await Melden(zweiter, film, 3000, 7200); + + var seiteA = await admin.GetFromJsonAsync>("/api/v1/movies"); + var seiteB = await zweiter.GetFromJsonAsync>("/api/v1/movies"); + + Assert.Multiple(() => + { + Assert.That(seiteA!.Items[0].State!.PositionSeconds, Is.EqualTo(600)); + Assert.That(seiteB!.Items[0].State!.PositionSeconds, Is.EqualTo(3000)); + }); + } + + [Test] + public async Task Einen_Titel_den_es_nicht_gibt_kann_man_nicht_melden() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + + var antwort = await Melden(client, Guid.CreateVersion7(), 60, 7200); + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } + + /// Ohne Angabe nimmt der Server die Laufzeit aus der Datei. + /// + /// + /// Die Prüfdateien haben keine lesbare Laufzeit — ffprobe findet nichts in + /// zweitausend Nullbytes. Ohne Laufzeit darf nichts vermutet werden, und + /// genau das wird hier festgehalten. + /// + [Test] + public async Task Ohne_Laufzeit_wird_nichts_als_gesehen_vermutet() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + var antwort = await Melden(client, film, 99999, null); + var stand = (await antwort.Content.ReadFromJsonAsync())!; + + Assert.That(stand.Watched, Is.False); + } + + // --- Serien -------------------------------------------------------- + + [Test] + public async Task Ungesehene_Folgen_werden_gezaehlt() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + Datei("Dark/Season 01/Dark - S01E03 - Gestern und heute.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + Assert.That(serie.UnwatchedCount, Is.EqualTo(3)); + + var folgen = await client.GetFromJsonAsync>($"/api/v1/series/{serie.Id}/episodes"); + await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true)); + + var danach = await client.GetFromJsonAsync($"/api/v1/series/{serie.Id}"); + Assert.That(danach!.UnwatchedCount, Is.EqualTo(2)); + } + + [Test] + public async Task Die_naechste_Folge_richtet_sich_nach_Staffel_und_Nummer() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + var naechste = await client.GetFromJsonAsync( + $"/api/v1/episodes/{folgen!.Items[0].Id}/next"); + var hinterDerLetzten = await client.GetAsync($"/api/v1/episodes/{folgen.Items[1].Id}/next"); + + Assert.Multiple(() => + { + Assert.That(naechste!.Episode, Is.EqualTo(2)); + Assert.That(hinterDerLetzten.StatusCode, Is.EqualTo(HttpStatusCode.NoContent), + "nach der letzten Folge kommt keine — das ist kein Fehler"); + }); + } + + [Test] + public async Task Die_erste_ungesehene_Folge_steht_unter_Naechste_Folge() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched", new WatchedRequest(true)); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/next"); + + Assert.Multiple(() => + { + Assert.That(reihe!.Items, Has.Count.EqualTo(1)); + Assert.That(reihe.Items[0].Episode, Is.EqualTo(2)); + Assert.That(reihe.Items[0].Title, Is.EqualTo("Dark"), "oben die Serie"); + }); + } + + /// Was in „Weiterschauen" steht, steht nicht nochmal daneben. + [Test] + public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serien!.Items[0].Id}/episodes"); + + await Melden(client, folgen!.Items[0].Id, 400, 2700); + + var weiter = await client.GetFromJsonAsync>("/api/v1/home/continue"); + var naechste = await client.GetFromJsonAsync>("/api/v1/home/next"); + + Assert.Multiple(() => + { + Assert.That(weiter!.Items, Has.Count.EqualTo(1)); + Assert.That(weiter.Items[0].Episode, Is.EqualTo(1)); + Assert.That(naechste!.Items, Is.Empty); + }); + } + + /// Die Spalte und die Regel dürfen nicht auseinanderlaufen. + /// + /// + /// InProgress ist das festgehaltene Ergebnis von + /// . Wer die Regel + /// ändert und das Nachführen vergisst, bekommt eine Startseite, die lügt — + /// und zwar lautlos. Deshalb hier gegeneinandergehalten. + /// + [Test] + public async Task Die_Reihe_zeigt_genau_das_was_die_Regel_sagt() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + + foreach (var (stelle, laenge) in new (double, double?)[] + { (1, 7200), (300, 7200), (7000, 7200), (120, 7200) }) + { + await Melden(client, film, stelle, laenge); + + var erwartet = ProgressRule.BelongsInContinueWatching(new UserItemState + { + UserId = Guid.Empty, + MovieId = film, + Position = TimeSpan.FromSeconds(stelle), + Runtime = laenge is { } l ? TimeSpan.FromSeconds(l) : null, + }); + + var reihe = await client.GetFromJsonAsync>("/api/v1/home/continue"); + Assert.That(reihe!.Items.Count == 1, Is.EqualTo(erwartet), + $"bei {stelle}s von {laenge}s"); + } + } +} diff --git a/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs b/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs new file mode 100644 index 0000000..7f56601 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/ProgressRuleTests.cs @@ -0,0 +1,196 @@ +using Flaro.Core.Playback; + +namespace Flaro.Core.Tests; + +/// Die Regel, um derentwillen dieser Server existiert. +[TestFixture] +public sealed class ProgressRuleTests +{ + private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2); + + /// Der Fall, der den Ausschlag gab. + /// + /// + /// Jellyfins MinResumePct steht auf 5. Bei zwei Stunden sind das + /// sechs Minuten, und wer nach fünf abbricht, findet beim nächsten Mal + /// nichts vor. Hier ist genau diese Stelle abgelegt. + /// + [Test] + public void Die_ersten_Minuten_zaehlen_auch() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(5), Zweistünder); + + Assert.Multiple(() => + { + Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(5))); + Assert.That(ergebnis.Watched, Is.False); + }); + } + + [Test] + public void Eine_einzige_Sekunde_zaehlt_auch() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(1), Zweistünder); + + Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(1))); + } + + /// Jellyfins MinResumeDurationSeconds steht auf 300. + /// + /// + /// Dort gilt eine vierminütige Folge nach dreissig Sekunden als + /// vollständig gesehen. Hier ist sie das, was sie ist: angefangen. + /// + [Test] + public void Ein_kurzer_Titel_gilt_nicht_sofort_als_gesehen() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(4)); + + Assert.Multiple(() => + { + Assert.That(ergebnis.Watched, Is.False); + Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromSeconds(30))); + }); + } + + [Test] + public void Kurz_vor_Schluss_gilt_er_als_gesehen() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(115), Zweistünder); + + Assert.That(ergebnis.Watched, Is.True); + } + + /// Und die Stelle bleibt trotzdem stehen. + /// + /// + /// Jellyfin setzt sie an dieser Stelle auf null. Wer den Abspann sehen + /// oder die letzte Szene nochmal will, fängt dort von vorn an. + /// + [Test] + public void Gesehen_wirft_die_Stelle_nicht_weg() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(118), Zweistünder); + + Assert.Multiple(() => + { + Assert.That(ergebnis.Watched, Is.True); + Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(118))); + }); + } + + [Test] + public void Genau_an_der_Grenze_gilt_er_als_gesehen() + { + var ergebnis = ProgressRule.Apply(Zweistünder * ProgressRule.WatchedFraction, Zweistünder); + + Assert.That(ergebnis.Watched, Is.True); + } + + [Test] + public void Eine_Sekunde_davor_noch_nicht() + { + var ergebnis = ProgressRule.Apply( + Zweistünder * ProgressRule.WatchedFraction - TimeSpan.FromSeconds(1), Zweistünder); + + Assert.That(ergebnis.Watched, Is.False); + } + + /// Ohne Laufzeit gibt es keinen Anteil — also kein Urteil. + /// + /// + /// Jellyfin nimmt in diesem Fall an, der Titel sei vollständig gesehen + /// („we'll just have to assume it was fully played"). Das ist die + /// Annahme, die man nicht zurücknehmen kann, ohne sie zu bemerken. + /// + [Test] + public void Ohne_Laufzeit_wird_nichts_vermutet() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(90), null); + + Assert.Multiple(() => + { + Assert.That(ergebnis.Watched, Is.False); + Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.FromMinutes(90))); + }); + } + + [Test] + public void Eine_Laufzeit_von_null_ist_keine() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromMinutes(3), TimeSpan.Zero); + + Assert.That(ergebnis.Watched, Is.False); + } + + /// Spieler melden beim Spulen und beim Beenden Unsinn. + [Test] + public void Eine_negative_Stelle_wird_zu_null() + { + var ergebnis = ProgressRule.Apply(TimeSpan.FromSeconds(-5), Zweistünder); + + Assert.That(ergebnis.Position, Is.EqualTo(TimeSpan.Zero)); + } + + [Test] + public void Jenseits_des_Endes_wird_auf_das_Ende_gekuerzt() + { + var ergebnis = ProgressRule.Apply(Zweistünder + TimeSpan.FromSeconds(3), Zweistünder); + + Assert.Multiple(() => + { + Assert.That(ergebnis.Position, Is.EqualTo(Zweistünder)); + Assert.That(ergebnis.Watched, Is.True); + }); + } + + // --- Weiterschauen --------------------------------------------------- + + [Test] + public void Angefangen_gehoert_in_Weiterschauen() + { + Assert.That(ProgressRule.BelongsInContinueWatching( + Zustand(TimeSpan.FromMinutes(20), Zweistünder)), Is.True); + } + + [Test] + public void Nie_angefangen_gehoert_nicht_hinein() + { + Assert.That(ProgressRule.BelongsInContinueWatching( + Zustand(TimeSpan.Zero, Zweistünder)), Is.False); + } + + [Test] + public void Durchgeschaut_gehoert_nicht_hinein() + { + Assert.That(ProgressRule.BelongsInContinueWatching( + Zustand(TimeSpan.FromMinutes(118), Zweistünder)), Is.False); + } + + [Test] + public void Weggelegtes_gehoert_nicht_hinein() + { + var zustand = Zustand(TimeSpan.FromMinutes(20), Zweistünder); + zustand.Dismissed = true; + + Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.False); + } + + /// Ein zweites Mal angefangen — dann steht er wieder da. + /// + /// + /// Das Häkchen bleibt: gesehen hat man ihn ja. Beides nebeneinander ist + /// genau das, was ohne weggeworfene Position möglich wird. + /// + [Test] + public void Ein_gesehener_Titel_kommt_beim_zweiten_Mal_zurueck() + { + var zustand = Zustand(TimeSpan.FromMinutes(10), Zweistünder); + zustand.Watched = true; + + Assert.That(ProgressRule.BelongsInContinueWatching(zustand), Is.True); + } + + private static UserItemState Zustand(TimeSpan stelle, TimeSpan? laenge) => + new() { UserId = Guid.CreateVersion7(), MovieId = Guid.CreateVersion7(), Position = stelle, Runtime = laenge }; +}