Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft
Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im Quelltext stehen. Version und Build. Die Version steht fest; daneben eine Kennung aus sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist "reden wir vom selben Build?" in zwei Sekunden beantwortet statt in einem halben Tag. Serien. Lag eine Datei direkt im Serienordner und trug vor der Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95 Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab zwei Serien. Und weder neue noch umgezogene Dateien standen in der Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite Zeile mit gleichem (LibraryId, Path) anlegte und der Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede Serienanlage wird jetzt im Tagesprotokoll begründet. Fehlermeldungen. "An error occurred while saving the entity changes" stand englisch in einer deutschen Oberfläche und nannte weder Ursache noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für "Failed to fetch", das aus dem Browser kam und nie übersetzt wurde. Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt serverseitig rückwärts, mit einem Knopf für mehr. Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein eigenes Listenfeld mit Tastaturbedienung. Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp. Sprache. Die Serversprache stand in der Serverliste zwischen Name, Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen eigenen Abschnitt ganz oben. Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden, ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer Zuordnung frischt nur noch die Kachel auf statt die ganze Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab8a66d898
commit
ac04901ccc
@@ -5,9 +5,21 @@ namespace Flaro.Api.Endpoints;
|
||||
// und bricht Clients, die niemand mehr aktualisiert.
|
||||
|
||||
public sealed record SystemInfo(
|
||||
string Version, string ServerName, bool NeedsSetup,
|
||||
string Version, string Build, string ServerName, bool NeedsSetup,
|
||||
IReadOnlyList<AttributionResponse> Attributions);
|
||||
|
||||
/// <summary>Ein Ablageort des Servers, mitsamt Belegung.</summary>
|
||||
///
|
||||
/// <param name="Variable">
|
||||
/// Die Umgebungsvariable, über die er gesetzt wird. <b>Sie wird mit
|
||||
/// angezeigt</b>, weil sie die Antwort auf die nächste Frage ist: „und wie
|
||||
/// ändere ich das?" Im Betrieb umbiegen lässt sich ein Pfad nicht — dann
|
||||
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||
/// </param>
|
||||
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
|
||||
public sealed record Speicherort(
|
||||
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
|
||||
|
||||
public sealed record ServerNameRequest(string Name);
|
||||
|
||||
public sealed record SetupRequest(string UserName, string Password);
|
||||
|
||||
@@ -261,6 +261,33 @@ public static class ManagementEndpoints
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ public static class SystemEndpoints
|
||||
{
|
||||
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
||||
Results.Ok(new SystemInfo(
|
||||
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
|
||||
// **Beides aus einer Hand.** Die Assembly-Version war hier
|
||||
// immer „1.0.0" und sagte deshalb nichts darüber, welches
|
||||
// Abbild läuft. Der Build daneben tut genau das.
|
||||
Version: Buildkennung.Version,
|
||||
Build: Buildkennung.Build,
|
||||
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
|
||||
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
|
||||
// Server hat, unterscheidet sie an einer Zahl.
|
||||
@@ -110,7 +114,7 @@ public static class SystemEndpoints
|
||||
.ToList()))
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch) =>
|
||||
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch, int? tail) =>
|
||||
{
|
||||
// **Selbst zusammengesetzt, nicht aus der Anfrage übernommen.**
|
||||
// Ein Datum, das durch `DateOnly` gegangen ist, kann kein
|
||||
@@ -122,9 +126,19 @@ public static class SystemEndpoints
|
||||
}
|
||||
|
||||
var datei = tagebuch.DateiFuer(tagesdatum);
|
||||
return File.Exists(datei)
|
||||
? Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log")
|
||||
: Results.NotFound();
|
||||
if (!File.Exists(datei)) return Results.NotFound();
|
||||
|
||||
// **Mit `tail` nur das Ende.** Die Ansicht braucht die letzten
|
||||
// Zeilen; die ganze Datei dafür zu schicken endete bei 2 MB über
|
||||
// eine wackelige Verbindung in „Failed to fetch". Ohne `tail`
|
||||
// kommt weiterhin alles — das ist der Weg zum Herunterladen.
|
||||
if (tail is > 0 and <= 5000)
|
||||
{
|
||||
return Results.Text(
|
||||
tagebuch.Ende(tagesdatum, tail.Value), "text/plain", System.Text.Encoding.UTF8);
|
||||
}
|
||||
|
||||
return Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
|
||||
@@ -215,6 +229,97 @@ public static class SystemEndpoints
|
||||
|
||||
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
|
||||
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
|
||||
// **Wo was liegt, und wie viel davon.**
|
||||
//
|
||||
// Die drei Pfade standen nirgends in der Oberfläche; wer wissen wollte,
|
||||
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
|
||||
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
||||
// nachsieht — nicht der letzte.
|
||||
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade) =>
|
||||
{
|
||||
Speicherort Messen(string name, string variable, string pfad) =>
|
||||
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
||||
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
|
||||
|
||||
return Results.Ok(new[]
|
||||
{
|
||||
Messen("Daten", "FLARO_DATA", pfade.Data),
|
||||
Messen("Bilder", "FLARO_CACHE", pfade.Cache),
|
||||
Messen("Umpacken", "FLARO_TRANSCODE", pfade.Transcode),
|
||||
});
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Bildspeicher leeren.**
|
||||
//
|
||||
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene
|
||||
// liegen als Datei genauso wieder da, sobald jemand sie neu wählt.
|
||||
// Darum ohne Umweg über eine Wartungsaktion — es ist ein Cache.
|
||||
app.MapDelete("/api/v1/system/storage/images", (FlaroPaths pfade) =>
|
||||
{
|
||||
var ordner = Path.Combine(pfade.Cache, "images");
|
||||
if (!Directory.Exists(ordner)) return Results.NoContent();
|
||||
|
||||
var befreit = Belegt(ordner);
|
||||
try
|
||||
{
|
||||
Directory.Delete(ordner, recursive: true);
|
||||
Directory.CreateDirectory(ordner);
|
||||
}
|
||||
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Problem(Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
|
||||
return Results.Ok(new { freed = befreit });
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Auch hier die Kennung.** Wer auf dem NAS nur „curl" hat, soll nicht
|
||||
// erst die Oberfläche öffnen müssen, um sie zu sehen.
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
version = Buildkennung.Version,
|
||||
build = Buildkennung.Build,
|
||||
})).AllowAnonymous();
|
||||
}
|
||||
|
||||
/// <summary>Wie viel ein Ordner belegt — rekursiv, ohne zu werfen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein unlesbarer Unterordner darf die Auskunft nicht verhindern; dann
|
||||
/// steht dort eben etwas zu wenig. Eine Zahl, die fast stimmt, ist besser
|
||||
/// als eine Fehlerseite.
|
||||
/// </remarks>
|
||||
private static long Belegt(string pfad)
|
||||
{
|
||||
if (!Directory.Exists(pfad)) return 0;
|
||||
try
|
||||
{
|
||||
return new DirectoryInfo(pfad)
|
||||
.EnumerateFiles("*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
})
|
||||
.Sum(d => d.Length);
|
||||
}
|
||||
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wie viel auf dem Volume noch frei ist — <c>null</c>, wenn unbekannt.</summary>
|
||||
private static long? Frei(string pfad)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfad)) ?? "/").AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception fehler) when (fehler is ArgumentException or IOException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user