Files
Flaro/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs
T
ScarriffleandClaude Opus 5 cfb1e71762 3559 von 5884 Dateien verschwanden lautlos
Der Scan erfindet keine Serie mehr, wenn weder Ordner noch Dateiname
eine nennen — richtig so. Nur wurde die Datei dann übersprungen, ohne
Zeile, ohne Spur. In der Mediathek standen 58 Serien und niemand konnte
sagen, wo die anderen geblieben sind.

Jetzt bekommt jede solche Datei eine Zeile ohne Zuordnung. Damit ist sie
zählbar, auflistbar und nach einer besseren Regel nachträglich
zuzuordnen. Untersucht wird sie nicht: ffprobe auf 3559 Dateien kostet
eine halbe Stunde für Angaben, die niemand zu sehen bekommt, und sobald
die Zuordnung steht, holt der nächste Lauf es nach.

Dazu ein Abschnitt "Nicht zugeordnet" in der Verwaltung, nach Grund
gebündelt und mit den Pfaden zum Aufklappen. Zwei Gründe gibt es, und
sie verlangen Verschiedenes: fehlt die Folgennummer, muss umbenannt
werden; fehlt der Serienname, genügt ein Ordner darum herum. Die Liste
lässt sich als Datei herunterladen — eine, die man markieren und
kopieren muss, schickt niemand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 17:23:36 +02:00

337 lines
16 KiB
C#

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<string, string[]>
{
["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<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["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<string, string[]>
{
["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<string, string[]>
{
["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
{
/// <summary>Für alles, was ein Geheimnis prüft.</summary>
public const string SignIn = "sign-in";
/// <summary>Für das Nachfragen des Fernsehers — darf häufig sein.</summary>
public const string Polling = "polling";
}