using Flaro.Api.Auth; using Flaro.Core.Libraries; using Flaro.Core.Library; using Flaro.Core.Users; using Flaro.Data; using Microsoft.EntityFrameworkCore; namespace Flaro.Api.Endpoints; public static class ManagementEndpoints { public static void MapManagement(this IEndpointRouteBuilder app) { var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator); benutzer.MapGet("/", async (FlaroContext db) => Results.Ok(await db.Users .OrderBy(u => u.UserName) .Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator)) .ToListAsync())); benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) => { var name = anfrage.UserName.Trim(); if (name.Length == 0 || anfrage.Password.Length < 8) return Results.ValidationProblem(new Dictionary { ["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."], }); if (await db.Users.AnyAsync(u => u.UserName == name)) return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict); var neu = new User { UserName = name, PasswordHash = PasswordHasher.Hash(anfrage.Password), IsAdministrator = anfrage.IsAdministrator, }; db.Users.Add(neu); await db.SaveChangesAsync(); return Results.Created($"/api/v1/users/{neu.Id}", new AuthenticatedUser(neu.Id, neu.UserName, neu.IsAdministrator)); }); var bibliotheken = app.MapGroup("/api/v1/libraries"); // Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an. // // **Mit Zählwert.** Die Seitenleiste führt jede Bibliothek einzeln // auf, und „Dokumentationen · 0" sagt mehr als ein Eintrag, hinter // dem sich nach dem Klick nichts findet — da ist der Unterschied // zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens // sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile. bibliotheken.MapGet("/", async (FlaroContext db, TimeProvider uhr) => { // **Ohne Rücksicht auf Gross- und Kleinschreibung.** // // SQLite vergleicht Text sonst zeichengenau, und dort stehen alle // Grossbuchstaben vor allen kleinen: „Serien" landete über „film", // obwohl f vor s kommt. Wer eine Mediathek umbenennt, sieht sie // dann scheinbar grundlos springen — es sah aus, als stünde die // zuletzt bearbeitete oben. // **Erst die eigene Reihenfolge, dann der Name.** Wer nie // umsortiert hat, bekommt das Alphabet; wer es getan hat, seine // Reihenfolge. Ohne den zweiten Schlüssel stünden alle // Nie-Sortierten in zufälliger Folge. var liste = await db.Libraries.AsNoTracking() .OrderBy(l => l.SortOrder) .ThenBy(l => EF.Functions.Collate(l.Name, "NOCASE")) .ToListAsync(); var jetzt = uhr.GetUtcNow(); // **Was noch wartet, je Mediathek.** Wer scannt, sieht das Ergebnis // an der Mediathek — dass zwanzig Titel irgendwo weiter unten // warten, erführe er sonst nur durch Weiterscrollen. // // Für einen gewöhnlichen Benutzer bleibt die Zahl null: der // Freigabefilter lässt ihn Unveröffentlichtes gar nicht erst // zählen, und das ist richtig so. var wartendeFilme = await db.Movies.AsNoTracking() .Where(m => m.PublishAt == null || m.PublishAt > jetzt) .GroupBy(m => m.LibraryId) .Select(g => new { Id = g.Key, Anzahl = g.Count() }) .ToDictionaryAsync(x => x.Id, x => x.Anzahl); var wartendeFolgen = await db.Episodes.AsNoTracking() .Where(f => f.PublishAt == null || f.PublishAt > jetzt) .GroupBy(f => db.Series.Where(s => s.Id == f.SeriesId) .Select(s => s.LibraryId).First()) .Select(g => new { Id = g.Key, Anzahl = g.Count() }) .ToDictionaryAsync(x => x.Id, x => x.Anzahl); var filme = await db.Movies.AsNoTracking() .GroupBy(m => m.LibraryId) .Select(g => new { Id = g.Key, Anzahl = g.Count() }) .ToDictionaryAsync(x => x.Id, x => x.Anzahl); var serien = await db.Series.AsNoTracking() .GroupBy(x => x.LibraryId) .Select(g => new { Id = g.Key, Anzahl = g.Count() }) .ToDictionaryAsync(x => x.Id, x => x.Anzahl); return Results.Ok(liste.Select(l => new LibraryResponse( l.Id, l.Name, l.Kind.ToString(), l.Path, l.Kind == LibraryKind.Shows ? serien.GetValueOrDefault(l.Id) : filme.GetValueOrDefault(l.Id), l.ReviewNewItems, l.Kind == LibraryKind.Shows ? wartendeFolgen.GetValueOrDefault(l.Id) : wartendeFilme.GetValueOrDefault(l.Id))).ToList()); }).RequireAuthorization(); // **Was nicht mehr da ist, aber noch in der Mediathek steht.** // // Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte // Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen // steht es hier, und das Löschen ist eine Entscheidung. // **Die Reihenfolge der Seitenleiste.** // // Als ganze Liste und nicht als „eins hoch": zwei Benutzer, die // gleichzeitig schieben, kämen sonst in einen Zustand, den keiner von // beiden wollte. Die Liste beschreibt, wie es danach aussehen soll. bibliotheken.MapPut("/order", async ( ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) => { if (anfrage.Ids is null or { Count: 0 }) return Results.BadRequest(new { fehler = "Ohne Reihenfolge gibt es nichts zu ordnen." }); // Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden. for (var i = 0; i < anfrage.Ids.Count; i++) { var platz = i + 1; var kennung = anfrage.Ids[i]; await db.Libraries.Where(l => l.Id == kennung) .ExecuteUpdateAsync(s => s.SetProperty(l => l.SortOrder, platz), ct); } return Results.NoContent(); }).RequireAuthorization(FlaroPolicies.Administrator); // Die Prüfung je Mediathek umstellen. bibliotheken.MapPut("/{id:guid}/review", async ( Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) => { var betroffen = await db.Libraries.Where(l => l.Id == id) .ExecuteUpdateAsync(s => s.SetProperty(l => l.ReviewNewItems, anfrage.ReviewNewItems), ct); return betroffen > 0 ? Results.NoContent() : Results.NotFound(); }).RequireAuthorization(FlaroPolicies.Administrator); // **Was zu keinem Titel gehört.** // // Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname // eine nennen — nur verschwanden die Dateien damit lautlos. Bei 3559 // von 5884 war das die halbe Mediathek, und in der Übersicht stand // eine Zahl, die niemand erklären konnte. Hier stehen sie, mit dem // Grund dazu. bibliotheken.MapGet("/{id:guid}/unassigned", async ( Guid id, FlaroContext db, int take = 300) => { var bibliothek = await db.Libraries.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id); if (bibliothek is null) return Results.NotFound(); var offen = db.MediaFiles.AsNoTracking() .Where(f => f.LibraryId == id && f.MovieId == null && f.EpisodeId == null && f.MissingSince == null); var gesamt = await offen.CountAsync(); var pfade = await offen.OrderBy(f => f.Path).Take(Math.Clamp(take, 1, 2000)).ToListAsync(); // Der Grund wird hier gerechnet, nicht gespeichert: er hängt allein // am Pfad und an der Benennungsregel. Eine Spalte dafür wäre beim // nächsten Regelwechsel veraltet, ohne dass es jemand merkt. return Results.Ok(new Unzugeordnet( gesamt, pfade.Select(f => new UnzugeordneteDatei( f.Id, f.Path, FileNames.WarumNicht(f.Path, bibliothek.Path))).ToList())); }).RequireAuthorization(FlaroPolicies.Administrator); bibliotheken.MapGet("/missing", async (FlaroContext db) => { var liste = await db.MediaFiles.AsNoTracking() .Where(f => f.MissingSince != null) .OrderBy(f => f.Path) .Select(f => new VermissteDatei( f.Id, f.Path, f.VersionLabel, f.MissingSince!.Value, db.Libraries.Where(l => l.Id == f.LibraryId).Select(l => l.Name).First(), f.MovieId != null ? db.Movies.Where(m => m.Id == f.MovieId).Select(m => m.Title).FirstOrDefault() : db.Episodes.Where(e => e.Id == f.EpisodeId) .Select(e => e.Title ?? ("Folge " + e.EpisodeNumber)).FirstOrDefault(), f.MovieId, f.EpisodeId)) .ToListAsync(); return Results.Ok(liste); }).RequireAuthorization(FlaroPolicies.Administrator); // Endgültig weg — mitsamt dem Titel, wenn es seine letzte Datei war. bibliotheken.MapDelete("/missing", async (Guid? fileId, FlaroContext db) => { var betroffen = db.MediaFiles.Where(f => f.MissingSince != null); if (fileId is { } eine) betroffen = betroffen.Where(f => f.Id == eine); // Die Titel merken, bevor die Dateien weg sind — danach lässt sich // nicht mehr feststellen, welche verwaist zurückbleiben. var filme = await betroffen.Where(f => f.MovieId != null) .Select(f => f.MovieId!.Value).Distinct().ToListAsync(); var folgen = await betroffen.Where(f => f.EpisodeId != null) .Select(f => f.EpisodeId!.Value).Distinct().ToListAsync(); var weg = await betroffen.ExecuteDeleteAsync(); await db.Movies .Where(m => filme.Contains(m.Id) && !db.MediaFiles.Any(f => f.MovieId == m.Id)) .ExecuteDeleteAsync(); await db.Episodes .Where(e => folgen.Contains(e.Id) && !db.MediaFiles.Any(f => f.EpisodeId == e.Id)) .ExecuteDeleteAsync(); await db.Series .Where(x => !db.Episodes.Any(e => e.SeriesId == x.Id)) .ExecuteDeleteAsync(); return Results.Ok(new { removed = weg }); }).RequireAuthorization(FlaroPolicies.Administrator); bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) => { if (!Enum.TryParse(anfrage.Kind, ignoreCase: true, out var art)) return Results.ValidationProblem(new Dictionary { ["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."], }); // **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad // führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und // die Ursache stünde allenfalls im Protokoll. if (!Directory.Exists(anfrage.Path)) return Results.ValidationProblem(new Dictionary { ["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " + "Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."], }); var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path, ReviewNewItems = anfrage.ReviewNewItems, }; db.Libraries.Add(neu); await db.SaveChangesAsync(); return Results.Created($"/api/v1/libraries/{neu.Id}", new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path, 0, neu.ReviewNewItems)); }).RequireAuthorization(FlaroPolicies.Administrator); // **Umbenennen, mehr nicht.** Die Art zu ändern hiesse, Filme in // Serien zu verwandeln; den Pfad zu ändern hiesse, einen ganzen // Bestand gegen einen anderen zu tauschen, während die Einträge // stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue // Bibliothek — und die legt man an. bibliotheken.MapPut("/{id:guid}", async ( Guid id, RenameLibraryRequest anfrage, FlaroContext db) => { var name = anfrage.Name.Trim(); if (name.Length == 0) return Results.ValidationProblem(new Dictionary { ["name"] = ["Ein Name darf nicht leer sein."], }); var vorhanden = await db.Libraries.FindAsync(id); if (vorhanden is null) return Results.NotFound(); vorhanden.Name = name; await db.SaveChangesAsync(); return Results.Ok(new LibraryResponse( vorhanden.Id, vorhanden.Name, vorhanden.Kind.ToString(), vorhanden.Path, null)); }).RequireAuthorization(FlaroPolicies.Administrator); // **Die Mediendateien bleiben liegen.** Entfernt wird, was Flaro über // sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim // Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server, // den man nicht noch einmal benutzt. bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) => { var vorhanden = await db.Libraries.FindAsync(id); if (vorhanden is null) return Results.NotFound(); db.Libraries.Remove(vorhanden); await db.SaveChangesAsync(); return Results.NoContent(); }).RequireAuthorization(FlaroPolicies.Administrator); // **Den Inhalt wegwerfen, die Mediathek behalten.** // // Wenn eine Zuordnungsregel falsch war, steht hinterher ein Bestand da, // den kein Scan mehr geradezieht: die falschen Einträge haben ihre // Dateien, gelten also nicht als verwaist, und bleiben. Die Mediathek // zu löschen und neu anzulegen tut dasselbe — nur verliert man dabei // Namen, Pfad und Einstellungen und tippt sie neu. // // Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein, // was Flaro über sie weiss. bibliotheken.MapPost("/{id:guid}/reset", async (Guid id, FlaroContext db) => { var vorhanden = await db.Libraries.FindAsync(id); if (vorhanden is null) return Results.NotFound(); // In dieser Reihenfolge: erst die Dateien, dann die Titel. Die // Fremdschlüssel zeigen von den Dateien auf Film und Folge. await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync(); await db.Movies.Where(m => m.LibraryId == id).ExecuteDeleteAsync(); await db.Episodes .Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == id)) .ExecuteDeleteAsync(); await db.Series.Where(s => s.LibraryId == id).ExecuteDeleteAsync(); return Results.NoContent(); }).RequireAuthorization(FlaroPolicies.Administrator); } } public static class FlaroPolicies { public const string Administrator = "administrator-only"; } public static class FlaroRateLimits { /// Für alles, was ein Geheimnis prüft. public const string SignIn = "sign-in"; /// Für das Nachfragen des Fernsehers — darf häufig sein. public const string Polling = "polling"; }