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:
co-authored by
Claude Opus 5
parent
c13d1e78d2
commit
e066f5fe87
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user