Ein Klick, der nichts tat, und niemand sagte warum
Eine Serienkachel kannte der Server als Titel nicht: „als gesehen markieren" lief in eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah sichtbar gar nichts. Dasselbe beim Favoriten. UserItemState bekommt eine Serienkennung, das Häkchen zieht alle Folgen mit, und was wirft, erscheint als Meldung statt in der Browserkonsole. Dazu eine Mehrfachauswahl in der Mediathek — gesehen, ungesehen, Metadaten neu holen für das Gewählte, in einer Anfrage statt in zweihundert. Ein neues Plakat steht sofort auf der Kachel, und ein Titel, den es nicht mehr gibt, endet nicht mehr in einer Sackgasse. Gemessen: ein POST auf /refresh ohne Rumpf antwortete nach der Erweiterung mit 404 — also genau der Aufruf „alles auffrischen", den es länger gibt als die Auswahl. Der Rumpf wird jetzt von Hand gelesen, und beide Formen stehen in einer Prüfung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c854c4a09a
commit
557708b769
@@ -319,7 +319,18 @@ public sealed record SeriesResponse(
|
||||
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
|
||||
/// über die Prüfliste.
|
||||
/// </remarks>
|
||||
DateTimeOffset? PublishAt = null);
|
||||
DateTimeOffset? PublishAt = null,
|
||||
|
||||
/// <summary>Favorit, Bewertung, Häkchen — zur Serie als Ganzem.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Neben <see cref="UnwatchedCount"/>, nicht an dessen Stelle.</b> Die
|
||||
/// Zahl sagt, wie viel noch aussteht; dieser Zustand sagt, was der
|
||||
/// Benutzer über die Serie selbst festgehalten hat. Ohne ihn konnte eine
|
||||
/// Kachel nicht wissen, ob „Als gesehen markieren" oder „Als ungesehen
|
||||
/// markieren" darauf gehört — sie bot immer das erste an.
|
||||
/// </remarks>
|
||||
ItemStateResponse? State = null);
|
||||
|
||||
public sealed record EpisodeResponse(
|
||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||
@@ -400,6 +411,25 @@ public sealed record ProgressRequest(
|
||||
|
||||
public sealed record WatchedRequest(bool Watched);
|
||||
public sealed record FavouriteRequest(bool Favourite);
|
||||
|
||||
/// <summary>Mehrere Titel auf einmal abhaken — oder das Häkchen wegnehmen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Anfrage statt zweihundert.</b> „Alle als gesehen markieren" über
|
||||
/// eine Mediathek hinweg wäre sonst ein Anfragensturm, bei dem mittendrin
|
||||
/// etwas scheitert und niemand weiss, was durchging.
|
||||
/// </remarks>
|
||||
public sealed record BulkWatchedRequest(IReadOnlyList<Guid> Ids, bool Watched);
|
||||
|
||||
/// <param name="Changed">Wie viele Titel sich dadurch geändert haben.</param>
|
||||
/// <param name="Unknown">
|
||||
/// Kennungen, zu denen es nichts gibt — stillschweigend zu übergehen hiesse,
|
||||
/// einen Teilerfolg als vollen auszugeben.
|
||||
/// </param>
|
||||
public sealed record BulkWatchedResponse(int Changed, IReadOnlyList<Guid> Unknown);
|
||||
|
||||
/// <summary>Welche Titel neu geholt werden sollen — leer heisst: alle.</summary>
|
||||
public sealed record RefreshRequest(IReadOnlyList<Guid>? Ids);
|
||||
public sealed record RatingRequest(int? Rating);
|
||||
|
||||
/// <summary>Was ein Benutzer mit einem Titel zu tun hatte.</summary>
|
||||
|
||||
@@ -18,6 +18,34 @@ public static class LibraryContentEndpoints
|
||||
/// </remarks>
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
/// <summary>Wie viele Titel eine Auswahl höchstens umfasst.</summary>
|
||||
private const int HöchstensJeZug = 1000;
|
||||
|
||||
/// <summary>Welche Titel im Rumpf stehen — oder <c>null</c>, wenn keiner da ist.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ein fehlender Rumpf ist kein Fehler, sondern die Voreinstellung.</b>
|
||||
/// „Alles auffrischen" schickt nichts mit, und das muss so bleiben: der
|
||||
/// Knopf dafür gibt es länger als die Auswahl.
|
||||
/// </remarks>
|
||||
private static async Task<IReadOnlyList<Guid>?> Auswahl(HttpRequest anfrage)
|
||||
{
|
||||
if (anfrage.ContentLength is null or 0) return null;
|
||||
if (!anfrage.HasJsonContentType()) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var gelesen = await anfrage.ReadFromJsonAsync<RefreshRequest>();
|
||||
return gelesen?.Ids;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
// Unlesbarer Rumpf heisst: keine Auswahl. Alles aufzufrischen ist
|
||||
// hier die harmlosere Auslegung — und sie kostet nur Zeit.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wonach eine Liste geordnet wird.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -104,22 +132,25 @@ public static class LibraryContentEndpoints
|
||||
/// noch niemand angefasst hat, und die Antwort trägt dort <c>null</c>.
|
||||
/// </remarks>
|
||||
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
FlaroContext db, ClaimsPrincipal wer, List<Guid>? filme = null, List<Guid>? folgen = null)
|
||||
FlaroContext db, ClaimsPrincipal wer,
|
||||
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? serien = null)
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } benutzer) return [];
|
||||
|
||||
var gesucht = (filme ?? folgen)!;
|
||||
var gesucht = (filme ?? folgen ?? serien)!;
|
||||
if (gesucht.Count == 0) return [];
|
||||
|
||||
var zeilen = await db.UserItemStates.AsNoTracking()
|
||||
.Where(z => z.UserId == benutzer)
|
||||
.Where(z => filme != null
|
||||
? z.MovieId != null && gesucht.Contains(z.MovieId!.Value)
|
||||
: z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value))
|
||||
: folgen != null
|
||||
? z.EpisodeId != null && gesucht.Contains(z.EpisodeId!.Value)
|
||||
: z.SeriesId != null && gesucht.Contains(z.SeriesId!.Value))
|
||||
.ToListAsync();
|
||||
|
||||
return zeilen.ToDictionary(
|
||||
z => (filme != null ? z.MovieId : z.EpisodeId)!.Value,
|
||||
z => (filme != null ? z.MovieId : folgen != null ? z.EpisodeId : z.SeriesId)!.Value,
|
||||
PlaybackEndpoints.Abbild);
|
||||
}
|
||||
|
||||
@@ -160,12 +191,33 @@ public static class LibraryContentEndpoints
|
||||
// erst beim nächsten neuen Film. Geholt wird über die bekannte
|
||||
// Kennung, nicht über eine neue Suche; eine Zuordnung von Hand
|
||||
// bleibt damit stehen.
|
||||
//
|
||||
// **Mit einer Auswahl im Rumpf nur diese Titel.** Zehn ausgewählte
|
||||
// Filme kosten zehn Anfragen bei TMDB; der ganze Bestand viertausend
|
||||
// und eine Viertelstunde. Ohne Rumpf bleibt es beim ganzen Bestand.
|
||||
//
|
||||
// **Der Rumpf wird von Hand gelesen, und das hat einen Grund.** Als
|
||||
// `RefreshRequest?` im Kopf der Methode lehnte der Server jede Anfrage
|
||||
// ohne Rumpf mit 404 ab — also genau den Aufruf „die ganze Mediathek
|
||||
// auffrischen", den es vorher schon gab. Gemessen, nicht vermutet: ohne
|
||||
// Rumpf 404, mit `{}` 202.
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/refresh", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
Guid id, HttpRequest anfrage, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
return koordinator.TryStart(id, erneut: true)
|
||||
IReadOnlySet<Guid>? nurDiese = null;
|
||||
if (await Auswahl(anfrage) is { Count: > 0 } gewaehlt)
|
||||
{
|
||||
if (gewaehlt.Count > HöchstensJeZug)
|
||||
return Results.Problem(
|
||||
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||
+ "Nimm weniger und wiederhole es.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
nurDiese = gewaehlt.ToHashSet();
|
||||
}
|
||||
|
||||
return koordinator.TryStart(id, erneut: true, nurDiese: nurDiese)
|
||||
? Results.AcceptedAtRoute("Scanstand", new { id })
|
||||
: Results.Problem(
|
||||
"Für diese Bibliothek läuft bereits ein Lauf.",
|
||||
@@ -401,6 +453,7 @@ public static class LibraryContentEndpoints
|
||||
// zusammen. Je Serie zu fragen wäre bei fünfzig Serien
|
||||
// einundfünfzig Abfragen.
|
||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, serien: ids);
|
||||
|
||||
var items = serien.Select(s =>
|
||||
{
|
||||
@@ -411,7 +464,8 @@ public static class LibraryContentEndpoints
|
||||
meine.Count(f => !gesehen.Contains(f.Id)),
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
|
||||
OriginalTitle: s.OriginalTitle,
|
||||
PublishAt: s.PublishAt);
|
||||
PublishAt: s.PublishAt,
|
||||
State: stände.GetValueOrDefault(s.Id));
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
@@ -538,7 +592,8 @@ public static class LibraryContentEndpoints
|
||||
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
|
||||
await GenresZu(db, film: null, serie: id),
|
||||
await MitwirkendeZu(db, film: null, serie: id),
|
||||
OriginalTitle: serie.OriginalTitle));
|
||||
OriginalTitle: serie.OriginalTitle,
|
||||
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id)));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||
|
||||
@@ -22,6 +22,15 @@ public static class PlaybackEndpoints
|
||||
{
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
/// <summary>Wie viele Titel eine Sammelanfrage höchstens trägt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Grosszügig, aber nicht offen.</b> Eine Mediathek mit 500 Filmen
|
||||
/// lässt sich damit in einem Zug abhaken; eine Anfrage mit 50 000
|
||||
/// Kennungen ist keine Bedienung mehr, sondern ein Angriff.
|
||||
/// </remarks>
|
||||
private const int HöchstensJeZug = 1000;
|
||||
|
||||
public static void MapPlayback(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapZustand(app);
|
||||
@@ -114,12 +123,66 @@ public static class PlaybackEndpoints
|
||||
zustand.Dismissed = false;
|
||||
}
|
||||
|
||||
// **Eine Serie zieht ihre Folgen mit.** Sonst stünde das Häkchen an
|
||||
// der Serie und daneben „noch 42 offen".
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
|
||||
zustand.UpdatedAt = clock.GetUtcNow();
|
||||
Nachfuehren(zustand);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(Abbild(zustand));
|
||||
});
|
||||
|
||||
// **Viele auf einmal.** Eigene Route, weil `/items/{id}` eine Kennung
|
||||
// im Pfad trägt und eine Liste keine hat.
|
||||
app.MapPut("/api/v1/items/watched", async (
|
||||
BulkWatchedRequest anfrage, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
|
||||
if (anfrage.Ids.Count == 0)
|
||||
return Results.Ok(new BulkWatchedResponse(0, []));
|
||||
if (anfrage.Ids.Count > HöchstensJeZug)
|
||||
return Results.Problem(
|
||||
$"Mehr als {HöchstensJeZug} Titel auf einmal gehen nicht. "
|
||||
+ "Nimm weniger und wiederhole es.",
|
||||
statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var unbekannt = new List<Guid>();
|
||||
var geaendert = 0;
|
||||
|
||||
// **Nacheinander, nicht in einem Rutsch.** Eine Serie zieht ihre
|
||||
// Folgen mit, und dafür gibt es keine Mengenoperation, die das
|
||||
// ebenso richtig täte. Gespeichert wird einmal am Ende.
|
||||
foreach (var id in anfrage.Ids.Distinct())
|
||||
{
|
||||
var zustand = await Zustand(db, benutzer, id);
|
||||
if (zustand is null) { unbekannt.Add(id); continue; }
|
||||
|
||||
if (zustand.Watched == anfrage.Watched && zustand.Position == TimeSpan.Zero) continue;
|
||||
|
||||
if (anfrage.Watched && !zustand.Watched)
|
||||
{
|
||||
zustand.PlayCount++;
|
||||
zustand.LastPlayedAt = jetzt;
|
||||
}
|
||||
zustand.Watched = anfrage.Watched;
|
||||
zustand.Position = TimeSpan.Zero;
|
||||
zustand.Dismissed = false;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
|
||||
if (zustand.SeriesId is { } serie)
|
||||
await FolgenSetzen(db, benutzer, serie, anfrage.Watched, clock);
|
||||
|
||||
geaendert++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new BulkWatchedResponse(geaendert, unbekannt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Aus „Weiterschauen" nehmen, ohne zu behaupten, man habe es gesehen.
|
||||
titel.MapPost("/dismiss", async (
|
||||
Guid id, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
@@ -455,14 +518,23 @@ public static class PlaybackEndpoints
|
||||
/// <summary>Holt den Stand oder legt ihn an — <c>null</c>, wenn es den Titel nicht gibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Erst Film, dann Folge.</b> Zwei Abfragen im schlechteren Fall. Eine
|
||||
/// gemeinsame Tabelle für beides würde eine sparen und dafür an jeder
|
||||
/// anderen Stelle im Modell kosten.
|
||||
/// <para>
|
||||
/// <b>Erst Film, dann Folge, dann Serie.</b> Drei Abfragen im schlechtesten
|
||||
/// Fall. Eine gemeinsame Tabelle für alles würde zwei sparen und dafür an
|
||||
/// jeder anderen Stelle im Modell kosten.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Dass die Serie fehlte, war ein stiller Fehler.</b> Jede Aktion auf
|
||||
/// einer Serienkachel — als gesehen markieren, zu den Favoriten — bekam
|
||||
/// eine 404, und weil das Kontextmenü die Ablehnung verschluckte, geschah
|
||||
/// sichtbar gar nichts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<UserItemState?> Zustand(FlaroContext db, Guid benutzer, Guid titel)
|
||||
{
|
||||
var vorhanden = await db.UserItemStates
|
||||
.FirstOrDefaultAsync(z => z.UserId == benutzer && (z.MovieId == titel || z.EpisodeId == titel));
|
||||
.FirstOrDefaultAsync(z => z.UserId == benutzer
|
||||
&& (z.MovieId == titel || z.EpisodeId == titel || z.SeriesId == titel));
|
||||
if (vorhanden is not null) return vorhanden;
|
||||
|
||||
if (await db.Movies.AnyAsync(m => m.Id == titel))
|
||||
@@ -479,9 +551,67 @@ public static class PlaybackEndpoints
|
||||
return neu;
|
||||
}
|
||||
|
||||
if (await db.Series.AnyAsync(s => s.Id == titel))
|
||||
{
|
||||
var neu = new UserItemState { UserId = benutzer, SeriesId = titel };
|
||||
db.UserItemStates.Add(neu);
|
||||
return neu;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Setzt das Häkchen an allen Folgen einer Serie — oder nimmt es weg.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>„Serie gesehen" heisst: jede Folge gesehen.</b> Nur die Zeile der
|
||||
/// Serie zu setzen liesse die Folgenliste unverändert und den Zähler
|
||||
/// „noch 42 offen" daneben stehen — zwei Angaben, die sich widersprechen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Stelle bleibt, wo sie ist.</b> Wer eine Serie von Hand abhakt,
|
||||
/// will sie aus dem Weg haben; eine halb gesehene Folge soll dadurch nicht
|
||||
/// ihren Fortschritt verlieren, falls er doch zurückkommt. Nur
|
||||
/// „Weiterschauen" räumt sich auf, weil ein Abgehaktes dort nichts zu
|
||||
/// suchen hat.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task FolgenSetzen(
|
||||
FlaroContext db, Guid benutzer, Guid serie, bool gesehen, TimeProvider clock)
|
||||
{
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => f.SeriesId == serie)
|
||||
.Select(f => f.Id)
|
||||
.ToListAsync();
|
||||
if (folgen.Count == 0) return;
|
||||
|
||||
var staende = await db.UserItemStates
|
||||
.Where(z => z.UserId == benutzer && z.EpisodeId != null && folgen.Contains(z.EpisodeId.Value))
|
||||
.ToListAsync();
|
||||
var bekannt = staende.ToDictionary(z => z.EpisodeId!.Value);
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
foreach (var folge in folgen)
|
||||
{
|
||||
if (!bekannt.TryGetValue(folge, out var zustand))
|
||||
{
|
||||
// Ungesehen ist der Normalfall — dafür braucht es keine Zeile.
|
||||
if (!gesehen) continue;
|
||||
zustand = new UserItemState { UserId = benutzer, EpisodeId = folge };
|
||||
db.UserItemStates.Add(zustand);
|
||||
}
|
||||
|
||||
if (gesehen && !zustand.Watched) zustand.PlayCount++;
|
||||
zustand.Watched = gesehen;
|
||||
zustand.Position = TimeSpan.Zero;
|
||||
zustand.Dismissed = false;
|
||||
if (gesehen) zustand.LastPlayedAt = jetzt;
|
||||
zustand.UpdatedAt = jetzt;
|
||||
Nachfuehren(zustand);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Die Laufzeit aus der längsten Datei hinter dem Titel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -489,10 +619,16 @@ public static class PlaybackEndpoints
|
||||
/// eine beschnittene sein mag. Gegen die kürzere zu messen hiesse, einen
|
||||
/// Film für gesehen zu halten, der noch zwanzig Minuten vor sich hat.
|
||||
/// </remarks>
|
||||
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand) =>
|
||||
await db.MediaFiles.AsNoTracking()
|
||||
private static async Task<TimeSpan?> Dateilaufzeit(FlaroContext db, UserItemState zustand)
|
||||
{
|
||||
// Eine Serie hat keine eigene Datei. Ohne diese Zeile verglich die
|
||||
// Abfrage `EpisodeId == null` und nahm die längste herrenlose Datei.
|
||||
if (zustand.MovieId is null && zustand.EpisodeId is null) return null;
|
||||
|
||||
return await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => zustand.MovieId != null ? d.MovieId == zustand.MovieId : d.EpisodeId == zustand.EpisodeId)
|
||||
.MaxAsync(d => (TimeSpan?)d.Duration);
|
||||
}
|
||||
|
||||
internal static ItemStateResponse Abbild(UserItemState z) => new(
|
||||
z.Position.TotalSeconds, z.Runtime?.TotalSeconds,
|
||||
|
||||
@@ -63,7 +63,13 @@ public sealed class ScanCoordinator(
|
||||
/// Dateisystem nichts geändert, und 5884 Dateien noch einmal abzulaufen
|
||||
/// kostet Minuten für nichts.
|
||||
/// </param>
|
||||
public bool TryStart(Guid libraryId, bool erneut = false, string? nurSprache = null)
|
||||
/// <param name="nurDiese">
|
||||
/// <b>Nur diese Titel auffrischen</b> — für „Metadaten neu holen" an einer
|
||||
/// Auswahl in der Mediathek. <c>null</c> heisst: der ganze Bestand.
|
||||
/// </param>
|
||||
public bool TryStart(
|
||||
Guid libraryId, bool erneut = false, string? nurSprache = null,
|
||||
IReadOnlySet<Guid>? 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<Guid>? 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<LibraryScanner>();
|
||||
bericht = await scanner.ScanAsync(libraryId, melder);
|
||||
@@ -122,15 +130,20 @@ public sealed class ScanCoordinator(
|
||||
|
||||
var metadaten = await bereich.ServiceProvider
|
||||
.GetRequiredService<MetadataEnricher>()
|
||||
.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<Zusammenlegung>().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<Zusammenlegung>().AlleAsync(libraryId)
|
||||
: Zusammenlegungsbericht.Nichts;
|
||||
|
||||
if (zusammengelegt.Gesamt > 0)
|
||||
{
|
||||
|
||||
@@ -23,6 +23,25 @@ public sealed class UserItemState
|
||||
public Guid? MovieId { get; init; }
|
||||
public Guid? EpisodeId { get; init; }
|
||||
|
||||
/// <summary>Die Serie als Ganzes — für Favorit, Bewertung und das Häkchen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Eine Serie wird nicht angeschaut, ihre Folgen werden es</b> — 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warum überhaupt eine Zeile und nicht nur gezählte Folgen.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? SeriesId { get; init; }
|
||||
|
||||
/// <summary>Wo der Benutzer steht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
@@ -329,6 +329,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> 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<FlaroContext> options) : DbCon
|
||||
// gibt, ist keine Erinnerung, sondern Datenmüll.
|
||||
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<Episode>(e =>
|
||||
|
||||
@@ -49,10 +49,16 @@ public sealed class MetadataEnricher(
|
||||
/// aufzufrischen kostet zwölf Anfragen, der ganze Bestand viertausend.
|
||||
/// <c>null</c> heisst: keine Einschränkung.
|
||||
/// </param>
|
||||
/// <param name="nurDiese">
|
||||
/// <b>Nur diese Filme und Serien anfassen.</b> 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. <c>null</c> heisst: alles.
|
||||
/// </param>
|
||||
public async Task<EnrichReport> EnrichAsync(
|
||||
Guid libraryId, bool erneut = false,
|
||||
IProgress<Scanschritt>? fortschritt = null, CancellationToken ct = default,
|
||||
string? nurSprache = null)
|
||||
string? nurSprache = null, IReadOnlySet<Guid>? 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<int> FilmeZuordnen(
|
||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||
string? nurSprache = null)
|
||||
string? nurSprache = null, IReadOnlySet<Guid>? 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<int> SerienZuordnen(
|
||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||
string? nurSprache = null)
|
||||
string? nurSprache = null, IReadOnlySet<Guid>? 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(
|
||||
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
|
||||
private async Task<int> FolgenZuordnen(
|
||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct,
|
||||
string? nurSprache = null)
|
||||
string? nurSprache = null, IReadOnlySet<Guid>? 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;
|
||||
|
||||
@@ -0,0 +1,921 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ApprovedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ConsumedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenFingerprint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ReviewNewItems")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Libraries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Character")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("EpisodeNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MetadataLanguage")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PublishAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeasonNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StillImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GenreId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FetchedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("BitRate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Container")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("Duration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("MissingSince")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ModifiedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ProbedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Channels")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Index")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsForced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("MediaFileId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MediaFileId", "Index")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MediaStreams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MetadataLanguage")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ParsedTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParsedYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("PublishAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int?>("RuntimeMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitleValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TitlePinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Image")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TmdbId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MetadataLanguage")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ParsedTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParsedYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("PublishAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int?>("RuntimeMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitleValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TitlePinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Dismissed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favourite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("InProgress")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("LastPlayedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Rating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("Runtime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdministrator")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Flaro.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Serienzustand : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,6 +709,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<long?>("Runtime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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")
|
||||
|
||||
@@ -316,6 +316,67 @@ public class MetadataTests
|
||||
Assert.That(nachher!.Items[0].Title, Is.EqualTo("Von Hand"));
|
||||
}
|
||||
|
||||
/// <summary>Auffrischen nimmt eine Auswahl entgegen — und geht auch ohne.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Gemessen, nachdem es einmal brach.</b> Als die Auswahl als
|
||||
/// <c>RefreshRequest?</c> 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.
|
||||
/// </remarks>
|
||||
[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<Page<MovieResponse>>("/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<ScanStatus> Fertig(HttpClient client, Guid bibliothek)
|
||||
{
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/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!;
|
||||
}
|
||||
|
||||
/// <summary>Eine falsche Zuordnung darf ihre eigene Korrektur nicht verhindern.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
@@ -350,6 +350,212 @@ public class PlaybackTests
|
||||
Assert.That(danach!.UnwatchedCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
/// <summary>Eine Serie als gesehen markieren hakt jede Folge ab.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Fall, der vorher stumm scheiterte.</b> Der Server kannte zu
|
||||
/// einer Serienkennung keinen Zustand und antwortete mit 404; die
|
||||
/// Oberfläche verschluckte die Ablehnung, und der Klick tat sichtbar
|
||||
/// nichts.
|
||||
/// </remarks>
|
||||
[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<Page<SeriesResponse>>("/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<SeriesResponse>($"/api/v1/series/{serie.Id}");
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Und wieder zurück — ohne Rest.</summary>
|
||||
[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<Page<SeriesResponse>>("/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<SeriesResponse>($"/api/v1/series/{serie.Id}");
|
||||
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
|
||||
$"/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);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Favorit an einer Serie — vorher ebenfalls eine stille 404.</summary>
|
||||
[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<Page<SeriesResponse>>("/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<Page<SeriesResponse>>("/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");
|
||||
}
|
||||
|
||||
/// <summary>Der Stand einer Serie gehört dem Benutzer, nicht dem Server.</summary>
|
||||
[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<Page<SeriesResponse>>("/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<SeriesResponse>($"/api/v1/series/{serie.Id}");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(seine!.State?.Watched, Is.Not.True);
|
||||
Assert.That(seine.UnwatchedCount, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Viele auf einmal — eine Anfrage, nicht zweihundert.</summary>
|
||||
[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<Page<MovieResponse>>("/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<BulkWatchedResponse>();
|
||||
|
||||
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Was es nicht gibt, wird benannt statt verschwiegen.</summary>
|
||||
[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<BulkWatchedResponse>();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(ergebnis!.Changed, Is.EqualTo(1));
|
||||
Assert.That(ergebnis.Unknown, Is.EqualTo(new[] { erfunden }));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Auch Serien gehören in eine Sammelmarkierung.</summary>
|
||||
[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<Page<SeriesResponse>>("/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<SeriesResponse>($"/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<BulkWatchedResponse>();
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
+21
-2
@@ -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<Sammelergebnis>('/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<Stand>(`/items/${id}/dismiss`, { method: 'POST' });
|
||||
@@ -490,8 +505,12 @@ export const genresVon = (id: string) => anfrage<string[]>(`/libraries/${id}/gen
|
||||
export const empfehlungen = (take = 20) =>
|
||||
anfrage<Seite<Film>>(`/home/suggestions${abfrage({ take })}`);
|
||||
|
||||
export const metadatenAuffrischen = (id: string) =>
|
||||
anfrage<void>(`/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<void>(`/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 = {
|
||||
|
||||
+21
-3
@@ -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 = '<div class="spinner"></div>';
|
||||
|
||||
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;
|
||||
|
||||
+42
-16
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
+11
-1
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>('.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);
|
||||
}
|
||||
|
||||
@@ -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()) },
|
||||
|
||||
+65
-17
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
/** Was hinter jeder Kachel steht — nach Kennung, für Sammelaktionen. */
|
||||
const bekannt = new Map<string, Kachel>();
|
||||
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<HTMLElement>('.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<HTMLElement>('.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<string>): 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<HTMLButtonElement>('[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<HTMLElement>(`.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.`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user