Files
Audiolib/frontend/src/components/common/FileBrowser.tsx
Audiolib 6166fd8ab0 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>
2026-05-26 21:23:47 +02:00

201 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from '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
onSelect: (path: string) => void
onClose: () => void
}
export default function FileBrowser({ initialPath = '/', onSelect, onClose }: Props) {
const [path, setPath] = useState(initialPath)
const [entries, setEntries] = useState<Entry[]>([])
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)
setError('')
try {
const r = await api.get('/api/filebrowser', { params: { path: p } })
setPath(r.data.path)
setParent(r.data.parent)
setEntries(r.data.entries)
} catch (err: any) {
setError(err.response?.data?.detail || 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
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: '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>
<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">
{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' }}>
{p}
</button>
))}
</div>
{/* Current path */}
<div className="px-4 py-2 bg-card border-b border-divider flex items-center gap-2 flex-shrink-0">
{parent && (
<button onClick={() => load(parent)} className="text-muted hover:text-ink flex-shrink-0 transition-colors">
<ChevronUp size={16} />
</button>
)}
<p className="text-muted font-mono truncate" style={{ fontSize: '11px' }}>{path}</p>
</div>
{/* Entry list */}
<div className="overflow-y-auto flex-1">
{loading ? (
<div className="flex justify-center py-10">
<Loader2 size={24} className="text-primary animate-spin" />
</div>
) : error ? (
<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>
) : (
entries.map((e) =>
e.isDir ? (
<button
key={e.path}
onClick={() => load(e.path)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-card text-muted hover:text-ink text-left transition-colors"
style={{ fontSize: '13px' }}
>
<Folder size={16} className="text-yellow-500 flex-shrink-0" />
<span className="flex-1 truncate">{e.name}</span>
<ChevronRight size={14} className="flex-shrink-0 text-muted2" />
</button>
) : (
<div
key={e.path}
className="flex items-center gap-3 px-4 py-2 text-muted2"
style={{ fontSize: '12px' }}
>
<FileAudio size={14} className="flex-shrink-0" />
<span className="truncate">{e.name}</span>
</div>
)
)
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-divider flex-shrink-0">
<button onClick={onClose} className="text-muted hover:text-ink transition-colors" style={{ fontSize: '13px' }}>
Abbrechen
</button>
<button
onClick={() => { onSelect(path); onClose() }}
className="flex items-center gap-2 bg-primary text-black px-4 py-2 rounded-lg font-medium hover:opacity-90 transition-opacity"
style={{ fontSize: '13px' }}
>
<Check size={14} />
Auswählen
</button>
</div>
</div>
</div>
)
}