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 <noreply@anthropic.com>
This commit is contained in:
Audiolib
2026-05-26 17:27:54 +02:00
parent a9a9a35efb
commit 3aab0ac9f1
5 changed files with 250 additions and 11 deletions

View File

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

View File

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

View File

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

11
frontend/src/api/logs.ts Normal file
View File

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

View File

@@ -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<Tab, string> = {
users: 'Benutzer',
libraries: 'Bibliotheken',
settings: 'Einstellungen',
logs: 'Logs',
}
export default function Admin() {
const [tab, setTab] = useState<Tab>('users')
@@ -13,7 +21,7 @@ export default function Admin() {
<div style={{ padding: '32px 32px 24px' }}>
<h1 className="text-ink font-semibold mb-6" style={{ fontSize: '22px', letterSpacing: '-0.5px' }}>Administration</h1>
<div className="flex gap-1 mb-6 bg-surface rounded-xl p-1 w-fit border border-divider">
{(['users', 'libraries', 'settings'] as Tab[]).map((t) => (
{(['users', 'libraries', 'settings', 'logs'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
@@ -22,7 +30,7 @@ export default function Admin() {
}`}
style={{ fontSize: '13px' }}
>
{t === 'users' ? 'Benutzer' : t === 'libraries' ? 'Bibliotheken' : 'Einstellungen'}
{TAB_LABELS[t]}
</button>
))}
</div>
@@ -30,6 +38,7 @@ export default function Admin() {
{tab === 'users' && <UsersPanel />}
{tab === 'libraries' && <LibrariesPanel />}
{tab === 'settings' && <SettingsPanel />}
{tab === 'logs' && <LogsPanel />}
</div>
)
}
@@ -386,3 +395,127 @@ function SettingsPanel() {
</div>
)
}
type LogName = 'app' | 'matching'
function LogsPanel() {
const [logName, setLogName] = useState<LogName>('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<HTMLDivElement>(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 (
<div>
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="flex gap-1 bg-surface rounded-lg p-1 border border-divider">
{(['app', 'matching'] as LogName[]).map((n) => (
<button
key={n}
onClick={() => setLogName(n)}
className={`px-3 py-1.5 rounded-md transition-colors ${logName === n ? 'bg-card text-ink font-medium' : 'text-muted hover:text-ink'}`}
style={{ fontSize: '12px' }}
>
{n === 'app' ? 'app.log' : 'matching.log'}
</button>
))}
</div>
<select
value={lines}
onChange={(e) => setLines(Number(e.target.value))}
className="bg-card border border-divider rounded-lg px-2 py-1.5 text-sm text-muted focus:outline-none focus:ring-1 focus:ring-primary"
>
{[100, 300, 500, 1000, 2000].map((n) => (
<option key={n} value={n}>{n} Zeilen</option>
))}
</select>
<button
onClick={load}
disabled={loading}
className="flex items-center gap-1.5 bg-card border border-divider px-3 py-1.5 rounded-lg text-muted hover:text-ink transition-colors disabled:opacity-50"
style={{ fontSize: '12px' }}
>
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> Aktualisieren
</button>
<button
onClick={handleClear}
disabled={clearing}
className="flex items-center gap-1.5 bg-card border border-divider px-3 py-1.5 rounded-lg text-muted hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
style={{ fontSize: '12px' }}
>
<Trash2 size={12} /> Leeren
</button>
</div>
{data && (
<p className="text-muted mb-2" style={{ fontSize: '11px' }}>
{data.exists
? `Zeige ${data.showing} von ${data.total} Zeilen`
: 'Log-Datei existiert noch nicht'}
</p>
)}
<div
className="bg-surface border border-divider rounded-xl overflow-auto"
style={{ height: 'calc(100vh - 280px)', minHeight: '200px' }}
>
{loading && !data ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="text-primary animate-spin" size={24} />
</div>
) : !data?.exists || data.lines.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-muted" style={{ fontSize: '13px' }}>Keine Einträge</p>
</div>
) : (
<pre
className="text-muted p-4"
style={{ fontSize: '11px', lineHeight: '1.6', whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontFamily: 'monospace' }}
>
{data.lines.map((line, i) => {
const isError = /\bERROR\b/.test(line)
const isWarn = /\bWARNING\b/.test(line)
return (
<span
key={i}
style={{ color: isError ? '#f87171' : isWarn ? '#fbbf24' : undefined }}
>
{line}{'\n'}
</span>
)
})}
<div ref={bottomRef} />
</pre>
)}
</div>
</div>
)
}