FileBrowser: diagnose container mounts + support extra audio path

This is a Docker volume issue, not a code bug. The backend container
only sees paths explicitly mounted in docker-compose.yml. A new mount
on the Linux host (e.g. NAS share, USB drive) is invisible to the
container until added as a volume — restarting the container alone
doesn't help, and restarting just the host doesn't either.

Backend: New GET /api/filebrowser/diagnose endpoint reads
/proc/self/mountinfo and returns the actual bind/nfs/cifs mounts the
container sees, plus a check of common candidate roots (/audiofiles,
/mnt, /media, /srv, /home, /app/data) showing whether they exist and
have content.

Frontend: Info icon in FileBrowser header toggles a diagnose panel
showing mounts and root candidates. Quick-access buttons now built
dynamically from candidate roots that actually exist. On 'path not
found' error: helpful inline explanation including the exact .env
variable and docker-compose command needed to add a new mount.

docker-compose.yml: New EXTRA_AUDIO_PATH env variable. Mounts a
second host path 1:1 (same path inside container, like
AUDIOFILES_PATH does). Defaults to ./data → /extra_audio_unused
when unset, which is a no-op.

.env.example: Documents EXTRA_AUDIO_PATH usage with example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 21:23:47 +02:00
parent 7c8e98917d
commit 6166fd8ab0
4 changed files with 149 additions and 7 deletions

View File

@@ -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

View File

@@ -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'."
),
}

View File

@@ -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: []

View File

@@ -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<string | null>(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 (
<div className="fixed inset-0 bg-black/70 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div
className="bg-surface border border-divider rounded-xl w-full max-w-lg flex flex-col"
style={{ maxHeight: '80vh' }}
style={{ maxHeight: '85vh' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-divider flex-shrink-0">
<h3 className="text-ink" style={{ fontSize: '13px', fontWeight: 600 }}>Ordner auswählen</h3>
<button onClick={onClose} className="text-muted hover:text-ink transition-colors">
<X size={16} />
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setShowDiagnose(!showDiagnose)}
className={`p-1 transition-colors ${showDiagnose ? 'text-primary' : 'text-muted hover:text-ink'}`}
title="Container-Mounts anzeigen"
>
<Info size={14} />
</button>
<button onClick={onClose} className="text-muted hover:text-ink transition-colors">
<X size={16} />
</button>
</div>
</div>
{/* Diagnose-Panel */}
{showDiagnose && diagnose && (
<div className="px-4 py-3 border-b border-divider bg-card flex-shrink-0 overflow-y-auto" style={{ maxHeight: '40vh' }}>
<p className="text-muted mb-2" style={{ fontSize: '11px', fontWeight: 600 }}>Aktive Volume-Mounts im Container:</p>
{diagnose.mounts.length === 0 ? (
<p className="text-muted" style={{ fontSize: '11px' }}>Keine Mounts gefunden</p>
) : (
<div className="space-y-0.5 mb-3">
{diagnose.mounts.map((m, i) => (
<div key={i} className="font-mono text-ink truncate" style={{ fontSize: '10px' }}>
<span className="text-primary">{m.mountPoint}</span>
<span className="text-muted"> ({m.fsType})</span>
</div>
))}
</div>
)}
<p className="text-muted mb-1" style={{ fontSize: '11px', fontWeight: 600 }}>Top-Level-Verzeichnisse:</p>
<div className="space-y-0.5 mb-3">
{diagnose.candidateRoots.map((r) => (
<div key={r.path} className="flex items-center gap-2 font-mono" style={{ fontSize: '10px' }}>
{r.exists ? (
<span className="text-primary"></span>
) : (
<span className="text-muted2">×</span>
)}
<span className={r.exists ? 'text-ink' : 'text-muted2 line-through'}>{r.path}</span>
{r.exists && r.entryCount !== null && r.entryCount !== undefined && (
<span className="text-muted">({r.entryCount} Einträge)</span>
)}
{r.permError && <span className="text-yellow-400">(kein Zugriff)</span>}
</div>
))}
</div>
<p className="text-muted leading-relaxed" style={{ fontSize: '10px' }}>{diagnose.hint}</p>
</div>
)}
{/* Quick access */}
<div className="flex gap-1 px-3 py-2 border-b border-divider flex-shrink-0 overflow-x-auto">
{['/audiofiles', '/data', '/media', '/'].map((p) => (
{quickPaths.map((p) => (
<button key={p} onClick={() => load(p)}
className={`flex-shrink-0 px-2 py-1 rounded transition-colors ${path === p ? 'bg-primary-dim text-primary' : 'text-muted hover:text-ink hover:bg-card'}`}
style={{ fontSize: '11px' }}>
@@ -77,7 +138,19 @@ export default function FileBrowser({ initialPath = '/', onSelect, onClose }: Pr
<Loader2 size={24} className="text-primary animate-spin" />
</div>
) : error ? (
<p className="text-red-400 px-4 py-6" style={{ fontSize: '13px' }}>{error}</p>
<div className="px-4 py-6">
<div className="flex items-start gap-2 mb-3">
<AlertCircle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-red-400" style={{ fontSize: '13px' }}>{error}</p>
</div>
<p className="text-muted" style={{ fontSize: '11px' }}>
Falls dein Mount auf der Host-Maschine existiert, aber hier nicht sichtbar ist: er muss
in <span className="font-mono text-ink">docker-compose.yml</span> als Volume gemountet werden.
Setze <span className="font-mono text-ink">EXTRA_AUDIO_PATH=/dein/pfad</span> in der <span className="font-mono text-ink">.env</span> und
starte mit <span className="font-mono text-ink">docker-compose down && docker-compose up -d</span> neu.
Klick oben auf <Info size={11} className="inline" /> für Details.
</p>
</div>
) : entries.length === 0 ? (
<p className="text-muted px-4 py-6" style={{ fontSize: '13px' }}>Ordner ist leer</p>
) : (