Files
Flaro/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs
T
ScarriffleandClaude Opus 5 8e9da1355c Der Server schrieb deutsche Sätze an eine Stelle, die niemand liest
„Die Anfrage war so nicht zu gebrauchen" — das ist der Ersatzsatz des
Browsers für eine Ablehnung ohne Begründung. Dabei stand die Begründung
da: „Benutzername darf nicht leer sein, das Passwort braucht mindestens 8
Zeichen." Nur eben in `errors`, und beide Clients lesen `detail`.

Zwei Formen waren betroffen. `Results.ValidationProblem` legt den Satz
unter `errors` ab, `Results.BadRequest(new { fehler = … })` unter
`fehler` — in beiden Fällen kommt er nirgends an. Zusammen 36 Stellen,
darunter jede Zeile, die ich gestern für die Meldungen geschrieben habe.
Dass die Clients `title` und `errors` auslassen, ist dabei richtig: dort
stehen sonst die englischen Prüftexte des Rahmenwerks, und „Not Found" ist
keine Auskunft.

Jetzt eine Form, hinter `Einwand.Bitte` / `.Fehlt` / `.Belegt`. Dass es
drei benannte Türen sind statt eines Aufrufs mit Zahl, hat einen Grund:
man sieht am Wort, ob der Server um eine bessere Eingabe bittet, etwas
nicht findet oder auf etwas Bestehendes stösst.

**Auffallen konnte es nicht**, und das ist der eigentliche Fund. Die
vorhandenen Tests prüften den Rumpf als Zeichenkette — der Satz *stand* ja
drin, nur im falschen Feld. Der neue Test prüft deshalb, in welchem Feld,
und ein Wächter am Quelltext schlägt an, wenn eine der beiden alten Formen
zurückkommt. Er hat beim ersten Lauf gleich eine Stelle gefunden, die mein
Suchmuster übersehen hatte.

Dazu drei Kleinigkeiten, die beim Lesen auffielen:

**Passwörter wurden getrimmt.** Der Dialog schnitt jedem Feld die Ränder
ab, auch dem Passwort — `login.ts` sendet es ungeschoren. Ein Leerzeichen
am Ende, und das angelegte Konto war für immer unbetretbar.

**Beim Einrichten stapelten sich die Fehlerzeilen**, weil `.error`
weggeräumt wurde und `.fehler` entsteht. Drei Fehlversuche, drei rote
Kästen.

**Der Benutzerdialog versprach acht Zeichen und prüfte nichts.** Jetzt
sagt er es selbst, ohne den Umweg über die Leitung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 17:12:57 +02:00

199 lines
9.7 KiB
C#

using Flaro.Api.Auth;
using Flaro.Core;
using Flaro.Core.Delivery;
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)
.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));
}).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.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);
}
/// <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;
}
}