diff --git a/.env.example b/.env.example index 9af92b6..06c2c32 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ JWT_SECRET=change_me_in_production_use_a_long_random_string # Der Pfad wird 1:1 in den Container gemountet, d.h. /media bleibt /media. # Beispiel: AUDIOFILES_PATH=/media AUDIOFILES_PATH=./audiofiles + +# Optionaler zweiter Pfad (NAS-Share, USB-Platte, neuer Mount). +# Wird ebenfalls 1:1 gemountet. Nach Setzen: docker-compose down && docker-compose up -d +# Beispiel: EXTRA_AUDIO_PATH=/mnt/nas-hoerbuecher +# EXTRA_AUDIO_PATH= DATABASE_URL=sqlite+aiosqlite:////app/data/db/audiolib.db HLS_CACHE_DIR=/app/data/hls_cache COVERS_DIR=/app/data/covers diff --git a/backend/app/routers/filebrowser.py b/backend/app/routers/filebrowser.py index 2e01d96..a715a73 100644 --- a/backend/app/routers/filebrowser.py +++ b/backend/app/routers/filebrowser.py @@ -35,3 +35,64 @@ async def browse( parent = parent_path if parent_path != path else None return {"path": path, "parent": parent, "entries": entries} + + +@router.get("/diagnose") +async def diagnose(_admin: User = Depends(require_admin)): + """Zeigt was der Backend-Container sieht: Bind-Mounts und Top-Level-Verzeichnisse.""" + # Aktive Mounts aus /proc/self/mountinfo + mounts = [] + try: + with open("/proc/self/mountinfo", "r") as f: + for line in f: + parts = line.split() + # Format: id parent major:minor root mount_point options - fstype source super_options + if len(parts) < 10 or "-" not in parts: + continue + sep = parts.index("-") + mount_point = parts[4] + fstype = parts[sep + 1] if sep + 1 < len(parts) else "?" + source = parts[sep + 2] if sep + 2 < len(parts) else "?" + # Interne Kernel-Mounts überspringen + if any(mount_point.startswith(p) for p in ("/proc", "/sys", "/dev", "/etc/", "/run", "/tmp")): + continue + if mount_point in ("/", "/etc/resolv.conf", "/etc/hostname", "/etc/hosts"): + continue + mounts.append({ + "mountPoint": mount_point, + "fsType": fstype, + "source": source, + }) + except Exception as e: + mounts_error = str(e) + else: + mounts_error = None + + # Top-Level-Verzeichnisse die im Container existieren und für Hörbücher in Frage kommen + candidates = ["/audiofiles", "/mnt", "/media", "/srv", "/home", "/app/data"] + roots = [] + for p in candidates: + if os.path.isdir(p): + try: + entries = os.listdir(p) + roots.append({ + "path": p, + "exists": True, + "entryCount": len([e for e in entries if not e.startswith(".")]), + }) + except PermissionError: + roots.append({"path": p, "exists": True, "entryCount": None, "permError": True}) + else: + roots.append({"path": p, "exists": False}) + + return { + "mounts": mounts, + "mountsError": mounts_error, + "candidateRoots": roots, + "hint": ( + "Pfade die hier NICHT in 'mounts' auftauchen, sind im Container nicht sichtbar. " + "Sie müssen in docker-compose.yml als Volume gemountet werden — " + "z.B. via EXTRA_AUDIO_PATH=/dein/host/pfad in der .env und dann " + "'docker-compose down && docker-compose up -d'." + ), + } diff --git a/docker-compose.yml b/docker-compose.yml index 96f527d..a73b610 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,9 @@ services: volumes: - ./data:/app/data - ${AUDIOFILES_PATH:-./audiofiles}:${AUDIOFILES_PATH:-/audiofiles}:ro + # Zusätzlicher Pfad (NAS-Share, USB-Platte, weiterer Mount): + # Setze EXTRA_AUDIO_PATH=/host/pfad in .env. Default ist ein No-Op. + - ${EXTRA_AUDIO_PATH:-./data}:${EXTRA_AUDIO_PATH:-/extra_audio_unused}:ro expose: - "8000" depends_on: [] diff --git a/frontend/src/components/common/FileBrowser.tsx b/frontend/src/components/common/FileBrowser.tsx index 943079e..c10866a 100644 --- a/frontend/src/components/common/FileBrowser.tsx +++ b/frontend/src/components/common/FileBrowser.tsx @@ -1,8 +1,10 @@ import React, { useState, useEffect } from 'react' -import { Folder, ChevronRight, ChevronUp, X, Check, Loader2, FileAudio } from 'lucide-react' +import { Folder, ChevronRight, ChevronUp, X, Check, Loader2, FileAudio, AlertCircle, Info } from 'lucide-react' import api from '../../api/client' interface Entry { name: string; path: string; isDir: boolean } +interface MountInfo { mountPoint: string; fsType: string; source: string } +interface RootInfo { path: string; exists: boolean; entryCount?: number | null; permError?: boolean } interface Props { initialPath?: string @@ -16,6 +18,8 @@ export default function FileBrowser({ initialPath = '/', onSelect, onClose }: Pr const [parent, setParent] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') + const [showDiagnose, setShowDiagnose] = useState(false) + const [diagnose, setDiagnose] = useState<{ mounts: MountInfo[]; candidateRoots: RootInfo[]; hint: string } | null>(null) const load = async (p: string) => { setLoading(true) @@ -32,26 +36,83 @@ export default function FileBrowser({ initialPath = '/', onSelect, onClose }: Pr } } + const loadDiagnose = async () => { + try { + const r = await api.get('/api/filebrowser/diagnose') + setDiagnose(r.data) + } catch {} + } + useEffect(() => { load(initialPath) }, []) + useEffect(() => { loadDiagnose() }, []) + + const visibleRoots = diagnose?.candidateRoots?.filter((r) => r.exists) || [] + const quickPaths = ['/audiofiles', ...visibleRoots.map((r) => r.path).filter((p) => p !== '/audiofiles'), '/'] return (
e.stopPropagation()} > {/* Header */}

Ordner auswählen

- +
+ + +
+ {/* Diagnose-Panel */} + {showDiagnose && diagnose && ( +
+

Aktive Volume-Mounts im Container:

+ {diagnose.mounts.length === 0 ? ( +

Keine Mounts gefunden

+ ) : ( +
+ {diagnose.mounts.map((m, i) => ( +
+ {m.mountPoint} + ({m.fsType}) +
+ ))} +
+ )} +

Top-Level-Verzeichnisse:

+
+ {diagnose.candidateRoots.map((r) => ( +
+ {r.exists ? ( + + ) : ( + × + )} + {r.path} + {r.exists && r.entryCount !== null && r.entryCount !== undefined && ( + ({r.entryCount} Einträge) + )} + {r.permError && (kein Zugriff)} +
+ ))} +
+

{diagnose.hint}

+
+ )} + {/* Quick access */}
- {['/audiofiles', '/data', '/media', '/'].map((p) => ( + {quickPaths.map((p) => (
) : error ? ( -

{error}

+
+
+ +

{error}

+
+

+ Falls dein Mount auf der Host-Maschine existiert, aber hier nicht sichtbar ist: er muss + in docker-compose.yml als Volume gemountet werden. + Setze EXTRA_AUDIO_PATH=/dein/pfad in der .env und + starte mit docker-compose down && docker-compose up -d neu. + Klick oben auf für Details. +

+
) : entries.length === 0 ? (

Ordner ist leer

) : (