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>
304 lines
15 KiB
C#
304 lines
15 KiB
C#
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;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
public static class SettingsEndpoints
|
|
{
|
|
public static void MapSettings(this IEndpointRouteBuilder app)
|
|
{
|
|
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
|
|
{
|
|
var zeilen = await db.Settings.AsNoTracking()
|
|
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName
|
|
|| s.Key == DatabaseTmdbSettings.LanguageName
|
|
|| s.Key == DatabaseFanartSettings.ApiKeyName
|
|
|| s.Key == DatabaseOmdbSettings.ApiKeyName)
|
|
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
|
|
|
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
|
// Angemeldeten das TMDB-Kontingent des Betreibers in die Hand, und
|
|
// zum Anzeigen genügt die Auskunft, dass einer da ist.
|
|
var aufgelöst = TmdbSettings.Resolve(
|
|
zeilen.GetValueOrDefault(DatabaseTmdbSettings.ApiKeyName),
|
|
zeilen.GetValueOrDefault(DatabaseTmdbSettings.LanguageName));
|
|
|
|
return Results.Ok(new MetadataSettingsResponse(
|
|
HasApiKey: !string.IsNullOrEmpty(aufgelöst.ApiKey),
|
|
Language: aufgelöst.Language,
|
|
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
|
// benutzt wird — und dass ein eigener ihn ablösen würde.
|
|
UsingProjectKey: aufgelöst.UsingProjectKey,
|
|
HasFanartKey: !string.IsNullOrWhiteSpace(
|
|
zeilen.GetValueOrDefault(DatabaseFanartSettings.ApiKeyName)),
|
|
HasOmdbKey: !string.IsNullOrWhiteSpace(
|
|
zeilen.GetValueOrDefault(DatabaseOmdbSettings.ApiKeyName))));
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
// **In welcher Sprache der Bestand vorliegt.**
|
|
//
|
|
// Der eigentliche Fehler war nicht, dass Titel englisch blieben —
|
|
// sondern dass niemand feststellen konnte, ob sie es sind. „Alles ist
|
|
// noch auf Englisch, obwohl Deutsch eingestellt ist" liess sich nicht
|
|
// beantworten, nur bestreiten. Hier steht die Zahl.
|
|
app.MapGet("/api/v1/settings/metadata/languages", async (FlaroContext db) =>
|
|
{
|
|
var eingestellt = await db.Settings.AsNoTracking()
|
|
.Where(x => x.Key == DatabaseTmdbSettings.LanguageName)
|
|
.Select(x => x.Value).FirstOrDefaultAsync();
|
|
var serversprache = TmdbSettings.Resolve(null, eingestellt).Language;
|
|
|
|
// Nur zugeordnete Titel zählen: was keine Kennung hat, hat auch
|
|
// keine Sprache — das ist ein Zuordnungsproblem, kein Sprachproblem.
|
|
var filme = await db.Movies.AsNoTracking().Where(m => m.TmdbId != null)
|
|
.GroupBy(m => m.MetadataLanguage)
|
|
.Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync();
|
|
var serien = await db.Series.AsNoTracking().Where(x => x.TmdbId != null)
|
|
.GroupBy(x => x.MetadataLanguage)
|
|
.Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync();
|
|
|
|
var zusammen = filme.Concat(serien)
|
|
.GroupBy(x => x.Sprache)
|
|
.Select(g => new Sprachanteil(g.Key, g.Sum(x => x.Anzahl)))
|
|
.OrderByDescending(x => x.Anzahl)
|
|
.ToList();
|
|
|
|
return Results.Ok(new Sprachverteilung(
|
|
serversprache,
|
|
zusammen,
|
|
Abweichend: zusammen.Where(x => x.Sprache != serversprache).Sum(x => x.Anzahl)));
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
// Frischt genau die auf, die nicht zur Serversprache passen.
|
|
app.MapPost("/api/v1/settings/metadata/languages/refresh", async (
|
|
FlaroContext db, ScanCoordinator koordinator) =>
|
|
{
|
|
var eingestellt = await db.Settings.AsNoTracking()
|
|
.Where(x => x.Key == DatabaseTmdbSettings.LanguageName)
|
|
.Select(x => x.Value).FirstOrDefaultAsync();
|
|
var serversprache = TmdbSettings.Resolve(null, eingestellt).Language;
|
|
|
|
var mediatheken = await db.Libraries.AsNoTracking().Select(l => l.Id).ToListAsync();
|
|
var gestartet = mediatheken.Count(id => koordinator.TryStart(id, erneut: true, serversprache));
|
|
|
|
return gestartet > 0
|
|
? Results.Accepted(value: new { started = gestartet, language = serversprache })
|
|
: Einwand.Belegt("In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.");
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
app.MapPut("/api/v1/settings/metadata", async (
|
|
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
|
{
|
|
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
|
|
if (anfrage.FanartApiKey is not null)
|
|
await Setzen(db, DatabaseFanartSettings.ApiKeyName, anfrage.FanartApiKey.Trim());
|
|
if (anfrage.OmdbApiKey is not null)
|
|
await Setzen(db, DatabaseOmdbSettings.ApiKeyName, anfrage.OmdbApiKey.Trim());
|
|
if (anfrage.Language is not null)
|
|
{
|
|
var sprache = anfrage.Language.Trim();
|
|
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
|
|
// Englisch an, und niemand wüsste, warum.
|
|
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
|
|
return Einwand.Bitte($"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“.");
|
|
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
return Results.NoContent();
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
// ------------------------------------------------------------------
|
|
// Wiedergabe
|
|
// ------------------------------------------------------------------
|
|
|
|
app.MapGet("/api/v1/settings/playback", async (Wiedergabeeinstellungen einstellungen) =>
|
|
{
|
|
var regeln = await einstellungen.RegelnAsync();
|
|
var stufe = await einstellungen.StufeAsync();
|
|
return Results.Ok(new PlaybackSettingsResponse(
|
|
regeln.FortsetzenAb.TotalSeconds, regeln.GesehenAnteil, stufe.AlsWort()));
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
app.MapPut("/api/v1/settings/playback", async (
|
|
PlaybackSettingsRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
|
{
|
|
var vorher = Wiedergabeeinstellungen.Lesen(
|
|
(await db.Settings.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.FortsetzenAbName))?.Value,
|
|
(await db.Settings.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.Key == Wiedergabeeinstellungen.GesehenAnteilName))?.Value);
|
|
|
|
var nachher = Wiedergabeeinstellungen.Lesen(
|
|
anfrage.ResumeAfterSeconds?.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
|
?? vorher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
anfrage.WatchedFraction?.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
|
?? vorher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
await Setzen(db, Wiedergabeeinstellungen.FortsetzenAbName,
|
|
nachher.FortsetzenAb.TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
await Setzen(db, Wiedergabeeinstellungen.GesehenAnteilName,
|
|
nachher.GesehenAnteil.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
|
|
|
if (anfrage.Encoder is not null)
|
|
await Setzen(db, Wiedergabeeinstellungen.TranscoderName, Kodierstufen.Lesen(anfrage.Encoder).AlsWort());
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
// **Was schon dasteht, muss neu bewertet werden.**
|
|
//
|
|
// `UserItemState.InProgress` ist eine gespeicherte Spalte — die
|
|
// Bruchrechnung lässt sich nicht nach SQL übersetzen. Eine neue
|
|
// Schwelle wirkte deshalb nur auf künftige Meldungen: wer gestern
|
|
// eine Sekunde hineingeschaut hat, stünde bis in alle Ewigkeit in
|
|
// „Weiterschauen". Also einmal durch, in Stapeln.
|
|
var geaendert = nachher == vorher ? 0 : await NeuBewerten(db, nachher, clock);
|
|
return Results.Ok(new PlaybackSettingsApplied(
|
|
nachher.FortsetzenAb.TotalSeconds, nachher.GesehenAnteil,
|
|
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 (
|
|
Kratzplatzeinstellungen kratz, Wartungsplaneinstellungen plan) =>
|
|
{
|
|
var regeln = await kratz.RegelnAsync();
|
|
var w = await plan.RegelnAsync();
|
|
return Results.Ok(new MaintenanceSettingsResponse(
|
|
regeln.ObergrenzeBytes / 1024d / 1024 / 1024,
|
|
(int)regeln.Unbenutzt.TotalDays,
|
|
(int)regeln.Schonfrist.TotalMinutes,
|
|
w.SicherungTage, w.SicherungBehalten, w.SicherungOrdner,
|
|
w.OptimierenTage, w.BilderTage));
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
app.MapPut("/api/v1/settings/maintenance", async (
|
|
MaintenanceSettingsRequest anfrage, FlaroContext db,
|
|
Kratzplatzeinstellungen einstellungen, Wartungsplaneinstellungen plan) =>
|
|
{
|
|
var vorher = await einstellungen.RegelnAsync();
|
|
|
|
// Was nicht geschickt wird, bleibt — ein Client, der nur ein Feld
|
|
// kennt, soll die anderen nicht zurücksetzen.
|
|
var nachher = Kratzplatzregeln.Geprueft(
|
|
anfrage.CapGb ?? vorher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
|
anfrage.UnusedDays ?? (int)vorher.Unbenutzt.TotalDays,
|
|
anfrage.GraceMinutes ?? (int)vorher.Schonfrist.TotalMinutes);
|
|
|
|
var zahl = System.Globalization.CultureInfo.InvariantCulture;
|
|
await Setzen(db, Kratzplatzeinstellungen.ObergrenzeSchluessel,
|
|
(nachher.ObergrenzeBytes / 1024d / 1024 / 1024).ToString("0.###", zahl));
|
|
await Setzen(db, Kratzplatzeinstellungen.UnbenutztSchluessel,
|
|
((int)nachher.Unbenutzt.TotalDays).ToString(zahl));
|
|
await Setzen(db, Kratzplatzeinstellungen.SchonfristSchluessel,
|
|
((int)nachher.Schonfrist.TotalMinutes).ToString(zahl));
|
|
var wVorher = await plan.RegelnAsync();
|
|
var wNachher = Wartungsregeln.Geprueft(
|
|
anfrage.BackupDays ?? wVorher.SicherungTage,
|
|
anfrage.BackupKeep ?? wVorher.SicherungBehalten,
|
|
anfrage.BackupFolder ?? wVorher.SicherungOrdner,
|
|
anfrage.OptimizeDays ?? wVorher.OptimierenTage,
|
|
anfrage.ImageDays ?? wVorher.BilderTage);
|
|
|
|
await Setzen(db, Wartungsplaneinstellungen.SicherungTageSchluessel,
|
|
wNachher.SicherungTage.ToString(zahl));
|
|
await Setzen(db, Wartungsplaneinstellungen.SicherungBehaltenSchluessel,
|
|
wNachher.SicherungBehalten.ToString(zahl));
|
|
await Setzen(db, Wartungsplaneinstellungen.SicherungOrdnerSchluessel,
|
|
wNachher.SicherungOrdner);
|
|
await Setzen(db, Wartungsplaneinstellungen.OptimierenTageSchluessel,
|
|
wNachher.OptimierenTage.ToString(zahl));
|
|
await Setzen(db, Wartungsplaneinstellungen.BilderTageSchluessel,
|
|
wNachher.BilderTage.ToString(zahl));
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
return Results.Ok(new MaintenanceSettingsResponse(
|
|
nachher.ObergrenzeBytes / 1024d / 1024 / 1024,
|
|
(int)nachher.Unbenutzt.TotalDays,
|
|
(int)nachher.Schonfrist.TotalMinutes,
|
|
wNachher.SicherungTage, wNachher.SicherungBehalten, wNachher.SicherungOrdner,
|
|
wNachher.OptimierenTage, wNachher.BilderTage));
|
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
|
}
|
|
|
|
/// <summary>Rechnet „steht in Weiterschauen" für alle Zeilen neu.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// In Stapeln zu fünfhundert: ein Haushalt mit drei Leuten und
|
|
/// fünftausend Titeln hat höchstens ein paar tausend Zeilen, aber alle
|
|
/// auf einmal zu laden ist eine Zahl, die man nicht kennt.
|
|
/// </remarks>
|
|
private static async Task<int> NeuBewerten(
|
|
FlaroContext db, Wiedergaberegeln regeln, TimeProvider clock)
|
|
{
|
|
const int Stapel = 500;
|
|
var geaendert = 0;
|
|
var jetzt = clock.GetUtcNow();
|
|
|
|
for (var sprung = 0; ; sprung += Stapel)
|
|
{
|
|
var zeilen = await db.UserItemStates
|
|
.OrderBy(z => z.Id).Skip(sprung).Take(Stapel).ToListAsync();
|
|
if (zeilen.Count == 0) break;
|
|
|
|
foreach (var z in zeilen)
|
|
{
|
|
var soll = ProgressRule.BelongsInContinueWatching(z, regeln);
|
|
if (soll == z.InProgress) continue;
|
|
z.InProgress = soll;
|
|
z.UpdatedAt = jetzt;
|
|
geaendert++;
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
if (zeilen.Count < Stapel) break;
|
|
}
|
|
|
|
return geaendert;
|
|
}
|
|
|
|
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
|
|
{
|
|
var vorhanden = await db.Settings.FindAsync(schlüssel);
|
|
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = schlüssel, Value = wert });
|
|
else vorhanden.Value = wert;
|
|
}
|
|
}
|