diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index deb232c..80a8cf5 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -319,7 +319,18 @@ public sealed record SeriesResponse( /// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur /// über die Prüfliste. /// - DateTimeOffset? PublishAt = null); + DateTimeOffset? PublishAt = null, + + /// Favorit, Bewertung, Häkchen — zur Serie als Ganzem. + /// + /// + /// Neben , nicht an dessen Stelle. 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. + /// + 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); + +/// Mehrere Titel auf einmal abhaken — oder das Häkchen wegnehmen. +/// +/// +/// Eine Anfrage statt zweihundert. „Alle als gesehen markieren" über +/// eine Mediathek hinweg wäre sonst ein Anfragensturm, bei dem mittendrin +/// etwas scheitert und niemand weiss, was durchging. +/// +public sealed record BulkWatchedRequest(IReadOnlyList Ids, bool Watched); + +/// Wie viele Titel sich dadurch geändert haben. +/// +/// Kennungen, zu denen es nichts gibt — stillschweigend zu übergehen hiesse, +/// einen Teilerfolg als vollen auszugeben. +/// +public sealed record BulkWatchedResponse(int Changed, IReadOnlyList Unknown); + +/// Welche Titel neu geholt werden sollen — leer heisst: alle. +public sealed record RefreshRequest(IReadOnlyList? Ids); public sealed record RatingRequest(int? Rating); /// Was ein Benutzer mit einem Titel zu tun hatte. diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 686e161..d9b7aca 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -18,6 +18,34 @@ public static class LibraryContentEndpoints /// private const int HöchstensJeSeite = 200; + /// Wie viele Titel eine Auswahl höchstens umfasst. + private const int HöchstensJeZug = 1000; + + /// Welche Titel im Rumpf stehen — oder null, wenn keiner da ist. + /// + /// + /// Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung. + /// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der + /// Knopf dafür gibt es länger als die Auswahl. + /// + private static async Task?> Auswahl(HttpRequest anfrage) + { + if (anfrage.ContentLength is null or 0) return null; + if (!anfrage.HasJsonContentType()) return null; + + try + { + var gelesen = await anfrage.ReadFromJsonAsync(); + 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; + } + } + /// Wonach eine Liste geordnet wird. /// /// @@ -104,22 +132,25 @@ public static class LibraryContentEndpoints /// noch niemand angefasst hat, und die Antwort trägt dort null. /// private static async Task> ZuständeZu( - FlaroContext db, ClaimsPrincipal wer, List? filme = null, List? folgen = null) + FlaroContext db, ClaimsPrincipal wer, + List? filme = null, List? folgen = null, List? 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? 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(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 diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 700efca..702f6d6 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -22,6 +22,15 @@ public static class PlaybackEndpoints { private const int HöchstensJeSeite = 200; + /// Wie viele Titel eine Sammelanfrage höchstens trägt. + /// + /// + /// Grosszügig, aber nicht offen. 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. + /// + 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(); + 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 /// Holt den Stand oder legt ihn an — null, wenn es den Titel nicht gibt. /// /// - /// Erst Film, dann Folge. Zwei Abfragen im schlechteren Fall. Eine - /// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder - /// anderen Stelle im Modell kosten. + /// + /// Erst Film, dann Folge, dann Serie. 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. + /// + /// + /// Dass die Serie fehlte, war ein stiller Fehler. 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. + /// /// private static async Task Zustand(FlaroContext db, Guid benutzer, Guid titel) { var vorhanden = await db.UserItemStates - .FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel)); + .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; } + /// Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg. + /// + /// + /// + /// „Serie gesehen" heisst: jede Folge gesehen. 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. + /// + /// + /// Die Stelle bleibt, wo sie ist. 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. + /// + /// + 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); + } + } + /// Die Laufzeit aus der längsten Datei hinter dem Titel. /// /// @@ -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. /// - private static async Task Dateilaufzeit(FlaroContext db, UserItemState zustand) => - await db.MediaFiles.AsNoTracking() + private static async Task 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, diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index 7b5a911..f564284 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -63,7 +63,13 @@ public sealed class ScanCoordinator( /// Dateisystem nichts geändert, und 5884 Dateien noch einmal abzulaufen /// kostet Minuten für nichts. /// - public bool TryStart(Guid libraryId, bool erneut = false, string? nurSprache = null) + /// + /// Nur diese Titel auffrischen — für „Metadaten neu holen" an einer + /// Auswahl in der Mediathek. null heisst: der ganze Bestand. + /// + public bool TryStart( + Guid libraryId, bool erneut = false, string? nurSprache = null, + IReadOnlySet? nurDiese = null) { var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null); @@ -75,11 +81,12 @@ public sealed class ScanCoordinator( // Lief schon einer, steht hier dessen Zustand und nicht unserer. if (!ReferenceEquals(vorher, start)) return false; - _ = Task.Run(() => Laufen(libraryId, erneut, nurSprache)); + _ = Task.Run(() => Laufen(libraryId, erneut, nurSprache, nurDiese)); return true; } - private async Task Laufen(Guid libraryId, bool erneut, string? nurSprache = null) + private async Task Laufen( + Guid libraryId, bool erneut, string? nurSprache = null, IReadOnlySet? nurDiese = null) { try { @@ -107,9 +114,10 @@ public sealed class ScanCoordinator( // **Nach einer Sprachumstellung wird nicht gescannt.** Am // Dateisystem hat sich nichts geändert; 5884 Dateien noch einmal - // abzulaufen kostete Minuten für nichts. + // abzulaufen kostete Minuten für nichts. Dasselbe gilt, wenn + // jemand eine Handvoll Titel ausgewählt hat. ScanReport? bericht = null; - if (nurSprache is null) + if (nurSprache is null && nurDiese is null) { var scanner = bereich.ServiceProvider.GetRequiredService(); bericht = await scanner.ScanAsync(libraryId, melder); @@ -122,15 +130,20 @@ public sealed class ScanCoordinator( var metadaten = await bereich.ServiceProvider .GetRequiredService() - .EnrichAsync(libraryId, erneut, melder, nurSprache: nurSprache); + .EnrichAsync(libraryId, erneut, melder, nurSprache: nurSprache, nurDiese: nurDiese); // **Erst jetzt lassen sich Dubletten überhaupt erkennen.** // Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich" // und der andere „Despicable Me"; dass es derselbe Film ist, // verrät allein die TMDB-Kennung, und die steht erst nach den // Metadaten fest. - var zusammengelegt = await bereich.ServiceProvider - .GetRequiredService().AlleAsync(libraryId); + // + // **Nicht bei einer Auswahl.** Wer zehn Titel auffrischt, erwartet + // nicht, dass nebenbei anderswo zwei Einträge verschwinden — und + // genau das sähe aus wie ein verlorener Film. + var zusammengelegt = nurDiese is null + ? await bereich.ServiceProvider.GetRequiredService().AlleAsync(libraryId) + : Zusammenlegungsbericht.Nichts; if (zusammengelegt.Gesamt > 0) { diff --git a/server/src/Flaro.Core/Playback/UserItemState.cs b/server/src/Flaro.Core/Playback/UserItemState.cs index 3db2cfe..36e90aa 100644 --- a/server/src/Flaro.Core/Playback/UserItemState.cs +++ b/server/src/Flaro.Core/Playback/UserItemState.cs @@ -23,6 +23,25 @@ public sealed class UserItemState public Guid? MovieId { get; init; } public Guid? EpisodeId { get; init; } + /// Die Serie als Ganzes — für Favorit, Bewertung und das Häkchen. + /// + /// + /// + /// Eine Serie wird nicht angeschaut, ihre Folgen werden es — das + /// bleibt richtig, und deshalb steht hier kein Fortschritt. Was hier steht, + /// ist, was sich an einer Folge nicht festmachen lässt: ob die Serie ein + /// Favorit ist, wie der Benutzer sie bewertet, und ob er sie für erledigt + /// erklärt hat. + /// + /// + /// Warum überhaupt eine Zeile und nicht nur gezählte Folgen. Ohne + /// sie lief jede Aktion auf einer Serienkachel — Häkchen, Favorit — in eine + /// 404: der Server kannte zu dieser Kennung keinen Zustand. Sichtbar war + /// davon nichts; der Klick tat einfach nichts. + /// + /// + public Guid? SeriesId { get; init; } + /// Wo der Benutzer steht. /// /// diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 63b03ce..198d3df 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -329,6 +329,8 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon .HasFilter("\"MovieId\" IS NOT NULL"); e.HasIndex(x => new { x.UserId, x.EpisodeId }).IsUnique() .HasFilter("\"EpisodeId\" IS NOT NULL"); + e.HasIndex(x => new { x.UserId, x.SeriesId }).IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); // Der Weg von „Weiterschauen": die offenen Zeilen eines // Benutzers, neueste zuerst. Ohne Index ein vollständiger @@ -341,6 +343,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon // gibt, ist keine Erinnerung, sondern Datenmüll. e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); }); b.Entity(e => diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 0a6ec1f..bf59970 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -49,10 +49,16 @@ public sealed class MetadataEnricher( /// aufzufrischen kostet zwölf Anfragen, der ganze Bestand viertausend. /// null heisst: keine Einschränkung. /// + /// + /// Nur diese Filme und Serien anfassen. Für „Metadaten neu holen" + /// an einer Auswahl in der Mediathek: zehn ausgewählte Titel kosten zehn + /// Anfragen, der ganze Bestand viertausend. Folgen gehören zu ihrer Serie + /// — ist die ausgewählt, kommen sie mit. null heisst: alles. + /// public async Task EnrichAsync( Guid libraryId, bool erneut = false, IProgress? fortschritt = null, CancellationToken ct = default, - string? nurSprache = null) + string? nurSprache = null, IReadOnlySet? nurDiese = null) { if (!await anbieter.IsConfiguredAsync(ct)) { @@ -63,9 +69,9 @@ public sealed class MetadataEnricher( int filme = 0, serien = 0, folgen = 0; try { - filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache); - serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache); - folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache); + filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese); + serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese); + folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache, nurDiese); } catch (MetadataCredentialsException fehler) { @@ -82,7 +88,7 @@ public sealed class MetadataEnricher( private async Task FilmeZuordnen( Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, - string? nurSprache = null) + string? nurSprache = null, IReadOnlySet? nurDiese = null) { var offen = await db.Movies .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null) @@ -93,6 +99,7 @@ public sealed class MetadataEnricher( // Suchen erneut zu starten. && (nurSprache == null || (m.TmdbId != null && m.MetadataLanguage != nurSprache))) + .Where(m => nurDiese == null || nurDiese.Contains(m.Id)) .ToListAsync(ct); var zugeordnet = 0; var durch = 0; @@ -228,12 +235,13 @@ public sealed class MetadataEnricher( private async Task SerienZuordnen( Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, - string? nurSprache = null) + string? nurSprache = null, IReadOnlySet? nurDiese = null) { var offen = await db.Series .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null) && (nurSprache == null || (s.TmdbId != null && s.MetadataLanguage != nurSprache))) + .Where(s => nurDiese == null || nurDiese.Contains(s.Id)) .ToListAsync(ct); var zugeordnet = 0; var durch = 0; @@ -363,13 +371,18 @@ public sealed class MetadataEnricher( /// Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind. private async Task FolgenZuordnen( Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, - string? nurSprache = null) + string? nurSprache = null, IReadOnlySet? nurDiese = null) { // **Mit Titel, nicht nur mit Kennung.** In der Fortschrittszeile stand // „Hole Metadaten zu Folge 2×13" — und bei 3815 Folgen aus achtzig // Serien sagt eine Folgennummer ohne Serie überhaupt nichts. + // + // **Eine Auswahl greift über die Serie, nicht über die Folge.** In der + // Mediathek wählt man Serienkacheln aus; dass damit deren Folgen + // gemeint sind, ist der ganze Sinn der Sache. var serien = await db.Series .Where(s => s.LibraryId == libraryId && s.TmdbId != null) + .Where(s => nurDiese == null || nurDiese.Contains(s.Id)) .Select(s => new { s.Id, TmdbId = s.TmdbId!.Value, s.Title }) .ToListAsync(ct); if (serien.Count == 0) return 0; diff --git a/server/src/Flaro.Data/Migrations/20261005160301_Serienzustand.Designer.cs b/server/src/Flaro.Data/Migrations/20261005160301_Serienzustand.Designer.cs new file mode 100644 index 0000000..447150a --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261005160301_Serienzustand.Designer.cs @@ -0,0 +1,921 @@ +// +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("20261005160301_Serienzustand")] + partial class Serienzustand + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + 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.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261005160301_Serienzustand.cs b/server/src/Flaro.Data/Migrations/20261005160301_Serienzustand.cs new file mode 100644 index 0000000..1f3eab4 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261005160301_Serienzustand.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Serienzustand : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SeriesId", + table: "UserItemStates", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_SeriesId", + table: "UserItemStates", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_UserItemStates_UserId_SeriesId", + table: "UserItemStates", + columns: new[] { "UserId", "SeriesId" }, + unique: true, + filter: "\"SeriesId\" IS NOT NULL"); + + migrationBuilder.AddForeignKey( + name: "FK_UserItemStates_Series_SeriesId", + table: "UserItemStates", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_UserItemStates_Series_SeriesId", + table: "UserItemStates"); + + migrationBuilder.DropIndex( + name: "IX_UserItemStates_SeriesId", + table: "UserItemStates"); + + migrationBuilder.DropIndex( + name: "IX_UserItemStates_UserId_SeriesId", + table: "UserItemStates"); + + migrationBuilder.DropColumn( + name: "SeriesId", + table: "UserItemStates"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 2ac6b69..4ec2658 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -709,6 +709,9 @@ namespace Flaro.Data.Migrations b.Property("Runtime") .HasColumnType("INTEGER"); + b.Property("SeriesId") + .HasColumnType("TEXT"); + b.Property("UpdatedAt") .HasColumnType("INTEGER"); @@ -724,6 +727,8 @@ namespace Flaro.Data.Migrations b.HasIndex("MovieId"); + b.HasIndex("SeriesId"); + b.HasIndex("UserId", "EpisodeId") .IsUnique() .HasFilter("\"EpisodeId\" IS NOT NULL"); @@ -732,6 +737,10 @@ namespace Flaro.Data.Migrations .IsUnique() .HasFilter("\"MovieId\" IS NOT NULL"); + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + b.HasIndex("UserId", "InProgress", "UpdatedAt"); b.ToTable("UserItemStates"); @@ -892,6 +901,11 @@ namespace Flaro.Data.Migrations .HasForeignKey("MovieId") .OnDelete(DeleteBehavior.Cascade); + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + b.HasOne("Flaro.Core.Users.User", null) .WithMany() .HasForeignKey("UserId") diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index c5c62bb..df71937 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -316,6 +316,67 @@ public class MetadataTests Assert.That(nachher!.Items[0].Title, Is.EqualTo("Von Hand")); } + /// Auffrischen nimmt eine Auswahl entgegen — und geht auch ohne. + /// + /// + /// Gemessen, nachdem es einmal brach. Als die Auswahl als + /// RefreshRequest? im Kopf der Methode stand, lehnte der Server + /// jede Anfrage ohne Rumpf mit 404 ab — also den Knopf „Metadaten neu + /// holen" für die ganze Mediathek, den es länger gibt als die Auswahl. + /// Beide Formen gehören deshalb in eine Prüfung. + /// + [Test] + public async Task Auffrischen_nimmt_eine_Auswahl_entgegen_und_geht_auch_ohne() + { + Datei("101 Dalmatians (1996)/101 Dalmatians (1996).mkv"); + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(8741, "101 Dalmatiner", 1996, "Deutsch.", null, null) + { + Language = "de-DE", + }, + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var kennung = filme!.Items[0].Id; + + // Ohne Rumpf: der alte Weg, und er muss weiter gehen. + var ohne = await client.PostAsync($"/api/v1/libraries/{bibliothek}/refresh", null); + Assert.That(ohne.StatusCode, Is.EqualTo(HttpStatusCode.Accepted), + "ein fehlender Rumpf heisst alles, nicht nicht-gefunden"); + await Fertig(client, bibliothek); + + // Mit Auswahl: nur dieser eine Titel. + var mit = await client.PostAsJsonAsync( + $"/api/v1/libraries/{bibliothek}/refresh", new RefreshRequest([kennung])); + Assert.That(mit.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + + var stand = await Fertig(client, bibliothek); + Assert.Multiple(() => + { + Assert.That(stand.Metadata?.MoviesMatched, Is.EqualTo(1)); + Assert.That(stand.FilesFound, Is.Null, + "bei einer Auswahl wird nicht noch einmal das Dateisystem abgelaufen"); + }); + } + + private static async Task Fertig(HttpClient client, Guid bibliothek) + { + for (var i = 0; i < 200; i++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State == "finished") return stand; + if (stand.State == "failed") Assert.Fail($"Gescheitert: {stand.Error}"); + await Task.Delay(50); + } + Assert.Fail("Nicht fertig geworden."); + return null!; + } + /// Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern. /// /// diff --git a/server/tests/Flaro.Api.Tests/PlaybackTests.cs b/server/tests/Flaro.Api.Tests/PlaybackTests.cs index 89767aa..ec77a6f 100644 --- a/server/tests/Flaro.Api.Tests/PlaybackTests.cs +++ b/server/tests/Flaro.Api.Tests/PlaybackTests.cs @@ -350,6 +350,212 @@ public class PlaybackTests Assert.That(danach!.UnwatchedCount, Is.EqualTo(2)); } + /// Eine Serie als gesehen markieren hakt jede Folge ab. + /// + /// + /// Der Fall, der vorher stumm scheiterte. Der Server kannte zu + /// einer Serienkennung keinen Zustand und antwortete mit 404; die + /// Oberfläche verschluckte die Ablehnung, und der Klick tat sichtbar + /// nichts. + /// + [Test] + public async Task Eine_Serie_als_gesehen_markieren_hakt_alle_Folgen_ab() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + Datei("Dark/Season 02/Dark - S02E01 - Beginnings.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + + var antwort = await client.PutAsJsonAsync( + $"/api/v1/items/{serie.Id}/watched", new WatchedRequest(true)); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK), + "eine Serienkennung ist eine gültige Titelkennung"); + + var danach = await client.GetFromJsonAsync($"/api/v1/series/{serie.Id}"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + + Assert.Multiple(() => + { + Assert.That(danach!.UnwatchedCount, Is.Zero, "keine Folge steht mehr aus"); + Assert.That(danach.State?.Watched, Is.True, "und die Serie selbst trägt das Häkchen"); + Assert.That(folgen!.Items.Select(f => f.State?.Watched), Is.All.True, + "jede einzelne Folge ist abgehakt, nicht nur die Serie"); + }); + } + + /// Und wieder zurück — ohne Rest. + [Test] + public async Task Eine_Serie_als_ungesehen_markieren_nimmt_jedes_Haekchen_weg() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + + await client.PutAsJsonAsync($"/api/v1/items/{serie.Id}/watched", new WatchedRequest(true)); + await client.PutAsJsonAsync($"/api/v1/items/{serie.Id}/watched", new WatchedRequest(false)); + + var danach = await client.GetFromJsonAsync($"/api/v1/series/{serie.Id}"); + var folgen = await client.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + + Assert.Multiple(() => + { + Assert.That(danach!.UnwatchedCount, Is.EqualTo(2)); + Assert.That(danach.State?.Watched, Is.False); + Assert.That(folgen!.Items.Select(f => f.State?.Watched), Is.All.False); + }); + } + + /// Favorit an einer Serie — vorher ebenfalls eine stille 404. + [Test] + public async Task Eine_Serie_laesst_sich_zu_den_Favoriten_legen() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + + var antwort = await client.PutAsJsonAsync( + $"/api/v1/items/{serie.Id}/favourite", new FavouriteRequest(true)); + antwort.EnsureSuccessStatusCode(); + + var liste = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(liste!.Items[0].State?.Favourite, Is.True, + "auch die Liste führt den Zustand mit — sonst wüsste die Kachel es nicht"); + } + + /// Der Stand einer Serie gehört dem Benutzer, nicht dem Server. + [Test] + public async Task Der_Serienstand_gehoert_dem_Benutzer() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + + using var server = new FlaroServer(); + var ich = await Eingelesen(server, "Shows"); + + var serien = await ich.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + await ich.PutAsJsonAsync($"/api/v1/items/{serie.Id}/watched", new WatchedRequest(true)); + + (await ich.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("mitbewohner", "ein-langes-passwort", false))).EnsureSuccessStatusCode(); + var andere = server.AlsAngemeldeter( + (await server.SignIn("mitbewohner", "ein-langes-passwort", "iPad", "geraet-2")).Token); + + var seine = await andere.GetFromJsonAsync($"/api/v1/series/{serie.Id}"); + + Assert.Multiple(() => + { + Assert.That(seine!.State?.Watched, Is.Not.True); + Assert.That(seine.UnwatchedCount, Is.EqualTo(1)); + }); + } + + /// Viele auf einmal — eine Anfrage, nicht zweihundert. + [Test] + public async Task Eine_Sammelmarkierung_hakt_alles_Gewaehlte_ab() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + Datei("Alien (1979)/Alien (1979).mkv"); + Datei("Heat (1995)/Heat (1995).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server); + + var seite = await client.GetFromJsonAsync>("/api/v1/movies"); + var zwei = seite!.Items.Take(2).Select(m => m.Id).ToList(); + + var antwort = await client.PutAsJsonAsync("/api/v1/items/watched", + new BulkWatchedRequest(zwei, true)); + var ergebnis = await antwort.Content.ReadFromJsonAsync(); + + var danach = await client.GetFromJsonAsync>("/api/v1/movies"); + + Assert.Multiple(() => + { + Assert.That(ergebnis!.Changed, Is.EqualTo(2)); + Assert.That(ergebnis.Unknown, Is.Empty); + Assert.That(danach!.Items.Count(m => m.State?.Watched == true), Is.EqualTo(2), + "genau die zwei gewählten, nicht der dritte"); + }); + } + + /// Was es nicht gibt, wird benannt statt verschwiegen. + [Test] + public async Task Eine_Sammelmarkierung_nennt_was_sie_nicht_gefunden_hat() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server); + var film = await ErsterFilm(client); + var erfunden = Guid.CreateVersion7(); + + var antwort = await client.PutAsJsonAsync("/api/v1/items/watched", + new BulkWatchedRequest([film, erfunden], true)); + var ergebnis = await antwort.Content.ReadFromJsonAsync(); + + Assert.Multiple(() => + { + Assert.That(ergebnis!.Changed, Is.EqualTo(1)); + Assert.That(ergebnis.Unknown, Is.EqualTo(new[] { erfunden })); + }); + } + + /// Auch Serien gehören in eine Sammelmarkierung. + [Test] + public async Task Eine_Sammelmarkierung_zieht_die_Folgen_einer_Serie_mit() + { + Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server, "Shows"); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items[0]; + + await client.PutAsJsonAsync("/api/v1/items/watched", + new BulkWatchedRequest([serie.Id], true)); + + var danach = await client.GetFromJsonAsync($"/api/v1/series/{serie.Id}"); + Assert.That(danach!.UnwatchedCount, Is.Zero); + } + + [Test] + public async Task Eine_leere_Sammelmarkierung_aendert_nichts_und_scheitert_nicht() + { + Datei("Matrix (1999)/Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var client = await Eingelesen(server); + + var antwort = await client.PutAsJsonAsync("/api/v1/items/watched", + new BulkWatchedRequest([], true)); + var ergebnis = await antwort.Content.ReadFromJsonAsync(); + + Assert.Multiple(() => + { + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(ergebnis!.Changed, Is.Zero); + }); + } + [Test] public async Task Die_naechste_Folge_richtet_sich_nach_Staffel_und_Nummer() { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 992e7f3..c7eeb43 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -56,6 +56,8 @@ export type Serie = { id: string; title: string; year: number | null; overview: string | null; posterImage: string | null; backdropImage: string | null; episodeCount: number; seasons: number[]; unwatchedCount: number; + /** Favorit, Bewertung, Häkchen — zur Serie als Ganzem, neben `unwatchedCount`. */ + state: Stand | null; logoImage: string | null; runtimeMinutes: number | null; rating: number | null; @@ -468,6 +470,19 @@ export const gesehen = (id: string, wert: boolean) => method: 'PUT', body: JSON.stringify({ watched: wert }), }); +/** Was aus einer Sammelmarkierung geworden ist. */ +export type Sammelergebnis = { changed: number; unknown: string[] }; + +/** Viele auf einmal abhaken. + * + * **Eine Anfrage, nicht zweihundert.** Bei einer Mediathek mit 455 Titeln + * wären Einzelanfragen ein Sturm, bei dem mittendrin etwas scheitert und + * niemand weiss, was durchging. */ +export const gesehenSammel = (ids: string[], wert: boolean) => + anfrage('/items/watched', { + method: 'PUT', body: JSON.stringify({ ids, watched: wert }), + }); + /** Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen. */ export const weglegen = (id: string) => anfrage(`/items/${id}/dismiss`, { method: 'POST' }); @@ -490,8 +505,12 @@ export const genresVon = (id: string) => anfrage(`/libraries/${id}/gen export const empfehlungen = (take = 20) => anfrage>(`/home/suggestions${abfrage({ take })}`); -export const metadatenAuffrischen = (id: string) => - anfrage(`/libraries/${id}/refresh`, { method: 'POST' }); +/** Metadaten neu holen — für die ganze Mediathek oder nur für eine Auswahl. */ +export const metadatenAuffrischen = (id: string, ids?: string[]) => + anfrage(`/libraries/${id}/refresh`, { + method: 'POST', + body: ids && ids.length > 0 ? JSON.stringify({ ids }) : undefined, + }); /** Eine laufende Wiedergabe — nur für Administratoren sichtbar. */ export type Wiedergabe = { diff --git a/web/app/src/bilder.ts b/web/app/src/bilder.ts index 7d6f49b..9cc4c40 100644 --- a/web/app/src/bilder.ts +++ b/web/app/src/bilder.ts @@ -18,7 +18,21 @@ const ARTEN: { art: Bildart; titel: string; verhaeltnis: string }[] = [ * **Die Kandidaten kosten nichts.** TMDB liefert sie beim Einzelabruf ohnehin * mit; bisher wurde alles ausser dem Schriftzug weggeworfen. */ -export function bilddialog(art: 'movies' | 'series', id: string, titel: string): void { +/** Bilder eines Titels austauschen. + * + * **`fertig` wird gerufen, wenn sich wirklich etwas geändert hat** — und erst + * beim Schliessen, nicht nach jeder einzelnen Wahl. Wer drei Bilder tauscht, + * löst nicht dreimal einen Neuaufbau der Kachel aus. Wer nur hineinschaut und + * wieder zumacht, gar keinen. + * + * **Warum es den Rückruf überhaupt gibt.** Ohne ihn zeigte die Kachel das + * alte Plakat weiter, bis man die ganze Oberfläche neu lud — und verlor dabei + * Bildlauf, Filter und offene Reiter. + */ +export function bilddialog( + art: 'movies' | 'series', id: string, titel: string, fertig?: () => void, +): void { + let geaendert = false; const d = document.createElement('dialog'); d.className = 'dlg dlg-breit'; @@ -38,7 +52,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): koerper.innerHTML = '
'; d.append(kopf, koerper); - d.addEventListener('close', () => d.remove()); + d.addEventListener('close', () => { d.remove(); if (geaendert) fertig?.(); }); d.addEventListener('click', e => { if (e.target === d) d.close(); }); document.body.appendChild(d); d.showModal(); @@ -189,7 +203,11 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string): b.onclick = async () => { b.disabled = true; - try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); } + try { + await bildSetzen(art, id, bildart, kandidat!.url); + geaendert = true; + await laden(); + } catch (fehler) { b.after(fehlerzeile(meldung(fehler))); b.disabled = false; diff --git a/web/app/src/card.ts b/web/app/src/card.ts index f97ae14..533679b 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -23,6 +23,8 @@ export type Kachel = { stand?: Stand | null; /** Die Zahl offener Folgen, als Plakette oben rechts. */ offen?: number | null; + /** Wie viele Folgen es überhaupt sind — um die Plakette wiederherzustellen. */ + gesamt?: number | null; /** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */ freigabe?: string | null; }; @@ -35,8 +37,9 @@ export const alsKachel = (e: Film | Serie): Kachel => ({ titel: e.title, zweite: e.year ? String(e.year) : null, bild: e.posterImage, - stand: 'state' in e ? e.state : null, + stand: e.state, offen: 'unwatchedCount' in e ? e.unwatchedCount : null, + gesamt: 'episodeCount' in e ? e.episodeCount : null, freigabe: e.publishAt, }); @@ -67,6 +70,11 @@ export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episod export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) => void): HTMLElement { const knopf = document.createElement('button'); knopf.className = 'kachel'; + // **Die Kennung steht am Knoten.** So kann ein Auswahlmodus über dem Raster + // arbeiten, ohne dass jede der fünf Stellen, die Kacheln baut, davon wissen + // muss — sie liest sie vom angeklickten Element ab. + knopf.dataset.id = k.id; + knopf.dataset.art = k.art; knopf.onclick = () => oeffnen(k.art, k.id); // **Das Kontextmenü an jeder Kachel, überall.** Gesehen, Favorit und @@ -248,11 +256,14 @@ function kachelpunkte( text: istGesehen ? 'Als ungesehen markieren' : 'Als gesehen markieren', icon: 'hakenKreis', tun: async () => { - await gesehen(k.id, !istGesehen); + // **Die Antwort des Servers gilt, nicht die Annahme hier.** Bei einer + // Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt, + // weiss nur der Server. + k.stand = await gesehen(k.id, !istGesehen); // **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu // laden verlöre Bildlauf und Filter — für einen Haken. - if (k.stand) k.stand.watched = !istGesehen; - hakenZeigen(knopf, !istGesehen); + k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0; + hakenZeigen(knopf, k, !istGesehen); }, }, { @@ -260,8 +271,7 @@ function kachelpunkte( icon: 'stern', tun: async () => { const neuerWert = !k.stand?.favourite; - await favorit(k.id, neuerWert); - if (k.stand) k.stand.favourite = neuerWert; + k.stand = await favorit(k.id, neuerWert); }, }, ]; @@ -286,7 +296,9 @@ function kachelpunkte( punkte.push({ text: 'Bilder bearbeiten', icon: 'stift', - tun: () => bilddialog(art, k.id, k.titel), + // **Die Kachel holt sich das neue Plakat selbst.** Vorher blieb das + // alte stehen, bis man die ganze Oberfläche neu lud. + tun: () => bilddialog(art, k.id, k.titel, () => void kachelAuffrischen(k, knopf)), }); punkte.push({ text: 'Titel ändern', @@ -354,18 +366,32 @@ async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise { } } -/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */ -function hakenZeigen(knopf: HTMLElement, an: boolean): void { +/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. + * + * **Die Ecke zeigt eines von beidem.** Haken oder Zahl offener Folgen — beides + * nebeneinander wäre ein Widerspruch. Wird eine Serie abgehakt, geht die Zahl; + * wird das Häkchen genommen, kommt sie zurück. + */ +function hakenZeigen(knopf: HTMLElement, k: Kachel, an: boolean): void { const plakat = knopf.querySelector('.plakat'); if (!plakat) return; - plakat.querySelector('.ecke.haken')?.remove(); + plakat.querySelector('.ecke')?.remove(); knopf.querySelector('.fortschritt')?.remove(); - if (!an) return; - const ecke = document.createElement('span'); - ecke.className = 'ecke haken'; - ecke.title = 'Gesehen'; - ecke.innerHTML = icons.haken; - plakat.appendChild(ecke); + if (an) { + const ecke = document.createElement('span'); + ecke.className = 'ecke haken'; + ecke.title = 'Gesehen'; + ecke.innerHTML = icons.haken; + plakat.appendChild(ecke); + return; + } + + if (k.offen == null || k.offen <= 0) return; + const plakette = document.createElement('span'); + plakette.className = 'ecke zahl'; + plakette.textContent = String(k.offen); + plakette.title = `${k.offen} Folgen offen`; + plakat.appendChild(plakette); } diff --git a/web/app/src/menue.ts b/web/app/src/menue.ts index 1256822..9570911 100644 --- a/web/app/src/menue.ts +++ b/web/app/src/menue.ts @@ -1,4 +1,5 @@ import { icons } from './icons'; +import { hinweisZeigen, meldung } from './util'; export type Menuepunkt = { text: string; @@ -112,7 +113,16 @@ function oeffnen( const t = document.createElement('span'); t.textContent = p.text; b.appendChild(t); - b.onclick = async () => { menueSchliessen(); await p.tun(); }; + // **Ein Fehler darf nicht verschwinden.** Vorher stand hier + // `await p.tun()` ohne Auffangnetz: wirft die Aktion — und eine abgelehnte + // Anfrage tut das —, wird daraus eine unbehandelte Zusage. Die landet in + // der Konsole des Browsers und sonst nirgends. Für den Benutzer sah es + // aus, als täte der Klick einfach nichts. + b.onclick = async () => { + menueSchliessen(); + try { await p.tun(); } + catch (fehler) { hinweisZeigen(meldung(fehler), 'fehler'); } + }; menue.appendChild(b); }); diff --git a/web/app/src/style.css b/web/app/src/style.css index 4d2882b..d395ecf 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1186,3 +1186,103 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover { @media (hover: none) { .kachel-mehr { opacity: .85; } } + +/* ---------- Kurze Meldungen am unteren Rand ---------- + * + * **Für alles, was keinen Ort auf der Seite hat.** Eine Aktion aus einem + * Kontextmenü wirkt, nachdem das Menü schon zu ist — scheitert sie, gäbe es + * sonst nirgends Platz dafür. Vorher verschwand so ein Fehler spurlos. + * + * Über allem ausser den Dialogen (`z-index: 200`): eine Meldung, die hinter + * einem Dialog aufgeht, hat niemand gesehen. Dialoge liegen in der obersten + * Ebene des Browsers und bleiben dennoch darüber — deshalb erscheinen + * Meldungen aus einem Dialog weiterhin in seiner eigenen Fehlerzeile. */ +.hinweise { + position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); + z-index: 200; display: flex; flex-direction: column; gap: 8px; + align-items: center; pointer-events: none; max-width: min(520px, calc(100vw - 32px)); +} +.hinweiszeile { + background: var(--raised); color: var(--text); + border: 1px solid var(--line); border-radius: var(--r-control); + padding: 10px 16px; font-size: 14px; line-height: 1.4; + box-shadow: 0 8px 24px rgba(0, 0, 0, .45); + animation: hinweis-auf .18s ease-out; +} +.hinweiszeile.fehler { + background: #3A1B1E; border-color: rgba(229, 72, 77, .45); color: #FFB3B5; +} +.hinweiszeile.geht { opacity: 0; transform: translateY(6px); transition: all .2s ease-in; } +@keyframes hinweis-auf { from { opacity: 0; transform: translateY(8px); } } + +@media (prefers-reduced-motion: reduce) { + .hinweiszeile { animation: none; } + .hinweiszeile.geht { transition: none; } +} + +/* ---------- Mehrfachauswahl in der Mediathek ---------- + * + * **Die Marke sitzt oben links**, gegenüber von Haken und Folgenzahl. Beide + * in dieselbe Ecke zu legen hiesse, dass die Auswahl verdeckt, was man + * auswählt. */ +.wahlmarke { + position: absolute; top: 8px; left: 8px; z-index: 4; + width: 26px; height: 26px; border-radius: 50%; + display: grid; place-items: center; + background: rgba(10, 10, 12, .62); + border: 2px solid rgba(255, 255, 255, .72); + backdrop-filter: blur(4px); + transition: background .15s ease-out, border-color .15s ease-out; +} +.wahlmarke svg { width: 15px; height: 15px; color: #fff; opacity: 0; transition: opacity .15s; } + +.kachel.gewaehlt .wahlmarke { + background: var(--accent); border-color: var(--accent); +} +.kachel.gewaehlt .wahlmarke svg { opacity: 1; } + +/* **Im Auswahlmodus kein Abspielknopf und kein Griff.** Beide führten + woandershin, und ein Klick darauf wählt jetzt nur noch aus. */ +.raster.auswahl .schweber, +.raster.auswahl .kachel-mehr { display: none; } +.raster.auswahl .kachel { cursor: pointer; } +.raster.auswahl .kachel:hover .plakat { transform: scale(1.02); } + +/* Das Gewählte deutlich abheben — Rahmen und leichtes Abdunkeln, damit es + auch ohne Farbwahrnehmung zu erkennen ist. */ +.kachel.gewaehlt .plakat { + outline: 3px solid var(--accent); outline-offset: -3px; +} +.kachel.gewaehlt .plakat img { filter: brightness(.72); } + +/* **Die Leiste klebt unten**, über dem Inhalt, damit sie beim Scrollen durch + vierhundert Kacheln nicht verschwindet. */ +.auswahlleiste { + position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); + z-index: 90; display: flex; align-items: center; gap: var(--s-regular); + flex-wrap: wrap; justify-content: center; + max-width: min(760px, calc(100vw - 24px)); + padding: 10px 16px; + background: var(--elevated); border: 1px solid var(--line); + border-radius: var(--r-karte); + box-shadow: 0 10px 32px rgba(0, 0, 0, .55); +} +.auswahlzahl { font-size: 14px; font-weight: 600; white-space: nowrap; } + +@media (max-width: 640px) { + .auswahlleiste { left: 12px; right: 12px; transform: none; max-width: none; } + .auswahlleiste .knopfreihe { width: 100%; } + .auswahlleiste .btn { flex: 1 1 auto; justify-content: center; } +} + +@media (prefers-reduced-motion: reduce) { + .wahlmarke, .wahlmarke svg { transition: none; } +} + +/* **Die Meldung weicht der Auswahlleiste aus.** Beide wollen den unteren + Rand; die Meldung lag zwar davor, verdeckte aber genau die Knöpfe, die man + als Nächstes drückt. */ +body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; } +@media (max-width: 640px) { + body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 148px; } +} diff --git a/web/app/src/util.ts b/web/app/src/util.ts index cdcc238..531e607 100644 --- a/web/app/src/util.ts +++ b/web/app/src/util.ts @@ -110,3 +110,40 @@ export function meldung(fehler: unknown): string { return text; } + +/** Eine kurze Meldung am unteren Rand, die von selbst wieder geht. + * + * **Warum es das braucht.** Eine Aktion aus einem Kontextmenü hat keinen Ort, + * an dem ein Fehler stehen könnte: das Menü ist in dem Augenblick schon zu. + * Vorher verschwand eine abgelehnte Anfrage deshalb spurlos — der Klick tat + * sichtbar nichts, und nichts sagte warum. + * + * **Sie nimmt den Fokus nicht.** `aria-live="polite"` liest sie vor, sobald + * der Benutzer eine Pause macht; ihn mitten im Tun zu unterbrechen wäre für + * eine Randnotiz zu viel. + */ +export function hinweisZeigen(text: string, art: 'gut' | 'fehler' = 'gut'): void { + let bereich = document.querySelector('.hinweise'); + if (!bereich) { + bereich = document.createElement('div'); + bereich.className = 'hinweise'; + bereich.setAttribute('aria-live', 'polite'); + document.body.appendChild(bereich); + } + + const zeile = document.createElement('div'); + zeile.className = `hinweiszeile ${art}`; + zeile.textContent = text; + bereich.appendChild(zeile); + + // Vier Sekunden: lang genug zum Lesen, kurz genug, um nicht im Weg zu + // stehen. Das Wegblenden darf nicht springen, deshalb erst die Klasse, + // dann das Entfernen nach dem Übergang. + window.setTimeout(() => { + zeile.classList.add('geht'); + window.setTimeout(() => { + zeile.remove(); + if (bereich && bereich.childElementCount === 0) bereich.remove(); + }, 200); + }, 4000); +} diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index b71d69e..798fccc 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -340,7 +340,7 @@ function pruefbereich(geaendert: () => void): HTMLElement { { text: 'Titel ändern', icon: 'stift', tun: () => titeldialog(art, e.id, e.title, () => void laden()) }, { text: 'Bilder bearbeiten', icon: 'stift', - tun: () => bilddialog(art, e.id, e.title) }, + tun: () => bilddialog(art, e.id, e.title, () => void laden()) }, { text: 'Zuordnung korrigieren', icon: 'suche', // Ohne Jahr — siehe card.ts: das der falschen Zuordnung grenzt falsch ein. tun: () => zuordnungsdialog(art, e.id, e.title, null, () => void laden()) }, diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 429f046..792dd9d 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -4,9 +4,12 @@ import { ApiFehler, type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand, type Mitwirkender, aehnliche, } from '../api'; -import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung } from '../util'; +import { + esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung, hinweisZeigen, +} from '../util'; import { icons, icon } from '../icons'; import { kachel, alsKachel, reihe } from '../card'; +import { vergessen } from '../ansichten'; type Art = 'filme' | 'serien'; @@ -41,7 +44,9 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem wurzel.append( hintergrund(eintrag.backdropImage), zurueckKnopf(), - kopfbereich(eintrag, null, null), + // **Mit Zustand.** Hier stand `null`, und darum wusste die + // Knopfreihe einer Serie nie, ob sie abgehakt ist. + kopfbereich(eintrag, eintrag.state, null), await staffeln(eintrag), besetzung(eintrag.credits), zuordnungsbereich(eintrag.title, eintrag.year)); @@ -49,12 +54,45 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem } catch (fehler) { wurzel.innerHTML = ''; // Am Status, nicht am Text: der Text ist jetzt ein deutscher Satz. - wurzel.appendChild(fehler instanceof ApiFehler && fehler.status === 404 - ? leer(art === 'filme' ? 'Diesen Film gibt es nicht mehr.' : 'Diese Serie gibt es nicht mehr.') - : fehlerzeile(meldung(fehler))); + if (fehler instanceof ApiFehler && fehler.status === 404) wurzel.appendChild(verschwunden()); + else wurzel.appendChild(fehlerzeile(meldung(fehler))); } } + /** Was dasteht, wenn es den Titel nicht mehr gibt. + * + * **Vorher war das eine Sackgasse**: ein Satz, sonst nichts. Dabei ist der + * häufigste Grund kein Verlust, sondern ein Zusammenlegen — zwei Einträge + * derselben Serie wurden beim Scan zu einem, und die Liste, aus der man + * gerade kam, zeigte noch die alte Kennung. Der Weg heraus ist, die + * gemerkten Listen wegzuwerfen und zurückzugehen; dann steht der Titel + * unter seiner neuen Kennung wieder da. + */ + function verschwunden(): HTMLElement { + vergessen(); + + const kasten = document.createElement('div'); + kasten.className = 'leer'; + + const satz = document.createElement('p'); + satz.textContent = art === 'filme' + ? 'Diesen Film gibt es nicht mehr.' + : 'Diese Serie gibt es nicht mehr.'; + + const warum = document.createElement('p'); + warum.className = 'hinweis'; + warum.textContent = 'Entweder wurde der Eintrag beim letzten Scan mit einem ' + + 'anderen zusammengelegt, oder die Dateien dazu sind weg. Die Liste ' + + 'wurde aufgefrischt — unter dem neuen Eintrag ist er vermutlich da.'; + + const raus = knopf('Zurück zur Liste', 'btn btn-primary'); + raus.prepend(icon('links')); + raus.onclick = zurueck; + + kasten.append(satz, warum, raus); + return kasten; + } + function zurueckKnopf(): HTMLElement { const b = knopf('Zurück', 'btn'); b.prepend(icon('links')); @@ -216,19 +254,22 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem // **Runde Knöpfe ohne Beschriftung, wie bei Plex.** Gesehen, Favorit, // weglegen — drei Dinge, die man oft tut und nie liest. - if ('files' in eintrag) { - reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false, - stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', - async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); })); + // + // **Auch bei einer Serie.** Vorher stand hier `'files' in eintrag`, und + // weil eine Serie keine Dateien hat, fehlte auf ihrer Seite jeder dieser + // Knöpfe. Was eine Serie nicht hat, ist eine Stelle — abhaken und zu den + // Favoriten legen lässt sie sich sehr wohl. + reihe.appendChild(rundKnopf('hakenKreis', stand?.watched ?? false, + stand?.watched ? 'Als ungesehen markieren' : 'Als gesehen markieren', + async () => { await gesehen(eintrag.id, !stand?.watched); await laden(); })); - reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false, - stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten', - async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); })); + reihe.appendChild(rundKnopf('stern', stand?.favourite ?? false, + stand?.favourite ? 'Aus Favoriten nehmen' : 'Zu Favoriten', + async () => { await favorit(eintrag.id, !stand?.favourite); await laden(); })); - if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) { - reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen', - async () => { await weglegen(eintrag.id); await laden(); })); - } + if ((stand?.positionSeconds ?? 0) > 0 && !stand?.dismissed) { + reihe.appendChild(rundKnopf('mehr', false, 'Aus „Weiterschauen" nehmen', + async () => { await weglegen(eintrag.id); await laden(); })); } const huelle = document.createElement('div'); @@ -244,7 +285,14 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem b.innerHTML = icons[bild]; b.title = titel; b.setAttribute('aria-label', titel); - b.onclick = async () => { b.disabled = true; try { await tun(); } catch { b.disabled = false; } }; + // **Ein leeres `catch` war hier zu wenig.** Scheiterte die Anfrage, ging + // der Knopf wieder an und sonst geschah nichts — ununterscheidbar davon, + // dass man danebengeklickt hat. + b.onclick = async () => { + b.disabled = true; + try { await tun(); } + catch (fehler) { b.disabled = false; hinweisZeigen(meldung(fehler), 'fehler'); } + }; return b; } diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts index 12dc2c2..c0fd972 100644 --- a/web/app/src/views/library.ts +++ b/web/app/src/views/library.ts @@ -1,7 +1,10 @@ -import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api'; -import { leer, fehlerzeile, meldung } from '../util'; +import { + inhalt, genresVon, empfehlungen as holeEmpfehlungen, gesehenSammel, metadatenAuffrischen, + istAdmin, type Bibliothek, type Ordnung, type Sammelergebnis, +} from '../api'; +import { leer, fehlerzeile, meldung, knopf, hinweisZeigen } from '../util'; import { icons } from '../icons'; -import { kachel, alsKachel, reihe } from '../card'; +import { kachel, alsKachel, reihe, type Kachel } from '../card'; import { beiRueckkehr } from '../ansichten'; import { auswahl, type Auswahlfeld } from '../auswahl'; @@ -156,13 +159,191 @@ export function mediathek( }; suchhuelle.appendChild(suchfeld); + // **Mehrere auf einmal.** „Die Staffel hab ich gesehen" hiess bisher + // zwanzigmal Kontextmenü. Der Knopf steht in der Filterzeile, weil er zu + // dem gehört, was man mit der Liste tut — nicht zu einer einzelnen Kachel. + const auswahlKnopf = document.createElement('button'); + auswahlKnopf.className = 'zuruecksetzen'; + auswahlKnopf.textContent = 'Auswählen'; + auswahlKnopf.onclick = () => auswahlmodusSetzen(!auswahlmodus); + zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle, - anzahl, zuruecksetzen, suchhuelle); + anzahl, zuruecksetzen, auswahlKnopf, suchhuelle); const gitter = document.createElement('div'); gitter.className = 'raster'; ziel.append(zeile, gitter); + /* ---------- Auswahl ---------- */ + + let auswahlmodus = false; + const gewaehlt = new Set(); + /** Was hinter jeder Kachel steht — nach Kennung, für Sammelaktionen. */ + const bekannt = new Map(); + const leiste = auswahlleiste(); + ziel.appendChild(leiste.element); + + /** Hängt einer Kachel die Wahlmarke an — oder nimmt sie wieder weg. */ + function markeAn(k: HTMLElement): void { + const plakat = k.querySelector('.plakat'); + if (!plakat || plakat.querySelector('.wahlmarke')) return; + const m = document.createElement('span'); + m.className = 'wahlmarke'; + m.innerHTML = icons.haken; + plakat.appendChild(m); + } + + function auswahlmodusSetzen(an: boolean): void { + auswahlmodus = an; + gitter.classList.toggle('auswahl', an); + auswahlKnopf.textContent = an ? 'Auswahl beenden' : 'Auswählen'; + auswahlKnopf.classList.toggle('gesetzt', an); + + if (an) { + gitter.querySelectorAll('.kachel').forEach(markeAn); + } else { + gewaehlt.clear(); + gitter.querySelectorAll('.wahlmarke').forEach(m => m.remove()); + gitter.querySelectorAll('.kachel.gewaehlt').forEach(k => { + k.classList.remove('gewaehlt'); + k.removeAttribute('aria-pressed'); + }); + } + leiste.auffrischen(gewaehlt.size, an); + } + + // **Im Auswahlmodus fängt das Raster den Klick ab**, bevor er die Kachel + // erreicht. Sonst öffnete jeder Haken zugleich die Detailseite — und der + // Griff mit den drei Punkten ein Kontextmenü obendrein. + gitter.addEventListener('click', e => { + if (!auswahlmodus) return; + const k = (e.target as HTMLElement).closest('.kachel'); + if (!k || !gitter.contains(k)) return; + e.preventDefault(); + e.stopPropagation(); + + const id = k.dataset.id; + if (!id) return; + const jetztGewaehlt = !gewaehlt.has(id); + if (jetztGewaehlt) gewaehlt.add(id); else gewaehlt.delete(id); + k.classList.toggle('gewaehlt', jetztGewaehlt); + k.setAttribute('aria-pressed', String(jetztGewaehlt)); + leiste.auffrischen(gewaehlt.size, true); + }, true); + + /** Die Leiste am unteren Rand — nur sichtbar, solange ausgewählt wird. */ + function auswahlleiste(): { element: HTMLElement; auffrischen: (zahl: number, an: boolean) => void } { + const b = document.createElement('div'); + b.className = 'auswahlleiste hidden'; + + const zahl = document.createElement('span'); + zahl.className = 'auswahlzahl'; + + const knoepfe = document.createElement('div'); + knoepfe.className = 'knopfreihe'; + + /** Baut einen Knopf, der die Auswahl bearbeitet und dabei alle sperrt. + * + * **Gesperrt, solange es läuft.** Zweimal „Als gesehen" hintereinander + * wären zwei Sammelanfragen auf denselben Titeln; die zweite käme + * zurück, bevor die erste durch ist, und die Meldung wäre geraten. */ + const aktion = (text: string, arbeit: (ids: string[]) => Promise): HTMLButtonElement => { + const b = knopf(text, 'btn'); + b.dataset.braucht = 'auswahl'; + b.onclick = async () => { + const ids = [...gewaehlt]; + if (ids.length === 0) return; + const alle = [...knoepfe.querySelectorAll('button')]; + alle.forEach(x => { x.disabled = true; }); + try { hinweisZeigen(await arbeit(ids)); } + catch (fehler) { hinweisZeigen(meldung(fehler), 'fehler'); } + finally { alle.forEach(x => { x.disabled = false; }); } + }; + return b; + }; + + knoepfe.append( + aktion('Als gesehen', async ids => { + const ergebnis = await gesehenSammel(ids, true); + nachMarkierung(ids, true); + return satzZurMarkierung(ergebnis, true); + }), + aktion('Als ungesehen', async ids => { + const ergebnis = await gesehenSammel(ids, false); + nachMarkierung(ids, false); + return satzZurMarkierung(ergebnis, false); + })); + + // Metadaten holen darf nur, wer die Mediathek verwaltet — der Server + // lehnt es sonst ab, und ein Knopf, der nur ablehnt, ist keiner. + if (istAdmin()) { + knoepfe.appendChild(aktion('Metadaten holen', async ids => { + await metadatenAuffrischen(bibliothek.id, ids); + return `Metadaten werden für ${anzahlwort(ids.length)} geholt. ` + + 'Der Fortschritt steht in der Verwaltung.'; + })); + } + + // **Abwählen bleibt immer anklickbar** — es ist der Weg heraus. + const wegKnopf = knopf('Abwählen', 'btn'); + wegKnopf.onclick = () => auswahlmodusSetzen(false); + knoepfe.appendChild(wegKnopf); + + b.append(zahl, knoepfe); + + return { + element: b, + auffrischen: (anzahl, an) => { + b.classList.toggle('hidden', !an); + zahl.textContent = anzahl === 0 + ? 'Nichts ausgewählt' + : `${anzahlwort(anzahl)} ausgewählt`; + knoepfe.querySelectorAll('[data-braucht="auswahl"]') + .forEach(x => { x.disabled = anzahl === 0; }); + }, + }; + } + + /** Schreibt die Kacheln fort, ohne die Liste neu zu holen. + * + * **Haken oder Zahl, nie beides.** Wird eine Serie abgehakt, verschwindet + * „noch 6 offen"; wird das Häkchen genommen, kommt die Zahl zurück — und + * zwar die volle Folgenzahl, denn genau das ist jetzt offen. + */ + function nachMarkierung(ids: string[], wert: boolean): void { + ids.forEach(id => { + const k = gitter.querySelector(`.kachel[data-id="${CSS.escape(id)}"]`); + const plakat = k?.querySelector('.plakat'); + if (!k || !plakat) return; + + const beschreibung = bekannt.get(id); + if (beschreibung) { + if (beschreibung.stand) beschreibung.stand.watched = wert; + beschreibung.offen = wert ? 0 : (beschreibung.gesamt ?? beschreibung.offen); + } + + plakat.querySelector('.ecke')?.remove(); + k.querySelector('.fortschritt')?.remove(); + + if (wert) { + const ecke = document.createElement('span'); + ecke.className = 'ecke haken'; + ecke.title = 'Gesehen'; + ecke.innerHTML = icons.haken; + plakat.appendChild(ecke); + return; + } + + const offen = beschreibung?.offen ?? 0; + if (offen <= 0) return; + const plakette = document.createElement('span'); + plakette.className = 'ecke zahl'; + plakette.textContent = String(offen); + plakette.title = `${offen} Folgen offen`; + plakat.appendChild(plakette); + }); + } + // Die Genres der Mediathek nachladen — die Liste steht erst, wenn der // Server sie genannt hat, und solange bleibt „Alle Genres" allein. void genresVon(bibliothek.id).then(namen => { @@ -230,7 +411,7 @@ export function mediathek( skip: vonVorn ? 0 : geladen, take: SEITE, }); - if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; } + if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; bekannt.clear(); } gesamt = seite.total; geladen += seite.items.length; @@ -242,12 +423,27 @@ export function mediathek( // was übrig bleibt, nicht was der Server gefunden hat. const uebrig = gesehen === '' ? seite.items : seite.items.filter(e => { - const ist = 'state' in e ? e.state?.watched === true - : 'unwatchedCount' in e ? e.unwatchedCount === 0 : false; + // **Eine Serie gilt als gesehen, wenn keine Folge mehr offen + // ist** — oder wenn der Benutzer sie von Hand abgehakt hat und + // die Liste seitdem nicht neu geladen wurde. Eine Serie ganz + // ohne Folgen ist nicht gesehen, sondern leer. + const ist = e.state?.watched === true + || ('unwatchedCount' in e && e.episodeCount > 0 && e.unwatchedCount === 0); return gesehen === 'gesehen' ? ist : !ist; }); - uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen))); + uebrig.forEach(e => { + const beschreibung = alsKachel(e); + const k = kachel(beschreibung, oeffnen); + // **Die Beschreibung bleibt greifbar.** Nach einer Sammelmarkierung + // muss die Kachel wissen, wie viele Folgen die Serie hat — sonst + // liesse sich die Plakette „noch 6 offen" nicht wiederherstellen. + bekannt.set(beschreibung.id, beschreibung); + // Nachgeladene Kacheln brauchen die Marke auch — sonst sind die + // ersten zweihundert auswählbar und der Rest nicht. + if (auswahlmodus) markeAn(k); + gitter.appendChild(k); + }); // **Die Zahl sagt, wie viel schon da ist.** „455" über einer Liste mit // 200 Kacheln wäre eine Behauptung, die man beim Scrollen widerlegt. @@ -378,3 +574,24 @@ function farbeAus(name: string): string { for (const z of name) summe = (summe * 31 + z.charCodeAt(0)) % 360; return `linear-gradient(135deg, hsl(${summe} 42% 28%), hsl(${(summe + 40) % 360} 38% 20%))`; } + +/** „1 Titel" / „14 Titel" — die Eins ohne Ziffer liest sich wie ein Satz. */ +function anzahlwort(n: number): string { + return n === 1 ? 'ein Titel' : `${n} Titel`; +} + +/** Was nach einer Sammelmarkierung dasteht. + * + * **Auch das, was nicht ging.** Der Server nennt die Kennungen, zu denen er + * nichts fand; sie zu verschweigen hiesse, einen Teilerfolg als vollen + * auszugeben. + */ +function satzZurMarkierung(ergebnis: Sammelergebnis, wert: boolean): string { + const was = wert ? 'als gesehen' : 'als ungesehen'; + const kern = ergebnis.changed === 0 + ? `Nichts geändert — alles war schon ${was} markiert.` + : `${anzahlwort(ergebnis.changed)} ${was} markiert.`; + return ergebnis.unknown.length === 0 + ? kern + : `${kern} ${anzahlwort(ergebnis.unknown.length)} gibt es nicht mehr.`; +}