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:
@@ -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
|
||||
|
||||
@@ -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'."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user