Stufe 4: Fortschritt ab der ersten Sekunde, ohne Schwelle

Der Anlass für diesen Server, jetzt als Code. Fortschritt, Gesehen-Status,
Weiterschauen, Nächste Folge, Favoriten und Bewertungen — alles je Benutzer.

**Die Jellyfin-Werte sind jetzt belegt statt behauptet.** Das Entwurfsdokument
hatte das ausdrücklich offengelassen („aus dem Gedächtnis"); hier stehen sie
aus `MediaBrowser.Model/Configuration/ServerConfiguration.cs` und
`Emby.Server.Implementations/Library/UserDataManager.cs`:

| | Vorgabe | Was passiert |
|---|---|---|
| `MinResumePct` | 5 | darunter `positionTicks = 0` — Kommentar im Quelltext: „ignore progress during the beginning" |
| `MaxResumePct` | 90 | darüber gesehen, Position **auf 0** |
| `MinResumeDurationSeconds` | 300 | alles unter fünf Minuten gilt nach dem ersten Lebenszeichen als gesehen |

Bei einem Zweistünder sind die ersten sechs Minuten damit weg. Eine
vierminütige Folge gilt nach dreissig Sekunden als vollständig gesehen. Und
fehlt die Laufzeit, nimmt Jellyfin an, der Titel sei durch — wörtlich:
„we'll just have to assume it was fully played".

**Flaro legt jede Meldung ab und wirft nie eine Position weg.** Eine Grenze
fürs Häkchen gibt es trotzdem — 90 %, sonst stünde jeder zu Ende geschaute
Film für immer bei 98 % in Weiterschauen. Der Unterschied ist nicht, *dass*
es die Grenze gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt,
dort zusätzlich die Stelle vernichtet. Wer einen gesehenen Film noch einmal
anfängt, hat beides — das Häkchen und den Punkt, an dem er steht.

Ohne bekannte Laufzeit wird nichts vermutet. Das ist der Fehler, den man
nicht zurücknehmen kann, ohne ihn zu bemerken.

---

**Eine Spalte statt einer zweiten Regel.** „Gehört in Weiterschauen" vergleicht
die Stelle mit einem Anteil der Laufzeit, und diese Rechnung lässt sich nicht
nach SQL übersetzen. Die Regel daneben noch einmal als Abfrage zu schreiben
wäre die Variante, bei der beide irgendwann auseinanderlaufen — unbemerkt,
weil keine von beiden dabei falsch aussieht. Stattdessen hält `InProgress`
das Ergebnis fest, geschrieben an genau einer Stelle. Eine Prüfung hält Reihe
und Regel über vier Stände gegeneinander.

**„Weiterschauen" und „Nächste Folge" überschneiden sich nicht.** Was
angefangen ist, steht links; was ansteht, rechts. Jellyfin zeigt beides
doppelt, und auf einer Startseite mit zwei Reihen nebeneinander sieht das
nach einem Fehler aus.

Dazu, weil die App sie braucht: Einzelabruf für Film und Serie, die nächste
Folge nach einer gegebenen, und der Zustand hängt an jeder Liste mit dran —
in einer Abfrage je Seite, nicht in einer je Titel.

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.

348 Tests grün. Im Container nachgewiesen: fünf Minuten „Der Untergang" von
2:36 bleiben stehen, bei Minute 150 kommt das Häkchen und die Stelle bleibt,
beim zweiten Anlauf steht der Film mit beidem wieder in der Reihe — und nach
Folge 1 von Dark zeigt „Nächste Folge" S01E02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 21:25:55 +02:00
co-authored by Claude Opus 5
parent 705ec87416
commit c4081ff251
12 changed files with 2185 additions and 10 deletions
+51 -3
View File
@@ -75,16 +75,26 @@ public sealed record MediaFileResponse(
public sealed record MovieResponse( public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview, Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage, string? PosterImage, string? BackdropImage,
IReadOnlyList<MediaFileResponse> Files); IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State);
public sealed record SeriesResponse( public sealed record SeriesResponse(
Guid Id, string Title, int? Year, string? Overview, Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage, string? PosterImage, string? BackdropImage,
int EpisodeCount, IReadOnlyList<int> Seasons); int EpisodeCount, IReadOnlyList<int> Seasons,
/// <summary>Wie viele Folgen dieser Benutzer noch nicht gesehen hat.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
int UnwatchedCount);
public sealed record EpisodeResponse( public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage, Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files); IReadOnlyList<MediaFileResponse> Files,
ItemStateResponse? State);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total); public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
@@ -132,3 +142,41 @@ public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary> /// <summary>Wie die Zuordnung jetzt aussieht.</summary>
public sealed record MatchState( public sealed record MatchState(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year); int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
// --- Stufe 4: Fortschritt und Zustand -------------------------------------
/// <summary>Eine Fortschrittsmeldung.</summary>
///
/// <remarks>
/// <b>In Sekunden, nicht in Ticks.</b> Jellyfin rechnet in Hundertnanosekunden,
/// und jeder Client muss das wissen. Sekunden als Gleitkommazahl sind in jeder
/// Sprache dasselbe und in der Entwicklerkonsole lesbar.
///
/// <c>RuntimeSeconds</c> 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.
/// </remarks>
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);
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
public sealed record ItemStateResponse(
double PositionSeconds, double? RuntimeSeconds,
bool Watched, int PlayCount, bool Favourite, int? Rating, bool Dismissed,
DateTimeOffset? LastPlayedAt);
/// <summary>Eine Zeile in „Weiterschauen".</summary>
///
/// <remarks>
/// Film und Folge in einer Form, weil die Reihe beide mischt. Bei einer Folge
/// steht in <c>Title</c> die Serie und in <c>SubTitle</c> die Folge — so liest
/// sich die Kachel wie bei Plex: oben, worum es geht, darunter, wo man ist.
/// </remarks>
public sealed record ContinueEntry(
Guid Id, string Kind, string Title, string? SubTitle,
int? Season, int? Episode, int? Year,
string? PosterImage, string? BackdropImage,
ItemStateResponse State);
@@ -1,3 +1,5 @@
using System.Security.Claims;
using Flaro.Core.Playback;
using Flaro.Data; using Flaro.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -45,6 +47,48 @@ public static class LibraryContentEndpoints
ILookup<Guid, MediaStreamResponse> spuren) => ILookup<Guid, MediaStreamResponse> spuren) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList()); new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
/// <summary>Die Stände dieses Benutzers zu den genannten Titeln.</summary>
///
/// <remarks>
/// 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 <c>null</c>.
/// </remarks>
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? 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);
}
/// <summary>Welche der genannten Folgen dieser Benutzer gesehen hat.</summary>
private static async Task<HashSet<Guid>> GeseheneFolgen(
FlaroContext db, ClaimsPrincipal wer, List<Guid> 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) private static void MapScan(IEndpointRouteBuilder app)
{ {
app.MapPost("/api/v1/libraries/{id:guid}/scan", async ( app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
@@ -82,7 +126,7 @@ public static class LibraryContentEndpoints
private static void MapBrowse(IEndpointRouteBuilder app) private static void MapBrowse(IEndpointRouteBuilder app)
{ {
app.MapGet("/api/v1/movies", async ( 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(); var abfrage = db.Movies.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung); 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 }) .Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList()); 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( var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id) 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<MovieResponse>(items, gesamt)); return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization(); }).RequireAuthorization();
app.MapGet("/api/v1/series", async ( 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(); var abfrage = db.Series.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung); 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 ids = serien.Select(s => s.Id).ToList();
var folgen = await db.Episodes.AsNoTracking() var folgen = await db.Episodes.AsNoTracking()
.Where(f => ids.Contains(f.SeriesId)) .Where(f => ids.Contains(f.SeriesId))
.Select(f => new { f.SeriesId, f.SeasonNumber }) .Select(f => new { f.Id, f.SeriesId, f.SeasonNumber })
.ToListAsync(); .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 items = serien.Select(s =>
{ {
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList(); var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
return new SeriesResponse( return new SeriesResponse(
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count, 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(); }).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt)); return Results.Ok(new Page<SeriesResponse>(items, gesamt));
}).RequireAuthorization(); }).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(); 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 }) .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
.ToListAsync(); .ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); 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( var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
dateien.Where(d => d.EpisodeId == f.Id) 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<EpisodeResponse>(items, items.Count)); return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization(); }).RequireAuthorization();
@@ -0,0 +1,360 @@
using System.Security.Claims;
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;
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<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 })
.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<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)));
}
return Results.Ok(new Page<ContinueEntry>(liste, liste.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 })
.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<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)));
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
// ------------------------------------------------------------------
/// <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>
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;
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
///
/// <remarks>
/// <b>Erst Film, dann Folge.</b> 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.
/// </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));
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;
}
/// <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) =>
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);
}
+1
View File
@@ -137,6 +137,7 @@ app.MapLibraryContent();
app.MapSettings(); app.MapSettings();
app.MapImages(); app.MapImages();
app.MapDelivery(); app.MapDelivery();
app.MapPlayback();
app.MapMatching(); app.MapMatching();
// Alles, was keine Schnittstelle ist, bekommt die Seite — der Router läuft // Alles, was keine Schnittstelle ist, bekommt die Seite — der Router läuft
@@ -0,0 +1,88 @@
namespace Flaro.Core.Playback;
/// <summary>Was eine Fortschrittsmeldung am Zustand ändert.</summary>
///
/// <remarks>
/// <para>
/// <b>Keine untere Schwelle. Keine Mindestlaufzeit. Keine verworfene
/// Position.</b> Das ist der Anlass für diesen Server, und es lohnt, den
/// Vergleich festzuhalten, damit später niemand „das macht Jellyfin doch
/// genauso" denkt.
/// </para>
/// <para>
/// Jellyfin rechnet in <c>UserDataManager.UpdatePlayState</c> mit drei Werten
/// (Vorgaben aus <c>ServerConfiguration</c>): <c>MinResumePct</c> 5,
/// <c>MaxResumePct</c> 90, <c>MinResumeDurationSeconds</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class ProgressRule
{
/// <summary>Ab welchem Anteil ein Titel als gesehen gilt.</summary>
///
/// <remarks>
/// <b>Eine Schwelle ist hier unvermeidlich</b>, 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, <i>dass</i> es diese Grenze
/// gibt, sondern was sie anrichtet: hier wird ein Häkchen gesetzt, dort
/// zusätzlich die Position weggeworfen.
/// </remarks>
public const double WatchedFraction = 0.90;
/// <summary>Was aus einer Meldung folgt.</summary>
/// <param name="Position">Die Stelle, die abgelegt wird — nie null gesetzt.</param>
/// <param name="Watched">Ob der Titel damit als gesehen gilt.</param>
public readonly record struct Outcome(TimeSpan Position, bool Watched);
/// <summary>Rechnet eine gemeldete Stelle in einen Zustand um.</summary>
///
/// <param name="position">Wo der Client steht.</param>
/// <param name="runtime">
/// 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.
/// </param>
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);
}
/// <summary>Ob ein Zustand in „Weiterschauen" gehört.</summary>
///
/// <remarks>
/// <b>Dieselbe Regel wie in der Abfrage, an einer Stelle.</b> 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.
/// </remarks>
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);
}
@@ -0,0 +1,84 @@
namespace Flaro.Core.Playback;
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
///
/// <remarks>
/// <para>
/// <b>Je Benutzer, nicht je Server.</b> Zwei Leute im selben Haushalt stehen
/// an verschiedenen Stellen desselben Films, und wessen Stand gilt, darf
/// nicht davon abhängen, wer zuletzt geschaut hat.
/// </para>
/// <para>
/// <b>Der Verweis zeigt auf Film <i>oder</i> Folge</b>, wie bei
/// <c>MediaFile</c>. 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.
/// </para>
/// </remarks>
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; }
/// <summary>Wo der Benutzer steht.</summary>
///
/// <remarks>
/// <b>Ab der ersten Sekunde, ohne untere Schwelle.</b> Das ist der
/// Unterschied, um dessentwillen dieser Server existiert — siehe
/// <see cref="ProgressRule"/>.
/// </remarks>
public TimeSpan Position { get; set; }
/// <summary>Wie lang der Titel ist, so wie der Client es gemeldet hat.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
public TimeSpan? Runtime { get; set; }
public bool Watched { get; set; }
public int PlayCount { get; set; }
public DateTimeOffset? LastPlayedAt { get; set; }
/// <summary>Aus „Weiterschauen" genommen, ohne als gesehen zu gelten.</summary>
///
/// <remarks>
/// <b>Der Fall, für den es sonst nichts gibt.</b> 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.
/// </remarks>
public bool Dismissed { get; set; }
public bool Favourite { get; set; }
/// <summary>Eine eigene Bewertung von 1 bis 10, oder keine.</summary>
public int? Rating { get; set; }
/// <summary>Ob dieser Titel in „Weiterschauen" gehört.</summary>
///
/// <remarks>
/// <b>Kein zweites Urteil, sondern das festgehaltene Ergebnis des
/// ersten.</b> Geschrieben wird es ausschliesslich aus
/// <see cref="ProgressRule.BelongsInContinueWatching"/>, 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.
/// </remarks>
public bool InProgress { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+33
View File
@@ -2,6 +2,7 @@ using Flaro.Core;
using Flaro.Core.Auth; using Flaro.Core.Auth;
using Flaro.Core.Libraries; using Flaro.Core.Libraries;
using Flaro.Core.Library; using Flaro.Core.Library;
using Flaro.Core.Playback;
using Flaro.Core.Users; using Flaro.Core.Users;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -32,6 +33,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<Series> Series => Set<Series>(); public DbSet<Series> Series => Set<Series>();
public DbSet<Episode> Episodes => Set<Episode>(); public DbSet<Episode> Episodes => Set<Episode>();
public DbSet<MediaStream> MediaStreams => Set<MediaStream>(); public DbSet<MediaStream> MediaStreams => Set<MediaStream>();
public DbSet<UserItemState> UserItemStates => Set<UserItemState>();
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary> /// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
/// ///
@@ -150,6 +152,37 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade); e.HasOne<MediaFile>().WithMany().HasForeignKey(x => x.MediaFileId).OnDelete(DeleteBehavior.Cascade);
}); });
b.Entity<UserItemState>(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<User>().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<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Episode>(e => b.Entity<Episode>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -0,0 +1,587 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("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<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Sehfortschritt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserItemStates",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
Position = table.Column<long>(type: "INTEGER", nullable: false),
Runtime = table.Column<long>(type: "INTEGER", nullable: true),
Watched = table.Column<bool>(type: "INTEGER", nullable: false),
PlayCount = table.Column<int>(type: "INTEGER", nullable: false),
LastPlayedAt = table.Column<long>(type: "INTEGER", nullable: true),
Dismissed = table.Column<bool>(type: "INTEGER", nullable: false),
Favourite = table.Column<bool>(type: "INTEGER", nullable: false),
Rating = table.Column<int>(type: "INTEGER", nullable: true),
InProgress = table.Column<bool>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserItemStates");
}
}
}
@@ -395,6 +395,70 @@ namespace Flaro.Data.Migrations
b.ToTable("Series"); b.ToTable("Series");
}); });
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("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 => modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
@@ -495,6 +559,25 @@ namespace Flaro.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .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 #pragma warning restore 612, 618
} }
} }
@@ -0,0 +1,464 @@
using System.Net;
using System.Net.Http.Json;
using Flaro.Api.Endpoints;
using Flaro.Core.Playback;
namespace Flaro.Api.Tests;
/// <summary>Fortschritt, Gesehen-Status und die Startseite — je Benutzer.</summary>
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]);
}
/// <summary>Legt eine Bibliothek an, scannt sie und meldet den Client.</summary>
private async Task<HttpClient> 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<LibraryResponse>())!;
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
for (var versuch = 0; versuch < 100; versuch++)
{
var stand = await client.GetFromJsonAsync<ScanStatus>($"/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<Guid> ErsterFilm(HttpClient client)
{
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
return seite!.Items[0].Id;
}
private static Task<HttpResponseMessage> Melden(HttpClient c, Guid id, double stelle, double? laenge) =>
c.PostAsJsonAsync($"/api/v1/items/{id}/progress", new ProgressRequest(stelle, laenge));
// ------------------------------------------------------------------
/// <summary>Der Fall, um dessentwillen der Server existiert.</summary>
///
/// <remarks>
/// Jellyfin verwirft bei zwei Stunden alles unterhalb von sechs Minuten —
/// <c>MinResumePct</c> steht auf 5. Hier kommt die Stelle zurück.
/// </remarks>
[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<ItemStateResponse>())!;
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<Page<MovieResponse>>("/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<ItemStateResponse>())!;
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<Page<ContinueEntry>>("/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<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(reihe!.Items, Is.Empty);
}
/// <summary>Ein zweites Mal angefangen — mit Häkchen und Stelle zugleich.</summary>
[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<Page<ContinueEntry>>("/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<Page<ContinueEntry>>("/api/v1/home/continue");
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/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<Page<ContinueEntry>>("/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<ItemStateResponse>())!;
var reihe = await client.GetFromJsonAsync<Page<ContinueEntry>>("/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<MovieResponse>($"/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));
}
/// <summary>Zwei Leute im selben Haushalt stehen an verschiedenen Stellen.</summary>
[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<Page<MovieResponse>>("/api/v1/movies");
var seiteB = await zweiter.GetFromJsonAsync<Page<MovieResponse>>("/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));
}
/// <summary>Ohne Angabe nimmt der Server die Laufzeit aus der Datei.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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<ItemStateResponse>())!;
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<Page<SeriesResponse>>("/api/v1/series");
var serie = serien!.Items[0];
Assert.That(serie.UnwatchedCount, Is.EqualTo(3));
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>($"/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<SeriesResponse>($"/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<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
var naechste = await client.GetFromJsonAsync<EpisodeResponse>(
$"/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<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/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<Page<ContinueEntry>>("/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");
});
}
/// <summary>Was in „Weiterschauen" steht, steht nicht nochmal daneben.</summary>
[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<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
await Melden(client, folgen!.Items[0].Id, 400, 2700);
var weiter = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/continue");
var naechste = await client.GetFromJsonAsync<Page<ContinueEntry>>("/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);
});
}
/// <summary>Die Spalte und die Regel dürfen nicht auseinanderlaufen.</summary>
///
/// <remarks>
/// <c>InProgress</c> ist das festgehaltene Ergebnis von
/// <see cref="ProgressRule.BelongsInContinueWatching"/>. Wer die Regel
/// ändert und das Nachführen vergisst, bekommt eine Startseite, die lügt —
/// und zwar lautlos. Deshalb hier gegeneinandergehalten.
/// </remarks>
[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<Page<ContinueEntry>>("/api/v1/home/continue");
Assert.That(reihe!.Items.Count == 1, Is.EqualTo(erwartet),
$"bei {stelle}s von {laenge}s");
}
}
}
@@ -0,0 +1,196 @@
using Flaro.Core.Playback;
namespace Flaro.Core.Tests;
/// <summary>Die Regel, um derentwillen dieser Server existiert.</summary>
[TestFixture]
public sealed class ProgressRuleTests
{
private static readonly TimeSpan Zweistünder = TimeSpan.FromHours(2);
/// <summary>Der Fall, der den Ausschlag gab.</summary>
///
/// <remarks>
/// Jellyfins <c>MinResumePct</c> 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.
/// </remarks>
[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)));
}
/// <summary>Jellyfins <c>MinResumeDurationSeconds</c> steht auf 300.</summary>
///
/// <remarks>
/// Dort gilt eine vierminütige Folge nach dreissig Sekunden als
/// vollständig gesehen. Hier ist sie das, was sie ist: angefangen.
/// </remarks>
[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);
}
/// <summary>Und die Stelle bleibt trotzdem stehen.</summary>
///
/// <remarks>
/// Jellyfin setzt sie an dieser Stelle auf null. Wer den Abspann sehen
/// oder die letzte Szene nochmal will, fängt dort von vorn an.
/// </remarks>
[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);
}
/// <summary>Ohne Laufzeit gibt es keinen Anteil — also kein Urteil.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>Spieler melden beim Spulen und beim Beenden Unsinn.</summary>
[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);
}
/// <summary>Ein zweites Mal angefangen — dann steht er wieder da.</summary>
///
/// <remarks>
/// Das Häkchen bleibt: gesehen hat man ihn ja. Beides nebeneinander ist
/// genau das, was ohne weggeworfene Position möglich wird.
/// </remarks>
[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 };
}