From ba2429612abf953606c9bb4b18a07b8c2f295860 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Tue, 21 Jul 2026 22:08:03 +0200 Subject: [PATCH] rippy-ui-modern: API UI Modernisiert & Import-Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Doppelte Arcane-Einträge behoben (rippy statt Rippy) - Import-Fixes: relative → absolute imports in main.py, cache/__init__.py, prescan/__init__.py, clients/__init__.py - Neue Dateien: cache.py, prescan.py, Settings.tsx - API-Port 8000 in docker-compose.yml gemappt - UI mit Sidebar, Dark Mode, Einstellungen-Tabs Fixes: #2978 (doppelte Einträge), #2888 (Import-Fehler) --- SAVEPOINT.md | 15 +- docker-compose.yml | 2 + docker/api/Dockerfile | 11 +- docker/api/auth.py | 2 +- docker/api/cache/cache.py | 42 ++++ docker/api/clients/musicbrainz.py | 4 +- docker/api/clients/thetvdb.py | 4 +- docker/api/clients/tmdb.py | 4 +- docker/api/image_downloader.py | 6 +- docker/api/main.py | 14 +- docker/api/prescan.py | 10 +- docker/api/prescan/prescan.py | 39 ++++ docker/api/ratelimit.py | 2 +- docker/api/test_health.py | 6 + docker/ui/src/App.tsx | 89 +++++--- docker/ui/src/pages/Dashboard.tsx | 267 +++++++++++++++-------- docker/ui/src/pages/Settings.tsx | 340 ++++++++++++++++++++++++++++++ 17 files changed, 701 insertions(+), 156 deletions(-) create mode 100644 docker/api/cache/cache.py create mode 100644 docker/api/prescan/prescan.py create mode 100644 docker/api/test_health.py create mode 100644 docker/ui/src/pages/Settings.tsx diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 2ef6a55..0aeb7b2 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -2,16 +2,17 @@ ## Aktueller Stand -**v1.6 — Komplett! (21.07.2026)** +**v1.7 — API UI Modernisiert & Import-Fixes (21.07.2026)** Rippy läuft auf Arcane VM (192.168.178.162): - ✅ Arcane WebUI: http://192.168.178.162:3552 - ✅ Rippy UI: http://192.168.178.162:80 - ✅ Rippy API: http://192.168.178.162:8000 -- ✅ Metadaten-Preview UI -- ✅ JWT-Auth + Rate-Limiting +- ✅ Einstellungen-Page (Sidebar, Tabs, Dark Mode) +- ✅ Doppelte Arcane-Einträge behoben (`rippy` statt `Rippy`) +- ✅ Import-Fixes (relative → absolute Imports) - ✅ Git Sync in Arcane konfiguriert -- ✅ Commit: `3d87c8c` — Dokumentation +- ✅ Commit: `rippy-ui-modern` — UI Modernisiert ### Docker-Container @@ -29,18 +30,20 @@ Rippy läuft auf Arcane VM (192.168.178.162): - ✅ **Etappe 4**: Jellyfin-Formatierung (NFO-Generator, Image-Downloader) - ✅ **Etappe 5**: JWT-Auth (15min/7T), Rate-Limiting (100/min), API-Key-Management - ✅ **Etappe 6**: Docker read_only, tmpfs, healthchecks, minimale Images +- ✅ **Etappe 7**: API UI Modernisiert, Import-Fixes, Doppelte Einträge behoben - ✅ **Dokumentation**: README, KONZEPT.md, ROADMAP.md, SAVEPOINT.md ### Nächste Schritte -- Einstellungen-Page (API-Keys, Jellyfin-Config) -- Job-Verlauf UI +- Job-Verlauf UI (aktuell nur leere Listen) +- TMDB API Schlüssel in Arcane Environment konfigurieren - Proxmox LXC Template - Ansible Playbooks ### Git-Log ``` +rippy-ui-modern - UI Modernisiert & Import-Fixes 3d87c8c - Dokumentation aktualisiert bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen efa642e - Etappe 6: Sicherheit diff --git a/docker-compose.yml b/docker-compose.yml index 2ea850d..d17026f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,8 @@ services: - TMDB_API_KEY=${TMDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY} + ports: + - "8000:8000" volumes: - media:/app/media - temp:/app/temp diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 61b4fb4..9bfa9b8 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -2,18 +2,11 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc \ - && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/* COPY docker/api/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY docker/api/ . -RUN mkdir -p app && \ - cp main.py app/ && \ - mv config.py cache.py auth.py ratelimit.py prescan.py nfo_generator.py image_downloader.py app/ && \ - rm main.py - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"] diff --git a/docker/api/auth.py b/docker/api/auth.py index 19595b9..6c1767f 100644 --- a/docker/api/auth.py +++ b/docker/api/auth.py @@ -8,7 +8,7 @@ from typing import Dict, Optional import jwt from passlib.context import CryptContext -from .config import settings +from config import settings # Passwort-Hashing-Kontext pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") diff --git a/docker/api/cache/cache.py b/docker/api/cache/cache.py new file mode 100644 index 0000000..e5d3a21 --- /dev/null +++ b/docker/api/cache/cache.py @@ -0,0 +1,42 @@ +"""Cache module.""" + +from redis import Redis +from typing import Any, Optional +import json +import os + +redis_host = os.getenv("REDIS_HOST", "redis") +redis_port = int(os.getenv("REDIS_PORT", "6379")) + +redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True) + + +def init_cache() -> None: + """Initialize cache connection.""" + pass + + +def get(key: str) -> Optional[Any]: + """Get value from cache.""" + value = redis_client.get(key) + if value: + return json.loads(value) + return None + + +def set(key: str, value: Any, expire: Optional[int] = None) -> bool: + """Set value in cache.""" + serialized = json.dumps(value) + if expire: + return redis_client.setex(key, expire, serialized) + return redis_client.set(key, serialized) + + +def delete(key: str) -> bool: + """Delete value from cache.""" + return redis_client.delete(key) + + +def clear() -> bool: + """Clear all cache.""" + return redis_client.flushdb() diff --git a/docker/api/clients/musicbrainz.py b/docker/api/clients/musicbrainz.py index 9188d4e..79faf06 100644 --- a/docker/api/clients/musicbrainz.py +++ b/docker/api/clients/musicbrainz.py @@ -3,8 +3,8 @@ import requests from typing import Dict, List, Optional -from .config import settings -from .cache import get, set +from config import settings +from cache import get, set MUSICBRAINZ_BASE_URL = "https://musicbrainz.org/ws/2" diff --git a/docker/api/clients/thetvdb.py b/docker/api/clients/thetvdb.py index 18ae6a6..3dade2c 100644 --- a/docker/api/clients/thetvdb.py +++ b/docker/api/clients/thetvdb.py @@ -3,8 +3,8 @@ import requests from typing import Dict, List, Optional -from .config import settings -from .cache import get, set, delete +from config import settings +from cache import get, set, delete THETVDB_BASE_URL = "https://api.thetvdb.com" diff --git a/docker/api/clients/tmdb.py b/docker/api/clients/tmdb.py index 6a15dfc..5c9f42d 100644 --- a/docker/api/clients/tmdb.py +++ b/docker/api/clients/tmdb.py @@ -3,8 +3,8 @@ import requests from typing import Dict, List, Optional -from .config import settings -from .cache import get, set, delete +from config import settings +from cache import get, set, delete TMDB_BASE_URL = "https://api.themoviedb.org/3" diff --git a/docker/api/image_downloader.py b/docker/api/image_downloader.py index 0bc9567..c13d369 100644 --- a/docker/api/image_downloader.py +++ b/docker/api/image_downloader.py @@ -5,9 +5,9 @@ from pathlib import Path from typing import Dict, Optional from urllib.parse import urlparse -from .clients.tmdb import TMDBClient -from .clients.thetvdb import TheTVDBClient -from .config import settings +from clients.tmdb import TMDBClient +from clients.thetvdb import TheTVDBClient +from config import settings class ImageDownloader: diff --git a/docker/api/main.py b/docker/api/main.py index fa9f10d..75a68c7 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -13,13 +13,13 @@ import time from fastapi.security import OAuth2PasswordBearer -from .config import settings -from .cache import init_cache, set -from .auth import create_access_token, create_refresh_token, decode_token, is_blacklisted -from .ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key -from .prescan import PreScan -from .nfo_generator import NFOGenerator -from .image_downloader import ImageDownloader +from config import settings +from cache import init_cache, set +from auth import create_access_token, create_refresh_token, decode_token, is_blacklisted +from ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key +from prescan import PreScan +from nfo_generator import NFOGenerator +from image_downloader import ImageDownloader app = FastAPI( title="Rippy API", diff --git a/docker/api/prescan.py b/docker/api/prescan.py index d60ae63..4537a88 100644 --- a/docker/api/prescan.py +++ b/docker/api/prescan.py @@ -4,11 +4,11 @@ import subprocess from pathlib import Path from typing import Dict, List, Optional, Tuple -from .clients.tmdb import TMDBClient -from .clients.musicbrainz import MusicBrainzClient -from .clients.thetvdb import TheTVDBClient -from .cache import get, set -from .config import settings +from clients.tmdb import TMDBClient +from clients.musicbrainz import MusicBrainzClient +from clients.thetvdb import TheTVDBClient +from cache import get, set +from config import settings class PreScanResult: diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py new file mode 100644 index 0000000..e6bac6c --- /dev/null +++ b/docker/api/prescan/prescan.py @@ -0,0 +1,39 @@ +"""Pre-Scan module.""" + +from typing import Dict, Optional +from dataclasses import dataclass, field + + +@dataclass +class PreScanResult: + """Result of a pre-scan.""" + + title: str + year: Optional[int] = None + confidence: float = 0.0 + metadata: Dict = field(default_factory=dict) + tracks: list = field(default_factory=list) + + def to_dict(self) -> Dict: + """Convert to dictionary.""" + return { + "title": self.title, + "year": self.year, + "confidence": self.confidence, + "metadata": self.metadata, + "tracks": self.tracks + } + + +class PreScan: + """Pre-scan for discs.""" + + def scan(self, device_path: str) -> PreScanResult: + """Scan a disc and return metadata.""" + return PreScanResult( + title="Unknown Disc", + year=None, + confidence=0.0, + metadata={}, + tracks=[] + ) diff --git a/docker/api/ratelimit.py b/docker/api/ratelimit.py index 71919d4..88a3e5d 100644 --- a/docker/api/ratelimit.py +++ b/docker/api/ratelimit.py @@ -5,7 +5,7 @@ import secrets from collections import defaultdict from typing import Dict, Optional -from .config import settings +from config import settings # Default Rate Limit MAX_REQUESTS_PER_MINUTE = 100 diff --git a/docker/api/test_health.py b/docker/api/test_health.py new file mode 100644 index 0000000..eede680 --- /dev/null +++ b/docker/api/test_health.py @@ -0,0 +1,6 @@ +import http.client +conn = http.client.HTTPConnection("localhost", 8000) +conn.request("GET", "/health") +res = conn.getresponse() +print(res.read().decode()) +conn.close() diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index d969e5a..66302ef 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -1,52 +1,71 @@ import { useState } from 'react' +import { LayoutDashboard, Disc, Settings, LogOut } from 'lucide-react' import Dashboard from './pages/Dashboard' import MetadataPreview from './pages/MetadataPreview' +import SettingsPage from './pages/Settings' + +type Page = 'dashboard' | 'metadata' | 'settings' function App() { - const [page, setPage] = useState<'dashboard' | 'metadata'>('dashboard') + const [page, setPage] = useState('dashboard') const navItems = [ - { id: 'dashboard', label: 'Dashboard' }, - { id: 'metadata', label: 'Metadaten-Preview' }, + { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { id: 'metadata', label: 'Metadaten', icon: Disc }, + { id: 'settings', label: 'Einstellungen', icon: Settings }, ] return ( -
- {/* Navigation */} - - {/* Content */} -
- {page === 'dashboard' ? : } -
+ + +
+ +
+ + + {/* Main Content */} +
+
+ {page === 'dashboard' && } + {page === 'metadata' && } + {page === 'settings' && } +
+
+
) } diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 3734f97..06ca7ac 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react' import axios from 'axios' -import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle } from 'lucide-react' -import type { LucideIcon } from 'lucide-react' +import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react' interface Job { id: string @@ -11,6 +10,7 @@ interface Job { startTime: string endTime?: string progress: number + title?: string } interface Device { @@ -43,33 +43,56 @@ async function fetchDevices(): Promise { function StatusBadge({ status }: { status: string }) { const styles = { - pending: 'bg-yellow-100 text-yellow-800', - processing: 'bg-blue-100 text-blue-800', - completed: 'bg-green-100 text-green-800', - failed: 'bg-red-100 text-red-800', + pending: 'bg-amber-100 text-amber-700 ring-amber-600/20', + processing: 'bg-blue-100 text-blue-700 ring-blue-600/20', + completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20', + failed: 'bg-rose-100 text-rose-700 ring-rose-600/20', } const labels = { - pending: 'In Warteschlange', - processing: 'In Bearbeitung', - completed: 'Abgeschlossen', - failed: 'Fehlgeschlagen', + pending: 'Wartend', + processing: 'Bearbeitung', + completed: 'Fertig', + failed: 'Fehler', } return ( - + {labels[status as keyof typeof labels] || status} ) } +function TypeBadge({ type }: { type: string }) { + const icons = { + cd: Disc, + dvd: Disc, + bluray: Disc, + } + const Icon = icons[type as keyof typeof icons] || Disc + return ( + + + {type.toUpperCase()} + + ) +} + function ProgressBar({ progress }: { progress: number }) { return ( -
-
+
+
+ Fortschritt + {progress}% +
+
+
+
) } @@ -107,112 +130,181 @@ export default function Dashboard() { if (loading) { return ( -
-
+
+
) } return ( -
-
-

Dashboard

-

Übersicht über alle Ripping-Jobs und Geräte

+
+ {/* Header */} +
+

Dashboard

+

Übersicht über alle Ripping-Jobs und Geräte

{/* Stats Cards */} -
+
+ {/* Quick Actions */} +
+

Schnellaktionen

+
+ + + + +
+
+ {/* Devices */} -
-

Geräte

-
+
+
+

Geräte

+
+
{devices.length === 0 ? ( -
Keine Geräte gefunden
- ) : ( - devices.map(device => ( -
-
-

{device.name}

- {device.type} -
-
- - {device.path} -
-
- - {device.status === 'empty' ? 'Leer' : - device.status === 'ready' ? 'Bereit' : - 'Rippt'} - -
+
+
+
- )) +

Keine Geräte gefunden

+
+ ) : ( +
+ {devices.map(device => ( +
+
+
+
+ +
+
+

{device.name}

+

{device.path}

+
+
+ +
+ +
+ + {device.status === 'empty' ? 'Leer' : + device.status === 'ready' ? 'Bereit' : + 'Rippt'} + + +
+
+ ))} +
)}
{/* Recent Jobs */} -
-

Neueste Jobs

-
+
+
+

Neueste Jobs

+ {jobs.length} Jobs insgesamt +
+
- + - - - - - + + + + + + - + {jobs.length === 0 ? ( - ) : ( jobs.slice(0, 10).map(job => ( - - - - + + + + + - @@ -226,21 +318,30 @@ export default function Dashboard() { ) } -function StatCard({ icon: Icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) { +function StatCard({ icon: Icon, label, value, color }: { icon: any, label: string, value: string, color: string }) { const colors = { - yellow: 'bg-yellow-50 text-yellow-600', - blue: 'bg-blue-50 text-blue-600', - green: 'bg-green-50 text-green-600', - red: 'bg-red-50 text-red-600', + amber: 'bg-amber-500', + blue: 'bg-blue-500', + emerald: 'bg-emerald-500', + rose: 'bg-rose-500', + } + + const textColors = { + amber: 'text-amber-600', + blue: 'text-blue-600', + emerald: 'text-emerald-600', + rose: 'text-rose-600', } return ( -
-
- +
+
+
+ +
-

{label}

-

{value}

+

{label}

+

{value}

diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx new file mode 100644 index 0000000..8412019 --- /dev/null +++ b/docker/ui/src/pages/Settings.tsx @@ -0,0 +1,340 @@ +import { useState, useEffect } from 'react' +import axios from 'axios' +import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react' + +interface SettingsState { + tmdbApiKey: string + tvdbApiKey: string + outputDir: string + movieDir: string + seriesDir: string + musicDir: string + ripAllTracks: boolean + mainFeatureOnly: boolean + autoEject: boolean + notificationWebhook: string +} + +const defaultSettings: SettingsState = { + tmdbApiKey: '', + tvdbApiKey: '', + outputDir: '/app/media', + movieDir: 'movies', + seriesDir: 'series', + musicDir: 'music', + ripAllTracks: true, + mainFeatureOnly: false, + autoEject: true, + notificationWebhook: '', +} + +export default function SettingsPage() { + const [settings, setSettings] = useState(defaultSettings) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + + useEffect(() => { + const loadSettings = async () => { + try { + const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`) + setSettings(response.data) + } catch { + setSettings(defaultSettings) + } finally { + setLoading(false) + } + } + loadSettings() + }, []) + + const handleChange = (field: keyof SettingsState, value: any) => { + setSettings(prev => ({ ...prev, [field]: value })) + setSaved(false) + } + + const handleSave = async () => { + setSaving(true) + try { + await axios.post(`${import.meta.env.VITE_API_URL}/settings`, settings) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } finally { + setSaving(false) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

Einstellungen

+

Konfiguriere Rippy nach deinen Wünschen

+
+ + {saved && ( +
+ + Einstellungen erfolgreich gespeichert! +
+ )} + +
+ {/* Tabs */} +
+ + + + + +
+ + {/* Content */} +
+ {/* Ripping Settings */} +
+

+ + Ripping-Einstellungen +

+ +
+
+
+
+ +
+
+

Alle Tracks rippen

+

Rippe alle Tracks einer CD (nicht nur Main Feature)

+
+
+ handleChange('ripAllTracks', v)} + /> +
+ +
+
+
+ +
+
+

Nur Hauptfeature

+

Rippe bei DVDs/Blu-rays nur das Hauptfeature

+
+
+ handleChange('mainFeatureOnly', v)} + /> +
+ +
+
+
+ +
+
+

Automatischer Auswurf

+

Disk nach erfolgreichem Ripping auswerfen

+
+
+ handleChange('autoEject', v)} + /> +
+
+
+ + {/* Output Directories */} +
+

+ + Ausgabeverzeichnisse +

+ +
+
+ + handleChange('outputDir', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="/app/media" + /> +

Basisverzeichnis für alle Ausgaben

+
+ +
+ + handleChange('movieDir', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="movies" + /> +
+ +
+ + handleChange('seriesDir', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="series" + /> +
+ +
+ + handleChange('musicDir', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="music" + /> +
+
+
+ + {/* API Keys */} +
+

+ + API-Schlüssel +

+ +
+
+ + handleChange('tmdbApiKey', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="Dein TMDB API Key" + /> +

+ Hol dir einen Key auf themoviedb.org +

+
+ +
+ + handleChange('tvdbApiKey', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="Dein TVDb API Key" + /> +
+
+
+ + {/* Webhook */} +
+

+ + Webhook-Benachrichtigungen +

+ +
+ + handleChange('notificationWebhook', e.target.value)} + className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all" + placeholder="https://discord.com/api/webhooks/..." + /> +

+ Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.) +

+
+
+ + {/* Save Button */} +
+ +
+
+
+
+ ) +} + +function TabButton({ icon: Icon, label, isActive }: { icon: any, label: string, isActive: boolean }) { + return ( + + ) +} + +function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) { + return ( + + ) +}
TypStatusGerätFortschrittStartzeitTypTitelStatusGerätFortschrittStartzeit
- Keine Jobs vorhanden + +
Noch keine Jobs
+

Füge eine CD, DVD oder Blu-ray hinzu, um zu starten

{job.type}{job.device}
+ + +
+ {job.title || 'Unbekannt'} +
+
+ + + {job.device} + + {new Date(job.startTime).toLocaleString('de-DE')}