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];