diff --git a/backend/app/main.py b/backend/app/main.py index 6015bbe..aeced0a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,3 @@ -import uuid import logging import os from contextlib import asynccontextmanager @@ -7,13 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from apscheduler.schedulers.asyncio import AsyncIOScheduler -from .database import init_db, AsyncSessionLocal +from .database import init_db from .config import get_settings -from .models import User, Library -from .services.auth import hash_password, verify_password, create_token from .services.file_watcher import start_file_watcher, stop_file_watcher from .services.podcast_feed import update_all_feeds -from sqlalchemy import select logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger(__name__) @@ -21,50 +17,6 @@ logger = logging.getLogger(__name__) _scheduler = AsyncIOScheduler() -async def _seed_admin(): - settings = get_settings() - async with AsyncSessionLocal() as db: - result = await db.execute(select(User).where(User.is_admin == True)) - existing = result.scalar_one_or_none() - if existing: - if not verify_password(settings.admin_password, existing.password_hash): - existing.password_hash = hash_password(settings.admin_password) - await db.commit() - logger.info("Admin-Passwort aus ENV aktualisiert.") - return - logger.info(f"Lege Admin-User an: {settings.admin_username}") - admin = User( - id=str(uuid.uuid4()), - username=settings.admin_username, - email=settings.admin_email, - password_hash=hash_password(settings.admin_password), - is_admin=True, - ) - db.add(admin) - await db.flush() - admin.token = create_token(admin.id) - await db.commit() - logger.info("Admin-User angelegt.") - - -async def _seed_default_library(): - settings = get_settings() - async with AsyncSessionLocal() as db: - result = await db.execute(select(Library)) - if result.scalar_one_or_none(): - return - folder_id = str(uuid.uuid4()) - lib = Library( - id=str(uuid.uuid4()), - name="Hörbücher", - display_name="Hörbücher", - folders=[{"id": folder_id, "fullPath": settings.audiofiles_path}], - media_type="book", - settings={"icon": "headphones", "provider": "google"}, - ) - db.add(lib) - await db.commit() - logger.info(f"Standard-Library angelegt: {settings.audiofiles_path}") @asynccontextmanager @@ -74,8 +26,6 @@ async def lifespan(app: FastAPI): os.makedirs(d, exist_ok=True) await init_db() - await _seed_admin() - await _seed_default_library() await start_file_watcher() # Podcast-Feed-Scheduler @@ -105,8 +55,9 @@ if os.path.exists(settings.covers_dir): app.mount("/covers", StaticFiles(directory=settings.covers_dir), name="covers") from .routers import auth, libraries, items, stream, me, users, settings as settings_router -from .routers import matching, podcasts +from .routers import matching, podcasts, setup, filebrowser +app.include_router(setup.router) app.include_router(auth.router) app.include_router(libraries.router) app.include_router(items.router) @@ -116,3 +67,4 @@ app.include_router(users.router) app.include_router(settings_router.router) app.include_router(matching.router) app.include_router(podcasts.router) +app.include_router(filebrowser.router) diff --git a/backend/app/routers/filebrowser.py b/backend/app/routers/filebrowser.py new file mode 100644 index 0000000..2e01d96 --- /dev/null +++ b/backend/app/routers/filebrowser.py @@ -0,0 +1,37 @@ +import os +from fastapi import APIRouter, Depends, HTTPException, Query +from ..dependencies import require_admin +from ..models.user import User + +router = APIRouter(prefix="/api/filebrowser", tags=["filebrowser"]) + + +@router.get("") +async def browse( + path: str = Query("/"), + _admin: User = Depends(require_admin), +): + path = os.path.normpath(path) + if not os.path.isdir(path): + raise HTTPException(status_code=404, detail="Pfad nicht gefunden") + + try: + names = sorted(os.listdir(path), key=lambda n: n.lower()) + except PermissionError: + raise HTTPException(status_code=403, detail="Zugriff verweigert") + + entries = [] + for name in names: + if name.startswith("."): + continue + full = os.path.join(path, name) + try: + is_dir = os.path.isdir(full) + except OSError: + continue + entries.append({"name": name, "path": full, "isDir": is_dir}) + + parent_path = os.path.dirname(path) + parent = parent_path if parent_path != path else None + + return {"path": path, "parent": parent, "entries": entries} diff --git a/backend/app/routers/setup.py b/backend/app/routers/setup.py new file mode 100644 index 0000000..8743c75 --- /dev/null +++ b/backend/app/routers/setup.py @@ -0,0 +1,43 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from pydantic import BaseModel +from ..dependencies import get_db +from ..models.user import User +from ..services.auth import hash_password, create_token + +router = APIRouter(prefix="/api/setup", tags=["setup"]) + + +class SetupRequest(BaseModel): + username: str + password: str + email: str = "" + + +@router.get("/status") +async def setup_status(db: AsyncSession = Depends(get_db)): + result = await db.execute(select(func.count()).select_from(User)) + count = result.scalar() + return {"needsSetup": count == 0} + + +@router.post("") +async def run_setup(body: SetupRequest, db: AsyncSession = Depends(get_db)): + result = await db.execute(select(func.count()).select_from(User)) + if result.scalar() > 0: + raise HTTPException(status_code=400, detail="Setup already completed") + + admin = User( + id=str(uuid.uuid4()), + username=body.username, + email=body.email, + password_hash=hash_password(body.password), + is_admin=True, + ) + db.add(admin) + await db.flush() + admin.token = create_token(admin.id) + await db.commit() + return {"success": True} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9c6112..e51523e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { useAuthStore } from './store/authStore' import { usePlayerStore } from './store/playerStore' import Layout from './components/common/Layout' import AudioPlayer from './components/player/AudioPlayer' import Login from './pages/Login' +import Setup from './pages/Setup' import Library from './pages/Library' import BookDetail from './pages/BookDetail' import PodcastDetail from './pages/PodcastDetail' @@ -20,8 +21,19 @@ function AppRoutes() { const { user, loadAuth } = useAuthStore() const { libraries } = useAuthStore() const { expanded, setExpanded } = usePlayerStore() + const [setupNeeded, setSetupNeeded] = useState(null) - useEffect(() => { loadAuth() }, []) + useEffect(() => { + fetch('/api/setup/status') + .then((r) => r.json()) + .then((d) => setSetupNeeded(d.needsSetup ?? false)) + .catch(() => setSetupNeeded(false)) + }, []) + + useEffect(() => { if (setupNeeded === false) loadAuth() }, [setupNeeded]) + + if (setupNeeded === null) return
+ if (setupNeeded) return setSetupNeeded(false)} /> const defaultLib = libraries?.[0]?.id diff --git a/frontend/src/components/common/FileBrowser.tsx b/frontend/src/components/common/FileBrowser.tsx new file mode 100644 index 0000000..b4677dc --- /dev/null +++ b/frontend/src/components/common/FileBrowser.tsx @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from 'react' +import { Folder, ChevronRight, ChevronUp, X, Check, Loader2 } from 'lucide-react' +import api from '../../api/client' + +interface Entry { name: string; path: string; isDir: 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([]) + const [parent, setParent] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + 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.filter((e: Entry) => e.isDir)) + } catch (err: any) { + setError(err.response?.data?.detail || 'Fehler beim Laden') + } finally { + setLoading(false) + } + } + + useEffect(() => { load(initialPath) }, []) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

