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.`;
+}