From 3aab0ac9f18d9e2a84c77e65d7452329955b076e Mon Sep 17 00:00:00 2001 From: Audiolib Date: Tue, 26 May 2026 17:27:54 +0200 Subject: [PATCH] Add logging system: app.log + matching.log with admin viewer - Backend: RotatingFileHandler for app.log (all) and matching.log (matcher/matching services) - New GET/DELETE /api/logs/{app|matching} endpoints (admin-only) - matcher.py: improved cover-download logging (URL, bytes, HTTP errors, missing cover URL) - Frontend: Logs tab in Admin panel with log switcher, line count selector, color-coded ERROR/WARNING lines, clear button Co-Authored-By: Claude Sonnet 4.6 --- backend/app/main.py | 42 ++++++++-- backend/app/routers/logs.py | 57 +++++++++++++ backend/app/services/matcher.py | 8 +- frontend/src/api/logs.ts | 11 +++ frontend/src/pages/Admin.tsx | 143 ++++++++++++++++++++++++++++++-- 5 files changed, 250 insertions(+), 11 deletions(-) create mode 100644 backend/app/routers/logs.py create mode 100644 frontend/src/api/logs.ts diff --git a/backend/app/main.py b/backend/app/main.py index aeced0a..4259f4c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ import logging +import logging.handlers import os from contextlib import asynccontextmanager from fastapi import FastAPI @@ -11,14 +12,45 @@ from .config import get_settings from .services.file_watcher import start_file_watcher, stop_file_watcher from .services.podcast_feed import update_all_feeds -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + +def _setup_logging(): + settings = get_settings() + os.makedirs(settings.log_dir, exist_ok=True) + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + + root = logging.getLogger() + root.setLevel(logging.INFO) + + # Console (bestehend) + console = logging.StreamHandler() + console.setFormatter(fmt) + root.addHandler(console) + + # app.log — alle Logs + app_fh = logging.handlers.RotatingFileHandler( + os.path.join(settings.log_dir, "app.log"), + maxBytes=5_000_000, backupCount=2, encoding="utf-8" + ) + app_fh.setFormatter(fmt) + root.addHandler(app_fh) + + # matching.log — nur Matching-bezogene Logger + match_fh = logging.handlers.RotatingFileHandler( + os.path.join(settings.log_dir, "matching.log"), + maxBytes=5_000_000, backupCount=2, encoding="utf-8" + ) + match_fh.setFormatter(fmt) + for name in ("app.services.matcher", "app.services.matching"): + logging.getLogger(name).addHandler(match_fh) + + +_setup_logging() logger = logging.getLogger(__name__) _scheduler = AsyncIOScheduler() - - @asynccontextmanager async def lifespan(app: FastAPI): settings = get_settings() @@ -28,7 +60,6 @@ async def lifespan(app: FastAPI): await init_db() await start_file_watcher() - # Podcast-Feed-Scheduler _scheduler.add_job(update_all_feeds, "interval", hours=settings.podcast_update_interval_hours, id="feed_update") _scheduler.start() @@ -55,7 +86,7 @@ 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, setup, filebrowser +from .routers import matching, podcasts, setup, filebrowser, logs app.include_router(setup.router) app.include_router(auth.router) @@ -68,3 +99,4 @@ app.include_router(settings_router.router) app.include_router(matching.router) app.include_router(podcasts.router) app.include_router(filebrowser.router) +app.include_router(logs.router) diff --git a/backend/app/routers/logs.py b/backend/app/routers/logs.py new file mode 100644 index 0000000..e027997 --- /dev/null +++ b/backend/app/routers/logs.py @@ -0,0 +1,57 @@ +import os +from fastapi import APIRouter, Depends, HTTPException, Query +from ..dependencies import require_admin +from ..models.user import User +from ..config import get_settings + +router = APIRouter(prefix="/api/logs", tags=["logs"]) + +_LOG_FILES = { + "app": "app.log", + "matching": "matching.log", +} + + +@router.get("/{log_name}") +async def get_log( + log_name: str, + lines: int = Query(300, ge=10, le=5000), + _admin: User = Depends(require_admin), +): + if log_name not in _LOG_FILES: + raise HTTPException(status_code=404, detail="Unbekannte Log-Datei") + + settings = get_settings() + path = os.path.join(settings.log_dir, _LOG_FILES[log_name]) + + if not os.path.exists(path): + return {"lines": [], "total": 0, "showing": 0, "exists": False, "path": path} + + with open(path, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + + last = all_lines[-lines:] + return { + "lines": [l.rstrip("\n") for l in last], + "total": len(all_lines), + "showing": len(last), + "exists": True, + "path": path, + } + + +@router.delete("/{log_name}") +async def clear_log( + log_name: str, + _admin: User = Depends(require_admin), +): + if log_name not in _LOG_FILES: + raise HTTPException(status_code=404, detail="Unbekannte Log-Datei") + + settings = get_settings() + path = os.path.join(settings.log_dir, _LOG_FILES[log_name]) + + if os.path.exists(path): + open(path, "w").close() + + return {"success": True} diff --git a/backend/app/services/matcher.py b/backend/app/services/matcher.py index b4092fe..1383948 100644 --- a/backend/app/services/matcher.py +++ b/backend/app/services/matcher.py @@ -85,6 +85,7 @@ async def _download_cover(url: str, item_id: str) -> str | None: if ".png" in url: ext = ".png" dest = os.path.join(settings.covers_dir, f"{item_id}{ext}") + logger.info(f"Cover-Download: {url}") try: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client: r = await client.get(url) @@ -92,9 +93,12 @@ async def _download_cover(url: str, item_id: str) -> str | None: os.makedirs(settings.covers_dir, exist_ok=True) with open(dest, "wb") as f: f.write(r.content) + logger.info(f"Cover gespeichert: {dest} ({len(r.content)} Bytes)") return dest + else: + logger.warning(f"Cover-Download HTTP {r.status_code}: {url}") except Exception as e: - logger.warning(f"Cover-Download fehlgeschlagen ({url}): {e}") + logger.warning(f"Cover-Download Fehler ({url}): {e}") return None @@ -133,6 +137,8 @@ async def _apply_match(db: AsyncSession, item: LibraryItem, result: MatchResult, cover_path = await _download_cover(result.cover_url, item.id) if cover_path: item.cover_path = cover_path + elif not result.cover_url: + logger.info(f"Kein Cover-URL in Match-Ergebnis ({result.source}: {result.source_id})") # Kapitel aus MusicBrainz-Tracklisting if result.chapters: diff --git a/frontend/src/api/logs.ts b/frontend/src/api/logs.ts new file mode 100644 index 0000000..b59edcf --- /dev/null +++ b/frontend/src/api/logs.ts @@ -0,0 +1,11 @@ +import api from './client' + +export async function getLog(name: 'app' | 'matching', lines = 300) { + const res = await api.get(`/api/logs/${name}`, { params: { lines } }) + return res.data +} + +export async function clearLog(name: 'app' | 'matching') { + const res = await api.delete(`/api/logs/${name}`) + return res.data +} diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index d95fb85..c291a02 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -1,10 +1,18 @@ -import React, { useEffect, useState } from 'react' -import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles } from 'lucide-react' +import React, { useEffect, useRef, useState } from 'react' +import { Users, Library, Settings, Trash2, Plus, RefreshCw, Loader2, Check, X, FolderOpen, Pencil, Sparkles, ScrollText } from 'lucide-react' import { getUsers, createUser, deleteUser, getSettings, updateSettings } from '../api/me' import { getLibraries, scanLibrary, matchAllLibrary, createLibrary, updateLibrary, deleteLibrary } from '../api/libraries' +import { getLog, clearLog } from '../api/logs' import FileBrowser from '../components/common/FileBrowser' -type Tab = 'users' | 'libraries' | 'settings' +type Tab = 'users' | 'libraries' | 'settings' | 'logs' + +const TAB_LABELS: Record = { + users: 'Benutzer', + libraries: 'Bibliotheken', + settings: 'Einstellungen', + logs: 'Logs', +} export default function Admin() { const [tab, setTab] = useState('users') @@ -13,7 +21,7 @@ export default function Admin() {

