Du hast drei Filme vermisst, und die Zahlen sagten schon, wo sie sind: 448 Filme plus 13 zweite Fassungen sind genau die 461 Dateien. Es fehlte keine Datei; drei Filme steckten als Fassung in einem anderen. Gemessen, warum. Der Dateinamenleser ist unschuldig — „Teil 2" bleibt im Titel. TMDB aber liefert für „Heiligtümer des Todes Teil 1" wie für „Teil 2" denselben Treffer zuerst, sobald ohne Jahr gesucht werden muss. Und weil zwei Einträge dieselbe Kennung nicht teilen dürfen, wurde der zweite geschluckt. Zwei Dateien, deren Namen verschiedene Jahre nennen, sind jetzt verschiedene Filme. Der erste Anlauf hatte ein Jahr Abstand als Toleranz erlaubt — für Dezember-Erstaufführungen — und verschluckte damit genau den Fall, um den es geht: Teil 1 ist von 2010, Teil 2 von 2011. Dazu sieht man endlich, was nicht zugeordnet werden konnte. „Nicht zugeordnet" listete bisher nur Dateien ganz ohne Titel, und die gibt es bei Filmen nie — deshalb standen dort nur Serien. Daneben eine Liste der Titel mit mehreren Dateien: zwei Fassungen sind die Regel, zwei verschiedene Filme der Fehler, und an den Dateinamen sieht man es sofort. Der Bearbeiten-Dialog hat jetzt Reiter statt Knöpfe, die weitere Fenster öffnen — ein Fenster über einem Fenster, und man wusste nicht mehr, was man zumacht. „Titel ändern" und „Bilder bearbeiten" sind aus dem Kontextmenü raus; über „Bearbeiten" kommt man an beides. Beim Prüfen gefunden: ein Dialog blieb nach einem Seitenwechsel über der neuen Seite stehen. Er hängt an body, nicht am Inhaltsbereich. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
503 lines
24 KiB
C#
503 lines
24 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);
|
|
|
|
// **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<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, 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";
|
|
}
|