Files
Flaro/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs
T
ScarriffleandClaude Opus 5 c13d1e78d2 Eine Mediathek für alles, was in keinem Katalog steht
Urlaubsaufnahmen, Mitschnitte, Vorträge. Jedes Verfahren, das bei Filmen
richtig ist, wäre hier falsch: „Sommer 2019 Teil 2" ist kein Film von 2019,
zwei Aufnahmen desselben Tages sind nicht zwei Fassungen derselben Sache,
und in keinem Katalog steht, was da liegt.

Die neue Art `Videos` nimmt den Dateinamen als Titel, zerlegt nichts, legt
nichts zusammen und fragt niemanden. Der Pfad ist dabei der
Vergleichsschlüssel, nicht der Name — zwei Dateien „Ausflug.mp4" in zwei
Ordnern sind zwei Ausflüge.

**Der Unterordner wird zur Kategorie.** Statt eine eigene Gruppierung zu
bauen, bekommt jedes Video den Namen seines Ordners als Genre. Damit
greifen Filter, Kategorienansicht und Suche, ohne dass es in der Oberfläche
etwas Neues gibt. Eine zweite Gruppierung daneben hiesse: zwei Wege für
dasselbe, und einer davon wäre überall sonst ungepflegt.

Die Anreicherung übergeht jetzt alles, was `MatchLocked` trägt. Das galt
bisher nur für von Hand korrigierte Zuordnungen und gilt damit auch hier —
eine Suche nach „Abend am Feuer" fände irgendeinen fremden Film, und der
stünde danach mit Plakat und Beschreibung in der Urlaubsmediathek.

Geprüft: vier Dateien in zwei Ordnern ergeben vier Titel mit ihren
Dateinamen, keine Jahre, keinen fremden Treffer — und genau zwei
Kategorien, denn was direkt in der Wurzel liegt, bekommt keine.

742 Tests grün, davon zwei neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 21:36:06 +02:00

602 lines
29 KiB
C#

