Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren" lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als Meldung statt in der Browserkonsole. Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt, endet nicht mehr in einer Sackgasse. Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit 404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in einer Prüfung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c854c4a09a
commit
557708b769
@@ -319,7 +319,18 @@ public sealed record SeriesResponse(
|
||||
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
|
||||
/// über die Prüfliste.
|
||||
/// </remarks>
|
||||
DateTimeOffset? PublishAt = null);
|
||||
DateTimeOffset? PublishAt = null,
|
||||
|
||||
/// <summary>Favorit, Bewertung, Häkchen — zur Serie als Ganzem.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <see cref="UnwatchedCount"/>, nicht an dessen Stelle.</b> Die
|
||||
/// Zahl sagt, wie viel noch aussteht; dieser Zustand sagt, was der
|
||||
/// Benutzer über die Serie selbst festgehalten hat. Ohne ihn konnte eine
|
||||
/// Kachel nicht wissen, ob „Als gesehen markieren" oder „Als ungesehen
|
||||
/// markieren" darauf gehört — sie bot immer das erste an.
|
||||
/// </remarks>
|
||||
ItemStateResponse? State = null);
|
||||
|
||||
public sealed record EpisodeResponse(
|
||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||
@@ -400,6 +411,25 @@ public sealed record ProgressRequest(
|
||||
|
||||
public sealed record WatchedRequest(bool Watched);
|
||||
public sealed record FavouriteRequest(bool Favourite);
|
||||
|
||||
/// <summary>Mehrere Titel auf einmal abhaken — oder das Häkchen wegnehmen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Anfrage statt zweihundert.</b> „Alle als gesehen markieren" über
|
||||
/// eine Mediathek hinweg wäre sonst ein Anfragensturm, bei dem mittendrin
|
||||
/// etwas scheitert und niemand weiss, was durchging.
|
||||
/// </remarks>
|
||||
public sealed record BulkWatchedRequest(IReadOnlyList<Guid> Ids, bool Watched);
|
||||
|
||||
/// <param name="Changed">Wie viele Titel sich dadurch geändert haben.</param>
|
||||
/// <param name="Unknown">
|
||||
/// Kennungen, zu denen es nichts gibt — stillschweigend zu übergehen hiesse,
|
||||
/// einen Teilerfolg als vollen auszugeben.
|
||||
/// </param>
|
||||
public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknown);
|
||||
|
||||
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
|
||||
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
|
||||
public sealed record RatingRequest(int? Rating);
|
||||
|
||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||
|
||||
@@ -18,6 +18,34 @@ public static class LibraryContentEndpoints
|
||||
/// </remarks>
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
/// <summary>Wie viele Titel eine Auswahl höchstens umfasst.</summary>
|
||||
private const int HöchstensJeZug = 1000;
|
||||
|
||||
/// <summary>Welche Titel im Rumpf stehen — oder <c>null</c>, wenn keiner da ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.</b>
|
||||
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
|
||||
/// Knopf dafür gibt es länger als die Auswahl.
|
||||
/// </remarks>
|
||||
private static async Task<IReadOnlyList<Guid>?> Auswahl(HttpRequest anfrage)
|
||||
{
|
||||
if (anfrage.ContentLength is null or 0) return null;
|
||||
if (!anfrage.HasJsonContentType()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var gelesen = await anfrage.ReadFromJsonAsync<RefreshRequest>();
|
||||
return gelesen?.Ids;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Unlesbarer Rumpf heisst: keine Auswahl. Alles aufzufrischen ist
|
||||
// hier die harmlosere Auslegung — und sie kostet nur Zeit.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -104,22 +132,25 @@ public static class LibraryContentEndpoints
|
||||
/// 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)
|
||||
FlaroContext db, ClaimsPrincipal wer,
|
||||
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
||||
|
||||
var gesucht = (filme ?? folgen)!;
|
||||
var gesucht = (filme ?? folgen ?? serien)!;
|
||||
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))
|
||||
: folgen != null
|
||||
? z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)
|
||||
: z.SeriesId != null && gesucht.Contains(z.SeriesId!.Value))
|
||||
.ToListAsync();
|
||||
|
||||
return zeilen.ToDictionary(
|
||||
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value,
|
||||
z => (filme != null ? z.MovieId : folgen != null ? z.EpisodeId : z.SeriesId)!.Value,
|
||||
PlaybackEndpoints.Abbild);
|
||||
}
|
||||
|
||||
@@ -160,12 +191,33 @@ public static class LibraryContentEndpoints
|
||||
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
||||
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
||||
// bleibt damit stehen.
|
||||
//
|
||||
// **Mit einer Auswahl im Rumpf nur diese Titel.** Zehn ausgewählte
|
||||
// Filme kosten zehn Anfragen bei TMDB; der ganze Bestand viertausend
|
||||
// und eine Viertelstunde. Ohne Rumpf bleibt es beim ganzen Bestand.
|
||||
//
|
||||
// **Der Rumpf wird von Hand gelesen, und das hat einen Grund.** Als
|
||||
// `RefreshRequest?` im Kopf der Methode lehnte der Server jede Anfrage
|
||||
// ohne Rumpf mit 404 ab — also genau den Aufruf „die ganze Mediathek
|
||||
// auffrischen", den es vorher schon gab. Gemessen, nicht vermutet: ohne
|
||||
// Rumpf 404, mit `{}` 202.
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
Guid id, HttpRequest anfrage, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
return koordinator.TryStart(id, erneut: true)
|
||||
IReadOnlySet<Guid>? nurDiese = null;
|
||||
if (await Auswahl(anfrage) is { Count: > 0 } gewaehlt)
|
||||
{
|
||||
if (gewaehlt.Count > HöchstensJeZug)
|
||||
return Results.Problem(
|
||||
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||
+ "Nimm weniger und wiederhole es.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
nurDiese = gewaehlt.ToHashSet();
|
||||
}
|
||||
|
||||
return koordinator.TryStart(id, erneut: true, nurDiese: nurDiese)
|
||||
? Results.AcceptedAtRoute("Scanstand", new { id })
|
||||
: Results.Problem(
|
||||
"Für diese Bibliothek läuft bereits ein Lauf.",
|
||||
@@ -401,6 +453,7 @@ public static class LibraryContentEndpoints
|
||||
// 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 stände = await ZuständeZu(db, wer, serien: ids);
|
||||
|
||||
var items = serien.Select(s =>
|
||||
{
|
||||
@@ -411,7 +464,8 @@ public static class LibraryContentEndpoints
|
||||
meine.Count(f => !gesehen.Contains(f.Id)),
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
|
||||
OriginalTitle: s.OriginalTitle,
|
||||
PublishAt: s.PublishAt);
|
||||
PublishAt: s.PublishAt,
|
||||
State: stände.GetValueOrDefault(s.Id));
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
@@ -538,7 +592,8 @@ public static class LibraryContentEndpoints
|
||||
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
|
||||
await GenresZu(db, film: null, serie: id),
|
||||
await MitwirkendeZu(db, film: null, serie: id),
|
||||
OriginalTitle: serie.OriginalTitle));
|
||||
OriginalTitle: serie.OriginalTitle,
|
||||
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id)));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||
|
||||
@@ -22,6 +22,15 @@ public static class PlaybackEndpoints
|
||||
{
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
/// <summary>Wie viele Titel eine Sammelanfrage höchstens trägt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Grosszügig, aber nicht offen.</b> Eine Mediathek mit 500 Filmen
|
||||
/// lässt sich damit in einem Zug abhaken; eine Anfrage mit 50 000
|
||||
/// Kennungen ist keine Bedienung mehr, sondern ein Angriff.
|
||||
/// </remarks>
|
||||
private const int HöchstensJeZug = 1000;
|
||||
|
||||
public static void MapPlayback(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapZustand(app);
|
||||
@@ -114,12 +123,66 @@ public static class PlaybackEndpoints
|
||||
zustand.Dismissed = false;
|
||||
}
|
||||
|
||||
// **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an
|
||||
// der Serie und daneben „noch 42 offen".
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
|
||||
// **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung
|
||||
// im Pfad trägt und eine Liste keine hat.
|
||||
app.MapPut("/api/v1/items/watched", async (
|
||||
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
if (anfrage.Ids.Count == 0)
|
||||
return Results.Ok(new BulkWatchedResponse(0, []));
|
||||
if (anfrage.Ids.Count > HöchstensJeZug)
|
||||
return Results.Problem(
|
||||
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||
+ "Nimm weniger und wiederhole es.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var unbekannt = new List<Guid>();
|
||||
var geaendert = 0;
|
||||
|
||||
// **Nacheinander, nicht in einem Rutsch.** Eine Serie zieht ihre
|
||||
// Folgen mit, und dafür gibt es keine Mengenoperation, die das
|
||||
// ebenso richtig täte. Gespeichert wird einmal am Ende.
|
||||
foreach (var id in anfrage.Ids.Distinct())
|
||||
{
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) { unbekannt.Add(id); continue; }
|
||||
|
||||
if (zustand.Watched == anfrage.Watched && zustand.Position == TimeSpan.Zero) continue;
|
||||
|
||||
if (anfrage.Watched && !zustand.Watched)
|
||||
{
|
||||
zustand.PlayCount++;
|
||||
zustand.LastPlayedAt = jetzt;
|
||||
}
|
||||
zustand.Watched = anfrage.Watched;
|
||||
zustand.Position = TimeSpan.Zero;
|
||||
zustand.Dismissed = false;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
|
||||
geaendert++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new BulkWatchedResponse(geaendert, unbekannt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
||||
titel.MapPost("/dismiss", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
@@ -455,14 +518,23 @@ public static class PlaybackEndpoints
|
||||
/// <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.
|
||||
/// <para>
|
||||
/// <b>Erst Film, dann Folge, dann Serie.</b> Drei Abfragen im schlechtesten
|
||||
/// Fall. Eine gemeinsame Tabelle für alles würde zwei sparen und dafür an
|
||||
/// jeder anderen Stelle im Modell kosten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Dass die Serie fehlte, war ein stiller Fehler.</b> Jede Aktion auf
|
||||
/// einer Serienkachel — als gesehen markieren, zu den Favoriten — bekam
|
||||
/// eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
|
||||
/// sichtbar gar nichts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
|
||||
{
|
||||
var vorhanden = await db.UserItemStates
|
||||
.FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel));
|
||||
.FirstOrDefaultAsync(z => z.UserId == benutzer
|
||||
&& (z.MovieId == titel || z.EpisodeId == titel || z.SeriesId == titel));
|
||||
if (vorhanden is not null) return vorhanden;
|
||||
|
||||
if (await db.Movies.AnyAsync(m => m.Id == titel))
|
||||
@@ -479,9 +551,67 @@ public static class PlaybackEndpoints
|
||||
return neu;
|
||||
}
|
||||
|
||||
if (await db.Series.AnyAsync(s => s.Id == titel))
|
||||
{
|
||||
var neu = new UserItemState { UserId = benutzer, SeriesId = titel };
|
||||
db.UserItemStates.Add(neu);
|
||||
return neu;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>„Serie gesehen" heisst: jede Folge gesehen.</b> Nur die Zeile der
|
||||
/// Serie zu setzen liesse die Folgenliste unverändert und den Zähler
|
||||
/// „noch 42 offen" daneben stehen — zwei Angaben, die sich widersprechen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Stelle bleibt, wo sie ist.</b> Wer eine Serie von Hand abhakt,
|
||||
/// will sie aus dem Weg haben; eine halb gesehene Folge soll dadurch nicht
|
||||
/// ihren Fortschritt verlieren, falls er doch zurückkommt. Nur
|
||||
/// „Weiterschauen" räumt sich auf, weil ein Abgehaktes dort nichts zu
|
||||
/// suchen hat.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task FolgenSetzen(
|
||||
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock)
|
||||
{
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == serie)
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
if (folgen.Count == 0) return;
|
||||
|
||||
var staende = await db.UserItemStates
|
||||
.Where(z => z.UserId == benutzer && z.EpisodeId != null && folgen.Contains(z.EpisodeId.Value))
|
||||
.ToListAsync();
|
||||
var bekannt = staende.ToDictionary(z => z.EpisodeId!.Value);
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
foreach (var folge in folgen)
|
||||
{
|
||||
if (!bekannt.TryGetValue(folge, out var zustand))
|
||||
{
|
||||
// Ungesehen ist der Normalfall — dafür braucht es keine Zeile.
|
||||
if (!gesehen) continue;
|
||||
zustand = new UserItemState { UserId = benutzer, EpisodeId = folge };
|
||||
db.UserItemStates.Add(zustand);
|
||||
}
|
||||
|
||||
if (gesehen && !zustand.Watched) zustand.PlayCount++;
|
||||
zustand.Watched = gesehen;
|
||||
zustand.Position = TimeSpan.Zero;
|
||||
zustand.Dismissed = false;
|
||||
if (gesehen) zustand.LastPlayedAt = jetzt;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -489,10 +619,16 @@ public static class PlaybackEndpoints
|
||||
/// 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()
|
||||
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand)
|
||||
{
|
||||
// Eine Serie hat keine eigene Datei. Ohne diese Zeile verglich die
|
||||
// Abfrage `EpisodeId == null` und nahm die längste herrenlose Datei.
|
||||
if (zustand.MovieId is null && zustand.EpisodeId is null) return null;
|
||||
|
||||
return await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
|
||||
.MaxAsync(d => (TimeSpan?)d.Duration);
|
||||
}
|
||||
|
||||
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
||||
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
||||
|
||||
Reference in New Issue
Block a user