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); // **Drei Zahlen, die alle stimmen und Verschiedenes meinen.** // // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 // Dateien. Keine davon ist falsch — es sind Titel, Dateien und // zweite Fassungen. Nebeneinander beantwortet sich die Frage von // selbst; einzeln bleibt sie offen. var folgen = await db.Episodes.AsNoTracking() .GroupBy(f => db.Series.Where(x => x.Id == f.SeriesId) .Select(x => x.LibraryId).First()) .Select(g => new { Id = g.Key, Anzahl = g.Count() }) .ToDictionaryAsync(x => x.Id, x => x.Anzahl); // Vermisste zählen nicht mit: sie liegen nicht mehr auf der Platte, // und der Scan hat sie deshalb auch nicht gesehen. // // **`MitTitel` zählt Titel, nicht Dateien** — und zwar nur solche, // zu denen wirklich noch eine Datei daliegt. Ohne das ginge die // Rechnung schief, sobald etwas vermisst wird: ein erster Versuch // verglich gegen alle Folgen und kam auf 1223 Titel bei 324 // Dateien. var dateien = await db.MediaFiles.AsNoTracking() .Where(f => f.MissingSince == null) .GroupBy(f => f.LibraryId) .Select(g => new { Id = g.Key, Alle = g.Count(), Ohne = g.Count(f => f.MovieId == null && f.EpisodeId == null), MitTitel = g.Where(f => f.MovieId != null).Select(f => f.MovieId).Distinct().Count() + g.Where(f => f.EpisodeId != null).Select(f => f.EpisodeId).Distinct().Count(), }) .ToDictionaryAsync(x => x.Id, x => x); return Results.Ok(liste.Select(l => { var zaehler = dateien.GetValueOrDefault(l.Id); var alleDateien = zaehler?.Alle ?? 0; var ohneTitel = zaehler?.Ohne ?? 0; // Jeder Titel, zu dem noch eine Datei daliegt, hat mindestens // eine. Was darüber liegt, sind zweite Fassungen — Plex' // „Optimized for Mobile" etwa. var mitTitel = zaehler?.MitTitel ?? 0; return 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), FileCount: alleDateien, EpisodeCount: l.Kind == LibraryKind.Shows ? folgen.GetValueOrDefault(l.Id) : null, UnassignedCount: ohneTitel, ExtraVersionCount: Math.Max(0, alleDateien - ohneTitel - mitTitel)); }).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); // **Was die Metadatenquelle nicht hergab.** // // `/unassigned` listet Dateien ganz ohne Titel — bei einer // Filmbibliothek ist das immer leer, weil der Scan für jede Datei // einen Eintrag anlegt. Sichtbar war damit nur die Hälfte: dass ein // Film zwar dasteht, aber unter seinem Dateinamen und ohne Plakat, // erfuhr niemand. In der Übersicht standen 448 Filme, in Plex 451 — // und nirgends, welche drei fehlen. bibliotheken.MapGet("/{id:guid}/unmatched", async ( Guid id, FlaroContext db, int take = 300) => { if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound(); var grenze = Math.Clamp(take, 1, 2000); var filme = await db.Movies.AsNoTracking() .Where(m => m.LibraryId == id && m.TmdbId == null) .OrderBy(m => m.SortTitleValue) .Take(grenze) .Select(m => new { m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year, Dateien = db.MediaFiles.Count(f => f.MovieId == m.Id), Beispiel = db.MediaFiles.Where(f => f.MovieId == m.Id) .Select(f => f.Path).FirstOrDefault(), }) .ToListAsync(); var serien = await db.Series.AsNoTracking() .Where(x => x.LibraryId == id && x.TmdbId == null) .OrderBy(x => x.SortTitleValue) .Take(grenze) .Select(x => new { x.Id, x.Title, Jahr = x.ParsedYear ?? x.Year, Dateien = db.Episodes.Count(f => f.SeriesId == x.Id), Beispiel = db.Episodes.Where(f => f.SeriesId == x.Id) .Join(db.MediaFiles, f => f.Id, d => d.EpisodeId, (_, d) => d.Path) .FirstOrDefault(), }) .ToListAsync(); var liste = filme .Select(m => new OhneZuordnung( m.Id, "movie", m.Title, m.Jahr, m.Dateien, Dateiname(m.Beispiel))) .Concat(serien.Select(x => new OhneZuordnung( x.Id, "series", x.Title, x.Jahr, x.Dateien, Dateiname(x.Beispiel)))) .OrderBy(x => x.Titel, StringComparer.CurrentCultureIgnoreCase) .ToList(); return Results.Ok(liste); }).RequireAuthorization(FlaroPolicies.Administrator); // **Titel mit mehr als einer Datei — dort versteckt sich Falsches.** // // Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in // Ordnung. Manchmal sind es aber zwei verschiedene Filme, die bei der // Metadatenquelle auf denselben Treffer liefen — und dann ist einer // davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das // an den Dateinamen in zwei Sekunden. bibliotheken.MapGet("/{id:guid}/versions", async ( Guid id, FlaroContext db, int take = 300) => { if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound(); var mehrfach = await db.Movies.AsNoTracking() .Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1) .OrderBy(m => m.SortTitleValue) .Take(Math.Clamp(take, 1, 2000)) .Select(m => new { m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year, Pfade = db.MediaFiles.Where(f => f.MovieId == m.Id) .Select(f => f.Path).ToList(), }) .ToListAsync(); var liste = mehrfach .Select(m => new Mehrfachtitel( m.Id, "movie", m.Title, m.Jahr, m.Pfade.Select(p => Dateiname(p) ?? p).ToList())) .ToList(); return Results.Ok(new Mehrfachbericht(liste.Count, liste)); }).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, ScanCoordinator koordinator) => { var vorhanden = await db.Libraries.FindAsync(id); if (vorhanden is null) return Results.NotFound(); // Aus demselben Grund wie beim Zurücksetzen — nur dass hier die // ganze Mediathek unter dem laufenden Scan verschwände. if (koordinator.IsRunning(id)) { return Results.Problem( "Für diese Mediathek läuft gerade ein Scan. " + "Entfernen geht erst, wenn er durch ist.", statusCode: StatusCodes.Status409Conflict); } 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, ScanCoordinator koordinator) => { var vorhanden = await db.Libraries.FindAsync(id); if (vorhanden is null) return Results.NotFound(); // **Nicht während eines Laufs.** // // Die Oberfläche sperrt den Knopf, aber darauf darf sich der // Server nicht verlassen: ein zweiter Administrator oder ein // Aufruf von Hand genügt. Wer Zeilen löscht, während der Scan // welche schreibt, bekommt einen Lauf, der mitten im Schreiben // abbricht — und einen Bestand, den niemand mehr erklären kann. if (koordinator.IsRunning(id)) { return Results.Problem( "Für diese Mediathek läuft gerade ein Scan. " + "Zurücksetzen geht erst, wenn er durch ist.", statusCode: StatusCodes.Status409Conflict); } // 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); } /// Nur der Dateiname — der volle Pfad geht niemanden etwas an. private static string? Dateiname(string? pfad) => pfad is null ? null : Path.GetFileName(pfad); } 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"; }