From e066f5fe87399f56c089f96af93851302144cc5f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 21:45:09 +0200 Subject: [PATCH] Eine Reihe, die nur zu ihrer Zeit dasteht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zu Halloween, zur Weihnachtszeit, zum Jahreswechsel, im Sommer — und zu allem, was sonst jemand feiert. **Nach Stichwörtern, nicht nach Genres.** „Stirb langsam" ist kein Familienfilm und trägt trotzdem „christmas". Eine Weihnachtsreihe nach Genres enthielte jeden Zeichentrickfilm und diesen Film nicht. TMDB führt die Stichwörter ohnehin; sie kommen jetzt über `append_to_response` mit und landen in einer eigenen Tabelle, nach demselben Muster wie Genres. Kleingeschrieben abgelegt — TMDB liefert „Christmas" und „christmas" je nach Eintrag, und zwei Schreibweisen wären zwei Stichwörter. **Einstellbar und nicht eingebaut.** Halloween und Weihnachten kennt jeder; der 1. August ist Schweizer Nationalfeiertag, der 4. Juli amerikanischer, und der Geburtstag des Betreibers steht in keiner Bibliothek. Eine feste Liste im Quelltext hiesse: für jeden Anlass eine neue Fassung. Vier sind vorangelegt — als Beispiel, nicht als Meinung darüber, was jemand feiert. **Über den Jahreswechsel hinweg.** „28.12. bis 02.01." ist kein Fehler, sondern der Normalfall für Silvester; ein Vergleich, der nur „von ≤ heute ≤ bis" kennt, fände diesen Zeitraum nie — und die Reihe stünde genau in den Tagen nicht da, für die sie gedacht ist. Geprüft, Tag für Tag, samt der Zusicherung, dass sich die vorangelegten Anlässe an keinem Tag im Jahr überschneiden. **Leer heisst leer.** Gilt heute kein Anlass oder findet er nichts, kommt eine Reihe ohne Namen zurück und der Client zeigt gar keine — statt einer mit Überschrift und nichts darunter. Scheitert der Abruf ganz, fehlt eine Reihe und nicht die Startseite. Bedient wird sie als Text, eine Zeile je Anlass: Name, von, bis, Stichwörter, Genres. Ein Listeneditor mit Zeilen zum Hinzufügen, Verschieben und Löschen wäre viel Oberfläche für etwas, das man dreimal im Jahr anfasst — und es ist dieselbe Form, in der es gespeichert wird. Ein Anlass ohne Stichwort und ohne Genre wird abgewiesen statt stillschweigend geschluckt: er fände nie etwas, und wer ihn eingetragen hat, suchte den Fehler woanders. **Die Stichwörter kommen erst beim nächsten Auffrischen der Metadaten.** Vorhandene Titel tragen noch keine — bis dahin bleibt die Reihe leer. 764 Tests grün, davon 22 neue. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Endpoints/Contracts.cs | 8 + .../Flaro.Api/Endpoints/PlaybackEndpoints.cs | 67 + .../Flaro.Api/Endpoints/SettingsEndpoints.cs | 30 + server/src/Flaro.Api/Program.cs | 1 + server/src/Flaro.Api/Saisoneinstellungen.cs | 62 + .../src/Flaro.Core/Library/CatalogEntities.cs | 26 + server/src/Flaro.Core/Library/Saisonanlass.cs | 93 ++ .../Flaro.Core/Metadata/IMetadataProvider.cs | 18 + server/src/Flaro.Data/FlaroContext.cs | 21 + server/src/Flaro.Data/MetadataEnricher.cs | 57 + .../20261008193712_Stichwoerter.Designer.cs | 1255 +++++++++++++++++ .../Migrations/20261008193712_Stichwoerter.cs | 99 ++ .../Migrations/FlaroContextModelSnapshot.cs | 70 + server/src/Flaro.Metadata/TmdbClient.cs | 34 +- server/tests/Flaro.Api.Tests/MetadataTests.cs | 88 ++ .../Flaro.Core.Tests/SaisonanlassTests.cs | 97 ++ web/app/src/api.ts | 17 + web/app/src/views/einstellungen/uebersicht.ts | 67 +- web/app/src/views/home.ts | 18 +- 19 files changed, 2121 insertions(+), 7 deletions(-) create mode 100644 server/src/Flaro.Api/Saisoneinstellungen.cs create mode 100644 server/src/Flaro.Core/Library/Saisonanlass.cs create mode 100644 server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.Designer.cs create mode 100644 server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.cs create mode 100644 server/tests/Flaro.Core.Tests/SaisonanlassTests.cs diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 042ee30..44b0851 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -537,6 +537,14 @@ public sealed record EpisodeResponse( public sealed record Page(IReadOnlyList Items, int Total); +/// Die saisonale Reihe — mit ihrem Namen, weil er wechselt. +/// +/// +/// Was über der Reihe steht. null heisst: heute hat nichts Saison, +/// oder der Anlass findet nichts — dann zeigt der Client gar keine Reihe. +/// +public sealed record Saisonreihe(string? Occasion, Page Items); + /// /// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel /// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist. diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs index 27d8357..64d4b2b 100644 --- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs @@ -462,6 +462,73 @@ public static class PlaybackEndpoints return Results.Ok(new Page(items, items.Count)); }); + + /* ---------- Was gerade Saison hat ---------- */ + + // **Eine Reihe, die nur zu ihrer Zeit dasteht.** + // + // Nach Stichwörtern der Metadatenquelle, nicht nach Genres: „Stirb + // langsam" ist kein Familienfilm und trägt trotzdem „christmas". + // Eine Weihnachtsreihe nach Genres enthielte jeden + // Zeichentrickfilm und diesen Film nicht. + // + // **Leer heisst leer.** Findet der Anlass nichts, kommt eine leere + // Seite ohne Namen zurück — der Client zeigt dann gar keine Reihe, + // statt einer mit Überschrift und nichts darunter. + start.MapGet("/seasonal", async ( + ClaimsPrincipal wer, FlaroContext db, Saisoneinstellungen einstellungen, + TimeProvider uhr, CancellationToken ct, int take = 24) => + { + if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized(); + + var heute = uhr.GetUtcNow(); + var anlass = (await einstellungen.AnlaesseAsync(ct)).FirstOrDefault(a => a.Gilt(heute)); + if (anlass is null) return Results.Ok(new Saisonreihe(null, new Page([], 0))); + + var stichworte = anlass.Stichworte.Select(w => w.ToLowerInvariant()).ToList(); + var genres = anlass.Genres; + + var treffer = await db.Movies.AsNoTracking() + .Where(m => db.Stichwortlinks.Any(l => l.MovieId == m.Id + && db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name))) + || db.GenreLinks.Any(l => l.MovieId == m.Id + && db.Genres.Any(g => g.Id == l.GenreId && genres.Contains(g.Name)))) + // **Stichworttreffer vor Genretreffer.** Ein Film, der + // „christmas" trägt, gehört sicherer in die Reihe als einer, + // der nur im passenden Genre liegt. + .Select(m => new + { + Film = m, + Sicher = db.Stichwortlinks.Count(l => l.MovieId == m.Id + && db.Stichworte.Any(w => w.Id == l.StichwortId && stichworte.Contains(w.Name))), + }) + .OrderByDescending(x => x.Sicher) + .ThenByDescending(x => x.Film.Rating ?? 0) + .Take(Math.Clamp(take, 1, HöchstensJeSeite)) + .Select(x => x.Film) + .ToListAsync(ct); + + if (treffer.Count == 0) return Results.Ok(new Saisonreihe(null, new Page([], 0))); + + var saisonIds = treffer.Select(m => m.Id).ToList(); + var saisondateien = await db.MediaFiles.AsNoTracking() + .Where(d => d.MovieId != null && saisonIds.Contains(d.MovieId!.Value)) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel }) + .ToListAsync(ct); + + var saisonitems = treffer.Select(m => new MovieResponse( + m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage, + saisondateien.Where(d => d.MovieId == m.Id) + .Select(d => new MediaFileResponse( + d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container, + d.Duration?.TotalSeconds, [], d.VersionLabel)) + .ToList(), + null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating, + PublishAt: m.PublishAt)).ToList(); + + return Results.Ok(new Saisonreihe( + anlass.Name, new Page(saisonitems, saisonitems.Count))); + }); } /// Der Zustand eines Titels, den noch niemand angefasst hat. diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 6d01153..a3f95a8 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -1,6 +1,7 @@ using Flaro.Api.Auth; using Flaro.Core; using Flaro.Core.Delivery; +using Flaro.Core.Library; using Flaro.Core.Playback; using Flaro.Data; using Flaro.Metadata; @@ -163,6 +164,35 @@ public static class SettingsEndpoints Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert)); }).RequireAuthorization(FlaroPolicies.Administrator); + /* ---------- Saisonale Reihen ---------- */ + + // **Einstellbar und nicht eingebaut.** Halloween und Weihnachten + // kennt jeder; der 1. August ist Schweizer Nationalfeiertag, der + // 4. Juli amerikanischer, und der Geburtstag des Betreibers steht + // in keiner Bibliothek. + app.MapGet("/api/v1/settings/seasonal", async (Saisoneinstellungen einstellungen) => + Results.Ok(await einstellungen.AnlaesseAsync())) + .RequireAuthorization(FlaroPolicies.Administrator); + + app.MapPut("/api/v1/settings/seasonal", async ( + List anlaesse, FlaroContext db) => + { + // **Unbrauchbares wird abgewiesen, nicht stillschweigend + // geschluckt.** Ein Anlass ohne Stichwörter fände nie etwas, und + // wer ihn eingetragen hat, suchte den Fehler woanders. + var schlecht = anlaesse.Where(a => !a.Brauchbar).ToList(); + if (schlecht.Count > 0) + { + return Einwand.Bitte( + $"„{schlecht[0].Name}“ ist unvollständig. Jeder Anlass braucht einen Namen, " + + "einen Zeitraum in der Form MM-TT und mindestens ein Stichwort oder Genre."); + } + + await Setzen(db, Saisoneinstellungen.Schluessel, Saisoneinstellungen.Schreiben(anlaesse)); + await db.SaveChangesAsync(); + return Results.Ok(anlaesse); + }).RequireAuthorization(FlaroPolicies.Administrator); + /* ---------- Wartung: was auf dem Kratzplatz liegen bleiben darf ---------- */ app.MapGet("/api/v1/settings/maintenance", async ( diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index e098955..f874c4a 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -91,6 +91,7 @@ builder.Services.AddScoped(); // ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann. builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHttpClient(c => { c.BaseAddress = new Uri(TmdbClient.BaseAddress); diff --git a/server/src/Flaro.Api/Saisoneinstellungen.cs b/server/src/Flaro.Api/Saisoneinstellungen.cs new file mode 100644 index 0000000..8aea7a6 --- /dev/null +++ b/server/src/Flaro.Api/Saisoneinstellungen.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using Flaro.Core.Library; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api; + +/// Die saisonalen Anlässe, aus den Einstellungen. +/// +/// +/// Als JSON in einer Zeile und nicht als Tabelle. Das hier ist +/// Konfiguration, kein Bestand: eine Handvoll Einträge, die zusammen +/// gelesen und zusammen geschrieben werden. Eine Tabelle dafür hiesse eine +/// Migration für etwas, das niemand einzeln abfragt. +/// +public sealed class Saisoneinstellungen(FlaroContext db) +{ + public const string Schluessel = "startseite.saisonal.anlaesse"; + + private IReadOnlyList? _gemerkt; + + public async Task> AnlaesseAsync(CancellationToken ct = default) + { + if (_gemerkt is { } fertig) return fertig; + + var zeile = await db.Settings.AsNoTracking() + .FirstOrDefaultAsync(s => s.Key == Schluessel, ct); + + return _gemerkt = Lesen(zeile?.Value); + } + + /// Liest die gespeicherte Liste — oder gibt die Vorgabe zurück. + /// + /// + /// Unlesbares ist kein Grund, die Startseite zu verlieren. Steht + /// dort Unsinn, gilt die Vorgabe; eine Ausnahme hier nähme dem Server + /// die ganze Reihe und nicht nur den falschen Eintrag. + /// + public static IReadOnlyList Lesen(string? roh) + { + if (string.IsNullOrWhiteSpace(roh)) return Saisonanlass.Vorgabe; + + try + { + var gelesen = JsonSerializer.Deserialize>( + roh, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + // Eine leere Liste ist eine Entscheidung — „keine saisonalen + // Reihen" — und nicht dasselbe wie „nichts eingestellt". + return gelesen is null ? Saisonanlass.Vorgabe : gelesen.Where(a => a.Brauchbar).ToList(); + } + catch (JsonException) + { + return Saisonanlass.Vorgabe; + } + } + + public static string Schreiben(IEnumerable anlaesse) => + JsonSerializer.Serialize( + anlaesse.Where(a => a.Brauchbar).ToList(), + new JsonSerializerOptions(JsonSerializerDefaults.Web)); +} diff --git a/server/src/Flaro.Core/Library/CatalogEntities.cs b/server/src/Flaro.Core/Library/CatalogEntities.cs index d8228da..c142ac2 100644 --- a/server/src/Flaro.Core/Library/CatalogEntities.cs +++ b/server/src/Flaro.Core/Library/CatalogEntities.cs @@ -97,3 +97,29 @@ public sealed class Fremdbewertung /// Was dastehen soll: „7,4", „87 %", „62". public required string Anzeige { get; set; } } + +/// Ein Stichwort der Metadatenquelle, einmal je Name. +/// +/// +/// Feiner als ein Genre, und deshalb eine eigene Tabelle. „Stirb +/// langsam" ist kein Familienfilm und trägt trotzdem „christmas". Eine +/// Weihnachtsreihe nach Genres enthielte jeden Zeichentrickfilm und diesen +/// Film nicht — nach Stichwörtern stimmt es. +/// +/// Kleingeschrieben abgelegt: TMDB liefert „Christmas" und „christmas" je +/// nach Eintrag, und zwei Schreibweisen wären zwei Stichwörter. +/// +public sealed class Stichwort +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required string Name { get; set; } +} + +/// Verbindet ein Stichwort mit einem Film oder einer Serie. +public sealed class Stichwortlink +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid StichwortId { get; init; } + public Guid? MovieId { get; init; } + public Guid? SeriesId { get; init; } +} diff --git a/server/src/Flaro.Core/Library/Saisonanlass.cs b/server/src/Flaro.Core/Library/Saisonanlass.cs new file mode 100644 index 0000000..1e4bc83 --- /dev/null +++ b/server/src/Flaro.Core/Library/Saisonanlass.cs @@ -0,0 +1,93 @@ +using System.Globalization; + +namespace Flaro.Core.Library; + +/// Ein Anlass, zu dem eine eigene Reihe auf der Startseite steht. +/// +/// Was über der Reihe steht — „Zu Halloween". +/// Tag im Jahr, ab dem sie erscheint, als „MM-TT". +/// Tag im Jahr, bis zu dem sie bleibt, als „MM-TT". +/// Stichwörter der Metadatenquelle, kleingeschrieben. +/// Genres, falls Stichwörter allein zu wenig hergeben. +/// +/// +/// +/// Einstellbar und nicht eingebaut. Halloween und Weihnachten kennt +/// jeder; der 1. August ist Schweizer Nationalfeiertag, der 4. Juli +/// amerikanischer, und der Geburtstag des Betreibers steht in keiner +/// Bibliothek. Eine feste Liste im Quelltext hiesse: für jeden Anlass eine +/// neue Fassung. +/// +/// +/// Stichwörter zuerst, Genres als Zugabe. „Stirb langsam" ist kein +/// Familienfilm und trägt trotzdem „christmas" — eine Weihnachtsreihe nach +/// Genres enthielte jeden Zeichentrickfilm und diesen Film nicht. +/// +/// +public sealed record Saisonanlass( + string Name, + string Von, + string Bis, + IReadOnlyList Stichworte, + IReadOnlyList Genres) +{ + /// Was ohne eigene Einstellung dasteht. + /// + /// + /// Vier, und bewusst nicht mehr. Eine lange Vorgabeliste wäre + /// eine Meinung darüber, was jemand feiert. Diese vier sind weit genug + /// verbreitet, um als Beispiel zu taugen, und zeigen zugleich die drei + /// Formen: ein Zeitraum im Jahr, einer über den Jahreswechsel, und + /// einer, der sich auf Genres stützt. + /// + public static IReadOnlyList Vorgabe { get; } = + [ + new("Zu Halloween", "10-01", "11-01", + ["halloween", "haunted house", "witch", "slasher", "zombie"], + ["Horror"]), + new("Zur Weihnachtszeit", "12-01", "12-27", + ["christmas", "santa claus", "christmas eve"], + []), + new("Zum Jahreswechsel", "12-28", "01-02", + ["new year's eve", "new year"], + []), + new("Für den Sommer", "06-15", "08-31", + ["summer", "beach", "summer vacation", "road trip"], + []), + ]; + + /// Ob dieser Anlass an diesem Tag gilt. + /// + /// + /// Über den Jahreswechsel hinweg. „28.12. bis 02.01." ist kein + /// Fehler, sondern der Normalfall für Silvester — ein Vergleich, der nur + /// „von ≤ heute ≤ bis" kennt, fände diesen Zeitraum nie. + /// + public bool Gilt(DateTimeOffset tag) + { + if (Tagnummer(Von) is not { } von || Tagnummer(Bis) is not { } bis) return false; + + var heute = tag.Month * 100 + tag.Day; + return von <= bis + ? heute >= von && heute <= bis + : heute >= von || heute <= bis; + } + + /// „12-24" wird zu 1224 — oder zu null, wenn es keiner ist. + private static int? Tagnummer(string roh) + { + var teile = (roh ?? "").Split('-'); + if (teile.Length != 2) return null; + if (!int.TryParse(teile[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var monat)) return null; + if (!int.TryParse(teile[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var tag)) return null; + if (monat is < 1 or > 12 || tag is < 1 or > 31) return null; + + return monat * 100 + tag; + } + + /// Ob der Anlass überhaupt etwas finden kann. + public bool Brauchbar => + !string.IsNullOrWhiteSpace(Name) + && Tagnummer(Von) is not null && Tagnummer(Bis) is not null + && (Stichworte.Count > 0 || Genres.Count > 0); +} diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs index aa02499..e0855f4 100644 --- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs +++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs @@ -51,6 +51,15 @@ public sealed record MovieMetadata( /// Die Altersfreigabe des eingestellten Landes, etwa „16". string? ContentRating = null, IReadOnlyList? Genres = null, + /// Stichwörter der Quelle, kleingeschrieben — „christmas", „halloween". + /// + /// + /// Feiner als Genres. „Stirb langsam" ist kein Familienfilm und + /// steht trotzdem unter dem Stichwort „christmas" — nach Genres liesse + /// sich eine Weihnachtsreihe nicht bauen, die diesen Film enthält und + /// nicht jeden Zeichentrickfilm. + /// + IReadOnlyList? Keywords = null, IReadOnlyList? Credits = null, /// Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund. /// @@ -86,6 +95,15 @@ public sealed record SeriesMetadata( string? Tagline = null, string? ContentRating = null, IReadOnlyList? Genres = null, + /// Stichwörter der Quelle, kleingeschrieben — „christmas", „halloween". + /// + /// + /// Feiner als Genres. „Stirb langsam" ist kein Familienfilm und + /// steht trotzdem unter dem Stichwort „christmas" — nach Genres liesse + /// sich eine Weihnachtsreihe nicht bauen, die diesen Film enthält und + /// nicht jeden Zeichentrickfilm. + /// + IReadOnlyList? Keywords = null, IReadOnlyList? Credits = null, /// Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund. /// diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 619507a..3104293 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -76,6 +76,8 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon public DbSet Genres => Set(); public DbSet GenreLinks => Set(); public DbSet Fremdbewertungen => Set(); + public DbSet Stichworte => Set(); + public DbSet Stichwortlinks => Set(); public DbSet People => Set(); public DbSet Credits => Set(); public DbSet Translations => Set(); @@ -272,6 +274,25 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE"); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => x.Name).IsUnique(); + e.Property(x => x.Name).HasMaxLength(100); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.StichwortId, x.MovieId }).IsUnique().HasFilter("\"MovieId\" IS NOT NULL"); + e.HasIndex(x => new { x.StichwortId, x.SeriesId }).IsUnique().HasFilter("\"SeriesId\" IS NOT NULL"); + e.HasIndex(x => x.MovieId); + e.HasIndex(x => x.SeriesId); + e.HasOne().WithMany().HasForeignKey(x => x.StichwortId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); + }); + b.Entity(e => { e.HasKey(x => x.Id); diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 6df0a4e..d91f165 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -251,6 +251,7 @@ public sealed class MetadataEnricher( await BewertungenSetzen(film.Id, null, gefunden.ImdbId, ct); await GenresSetzen(gefunden.Genres, film: film.Id, serie: null, ct); + await StichworteSetzen(gefunden.Keywords, film: film.Id, serie: null, ct); await MitwirkendeSetzen(gefunden.Credits, film: film.Id, serie: null, ct); zugeordnet++; @@ -409,6 +410,7 @@ public sealed class MetadataEnricher( await BewertungenSetzen(null, serie.Id, gefunden.ImdbId, ct); await GenresSetzen(gefunden.Genres, film: null, serie: serie.Id, ct); + await StichworteSetzen(gefunden.Keywords, film: null, serie: serie.Id, ct); await MitwirkendeSetzen(gefunden.Credits, film: null, serie: serie.Id, ct); if (pruefungNoetig && vorher.TmdbId is not null @@ -604,6 +606,61 @@ public sealed class MetadataEnricher( } } + /// Schreibt die Stichwörter — nach demselben Muster wie Genres. + /// + /// + /// Ersetzend, nicht ergänzend. Nimmt TMDB ein Stichwort zurück, + /// soll es auch hier verschwinden — sonst stünde ein Film jahrelang in + /// einer Reihe, in die er nach Meinung der Quelle längst nicht mehr + /// gehört. + /// + private async Task StichworteSetzen( + IReadOnlyList? namen, Guid? film, Guid? serie, CancellationToken ct) + { + await db.Stichwortlinks + .Where(l => film != null ? l.MovieId == film : l.SeriesId == serie) + .ExecuteDeleteAsync(ct); + + if (namen is not { Count: > 0 }) return; + + foreach (var name in namen) + { + db.Stichwortlinks.Add(new Stichwortlink + { + StichwortId = await StichwortKennung(name, ct), MovieId = film, SeriesId = serie, + }); + } + } + + /// Die Kennung eines Stichworts — angelegt, falls es sie nicht gibt. + /// + /// + /// Dieselbe Vorsicht wie bei : mehrere + /// Bibliotheken scannen gleichzeitig, jede mit eigenem Kontext, und + /// zwei davon stossen auf „christmas". + /// + private async Task StichwortKennung(string name, CancellationToken ct) + { + var vorhanden = db.Stichworte.Local.FirstOrDefault(g => g.Name == name) + ?? await db.Stichworte.FirstOrDefaultAsync(g => g.Name == name, ct); + if (vorhanden is not null) return vorhanden.Id; + + var neu = new Stichwort { Name = name }; + db.Stichworte.Add(neu); + try + { + await db.SaveChangesAsync(ct); + return neu.Id; + } + catch (DbUpdateException) + { + db.Entry(neu).State = EntityState.Detached; + var fremd = await db.Stichworte.FirstOrDefaultAsync(g => g.Name == name, ct); + if (fremd is not null) return fremd.Id; + throw; + } + } + /// Schreibt Besetzung und Stab — ebenfalls ersetzend. private async Task MitwirkendeSetzen( IReadOnlyList? mitwirkende, Guid? film, Guid? serie, CancellationToken ct) diff --git a/server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.Designer.cs b/server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.Designer.cs new file mode 100644 index 0000000..59cb30b --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.Designer.cs @@ -0,0 +1,1255 @@ +// +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("20261008193712_Stichwoerter")] + partial class Stichwoerter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Fremdbewertung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Anzeige") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Quelle") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Wert") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("MovieId", "Quelle") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Quelle") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Fremdbewertungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FassungenGeprueftBei") + .HasColumnType("INTEGER"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwort", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stichworte"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwortlink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StichwortId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("StichwortId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("StichwortId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Stichwortlinks"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("IsOwner") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .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.Fremdbewertung", b => + { + 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.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.Meldung", 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(); + }); + + 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.Library.Stichwortlink", b => + { + 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.Library.Stichwort", null) + .WithMany() + .HasForeignKey("StichwortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", 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.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.cs b/server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.cs new file mode 100644 index 0000000..20fd727 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261008193712_Stichwoerter.cs @@ -0,0 +1,99 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Stichwoerter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Stichworte", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Stichworte", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Stichwortlinks", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + StichwortId = table.Column(type: "TEXT", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Stichwortlinks", x => x.Id); + table.ForeignKey( + name: "FK_Stichwortlinks_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Stichwortlinks_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Stichwortlinks_Stichworte_StichwortId", + column: x => x.StichwortId, + principalTable: "Stichworte", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Stichworte_Name", + table: "Stichworte", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Stichwortlinks_MovieId", + table: "Stichwortlinks", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_Stichwortlinks_SeriesId", + table: "Stichwortlinks", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_Stichwortlinks_StichwortId_MovieId", + table: "Stichwortlinks", + columns: new[] { "StichwortId", "MovieId" }, + unique: true, + filter: "\"MovieId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Stichwortlinks_StichwortId_SeriesId", + table: "Stichwortlinks", + columns: new[] { "StichwortId", "SeriesId" }, + unique: true, + filter: "\"SeriesId\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Stichwortlinks"); + + migrationBuilder.DropTable( + name: "Stichworte"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 348fa4d..d9ac304 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -825,6 +825,57 @@ namespace Flaro.Data.Migrations b.ToTable("Series"); }); + modelBuilder.Entity("Flaro.Core.Library.Stichwort", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Stichworte"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Stichwortlink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StichwortId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("StichwortId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("StichwortId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Stichwortlinks"); + }); + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => { b.Property("Id") @@ -1134,6 +1185,25 @@ namespace Flaro.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Flaro.Core.Library.Stichwortlink", b => + { + 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.Library.Stichwort", null) + .WithMany() + .HasForeignKey("StichwortId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => { b.HasOne("Flaro.Core.Library.Episode", null) diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index be960b0..a599888 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -124,7 +124,7 @@ public sealed class TmdbClient( // TMDB begrenzt, wie oft man fragen darf. var film = await HolenAsync( $"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" + - "&append_to_response=credits,external_ids,release_dates,images" + + "&append_to_response=credits,external_ids,release_dates,images,keywords" + // **Sprachlos ist erwünscht.** Viele Schriftzüge tragen gar keine // Sprache (`null`), weil sie keinen Text enthalten, der übersetzt // werden müsste. Ohne diese Angabe liefert TMDB sie nicht mit. @@ -152,6 +152,7 @@ public sealed class TmdbClient( Tagline = Leer(film.Tagline), ContentRating = Freigabe(film.ReleaseDates, Land(einstellung.Language)), Genres = Namen(film.Genres), + Keywords = Stichworte(film.Keywords?.Keywords), Credits = Mitwirkende(film.Credits), Logo = Schriftzug(film.Images, Sprachkürzel(einstellung.Language)), // Die Sprache, in der diese Antwort kam — der Server merkt sie @@ -208,7 +209,7 @@ public sealed class TmdbClient( var serie = await HolenAsync( $"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" + - "&append_to_response=credits,external_ids,content_ratings,images" + + "&append_to_response=credits,external_ids,content_ratings,images,keywords" + $"&include_image_language={Uri.EscapeDataString(Sprachkürzel(einstellung.Language))},en,null", einstellung, ct); @@ -229,6 +230,9 @@ public sealed class TmdbClient( Tagline = Leer(serie.Tagline), ContentRating = Freigabe(serie.ContentRatings, Land(einstellung.Language)), Genres = Namen(serie.Genres), + // **Bei Serien heisst das Feld anders.** TMDB nennt es dort + // `results` statt `keywords`; derselbe Inhalt, ein anderer Name. + Keywords = Stichworte(serie.Keywords?.Keywords ?? serie.Keywords?.Results), Credits = Mitwirkende(serie.Credits), Logo = Schriftzug(serie.Images, Sprachkürzel(einstellung.Language)), // Die Sprache, in der diese Antwort kam — der Server merkt sie @@ -524,6 +528,19 @@ public sealed class TmdbClient( private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache) => Bild(Bestes(bilder?.Logos, sprache)?.FilePath, LogoSize); + /// Die Stichwörter, kleingeschrieben und ohne Leeres. + /// + /// + /// Kleingeschrieben, weil danach verglichen wird. TMDB liefert + /// „Christmas" und „christmas" je nach Eintrag; eine saisonale Reihe, + /// die beides für Verschiedenes hält, zeigt den halben Bestand. + /// + private static IReadOnlyList? Stichworte(List? roh) => + roh?.Where(k => !string.IsNullOrWhiteSpace(k.Name)) + .Select(k => k.Name!.Trim().ToLowerInvariant()) + .Distinct() + .ToList(); + private static string? Freigabe(TmdbReleaseDates? quelle, string land) => Leer(quelle?.Results? .FirstOrDefault(r => string.Equals(r.Country, land, StringComparison.OrdinalIgnoreCase))? @@ -620,7 +637,8 @@ public sealed class TmdbClient( [property: JsonPropertyName("credits")] TmdbCredits? Credits = null, [property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null, [property: JsonPropertyName("release_dates")] TmdbReleaseDates? ReleaseDates = null, - [property: JsonPropertyName("images")] TmdbImages? Images = null); + [property: JsonPropertyName("images")] TmdbImages? Images = null, + [property: JsonPropertyName("keywords")] TmdbKeywords? Keywords = null); private sealed record TmdbSeries( [property: JsonPropertyName("id")] int Id, @@ -637,10 +655,18 @@ public sealed class TmdbClient( [property: JsonPropertyName("credits")] TmdbCredits? Credits = null, [property: JsonPropertyName("external_ids")] TmdbExternalIds? ExternalIds = null, [property: JsonPropertyName("content_ratings")] TmdbContentRatings? ContentRatings = null, - [property: JsonPropertyName("images")] TmdbImages? Images = null); + [property: JsonPropertyName("images")] TmdbImages? Images = null, + [property: JsonPropertyName("keywords")] TmdbKeywords? Keywords = null); private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name); + private sealed record TmdbName([property: JsonPropertyName("name")] string? Name); + + /// Bei Filmen heisst die Liste „keywords", bei Serien „results". + private sealed record TmdbKeywords( + [property: JsonPropertyName("keywords")] List? Keywords = null, + [property: JsonPropertyName("results")] List? Results = null); + private sealed record TmdbImages( [property: JsonPropertyName("logos")] List? Logos, [property: JsonPropertyName("posters")] List? Posters = null, diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index 915c36f..121f4d4 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -561,6 +561,94 @@ public class MetadataTests } + /// Die saisonale Reihe findet, was das Stichwort trägt. + /// + /// + /// Nach Stichwörtern, nicht nach Genres. „Stirb langsam" ist kein + /// Familienfilm und trägt trotzdem „christmas" — eine Weihnachtsreihe + /// nach Genres enthielte jeden Zeichentrickfilm und diesen Film nicht. + /// Genau das prüft dieser Test: der Actionfilm mit dem Stichwort ist + /// drin, der Familienfilm ohne es nicht. + /// + [Test] + public async Task Die_saisonale_Reihe_geht_nach_Stichwort_und_nicht_nach_Genre() + { + Datei("Stirb langsam (1988)/Stirb langsam (1988).mkv"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(562, "Stirb langsam", 1988, null, null, null) + { + Genres = ["Action"], + Keywords = ["christmas", "terrorist"], + }, + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + // Ein Anlass, der heute gilt — und einer, der nie gilt. + await client.PutAsJsonAsync("/api/v1/settings/seasonal", new[] + { + new { name = "Gerade jetzt", von = "01-01", bis = "12-31", stichworte = new[] { "christmas" }, genres = Array.Empty() }, + }); + + var reihe = await client.GetFromJsonAsync("/api/v1/home/seasonal"); + + Assert.Multiple(() => + { + Assert.That(reihe!.Occasion, Is.EqualTo("Gerade jetzt")); + Assert.That(reihe.Items.Items.Select(f => f.Title), Is.EquivalentTo(new[] { "Stirb langsam" })); + }); + } + + /// Gilt heute kein Anlass, kommt keine Reihe. + /// + /// + /// Und zwar ohne Namen: der Client zeigt dann gar keine Reihe, statt + /// einer mit Überschrift und nichts darunter. + /// + [Test] + public async Task Ohne_geltenden_Anlass_bleibt_die_Reihe_leer() + { + Datei("Stirb langsam (1988)/Stirb langsam (1988).mkv"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(562, "Stirb langsam", 1988, null, null, null) + { + Keywords = ["christmas"], + }, + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + // Ein einzelner Tag, der heute mit grosser Wahrscheinlichkeit nicht ist — + // und wenn doch, prüft der zweite Anlass daneben dasselbe. + var heute = DateTimeOffset.UtcNow; + var fern = heute.AddDays(100); + + await client.PutAsJsonAsync("/api/v1/settings/seasonal", new[] + { + new + { + name = "Weit weg", von = $"{fern:MM-dd}", bis = $"{fern:MM-dd}", + stichworte = new[] { "christmas" }, genres = Array.Empty(), + }, + }); + + var reihe = await client.GetFromJsonAsync("/api/v1/home/seasonal"); + + Assert.Multiple(() => + { + Assert.That(reihe!.Occasion, Is.Null); + Assert.That(reihe.Items.Items, Is.Empty); + }); + } + /// Eine Videomediathek nimmt den Dateinamen und fragt niemanden. /// /// diff --git a/server/tests/Flaro.Core.Tests/SaisonanlassTests.cs b/server/tests/Flaro.Core.Tests/SaisonanlassTests.cs new file mode 100644 index 0000000..679b178 --- /dev/null +++ b/server/tests/Flaro.Core.Tests/SaisonanlassTests.cs @@ -0,0 +1,97 @@ +using Flaro.Core.Library; + +namespace Flaro.Core.Tests; + +/// Wann ein saisonaler Anlass gilt. +/// +/// +/// Der Fall, an dem eine naive Prüfung scheitert: Silvester geht vom +/// 28. Dezember bis zum 2. Januar. Ein Vergleich, der nur „von ≤ heute ≤ +/// bis" kennt, fände diesen Zeitraum nie — und die Reihe stünde genau in +/// den Tagen nicht da, für die sie gedacht ist. +/// +public class SaisonanlassTests +{ + private static DateTimeOffset Am(int monat, int tag) => + new(2026, monat, tag, 12, 0, 0, TimeSpan.Zero); + + private static Saisonanlass Anlass(string von, string bis) => + new("Probe", von, bis, ["irgendwas"], []); + + [TestCase(10, 1, true)] + [TestCase(10, 15, true)] + [TestCase(11, 1, true)] + [TestCase(9, 30, false)] + [TestCase(11, 2, false)] + public void Ein_Zeitraum_im_Jahr_gilt_von_Rand_zu_Rand(int monat, int tag, bool erwartet) => + Assert.That(Anlass("10-01", "11-01").Gilt(Am(monat, tag)), Is.EqualTo(erwartet)); + + [TestCase(12, 28, true)] + [TestCase(12, 31, true)] + [TestCase(1, 1, true)] + [TestCase(1, 2, true)] + [TestCase(1, 3, false)] + [TestCase(12, 27, false)] + [TestCase(6, 15, false)] + public void Ein_Zeitraum_ueber_den_Jahreswechsel_gilt_auch(int monat, int tag, bool erwartet) => + Assert.That(Anlass("12-28", "01-02").Gilt(Am(monat, tag)), Is.EqualTo(erwartet)); + + [Test] + public void Ein_einzelner_Tag_ist_ein_Zeitraum_von_null_Laenge() + { + var august = Anlass("08-01", "08-01"); + + Assert.Multiple(() => + { + Assert.That(august.Gilt(Am(8, 1)), Is.True); + Assert.That(august.Gilt(Am(8, 2)), Is.False); + Assert.That(august.Gilt(Am(7, 31)), Is.False); + }); + } + + [TestCase("")] + [TestCase("Oktober")] + [TestCase("13-01")] + [TestCase("10-32")] + [TestCase("10")] + public void Ein_unleserliches_Datum_gilt_nie(string von) => + Assert.That(Anlass(von, "11-01").Gilt(Am(10, 15)), Is.False); + + /// Ein Anlass ohne Stichwort und ohne Genre fände nie etwas. + /// + /// + /// Ihn stillschweigend zu speichern hiesse, den Benutzer den Fehler + /// woanders suchen zu lassen — die Reihe erschiene einfach nicht. + /// + [Test] + public void Ohne_Stichwort_und_ohne_Genre_ist_ein_Anlass_unbrauchbar() + { + Assert.Multiple(() => + { + Assert.That(new Saisonanlass("Leer", "10-01", "11-01", [], []).Brauchbar, Is.False); + Assert.That(new Saisonanlass("", "10-01", "11-01", ["x"], []).Brauchbar, Is.False); + Assert.That(new Saisonanlass("Mit Genre", "10-01", "11-01", [], ["Horror"]).Brauchbar, Is.True); + Assert.That(new Saisonanlass("Mit Wort", "10-01", "11-01", ["x"], []).Brauchbar, Is.True); + }); + } + + /// Die Vorgabe deckt das Jahr ab, ohne sich zu überschneiden. + /// + /// + /// Zwei gleichzeitig geltende Anlässe wären kein Fehler — der Server + /// nimmt den ersten —, aber einer davon stünde nie da, und das merkte + /// niemand. + /// + [Test] + public void Die_vorangelegten_Anlaesse_ueberschneiden_sich_nicht() + { + for (var tag = new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero); + tag.Year == 2026; + tag = tag.AddDays(1)) + { + var geltend = Saisonanlass.Vorgabe.Where(a => a.Gilt(tag)).Select(a => a.Name).ToList(); + Assert.That(geltend, Has.Count.LessThanOrEqualTo(1), + $"am {tag:dd.MM.} gelten mehrere: {string.Join(", ", geltend)}"); + } + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index eab208f..1b98533 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -575,6 +575,23 @@ export const serie = (id: string) => anfrage(`/series/${id}`); /* ---------- Stand je Benutzer ---------- */ +/** Die Reihe, die nur zu ihrer Zeit dasteht — `occasion` ist die Überschrift. */ +export const saisonal = (take = 24) => + anfrage<{ occasion: string | null; items: Seite }>(`/home/seasonal${abfrage({ take })}`); + +/** Die saisonalen Anlässe — Name, Zeitraum, Stichwörter. */ +export type Saisonanlass = { + name: string; von: string; bis: string; + stichworte: string[]; genres: string[]; +}; + +export const saisonanlaesse = () => anfrage('/settings/seasonal'); + +export const saisonanlaesseSetzen = (anlaesse: Saisonanlass[]) => + anfrage('/settings/seasonal', { + method: 'PUT', body: JSON.stringify(anlaesse), + }); + export const weiterschauen = (take = 20) => anfrage>(`/home/continue${abfrage({ take })}`); diff --git a/web/app/src/views/einstellungen/uebersicht.ts b/web/app/src/views/einstellungen/uebersicht.ts index 54effa4..c336552 100644 --- a/web/app/src/views/einstellungen/uebersicht.ts +++ b/web/app/src/views/einstellungen/uebersicht.ts @@ -1,7 +1,8 @@ import { doppelteZusammenlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, aufmacherhoeheSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte, - bildspeicherLeeren, kratzplatzAufraeumen, type Wiedergabe, + bildspeicherLeeren, kratzplatzAufraeumen, saisonanlaesse, saisonanlaesseSetzen, + type Wiedergabe, type Saisonanlass, } from '../../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util'; import { dialog } from '../../dialog'; @@ -168,6 +169,46 @@ export function serverbereich(geaendert: () => void): HTMLElement { tun: async wert => { await aufmacherhoeheSetzen(wert('stufe')); location.reload(); }, }))); + /* ---------- Saisonale Reihen ---------- */ + + // **Eine Zeile je Anlass, als Text.** Ein Listeneditor mit Zeilen zum + // Hinzufügen, Verschieben und Löschen wäre viel Oberfläche für etwas, + // das man dreimal im Jahr anfasst. Vier Felder, durch Striche + // getrennt, kann man lesen und in zehn Sekunden ändern — und es ist + // dieselbe Form, in der es gespeichert wird. + const anlaesse = await saisonanlaesse().catch(() => []); + const heute = new Date(); + const jetzt = `${String(heute.getMonth() + 1).padStart(2, '0')}-${String(heute.getDate()).padStart(2, '0')}`; + const laufend = anlaesse.find(a => gilt(a, jetzt)); + + liste.appendChild(zeile( + 'Saisonale Reihen', + anlaesse.length === 0 + ? 'Keine' + : `${anlaesse.length} Anlässe` + (laufend ? ` · gerade: ${laufend.name}` : ' · gerade keiner'), + 'Eine Reihe auf der Startseite, die nur zu ihrer Zeit dasteht. ' + + 'Gesucht wird nach Stichwörtern der Metadatenquelle — „Stirb langsam" ' + + 'ist kein Familienfilm und trägt trotzdem „christmas".', + () => dialog({ + titel: 'Saisonale Reihen', + felder: [{ + name: 'liste', + beschriftung: 'Ein Anlass je Zeile', + art: 'mehrzeilig', + zeilen: 8, + wert: anlaesse.map(alsZeile).join('\n'), + hinweis: 'Name | von | bis | Stichwörter | Genres — Datum als MM-TT, ' + + 'Stichwörter und Genres mit Komma getrennt. Ein Zeitraum darf über ' + + 'den Jahreswechsel gehen (12-28 bis 01-02). Genres sind freiwillig.', + }], + bestaetigen: 'Übernehmen', + tun: async wert => { + await saisonanlaesseSetzen( + wert('liste').split('\n').map(z => z.trim()).filter(Boolean).map(ausZeile)); + location.reload(); + }, + }))); + // **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst; // dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen — // etwa nachdem eine Zuordnung von Hand geändert wurde. @@ -388,3 +429,27 @@ export function speicherbereich(): HTMLElement { return bereich; } +/* ---------- Saisonale Anlässe als Text ---------- */ + +/** „Zu Halloween | 10-01 | 11-01 | halloween, witch | Horror" */ +function alsZeile(a: Saisonanlass): string { + return [a.name, a.von, a.bis, a.stichworte.join(', '), a.genres.join(', ')].join(' | '); +} + +/** Und wieder zurück. Fehlende Felder bleiben leer — der Server weist ab, + * was damit unbrauchbar ist, statt es stillschweigend zu schlucken. */ +function ausZeile(z: string): Saisonanlass { + const [name = '', von = '', bis = '', stichworte = '', genres = ''] = z.split('|').map(t => t.trim()); + const teile = (t: string) => t.split(',').map(x => x.trim()).filter(Boolean); + return { name, von, bis, stichworte: teile(stichworte), genres: teile(genres) }; +} + +/** Ob ein Anlass heute gilt — dieselbe Regel wie auf dem Server. + * + * **Auch über den Jahreswechsel.** „28.12. bis 02.01." ist kein Fehler, + * sondern der Normalfall für Silvester. */ +function gilt(a: Saisonanlass, heute: string): boolean { + const zahl = (t: string) => Number(t.replace('-', '')); + const [von, bis, nun] = [zahl(a.von), zahl(a.bis), zahl(heute)]; + return von <= bis ? nun >= von && nun <= bis : nun >= von || nun <= bis; +} diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts index e3810a2..55ab51d 100644 --- a/web/app/src/views/home.ts +++ b/web/app/src/views/home.ts @@ -1,5 +1,5 @@ import { - inhalt, weiterschauen, naechsteFolgen, empfehlungen, + inhalt, weiterschauen, naechsteFolgen, empfehlungen, saisonal, type Bibliothek, } from '../api'; import { leer, fehlerzeile, meldung } from '../util'; @@ -39,10 +39,14 @@ export function startseite( // **Alles auf einmal anfragen.** Bei fünf Mediatheken wären es sonst // acht Anfragen nacheinander, und die Startseite baut sich vor den // Augen des Benutzers Zeile für Zeile auf. - const [weiter, naechste, vorschlag, ...bestaende] = await Promise.all([ + const [weiter, naechste, vorschlag, saison, ...bestaende] = await Promise.all([ weiterschauen(), naechsteFolgen(), empfehlungen(24).catch(() => ({ items: [], total: 0 })), + // **Scheitert sie, fehlt eine Reihe und nicht die Startseite.** Ein + // älterer Server kennt den Weg nicht; das darf nicht dazu führen, + // dass gar nichts erscheint. + saisonal(24).catch(() => ({ occasion: null, items: { items: [], total: 0 } })), ...bibliotheken.map(b => inhalt(b, { sort: 'added', take: 24 })), ]); @@ -100,6 +104,16 @@ export function startseite( naechste.items.map(e => kachel(angefangenAlsKachel(e), oeffnen)))); } + // **Weit oben, weil sie nur jetzt gilt.** „Zu Halloween" unter den + // Neuzugängen wäre im November verschwunden, ohne dass jemand sie + // gesehen hat. Ohne Anlass oder ohne Treffer kommt `occasion` leer + // zurück — dann steht hier gar keine Reihe statt einer mit + // Überschrift und nichts darunter. + if (saison.occasion && saison.items.items.length) { + wurzel.appendChild(reihe(saison.occasion, + saison.items.items.map(e => kachel(alsKachel(e), oeffnen)))); + } + let etwasDa = false; bibliotheken.forEach((b, i) => { const seite = bestaende[i];