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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user