Administration

- {(['users', 'libraries', 'settings'] as Tab[]).map((t) => ( + {(['users', 'libraries', 'settings', 'logs'] as Tab[]).map((t) => ( ))}
@@ -30,6 +38,7 @@ export default function Admin() { {tab === 'users' && } {tab === 'libraries' && } {tab === 'settings' && } + {tab === 'logs' && }
) } @@ -386,3 +395,127 @@ function SettingsPanel() { ) } + +type LogName = 'app' | 'matching' + +function LogsPanel() { + const [logName, setLogName] = useState('app') + const [lines, setLines] = useState(300) + const [data, setData] = useState<{ lines: string[]; total: number; showing: number; exists: boolean } | null>(null) + const [loading, setLoading] = useState(false) + const [clearing, setClearing] = useState(false) + const bottomRef = useRef(null) + + const load = async () => { + setLoading(true) + try { + const d = await getLog(logName, lines) + setData(d) + } finally { + setLoading(false) + } + } + + useEffect(() => { load() }, [logName, lines]) + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'auto' }) + }, [data]) + + const handleClear = async () => { + if (!confirm(`Log „${logName}" wirklich leeren?`)) return + setClearing(true) + await clearLog(logName).catch(() => {}) + setClearing(false) + await load() + } + + return ( +
+
+
+ {(['app', 'matching'] as LogName[]).map((n) => ( + + ))} +
+ + + + + + +
+ + {data && ( +

+ {data.exists + ? `Zeige ${data.showing} von ${data.total} Zeilen` + : 'Log-Datei existiert noch nicht'} +

+ )} + +
+ {loading && !data ? ( +
+ +
+ ) : !data?.exists || data.lines.length === 0 ? ( +
+

Keine Einträge

+
+ ) : ( +
+            {data.lines.map((line, i) => {
+              const isError = /\bERROR\b/.test(line)
+              const isWarn = /\bWARNING\b/.test(line)
+              return (
+                
+                  {line}{'\n'}
+                
+              )
+            })}
+            
+
+ )} +
+
+ ) +}