Etappe 3: Metadaten-Lookup + Pre-Scan
- SQLite-Cache für API-Rate-Limits (LRU, 10k Einträge) - TMDB/MusicBrainz/TheTVDB Clients - Pre-Scan-Modul für TOC-Lesung ohne Ripping - Metadaten-Preview UI - Jellyfin-Formatierung (NFO + Images) - API Endpoints für Lookup, Confirm, Format
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""SQLite Cache für API-Rate-Limits (LRU, 10k Einträge, TTL)."""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
CACHE_DIR = Path("/app/cache")
|
||||
CACHE_FILE = CACHE_DIR / "api_cache.db"
|
||||
MAX_ENTRIES = 10000
|
||||
DEFAULT_TTL = 86400 # 24 Stunden
|
||||
|
||||
|
||||
def init_cache() -> None:
|
||||
"""Initialisiere Cache-DB."""
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(CACHE_FILE)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
ttl INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_key ON api_cache(key)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_connection() -> sqlite3.Connection:
|
||||
"""Hole DB-Verbindung mit LRU-Check."""
|
||||
conn = sqlite3.connect(CACHE_FILE)
|
||||
|
||||
# LRU: entferne alte Einträge wenn MAX_ENTRIES überschritten
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) FROM api_cache")
|
||||
count = cursor.fetchone()[0]
|
||||
|
||||
if count >= MAX_ENTRIES:
|
||||
cursor.execute("""
|
||||
DELETE FROM api_cache
|
||||
WHERE id IN (
|
||||
SELECT id FROM api_cache
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
)
|
||||
""", (MAX_ENTRIES // 10,))
|
||||
conn.commit()
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def get(key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Hole Wert aus Cache."""
|
||||
conn = _get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT value, created_at, ttl FROM api_cache WHERE key = ?",
|
||||
(key,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row is None:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
value, created_at, ttl = row
|
||||
if time.time() > created_at + ttl:
|
||||
# TTL abgelaufen
|
||||
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
conn.close()
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def set(key: str, value: Dict[str, Any], ttl: int = DEFAULT_TTL) -> None:
|
||||
"""Speichere Wert im Cache."""
|
||||
conn = _get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO api_cache (key, value, created_at, ttl)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (key, json.dumps(value), int(time.time()), ttl))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete(key: str) -> None:
|
||||
"""Lösche Eintrag aus Cache."""
|
||||
conn = _get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM api_cache WHERE key = ?", (key,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""Lösche gesamten Cache."""
|
||||
conn = _get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM api_cache")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
"""Cache package."""
|
||||
|
||||
from .cache import init_cache, get, set, delete, clear
|
||||
|
||||
__all__ = ["init_cache", "get", "set", "delete", "clear"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""API Clients package."""
|
||||
|
||||
from .tmdb import TMDBClient
|
||||
from .musicbrainz import MusicBrainzClient
|
||||
from .thetvdb import TheTVDBClient
|
||||
|
||||
__all__ = ["TMDBClient", "MusicBrainzClient", "TheTVDBClient"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""MusicBrainz API Client mit Caching."""
|
||||
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import settings
|
||||
from .cache import get, set
|
||||
|
||||
|
||||
MUSICBRAINZ_BASE_URL = "https://musicbrainz.org/ws/2"
|
||||
|
||||
|
||||
class MusicBrainzClient:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "Rippy/1.0 (https://rippy.local)",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
|
||||
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
||||
"""Mache API-Request mit Caching."""
|
||||
cache_key = f"musicbrainz:{endpoint}:{params or {}}"
|
||||
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{MUSICBRAINZ_BASE_URL}/{endpoint}",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# XML-Response parsen
|
||||
if endpoint.endswith(".json"):
|
||||
data = response.json()
|
||||
else:
|
||||
# Fallback: XML als Text
|
||||
data = {"raw": response.text}
|
||||
|
||||
set(cache_key, data)
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"MusicBrainz API Error: {e}")
|
||||
return None
|
||||
|
||||
def search_artist(self, name: str) -> List[Dict]:
|
||||
"""Suche Künstler."""
|
||||
params = {"query": f"artist:{name}", "fmt": "json", "limit": 10}
|
||||
result = self._request("artist", params)
|
||||
|
||||
if result and "artists" in result:
|
||||
return result["artists"]
|
||||
return []
|
||||
|
||||
def search_release(self, title: str, artist: str = None) -> List[Dict]:
|
||||
"""Suche Releases."""
|
||||
query = f"release:{title}"
|
||||
if artist:
|
||||
query += f" AND artist:{artist}"
|
||||
|
||||
params = {"query": query, "fmt": "json", "limit": 10}
|
||||
result = self._request("release", params)
|
||||
|
||||
if result and "releases" in result:
|
||||
return result["releases"]
|
||||
return []
|
||||
|
||||
def get_release_info(self, release_id: str) -> Optional[Dict]:
|
||||
"""Hole Release-Details."""
|
||||
params = {"fmt": "json", "inc": "artists+media+recordings"}
|
||||
return self._request(f"release/{release_id}", params)
|
||||
|
||||
def get_artist_info(self, artist_id: str) -> Optional[Dict]:
|
||||
"""Hole Künstler-Details."""
|
||||
params = {"fmt": "json", "inc": "aliases+ratings"}
|
||||
return self._request(f"artist/{artist_id}", params)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""TheTVDB API Client mit Caching."""
|
||||
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import settings
|
||||
from .cache import get, set, delete
|
||||
|
||||
|
||||
THETVDB_BASE_URL = "https://api.thetvdb.com"
|
||||
|
||||
|
||||
class TheTVDBClient:
|
||||
def __init__(self):
|
||||
self.api_key = settings.thetvdb_api_key
|
||||
self.base_url = THETVDB_BASE_URL
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
self._cached_token = None
|
||||
|
||||
def _get_token(self) -> str:
|
||||
"""Hole API-Token (gecachted)."""
|
||||
if self._cached_token:
|
||||
return self._cached_token
|
||||
|
||||
try:
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/login",
|
||||
json={"apikey": self.api_key},
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if "token" in data:
|
||||
self._cached_token = data["token"]
|
||||
return self._cached_token
|
||||
except Exception as e:
|
||||
print(f"TheTVDB Login Error: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
||||
"""Mache API-Request mit Caching."""
|
||||
cache_key = f"thetvdb:{endpoint}:{params or {}}"
|
||||
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {self._get_token()}"
|
||||
})
|
||||
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/{endpoint}",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
set(cache_key, data)
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"TheTVDB API Error: {e}")
|
||||
return None
|
||||
|
||||
def search_series(self, name: str) -> List[Dict]:
|
||||
"""Suche Serien."""
|
||||
params = {"name": name}
|
||||
result = self._request("search/series", params)
|
||||
|
||||
if result and "data" in result:
|
||||
return result["data"]
|
||||
return []
|
||||
|
||||
def get_series_details(self, series_id: int) -> Optional[Dict]:
|
||||
"""Hole Serien-Details."""
|
||||
return self._request(f"series/{series_id}")
|
||||
|
||||
def get_episode_details(self, series_id: int, episode_id: int) -> Optional[Dict]:
|
||||
"""Hole Episode-Details."""
|
||||
return self._request(f"series/{series_id}/episodes/{episode_id}")
|
||||
|
||||
def get_series_images(self, series_id: int) -> Dict[str, str]:
|
||||
"""Hole Serien-Poster/Fanart URLs."""
|
||||
params = {"type": "poster"}
|
||||
result = self._request(f"series/{series_id}/images/query", params)
|
||||
|
||||
if result and "data" in result:
|
||||
images = result["data"]
|
||||
# Finde Poster und Fanart
|
||||
poster = next((img for img in images if img.get("subKey") == "poster"), None)
|
||||
fanart = next((img for img in images if img.get("subKey") == "fanart"), None)
|
||||
|
||||
return {
|
||||
"poster": f"{self.base_url}/images/series/{series_id}_{poster['fileName']}" if poster else "",
|
||||
"fanart": f"{self.base_url}/images/series/{series_id}_{fanart['fileName']}" if fanart else ""
|
||||
}
|
||||
return {}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Leere TheTVDB-Cache."""
|
||||
delete("thetvdb:*")
|
||||
@@ -0,0 +1,115 @@
|
||||
"""TMDB API Client mit Caching."""
|
||||
|
||||
import requests
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .config import settings
|
||||
from .cache import get, set, delete
|
||||
|
||||
|
||||
TMDB_BASE_URL = "https://api.themoviedb.org/3"
|
||||
TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p"
|
||||
|
||||
|
||||
class TMDBClient:
|
||||
def __init__(self):
|
||||
self.api_key = settings.tmdb_api_key
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
|
||||
def _request(self, endpoint: str, params: Dict = None) -> Optional[Dict]:
|
||||
"""Mache API-Request mit Caching."""
|
||||
cache_key = f"tmdb:{endpoint}:{params or {}}"
|
||||
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{TMDB_BASE_URL}/{endpoint}",
|
||||
params=params,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Cache nur für erfolgreiche Requests
|
||||
if response.status_code == 200:
|
||||
set(cache_key, data)
|
||||
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"TMDB API Error: {e}")
|
||||
return None
|
||||
|
||||
def search_movie(self, title: str, year: int = None) -> List[Dict]:
|
||||
"""Suche Filme."""
|
||||
params = {"query": title, "language": "de-DE", "include_adult": False}
|
||||
if year:
|
||||
params["year"] = year
|
||||
|
||||
result = self._request("search/movie", params)
|
||||
if result and "results" in result:
|
||||
return result["results"]
|
||||
return []
|
||||
|
||||
def search_tv(self, title: str, year: int = None) -> List[Dict]:
|
||||
"""Suche Serien."""
|
||||
params = {"query": title, "language": "de-DE", "include_adult": False}
|
||||
if year:
|
||||
params["first_air_date_year"] = year
|
||||
|
||||
result = self._request("search/tv", params)
|
||||
if result and "results" in result:
|
||||
return result["results"]
|
||||
return []
|
||||
|
||||
def get_movie_details(self, movie_id: int) -> Optional[Dict]:
|
||||
"""Hole Film-Details."""
|
||||
params = {"language": "de-DE"}
|
||||
return self._request(f"movie/{movie_id}", params)
|
||||
|
||||
def get_tv_details(self, tv_id: int) -> Optional[Dict]:
|
||||
"""Hole Serien-Details."""
|
||||
params = {"language": "de-DE"}
|
||||
return self._request(f"tv/{tv_id}", params)
|
||||
|
||||
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
||||
"""Hole Poster/Fanart URLs."""
|
||||
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
||||
result = self._request(f"movie/{movie_id}/images", params)
|
||||
|
||||
if not result or "posters" not in result:
|
||||
return {}
|
||||
|
||||
posters = result["posters"]
|
||||
fanart = result.get("backdrops", [])
|
||||
|
||||
return {
|
||||
"poster": f"{TMDB_IMAGE_BASE_URL}/w500{posters[0]['file_path']}" if posters else "",
|
||||
"fanart": f"{TMDB_IMAGE_BASE_URL}/w1920{fanart[0]['file_path']}" if fanart else ""
|
||||
}
|
||||
|
||||
def get_tv_images(self, tv_id: int) -> Dict[str, str]:
|
||||
"""Hole Serien-Poster/Fanart URLs."""
|
||||
params = {"language": "de-DE", "include_image_language": "de,en,null"}
|
||||
result = self._request(f"tv/{tv_id}/images", params)
|
||||
|
||||
if not result or "posters" not in result:
|
||||
return {}
|
||||
|
||||
posters = result["posters"]
|
||||
fanart = result.get("backdrops", [])
|
||||
|
||||
return {
|
||||
"poster": f"{TMDB_IMAGE_BASE_URL}/w500{posters[0]['file_path']}" if posters else "",
|
||||
"fanart": f"{TMDB_IMAGE_BASE_URL}/w1920{fanart[0]['file_path']}" if fanart else ""
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Leere TMDB-Cache."""
|
||||
delete("tmdb:*")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""API-Konfiguration."""
|
||||
|
||||
from pydantic import BaseSettings
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Rippy API Settings."""
|
||||
|
||||
# API Keys
|
||||
tmdb_api_key: Optional[str] = None
|
||||
thetvdb_api_key: Optional[str] = None
|
||||
musicbrainz_user: Optional[str] = None
|
||||
|
||||
# Cache
|
||||
cache_ttl: int = 86400 # 24h default
|
||||
|
||||
# Ripping
|
||||
rip_output_dir: str = "/app/media"
|
||||
temp_dir: str = "/app/temp"
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Jellyfin Image Downloader."""
|
||||
|
||||
import requests
|
||||
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
|
||||
|
||||
|
||||
class ImageDownloader:
|
||||
def __init__(self):
|
||||
self.tmdb = TMDBClient()
|
||||
self.thetvdb = TheTVDBClient()
|
||||
self.base_url = "https://image.tmdb.org/t/p"
|
||||
|
||||
def download_image(self, url: str, output_path: Path, width: int = 500) -> bool:
|
||||
"""Lade Image herunter."""
|
||||
try:
|
||||
# TMDB URL anpassen
|
||||
if url.startswith("https://image.tmdb.org"):
|
||||
# Konvertiere zu gewünschter Größe
|
||||
path = url.replace(f"{self.base_url}/", "")
|
||||
url = f"{self.base_url}/w{width}/{path}"
|
||||
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Image download error: {e}")
|
||||
return False
|
||||
|
||||
def download_poster(self, title: str, output_dir: Path, width: int = 500) -> Optional[Path]:
|
||||
"""Lade Poster herunter."""
|
||||
# TMDB Search
|
||||
movies = self.tmdb.search_movie(title)
|
||||
if movies:
|
||||
movie = movies[0]
|
||||
images = self.tmdb.get_movie_images(movie["id"])
|
||||
|
||||
if images.get("poster"):
|
||||
output_path = output_dir / "poster.jpg"
|
||||
if self.download_image(images["poster"], output_path, width):
|
||||
return output_path
|
||||
|
||||
return None
|
||||
|
||||
def download_fanart(self, title: str, output_dir: Path, width: int = 1920) -> Optional[Path]:
|
||||
"""Lade Fanart herunter."""
|
||||
# TMDB Search
|
||||
movies = self.tmdb.search_movie(title)
|
||||
if movies:
|
||||
movie = movies[0]
|
||||
images = self.tmdb.get_movie_images(movie["id"])
|
||||
|
||||
if images.get("fanart"):
|
||||
output_path = output_dir / "fanart.jpg"
|
||||
if self.download_image(images["fanart"], output_path, width):
|
||||
return output_path
|
||||
|
||||
return None
|
||||
|
||||
def download_series_images(self, title: str, output_dir: Path) -> Dict[str, Optional[Path]]:
|
||||
"""Lade Serien-Poster und Fanart herunter."""
|
||||
result = {
|
||||
"poster": None,
|
||||
"fanart": None
|
||||
}
|
||||
|
||||
tv_shows = self.tmdb.search_tv(title)
|
||||
if tv_shows:
|
||||
tv = tv_shows[0]
|
||||
images = self.tmdb.get_tv_images(tv["id"])
|
||||
|
||||
if images.get("poster"):
|
||||
output_path = output_dir / "poster.jpg"
|
||||
if self.download_image(images["poster"], output_path, 500):
|
||||
result["poster"] = output_path
|
||||
|
||||
if images.get("fanart"):
|
||||
output_path = output_dir / "fanart.jpg"
|
||||
if self.download_image(images["fanart"], output_path, 1920):
|
||||
result["fanart"] = output_path
|
||||
|
||||
return result
|
||||
|
||||
def download_music_images(self, artist: str, album: str, output_dir: Path) -> Dict[str, Optional[Path]]:
|
||||
"""Lade Musik-Album-Cover herunter (via TMDB als Fallback)."""
|
||||
result = {
|
||||
"album": None
|
||||
}
|
||||
|
||||
# TMDB Search für Soundtracks
|
||||
query = f"{album} soundtrack"
|
||||
movies = self.tmdb.search_movie(query)
|
||||
if movies:
|
||||
movie = movies[0]
|
||||
images = self.tmdb.get_movie_images(movie["id"])
|
||||
|
||||
if images.get("poster"):
|
||||
output_path = output_dir / "album.jpg"
|
||||
if self.download_image(images["poster"], output_path, 500):
|
||||
result["album"] = output_path
|
||||
|
||||
return result
|
||||
+164
-1
@@ -1,10 +1,20 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime
|
||||
import os
|
||||
import subprocess
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from .config import settings
|
||||
from .cache import init_cache, set
|
||||
from .prescan import PreScan
|
||||
from .nfo_generator import NFOGenerator
|
||||
from .image_downloader import ImageDownloader
|
||||
|
||||
app = FastAPI(
|
||||
title="Rippy API",
|
||||
@@ -12,6 +22,15 @@ app = FastAPI(
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# SSE-Connections
|
||||
sse_connections: List = []
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialisiere Cache beim Start."""
|
||||
init_cache()
|
||||
|
||||
# CORS hinzufügen
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -86,3 +105,147 @@ async def get_devices():
|
||||
pass
|
||||
|
||||
return devices
|
||||
|
||||
|
||||
# SSE-Stream für Echtzeit-Updates
|
||||
@app.get("/stream/jobs")
|
||||
async def job_stream():
|
||||
"""SSE-Stream für Job-Updates."""
|
||||
async def event_generator():
|
||||
while True:
|
||||
if sse_connections:
|
||||
# Job-Status aktualisieren
|
||||
jobs = await get_jobs()
|
||||
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# Metadaten-Lookup Endpoints
|
||||
class MetadataLookupRequest(BaseModel):
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
disc_type: str = "dvd"
|
||||
|
||||
|
||||
@app.post("/metadata/lookup")
|
||||
async def lookup_metadata(request: MetadataLookupRequest):
|
||||
"""Suche Metadaten für Disc."""
|
||||
prescan = PreScan()
|
||||
|
||||
# Dummy device für Pre-Scan
|
||||
device = "/dev/dvd" if request.disc_type in ["dvd", "bluray"] else "/dev/cdrom"
|
||||
|
||||
result = prescan.scan(device)
|
||||
|
||||
return {
|
||||
"title": result.title,
|
||||
"year": result.year,
|
||||
"confidence": result.confidence,
|
||||
"metadata": result.metadata,
|
||||
"tracks": result.tracks
|
||||
}
|
||||
|
||||
|
||||
@app.post("/metadata/confirm")
|
||||
async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None):
|
||||
"""Bestätige Metadaten."""
|
||||
# In Cache speichern
|
||||
cache_key = f"confirmed:{title}:{year}"
|
||||
set(cache_key, {"title": title, "year": year, "metadata": metadata or {}})
|
||||
|
||||
return {"status": "confirmed", "key": cache_key}
|
||||
|
||||
|
||||
# Pre-Scan Endpoint
|
||||
class PreScanRequest(BaseModel):
|
||||
device_path: str
|
||||
|
||||
|
||||
@app.post("/prescan")
|
||||
async def run_prescan(request: PreScanRequest):
|
||||
"""Führe Pre-Scan durch."""
|
||||
try:
|
||||
prescan = PreScan()
|
||||
result = prescan.scan(request.device_path)
|
||||
return result.to_dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Jellyfin-Formatierung Endpoints
|
||||
class JellyfinFormatRequest(BaseModel):
|
||||
title: str
|
||||
year: Optional[int]
|
||||
metadata: Dict
|
||||
disc_type: str
|
||||
output_dir: str
|
||||
|
||||
|
||||
@app.post("/jellyfin/format")
|
||||
async def jellyfin_format(request: JellyfinFormatRequest):
|
||||
"""Formatiere für Jellyfin (NFO + Images)."""
|
||||
try:
|
||||
nfo_gen = NFOGenerator()
|
||||
img_downloader = ImageDownloader()
|
||||
|
||||
# Ordnerstruktur erstellen
|
||||
output_path = Path(request.output_dir)
|
||||
|
||||
if request.disc_type in ["dvd", "bluray"]:
|
||||
# Film-Formatierung
|
||||
title = request.metadata.get("title", request.title)
|
||||
year = request.year or request.metadata.get("year")
|
||||
|
||||
# movie.nfo
|
||||
movie_nfo = nfo_gen.generate_movie_nfo(
|
||||
title=title,
|
||||
year=year or 2000,
|
||||
overview=request.metadata.get("overview", ""),
|
||||
rating=request.metadata.get("rating", 0),
|
||||
runtime=request.metadata.get("runtime", 0),
|
||||
genres=request.metadata.get("genres", []),
|
||||
director=request.metadata.get("director", ""),
|
||||
actors=request.metadata.get("actors", [])
|
||||
)
|
||||
|
||||
nfo_path = output_path / "movie.nfo"
|
||||
nfo_gen.save_nfo(movie_nfo, nfo_path)
|
||||
|
||||
# Poster und Fanart
|
||||
img_downloader.download_poster(title, output_path, 500)
|
||||
img_downloader.download_fanart(title, output_path, 1920)
|
||||
|
||||
return {
|
||||
"status": "formatted",
|
||||
"nfo_path": str(nfo_path),
|
||||
"poster_path": str(output_path / "poster.jpg"),
|
||||
"fanart_path": str(output_path / "fanart.jpg")
|
||||
}
|
||||
else:
|
||||
# Audio-Formatierung
|
||||
artist = request.metadata.get("artist", "Unknown Artist")
|
||||
album = title
|
||||
|
||||
# album.nfo
|
||||
album_nfo = nfo_gen.generate_album_nfo(
|
||||
title=album,
|
||||
artist=artist,
|
||||
year=year or 2000,
|
||||
genres=request.metadata.get("genres", [])
|
||||
)
|
||||
|
||||
nfo_path = output_path / "album.nfo"
|
||||
nfo_gen.save_nfo(album_nfo, nfo_path)
|
||||
|
||||
# Album-Cover
|
||||
img_downloader.download_music_images(artist, album, output_path)
|
||||
|
||||
return {
|
||||
"status": "formatted",
|
||||
"nfo_path": str(nfo_path),
|
||||
"album_cover_path": str(output_path / "album.jpg")
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from xml.dom.minidom import getDOMImplementation
|
||||
|
||||
|
||||
class NFOGenerator:
|
||||
def __init__(self):
|
||||
self.dom_impl = getDOMImplementation()
|
||||
|
||||
def _create_element(self, doc, name: str, text: str = None) -> None:
|
||||
"""Hilfsfunktion für Element-Erstellung."""
|
||||
element = doc.createElement(name)
|
||||
if text:
|
||||
element.appendChild(doc.createTextNode(str(text)))
|
||||
return element
|
||||
|
||||
def generate_movie_nfo(self, title: str, year: int,
|
||||
overview: str = "", rating: float = 0.0,
|
||||
runtime: int = 0, genres: List[str] = None,
|
||||
director: str = "", writer: str = "",
|
||||
actors: List[str] = None, studio: str = "",
|
||||
premiered: str = "", mpaa: str = "") -> str:
|
||||
"""Generiere movie.nfo für Filme."""
|
||||
doc = self.dom_impl.createDocument(None, "movie", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "year", year))
|
||||
root.appendChild(self._create_element(doc, "plot", overview))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
root.appendChild(self._create_element(doc, "runtime", runtime))
|
||||
|
||||
if genres:
|
||||
for genre in genres:
|
||||
root.appendChild(self._create_element(doc, "genre", genre))
|
||||
|
||||
if director:
|
||||
root.appendChild(self._create_element(doc, "director", director))
|
||||
|
||||
if writer:
|
||||
root.appendChild(self._create_element(doc, "writer", writer))
|
||||
|
||||
if actors:
|
||||
for actor in actors:
|
||||
actor_node = doc.createElement("actor")
|
||||
actor_node.appendChild(self._create_element(doc, "name", actor))
|
||||
root.appendChild(actor_node)
|
||||
|
||||
if studio:
|
||||
root.appendChild(self._create_element(doc, "studio", studio))
|
||||
|
||||
if premiered:
|
||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||
|
||||
if mpaa:
|
||||
root.appendChild(self._create_element(doc, "mpaa", mpaa))
|
||||
|
||||
# Attribution
|
||||
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def generate_series_nfo(self, title: str, year: int,
|
||||
overview: str = "", rating: float = 0.0,
|
||||
genres: List[str] = None, studio: str = "",
|
||||
premiered: str = "") -> str:
|
||||
"""Generiere series.nfo für Serien."""
|
||||
doc = self.dom_impl.createDocument(None, "tvshow", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "year", year))
|
||||
root.appendChild(self._create_element(doc, "plot", overview))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
|
||||
if genres:
|
||||
for genre in genres:
|
||||
root.appendChild(self._create_element(doc, "genre", genre))
|
||||
|
||||
if studio:
|
||||
root.appendChild(self._create_element(doc, "studio", studio))
|
||||
|
||||
if premiered:
|
||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||
|
||||
# Attribution
|
||||
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def generate_episode_nfo(self, title: str, season: int, episode: int,
|
||||
overview: str = "", rating: float = 0.0,
|
||||
director: str = "", premiered: str = "",
|
||||
writers: List[str] = None) -> str:
|
||||
"""Generiere episode.nfo für Episoden."""
|
||||
doc = self.dom_impl.createDocument(None, "episodedetails", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "season", season))
|
||||
root.appendChild(self._create_element(doc, "episode", episode))
|
||||
root.appendChild(self._create_element(doc, "plot", overview))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
|
||||
if director:
|
||||
root.appendChild(self._create_element(doc, "director", director))
|
||||
|
||||
if premiered:
|
||||
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||
|
||||
if writers:
|
||||
for writer in writers:
|
||||
root.appendChild(self._create_element(doc, "credits", writer))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def generate_album_nfo(self, title: str, artist: str, year: int,
|
||||
genres: List[str] = None, rating: float = 0.0,
|
||||
review: str = "") -> str:
|
||||
"""Generiere album.nfo für Musikalben."""
|
||||
doc = self.dom_impl.createDocument(None, "musicalbum", None)
|
||||
root = doc.documentElement
|
||||
|
||||
root.appendChild(self._create_element(doc, "title", title))
|
||||
root.appendChild(self._create_element(doc, "artist", artist))
|
||||
root.appendChild(self._create_element(doc, "year", year))
|
||||
root.appendChild(self._create_element(doc, "rating", rating))
|
||||
root.appendChild(self._create_element(doc, "review", review))
|
||||
|
||||
if genres:
|
||||
for genre in genres:
|
||||
root.appendChild(self._create_element(doc, "genre", genre))
|
||||
|
||||
# Attribution
|
||||
root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz"))
|
||||
|
||||
return doc.toprettyxml(indent=" ")
|
||||
|
||||
def save_nfo(self, content: str, output_path: Path) -> bool:
|
||||
"""Speichere NFO-Datei."""
|
||||
try:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"NFO save error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Beispieldaten
|
||||
if __name__ == "__main__":
|
||||
nfo_gen = NFOGenerator()
|
||||
|
||||
# movie.nfo
|
||||
movie_nfo = nfo_gen.generate_movie_nfo(
|
||||
title="Inception",
|
||||
year=2010,
|
||||
overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
|
||||
rating=8.8,
|
||||
runtime=148,
|
||||
genres=["Action", "Sci-Fi", "Thriller"],
|
||||
director="Christopher Nolan",
|
||||
actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"]
|
||||
)
|
||||
|
||||
print(movie_nfo)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class PreScanResult:
|
||||
def __init__(
|
||||
self,
|
||||
disc_type: str,
|
||||
title: str,
|
||||
year: int = None,
|
||||
confidence: float = 0.0,
|
||||
metadata: Dict = None,
|
||||
tracks: List[Dict] = None
|
||||
):
|
||||
self.disc_type = disc_type
|
||||
self.title = title
|
||||
self.year = year
|
||||
self.confidence = confidence
|
||||
self.metadata = metadata or {}
|
||||
self.tracks = tracks or []
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return {
|
||||
"disc_type": self.disc_type,
|
||||
"title": self.title,
|
||||
"year": self.year,
|
||||
"confidence": self.confidence,
|
||||
"metadata": self.metadata,
|
||||
"tracks": self.tracks
|
||||
}
|
||||
|
||||
|
||||
class PreScan:
|
||||
def __init__(self):
|
||||
self.tmdb = TMDBClient()
|
||||
self.musicbrainz = MusicBrainzClient()
|
||||
self.thetvdb = TheTVDBClient()
|
||||
|
||||
def scan(self, device_path: str) -> PreScanResult:
|
||||
"""Führe Pre-Scan durch."""
|
||||
# 1. Disc-Typ erkennen
|
||||
disc_type = self._detect_disc_type(device_path)
|
||||
|
||||
# 2. TOC lesen
|
||||
toc = self._read_toc(device_path, disc_type)
|
||||
|
||||
# 3. Metadaten lookup
|
||||
if disc_type == "CD":
|
||||
return self._scan_audio(device_path, toc)
|
||||
else:
|
||||
return self._scan_video(device_path, toc)
|
||||
|
||||
def _detect_disc_type(self, device_path: str) -> str:
|
||||
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["isosize", "-x", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
# Ausgabe parsen
|
||||
if result.returncode == 0:
|
||||
size = int(result.stdout.strip())
|
||||
if size < 700 * 1024 * 1024: # < 700MB
|
||||
return "CD"
|
||||
elif size < 15 * 1024 * 1024 * 1024: # < 15GB
|
||||
return "DVD"
|
||||
else:
|
||||
return "Blu-ray"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback
|
||||
return "DVD"
|
||||
|
||||
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
||||
"""Lese TOC (Table of Contents)."""
|
||||
toc = {
|
||||
"title": None,
|
||||
"year": None,
|
||||
"tracks": [],
|
||||
"duration": 0
|
||||
}
|
||||
|
||||
try:
|
||||
if disc_type == "CD":
|
||||
# cdparanoia für CD-TOC
|
||||
result = subprocess.run(
|
||||
["cdparanoia", "-Q", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Parse cdparanoia output
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'track' in line.lower():
|
||||
# Track-Info parsen
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
toc["tracks"].append({
|
||||
"track_number": parts[1],
|
||||
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
|
||||
"duration": 0
|
||||
})
|
||||
else:
|
||||
# makemkvcon für DVD/Blu-ray (nur TOC, kein Ripping)
|
||||
result = subprocess.run(
|
||||
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
# Parse makemkvcon output
|
||||
for line in result.stdout.split('\n'):
|
||||
if line.startswith('DRV:'):
|
||||
# Disc-Info
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 4:
|
||||
toc["title"] = parts[3].strip().strip('"')
|
||||
elif line.startswith('TINFO:'):
|
||||
# Titel-Info
|
||||
parts = line.split(',')
|
||||
if len(parts) >= 5:
|
||||
toc["tracks"].append({
|
||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Pre-Scan TOC Error: {e}")
|
||||
|
||||
return toc
|
||||
|
||||
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||
"""Pre-Scan für Audio-CD."""
|
||||
# Cache-Key
|
||||
cache_key = f"prescan:audio:{device_path}"
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return PreScanResult(**cached)
|
||||
|
||||
# Künstler und Album aus Titel extrahieren
|
||||
artist = None
|
||||
album = toc["title"] or "Unknown Album"
|
||||
|
||||
if " - " in album:
|
||||
parts = album.split(" - ", 1)
|
||||
if len(parts) == 2:
|
||||
artist = parts[0]
|
||||
album = parts[1]
|
||||
|
||||
# MusicBrainz Search
|
||||
if artist:
|
||||
artists = self.musicbrainz.search_artist(artist)
|
||||
if artists:
|
||||
# Best match
|
||||
best_match = artists[0]
|
||||
confidence = 0.8
|
||||
|
||||
# Album suchen
|
||||
releases = self.musicbrainz.search_release(album, artist)
|
||||
if releases:
|
||||
confidence = 0.9
|
||||
release_id = releases[0]["id"]
|
||||
release_info = self.musicbrainz.get_release_info(release_id)
|
||||
|
||||
if release_info:
|
||||
# Tracks aus Release Info
|
||||
for medium in release_info.get("media", []):
|
||||
for track in medium.get("tracks", []):
|
||||
toc["tracks"].append({
|
||||
"track_number": track.get("position", 0),
|
||||
"title": track.get("title", "Unknown"),
|
||||
"duration": track.get("duration", 0) // 1000
|
||||
})
|
||||
|
||||
# TMDB Fallback für bekannte Soundtracks
|
||||
if not artist:
|
||||
movies = self.tmdb.search_movie(album)
|
||||
if movies:
|
||||
confidence = 0.7
|
||||
movie = movies[0]
|
||||
movie_details = self.tmdb.get_movie_details(movie["id"])
|
||||
|
||||
if movie_details:
|
||||
# Soundtrack als "Soundtrack - [Filmname]"
|
||||
artist = album
|
||||
album = f"Soundtrack - {movie_details.get('title', album)}"
|
||||
toc["title"] = album
|
||||
|
||||
# Cache result
|
||||
result = PreScanResult(
|
||||
disc_type="CD",
|
||||
title=album,
|
||||
tracks=toc["tracks"],
|
||||
confidence=confidence if 'confidence' in dir() else 0.5
|
||||
)
|
||||
|
||||
set(cache_key, result.to_dict())
|
||||
return result
|
||||
|
||||
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||
"""Pre-Scan für DVD/Blu-ray."""
|
||||
# Cache-Key
|
||||
cache_key = f"prescan:video:{device_path}"
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return PreScanResult(**cached)
|
||||
|
||||
# Disc-Titel
|
||||
title = toc["title"] or "Unknown Title"
|
||||
|
||||
# TMDB Search
|
||||
confidence = 0.0
|
||||
metadata = {}
|
||||
matched = False
|
||||
|
||||
# Versuche Film-Suche
|
||||
movies = self.tmdb.search_movie(title)
|
||||
if movies:
|
||||
for movie in movies:
|
||||
# Prüfe auf gute Übereinstimmung
|
||||
if movie.get("title", "").lower() == title.lower():
|
||||
confidence = 0.95
|
||||
movie_id = movie["id"]
|
||||
movie_details = self.tmdb.get_movie_details(movie_id)
|
||||
|
||||
if movie_details:
|
||||
metadata = {
|
||||
"type": "movie",
|
||||
"id": movie_id,
|
||||
"title": movie_details.get("title", title),
|
||||
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
|
||||
"overview": movie_details.get("overview", ""),
|
||||
"poster_path": movie_details.get("poster_path", ""),
|
||||
"backdrop_path": movie_details.get("backdrop_path", ""),
|
||||
"runtime": movie_details.get("runtime", 0),
|
||||
"genres": [g["name"] for g in movie_details.get("genres", [])]
|
||||
}
|
||||
matched = True
|
||||
break
|
||||
|
||||
# Versuche Serien-Suche
|
||||
if not matched:
|
||||
tv_shows = self.tmdb.search_tv(title)
|
||||
if tv_shows:
|
||||
for show in tv_shows:
|
||||
if show.get("name", "").lower() == title.lower():
|
||||
confidence = 0.9
|
||||
tv_id = show["id"]
|
||||
tv_details = self.tmdb.get_tv_details(tv_id)
|
||||
|
||||
if tv_details:
|
||||
metadata = {
|
||||
"type": "tv",
|
||||
"id": tv_id,
|
||||
"title": tv_details.get("name", title),
|
||||
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
|
||||
"overview": tv_details.get("overview", ""),
|
||||
"poster_path": tv_details.get("poster_path", ""),
|
||||
"backdrop_path": tv_details.get("backdrop_path", ""),
|
||||
"genres": [g["name"] for g in tv_details.get("genres", [])]
|
||||
}
|
||||
matched = True
|
||||
break
|
||||
|
||||
# Fallback auf Titel ohne Lookup
|
||||
if not matched:
|
||||
confidence = 0.3
|
||||
metadata = {
|
||||
"type": "unknown",
|
||||
"title": title,
|
||||
"year": None
|
||||
}
|
||||
|
||||
# Cache result
|
||||
result = PreScanResult(
|
||||
disc_type=toc.get("disc_type", "DVD"),
|
||||
title=title,
|
||||
year=metadata.get("year"),
|
||||
confidence=confidence,
|
||||
metadata=metadata,
|
||||
tracks=toc.get("tracks", [])
|
||||
)
|
||||
|
||||
set(cache_key, result.to_dict())
|
||||
return result
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Pre-Scan package."""
|
||||
|
||||
from .prescan import PreScan, PreScanResult
|
||||
|
||||
__all__ = ["PreScan", "PreScanResult"]
|
||||
+43
-1
@@ -1,10 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import MetadataPreview from './pages/MetadataPreview'
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState<'dashboard' | 'metadata'>('dashboard')
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'metadata', label: 'Metadaten-Preview' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Dashboard />
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-md">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<svg className="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<span className="font-bold text-xl text-gray-900">Rippy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setPage(item.id as 'dashboard' | 'metadata')}
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
page === item.id
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<main className="max-w-7xl mx-auto py-6">
|
||||
{page === 'dashboard' ? <Dashboard /> : <MetadataPreview />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { BarChart, Activity, HardDrive, Clock, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface Job {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import axios from 'axios'
|
||||
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface Metadata {
|
||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||
id: number
|
||||
title: string
|
||||
year?: number
|
||||
overview?: string
|
||||
poster_path?: string
|
||||
backdrop_path?: string
|
||||
runtime?: number
|
||||
genres?: string[]
|
||||
}
|
||||
|
||||
interface Track {
|
||||
track_number: number
|
||||
title: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface PrescanResult {
|
||||
disc_type: 'cd' | 'dvd' | 'bluray'
|
||||
title: string
|
||||
year?: number
|
||||
confidence: number
|
||||
metadata?: Metadata
|
||||
tracks?: Track[]
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000'
|
||||
|
||||
export default function MetadataPreview() {
|
||||
const [device, setDevice] = useState<string>('')
|
||||
const [result, setResult] = useState<PrescanResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!device) {
|
||||
setError('Bitte wählen Sie ein Gerät aus.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/prescan`, { device_path: device })
|
||||
setResult(response.data)
|
||||
} catch (err) {
|
||||
setError('Pre-Scan fehlgeschlagen.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!result) return
|
||||
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/metadata/confirm`, {
|
||||
title: result.title,
|
||||
year: result.year,
|
||||
metadata: result.metadata
|
||||
})
|
||||
|
||||
// Job erstellen
|
||||
await axios.post(`${API_URL}/jobs`, {
|
||||
device: device,
|
||||
type: result.disc_type
|
||||
})
|
||||
} catch (err) {
|
||||
setError('Bestätigung fehlgeschlagen.')
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = (type: string): LucideIcon => {
|
||||
switch (type) {
|
||||
case 'cd':
|
||||
return Music
|
||||
case 'dvd':
|
||||
case 'bluray':
|
||||
return Film
|
||||
default:
|
||||
return BookOpen
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceColor = (confidence: number): string => {
|
||||
if (confidence >= 0.9) return 'text-green-600 bg-green-100'
|
||||
if (confidence >= 0.7) return 'text-yellow-600 bg-yellow-100'
|
||||
return 'text-red-600 bg-red-100'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Metadaten-Preview</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device Selection */}
|
||||
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
value={device}
|
||||
onChange={(e) => setDevice(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Gerät auswählen...</option>
|
||||
<option value="/dev/cdrom">CD-ROM (/dev/cdrom)</option>
|
||||
<option value="/dev/dvd">DVD (/dev/dvd)</option>
|
||||
<option value="/dev/bluray">Blu-ray (/dev/bluray)</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={!device || loading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white ${
|
||||
!device || loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Scannen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search size={20} />
|
||||
Scan starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3 text-red-700">
|
||||
<AlertCircle size={24} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{getIcon(result.disc_type)({ size: 32 })}
|
||||
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
||||
{result.disc_type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold">{result.title}</h2>
|
||||
{result.year && (
|
||||
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confidence Score */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">Übereinstimmung:</span>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getConfidenceColor(result.confidence)}`}>
|
||||
{Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="p-6">
|
||||
{result.metadata && (
|
||||
<div className="space-y-4">
|
||||
{result.metadata.overview && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Beschreibung</h3>
|
||||
<p className="text-gray-700 leading-relaxed">{result.metadata.overview}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.genres && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Genre</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.metadata.genres.map((genre, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.metadata.runtime && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Laufzeit</h3>
|
||||
<p className="text-gray-700">{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracks */}
|
||||
{result.tracks && result.tracks.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 uppercase mb-3">Tracks</h3>
|
||||
<div className="space-y-2">
|
||||
{result.tracks.map((track, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 font-medium text-gray-500">{track.track_number}</span>
|
||||
<span className="text-gray-900">{track.title}</span>
|
||||
</div>
|
||||
{track.duration && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="p-6 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||
<CheckCircle className="text-green-600" size={20} />
|
||||
<span>Bereit zum Ripping</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
Bestätigen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle size={20} />
|
||||
Rippen starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user