diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
index 313abc1..b1f077b 100644
--- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
@@ -71,6 +71,24 @@ public static class SystemEndpoints
Uptime: (DateTimeOffset.UtcNow - Gestartet).TotalSeconds)))
.RequireAuthorization(FlaroPolicies.Administrator);
+ // **Ordner zum Durchklicken.** Der Pfad einer Mediathek ist der im
+ // Container, nicht der auf dem NAS — eine Unterscheidung, die nur
+ // kennt, wer die Compose-Datei selbst geschrieben hat. Ein Tippfehler
+ // darin ergibt eine leere Mediathek ohne erkennbaren Grund. Wer sich
+ // durchklickt, sieht stattdessen, was wirklich da ist.
+ //
+ // Das Textfeld daneben bleibt trotzdem: eintippen ist schneller, wenn
+ // man den Pfad kennt, und bei sehr tiefen Bäumen die einzige Geduld
+ // schonende Möglichkeit.
+ app.MapGet("/api/v1/system/folders", (string? path) =>
+ Ordnerbaum.Lesen(path) is { } baum
+ ? Results.Ok(baum)
+ : Results.Problem(
+ title: "Ordner nicht gefunden",
+ detail: $"Den Ordner {path} gibt es nicht, oder es ist keiner.",
+ statusCode: StatusCodes.Status404NotFound))
+ .RequireAuthorization(FlaroPolicies.Administrator);
+
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
{
var name = anfrage.Name.Trim();
diff --git a/server/src/Flaro.Api/Ordnerbaum.cs b/server/src/Flaro.Api/Ordnerbaum.cs
new file mode 100644
index 0000000..29182ac
--- /dev/null
+++ b/server/src/Flaro.Api/Ordnerbaum.cs
@@ -0,0 +1,123 @@
+namespace Flaro.Api;
+
+/// Zeigt Ordner des Dateisystems, damit Pfade nicht getippt werden müssen.
+///
+///
+///
+/// Warum das überhaupt nötig ist. Ein Pfad in einer Mediathek ist der
+/// Pfad im Container, nicht der auf dem NAS — also das, was in der
+/// Compose-Datei hinter dem Doppelpunkt steht. Diese Unterscheidung kennt
+/// niemand, der nicht gerade die Compose-Datei geschrieben hat, und ein
+/// Tippfehler darin ergibt eine leere Mediathek ohne erkennbaren Grund.
+/// Wer sich durchklicken kann, sieht stattdessen, was wirklich da ist.
+///
+///
+/// Nur für Administratoren, und das ist keine Förmlichkeit. Diese
+/// Auskunft legt den Aufbau des Dateisystems offen. Im Container ist das
+/// wenig, aber eingehängte Ordner tragen die Namen echter Verzeichnisse.
+///
+///
+public static class Ordnerbaum
+{
+ /// Wo die Suche beginnt, wenn niemand einen Pfad nennt.
+ ///
+ ///
+ /// In dieser Reihenfolge, weil die Anleitung Medien nach /media
+ /// einhängen lässt. Steht dort nichts, ist die Wurzel der ehrlichste
+ /// Anfang — raten hilft niemandem.
+ ///
+ private static readonly string[] Anfänge = ["/media", "/mnt", "/data", "/volume1"];
+
+ /// Was an der Wurzel nicht angezeigt wird.
+ ///
+ ///
+ /// Keine Sicherheitsmassnahme, eine Aufräumarbeit. Wer den Pfad
+ /// kennt, kann ihn weiterhin eintippen; verborgen wird nichts. Es geht
+ /// darum, dass eine Liste aus zwanzig Einträgen, von denen achtzehn
+ /// /proc, /sys und /usr heissen, unbrauchbar ist.
+ /// Medien liegen dort nie.
+ ///
+ private static readonly HashSet AnDerWurzelVerborgen = new(StringComparer.Ordinal)
+ {
+ "bin", "boot", "dev", "etc", "lib", "lib32", "lib64", "libx32",
+ "proc", "root", "run", "sbin", "sys", "tmp", "usr", "var",
+ };
+
+ public sealed record Eintrag(string Name, string Path);
+
+ public sealed record Antwort(
+ string Path,
+ string? Parent,
+ IReadOnlyList Entries,
+ /// Warum die Liste leer ist — oder null, wenn sie es nicht ist.
+ string? Note);
+
+ /// Der Pfad, mit dem die Auswahl aufgeht.
+ public static string Anfang()
+ {
+ foreach (var kandidat in Anfänge)
+ {
+ if (Directory.Exists(kandidat)) return kandidat;
+ }
+
+ // Nicht hart „/": unter Windows und macOS gibt es das so nicht, und
+ // die Tests laufen dort.
+ return Path.GetPathRoot(Environment.CurrentDirectory) is { Length: > 0 } wurzel
+ ? wurzel
+ : "/";
+ }
+
+ /// Listet die Unterordner eines Verzeichnisses.
+ ///
+ ///
+ /// Gibt auch dann eine Antwort, wenn das Lesen scheitert. Ein
+ /// Ordner ohne Leserecht ist kein Fehler des Aufrufers, sondern eine
+ /// Tatsache über das Dateisystem — und eine, die in der Oberfläche
+ /// stehen soll, statt als 500 im Protokoll zu landen. null heisst
+ /// dagegen: diesen Pfad gibt es nicht.
+ ///
+ public static Antwort? Lesen(string? angefragt)
+ {
+ var pfad = string.IsNullOrWhiteSpace(angefragt) ? Anfang() : angefragt.Trim();
+
+ // Macht aus „/media/../etc" wieder „/etc". Das ist keine Abwehr —
+ // Administratoren dürfen überallhin — sondern sorgt dafür, dass der
+ // angezeigte Pfad der ist, den man auch eintippen würde.
+ try { pfad = Path.GetFullPath(pfad); }
+ catch (Exception e) when (e is ArgumentException or NotSupportedException or PathTooLongException)
+ {
+ return null;
+ }
+
+ if (!Directory.Exists(pfad)) return null;
+
+ var eltern = Path.GetDirectoryName(pfad.TrimEnd(Path.DirectorySeparatorChar));
+ var anDerWurzel = string.IsNullOrEmpty(eltern);
+
+ List eintraege;
+ string? hinweis = null;
+ try
+ {
+ eintraege = Directory.EnumerateDirectories(pfad)
+ .Select(p => new Eintrag(Path.GetFileName(p), p))
+ .Where(e => e.Name.Length > 0)
+ .Where(e => !anDerWurzel || !AnDerWurzelVerborgen.Contains(e.Name))
+ .OrderBy(e => e.Name, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ if (eintraege.Count == 0) hinweis = "Keine Unterordner.";
+ }
+ catch (UnauthorizedAccessException)
+ {
+ eintraege = [];
+ hinweis = "Kein Leserecht für diesen Ordner.";
+ }
+ catch (IOException)
+ {
+ eintraege = [];
+ hinweis = "Der Ordner liess sich nicht lesen.";
+ }
+
+ return new Antwort(pfad, anDerWurzel ? null : eltern, eintraege, hinweis);
+ }
+}