using System.Security.Claims;
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
{
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
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, u.Language, u.AvatarImage, u.IsOwner))
.ToListAsync()));
/* ---------- Rechte, Besitz, Löschen ---------- */
// **Warum es einen Besitzer gibt.** Sobald Administratoren einander
// das Recht entziehen können, gibt es einen Zustand ohne jeden
// Administrator — und aus dem führt kein Weg zurück ausser dem an der
// Datenbank. Ein Konto, das ausgenommen ist, schliesst ihn aus.
benutzer.MapPut("/{id:guid}/administrator", async (
Guid id, RechtRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, CancellationToken ct) =>
{
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
if (ziel.IsOwner && !anfrage.IsAdministrator)
return Einwand.Bitte("Dem Besitzer lässt sich das Adminrecht nicht nehmen.");
// **Nicht an sich selbst.** Wer sich im Eifer das eigene Recht
// nimmt, steht vor einer Verwaltung, die er nicht mehr öffnen
// kann — und das Zurücknehmen braucht genau die.
if (Kennung(wer) == id && !anfrage.IsAdministrator)
return Einwand.Bitte("Das eigene Adminrecht kannst du dir nicht selbst nehmen.");
ziel.IsAdministrator = anfrage.IsAdministrator;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
// **Nur der Besitzer gibt den Besitz weiter**, und nur an jemanden,
// der schon Administrator ist: sonst wäre der Server einen Klick von
// einem Besitzer entfernt, der die Verwaltung gar nicht sieht.
benutzer.MapPost("/{id:guid}/owner", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
var bisher = await db.Users.FirstOrDefaultAsync(u => u.Id == ich, ct);
if (bisher is not { IsOwner: true })
return Einwand.Bitte("Nur der Besitzer kann den Besitz weitergeben.");
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Einwand.Fehlt("Dieses Konto gibt es nicht mehr.");
if (!ziel.IsAdministrator)
return Einwand.Bitte("Der Besitz geht nur an einen Administrator.");
bisher.IsOwner = false;
ziel.IsOwner = true;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
benutzer.MapDelete("/{id:guid}", async (
Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
var ziel = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
if (ziel is null) return Results.NoContent();
if (ziel.IsOwner) return Einwand.Bitte("Den Besitzer kann man nicht löschen.");
if (Kennung(wer) == id)
return Einwand.Bitte("Das eigene Konto kannst du hier nicht löschen.");
// Geräte, Sehstände, Sehprotokoll, Meldungen und offene
// Einladungen hängen mit `Cascade` daran — siehe `FlaroContext`.
// Ein Konto zu löschen und seine Spuren zu lassen hiesse, Zahlen
// über jemanden zu führen, den es nicht mehr gibt.
db.Users.Remove(ziel);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
{
var name = anfrage.UserName.Trim();
if (name.Length == 0 || anfrage.Password.Length < 8)
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
if (await db.Users.AnyAsync(u => u.UserName == name))
return Einwand.Belegt($"„{name}“ gibt es schon.");
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 Einwand.Bitte("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.
//
// **`ignorierte` zeigt, was abgehakt wurde.** Ohne diesen Schalter
// wäre das Abhaken unumkehrbar, und niemand wüsste hinterher, was er
// weggeklickt hat — dieselbe Überlegung wie bei den Meldungen.
bibliotheken.MapGet("/{id:guid}/versions", async (
Guid id, FlaroContext db, int take = 300, bool ignorierte = false) =>
{
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)
// Abgehakt ist, wer bei genau dieser Dateizahl abgehakt
// wurde. Kommt eine Fassung dazu, stimmt die Zahl nicht mehr
// und die Gruppe meldet sich von selbst zurück.
.Where(m => ignorierte
? m.FassungenGeprueftBei == db.MediaFiles.Count(f => f.MovieId == m.Id)
: m.FassungenGeprueftBei != db.MediaFiles.Count(f => f.MovieId == m.Id))
.OrderBy(m => m.SortTitleValue)
.Take(Math.Clamp(take, 1, 2000))
.Select(m => new
{
m.Id, m.Title, Jahr = m.ParsedYear ?? m.Year,
Dateien = db.MediaFiles.Where(f => f.MovieId == m.Id)
.Select(f => new { f.Id, f.Path }).ToList(),
})
.ToListAsync();
var liste = mehrfach
.Select(m => new Mehrfachtitel(
m.Id, "movie", m.Title, m.Jahr,
m.Dateien.Select(f => new Fassungsdatei(f.Id, Dateiname(f.Path) ?? f.Path)).ToList()))
.ToList();
return Results.Ok(new Mehrfachbericht(liste.Count, liste));
}).RequireAuthorization(FlaroPolicies.Administrator);
/// Abhaken und wieder hervorholen — beides über denselben Weg.
bibliotheken.MapPost("/{id:guid}/versions/acknowledge", async (
Guid id, FassungenRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id, ct)) return Results.NotFound();
if (anfrage.Ids is not { Count: > 0 }) return Einwand.Bitte("Es war kein Titel ausgewählt.");
var gewaehlt = anfrage.Ids.ToHashSet();
// **Die Zahl wird hier gerechnet, nicht vom Client geschickt.**
// Ein Client, der eine veraltete Liste anzeigt, hakte sonst eine
// Gruppe mit zwei Dateien ab, die inzwischen drei hat — und die
// dritte verschwände ungesehen.
var betroffen = await db.Movies
.Where(m => m.LibraryId == id && gewaehlt.Contains(m.Id))
.Select(m => new { Film = m, Anzahl = db.MediaFiles.Count(f => f.MovieId == m.Id) })
.ToListAsync(ct);
foreach (var z in betroffen)
{
z.Film.FassungenGeprueftBei = anfrage.Ignorieren ? z.Anzahl : null;
}
await db.SaveChangesAsync(ct);
return Results.Ok(new { geaendert = betroffen.Count });
}).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 Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows, Videos.");
// **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 Einwand.Bitte($"„{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 Einwand.Bitte("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);
}
/// <summary>Nur der Dateiname — der volle Pfad geht niemanden etwas an.</summary>
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
{
/// <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";
}