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 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); } /// Rechnet „steht in Weiterschauen" für alle Zeilen neu. /// /// /// 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. /// private static async Task 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; } }