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:
co-authored by
Claude Opus 5
parent
705ec87416
commit
c4081ff251
@@ -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<Guid, MediaStreamResponse> spuren) =>
|
||||
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)
|
||||
{
|
||||
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<MovieResponse>(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<SeriesResponse>(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<EpisodeResponse>(items, items.Count));
|
||||
}).RequireAuthorization();
|
||||
|
||||
Reference in New Issue
Block a user