Ordner auswählen

+ +
+ + {/* Current path */} +
+ {parent && ( + + )} +

{path}

+
+ + {/* Entry list */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +

{error}

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

Keine Unterordner

+ ) : ( + entries.map((e) => ( + + )) + )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index b61a403..fa4d3e6 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useState } from 'react' -import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X } from 'lucide-react' +import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen } from 'lucide-react' import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me' import { getLibraries, scanLibrary, createLibrary, deleteLibrary } from '../api/libraries' +import FileBrowser from '../components/common/FileBrowser' type Tab = 'users' | 'libraries' | 'settings' @@ -119,6 +120,7 @@ function LibrariesPanel() { const [loading, setLoading] = useState(true) const [scanning, setScanning] = useState(null) const [showCreate, setShowCreate] = useState(false) + const [showBrowser, setShowBrowser] = useState(false) const [form, setForm] = useState({ name: '', path: '', mediaType: 'book' }) useEffect(() => { getLibraries().then(setLibraries).finally(() => setLoading(false)) }, []) @@ -160,10 +162,27 @@ function LibrariesPanel() { value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary" /> - setForm({ ...form, path: e.target.value })} - className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary" - /> +
+ setForm({ ...form, path: e.target.value })} + className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary" + /> + +
+ {showBrowser && ( + setForm({ ...form, path: p })} + onClose={() => setShowBrowser(false)} + /> + )} setUsername(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary" + placeholder="admin" + autoFocus + /> +
+
+ +
+ setPassword(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 pr-10 text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-primary" + placeholder="••••••••" + /> + +
+
+ {error &&

{error}

} + + + + + ) +}