Eine Reihe, die nur zu ihrer Zeit dasteht

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 21:45:09 +02:00
co-authored by Claude Opus 5
parent c13d1e78d2
commit e066f5fe87
19 changed files with 2121 additions and 7 deletions
@@ -537,6 +537,14 @@ public sealed record EpisodeResponse(
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
/// <summary>Die saisonale Reihe — mit ihrem Namen, weil er wechselt.</summary>
///
/// <param name="Occasion">
/// Was über der Reihe steht. <c>null</c> heisst: heute hat nichts Saison,
/// oder der Anlass findet nichts — dann zeigt der Client gar keine Reihe.
/// </param>
public sealed record Saisonreihe(string? Occasion, Page<MovieResponse> Items);
/// <param name="HasFanartKey">
/// Ob eine zweite Bildquelle eingerichtet ist. Auch hier kommt der Schlüssel
/// selbst nicht zurück — zum Anzeigen genügt, dass einer da ist.
@@ -462,6 +462,73 @@ public static class PlaybackEndpoints
return Results.Ok(new Page<MovieResponse>(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<MovieResponse>([], 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<MovieResponse>([], 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<MovieResponse>(saisonitems, saisonitems.Count)));
});
}
/// <summary>Der Zustand eines Titels, den noch niemand angefasst hat.</summary>
@@ -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<Saisonanlass> 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 (
+1
View File
@@ -91,6 +91,7 @@ builder.Services.AddScoped<Uebersetzer>();
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
builder.Services.AddScoped<Wiedergabeeinstellungen>();
builder.Services.AddScoped<Saisoneinstellungen>();
builder.Services.AddHttpClient<TmdbClient>(c =>
{
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
@@ -0,0 +1,62 @@
using System.Text.Json;
using Flaro.Core.Library;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Die saisonalen Anlässe, aus den Einstellungen.</summary>
///
/// <remarks>
/// <b>Als JSON in einer Zeile und nicht als Tabelle.</b> 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.
/// </remarks>
public sealed class Saisoneinstellungen(FlaroContext db)
{
public const string Schluessel = "startseite.saisonal.anlaesse";
private IReadOnlyList<Saisonanlass>? _gemerkt;
public async Task<IReadOnlyList<Saisonanlass>> 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);
}
/// <summary>Liest die gespeicherte Liste — oder gibt die Vorgabe zurück.</summary>
///
/// <remarks>
/// <b>Unlesbares ist kein Grund, die Startseite zu verlieren.</b> Steht
/// dort Unsinn, gilt die Vorgabe; eine Ausnahme hier nähme dem Server
/// die ganze Reihe und nicht nur den falschen Eintrag.
/// </remarks>
public static IReadOnlyList<Saisonanlass> Lesen(string? roh)
{
if (string.IsNullOrWhiteSpace(roh)) return Saisonanlass.Vorgabe;
try
{
var gelesen = JsonSerializer.Deserialize<List<Saisonanlass>>(
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<Saisonanlass> anlaesse) =>
JsonSerializer.Serialize(
anlaesse.Where(a => a.Brauchbar).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
}
@@ -97,3 +97,29 @@ public sealed class Fremdbewertung
/// <summary>Was dastehen soll: „7,4", „87 %", „62".</summary>
public required string Anzeige { get; set; }
}
/// <summary>Ein Stichwort der Metadatenquelle, einmal je Name.</summary>
///
/// <remarks>
/// <b>Feiner als ein Genre, und deshalb eine eigene Tabelle.</b> „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.
/// </remarks>
public sealed class Stichwort
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Name { get; set; }
}
/// <summary>Verbindet ein Stichwort mit einem Film oder einer Serie.</summary>
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; }
}
@@ -0,0 +1,93 @@
using System.Globalization;
namespace Flaro.Core.Library;
/// <summary>Ein Anlass, zu dem eine eigene Reihe auf der Startseite steht.</summary>
///
/// <param name="Name">Was über der Reihe steht — „Zu Halloween".</param>
/// <param name="Von">Tag im Jahr, ab dem sie erscheint, als „MM-TT".</param>
/// <param name="Bis">Tag im Jahr, bis zu dem sie bleibt, als „MM-TT".</param>
/// <param name="Stichworte">Stichwörter der Metadatenquelle, kleingeschrieben.</param>
/// <param name="Genres">Genres, falls Stichwörter allein zu wenig hergeben.</param>
///
/// <remarks>
/// <para>
/// <b>Einstellbar und nicht eingebaut.</b> 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.
/// </para>
/// <para>
/// <b>Stichwörter zuerst, Genres als Zugabe.</b> „Stirb langsam" ist kein
/// Familienfilm und trägt trotzdem „christmas" — eine Weihnachtsreihe nach
/// Genres enthielte jeden Zeichentrickfilm und diesen Film nicht.
/// </para>
/// </remarks>
public sealed record Saisonanlass(
string Name,
string Von,
string Bis,
IReadOnlyList<string> Stichworte,
IReadOnlyList<string> Genres)
{
/// <summary>Was ohne eigene Einstellung dasteht.</summary>
///
/// <remarks>
/// <b>Vier, und bewusst nicht mehr.</b> 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.
/// </remarks>
public static IReadOnlyList<Saisonanlass> 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"],
[]),
];
/// <summary>Ob dieser Anlass an diesem Tag gilt.</summary>
///
/// <remarks>
/// <b>Über den Jahreswechsel hinweg.</b> „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.
/// </remarks>
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;
}
/// <summary>„12-24" wird zu 1224 — oder zu <c>null</c>, wenn es keiner ist.</summary>
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;
}
/// <summary>Ob der Anlass überhaupt etwas finden kann.</summary>
public bool Brauchbar =>
!string.IsNullOrWhiteSpace(Name)
&& Tagnummer(Von) is not null && Tagnummer(Bis) is not null
&& (Stichworte.Count > 0 || Genres.Count > 0);
}
@@ -51,6 +51,15 @@ public sealed record MovieMetadata(
/// <summary>Die Altersfreigabe des eingestellten Landes, etwa „16".</summary>
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
/// <summary>Stichwörter der Quelle, kleingeschrieben — „christmas", „halloween".</summary>
///
/// <remarks>
/// <b>Feiner als Genres.</b> „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.
/// </remarks>
IReadOnlyList<string>? Keywords = null,
IReadOnlyList<CreditMetadata>? Credits = null,
/// <summary>Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund.</summary>
///
@@ -86,6 +95,15 @@ public sealed record SeriesMetadata(
string? Tagline = null,
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
/// <summary>Stichwörter der Quelle, kleingeschrieben — „christmas", „halloween".</summary>
///
/// <remarks>
/// <b>Feiner als Genres.</b> „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.
/// </remarks>
IReadOnlyList<string>? Keywords = null,
IReadOnlyList<CreditMetadata>? Credits = null,
/// <summary>Der Schriftzug des Titels, freigestellt auf durchsichtigem Grund.</summary>
///
+21
View File
@@ -76,6 +76,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
public DbSet<Fremdbewertung> Fremdbewertungen => Set<Fremdbewertung>();
public DbSet<Stichwort> Stichworte => Set<Stichwort>();
public DbSet<Stichwortlink> Stichwortlinks => Set<Stichwortlink>();
public DbSet<Person> People => Set<Person>();
public DbSet<Credit> Credits => Set<Credit>();
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>();
@@ -272,6 +274,25 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.Name).HasMaxLength(64).UseCollation("NOCASE");
});
b.Entity<Stichwort>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Name).IsUnique();
e.Property(x => x.Name).HasMaxLength(100);
});
b.Entity<Stichwortlink>(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<Stichwort>().WithMany().HasForeignKey(x => x.StichwortId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
b.Entity<Fremdbewertung>(e =>
{
e.HasKey(x => x.Id);
+57
View File
@@ -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(
}
}
/// <summary>Schreibt die Stichwörter — nach demselben Muster wie Genres.</summary>
///
/// <remarks>
/// <b>Ersetzend, nicht ergänzend.</b> 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.
/// </remarks>
private async Task StichworteSetzen(
IReadOnlyList<string>? 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,
});
}
}
/// <summary>Die Kennung eines Stichworts — angelegt, falls es sie nicht gibt.</summary>
///
/// <remarks>
/// Dieselbe Vorsicht wie bei <see cref="GenreKennung"/>: mehrere
/// Bibliotheken scannen gleichzeitig, jede mit eigenem Kontext, und
/// zwei davon stossen auf „christmas".
/// </remarks>
private async Task<Guid> 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;
}
}
/// <summary>Schreibt Besetzung und Stab — ebenfalls ersetzend.</summary>
private async Task MitwirkendeSetzen(
IReadOnlyList<CreditMetadata>? mitwirkende, Guid? film, Guid? serie, CancellationToken ct)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Stichwoerter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Stichworte",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(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<Guid>(type: "TEXT", nullable: false),
StichwortId = table.Column<Guid>(type: "TEXT", nullable: false),
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
SeriesId = table.Column<Guid>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Stichwortlinks");
migrationBuilder.DropTable(
name: "Stichworte");
}
}
}
@@ -825,6 +825,57 @@ namespace Flaro.Data.Migrations
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Library.Stichwort", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("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)
+30 -4
View File
@@ -124,7 +124,7 @@ public sealed class TmdbClient(
// TMDB begrenzt, wie oft man fragen darf.
var film = await HolenAsync<TmdbMovie>(
$"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<TmdbSeries>(
$"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);
/// <summary>Die Stichwörter, kleingeschrieben und ohne Leeres.</summary>
///
/// <remarks>
/// <b>Kleingeschrieben, weil danach verglichen wird.</b> TMDB liefert
/// „Christmas" und „christmas" je nach Eintrag; eine saisonale Reihe,
/// die beides für Verschiedenes hält, zeigt den halben Bestand.
/// </remarks>
private static IReadOnlyList<string>? Stichworte(List<TmdbName>? 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);
/// <summary>Bei Filmen heisst die Liste „keywords", bei Serien „results".</summary>
private sealed record TmdbKeywords(
[property: JsonPropertyName("keywords")] List<TmdbName>? Keywords = null,
[property: JsonPropertyName("results")] List<TmdbName>? Results = null);
private sealed record TmdbImages(
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos,
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters = null,