diff --git a/docker/api/cache.py b/docker/api/cache.py new file mode 100644 index 0000000..e7dcb34 --- /dev/null +++ b/docker/api/cache.py @@ -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() diff --git a/docker/api/cache/__init__.py b/docker/api/cache/__init__.py new file mode 100644 index 0000000..712d004 --- /dev/null +++ b/docker/api/cache/__init__.py @@ -0,0 +1,5 @@ +"""Cache package.""" + +from .cache import init_cache, get, set, delete, clear + +__all__ = ["init_cache", "get", "set", "delete", "clear"] diff --git a/docker/api/clients/__init__.py b/docker/api/clients/__init__.py new file mode 100644 index 0000000..6fe5432 --- /dev/null +++ b/docker/api/clients/__init__.py @@ -0,0 +1,7 @@ +"""API Clients package.""" + +from .tmdb import TMDBClient +from .musicbrainz import MusicBrainzClient +from .thetvdb import TheTVDBClient + +__all__ = ["TMDBClient", "MusicBrainzClient", "TheTVDBClient"] diff --git a/docker/api/clients/musicbrainz.py b/docker/api/clients/musicbrainz.py new file mode 100644 index 0000000..9188d4e --- /dev/null +++ b/docker/api/clients/musicbrainz.py @@ -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) diff --git a/docker/api/clients/thetvdb.py b/docker/api/clients/thetvdb.py new file mode 100644 index 0000000..18ae6a6 --- /dev/null +++ b/docker/api/clients/thetvdb.py @@ -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:*") diff --git a/docker/api/clients/tmdb.py b/docker/api/clients/tmdb.py new file mode 100644 index 0000000..6a15dfc --- /dev/null +++ b/docker/api/clients/tmdb.py @@ -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:*") diff --git a/docker/api/config.py b/docker/api/config.py new file mode 100644 index 0000000..74ce0f0 --- /dev/null +++ b/docker/api/config.py @@ -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() diff --git a/docker/api/image_downloader.py b/docker/api/image_downloader.py new file mode 100644 index 0000000..0bc9567 --- /dev/null +++ b/docker/api/image_downloader.py @@ -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 diff --git a/docker/api/main.py b/docker/api/main.py index 3da46bf..c10133c 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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)) diff --git a/docker/api/nfo_generator.py b/docker/api/nfo_generator.py new file mode 100644 index 0000000..44d0044 --- /dev/null +++ b/docker/api/nfo_generator.py @@ -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) diff --git a/docker/api/prescan.py b/docker/api/prescan.py new file mode 100644 index 0000000..d60ae63 --- /dev/null +++ b/docker/api/prescan.py @@ -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 diff --git a/docker/api/prescan/__init__.py b/docker/api/prescan/__init__.py new file mode 100644 index 0000000..bf8ef35 --- /dev/null +++ b/docker/api/prescan/__init__.py @@ -0,0 +1,5 @@ +"""Pre-Scan package.""" + +from .prescan import PreScan, PreScanResult + +__all__ = ["PreScan", "PreScanResult"] diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index 6f9e906..d969e5a 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -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 (
- + {/* Navigation */} + + + {/* Content */} +
+ {page === 'dashboard' ? : } +
) } diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx index 849c723..3734f97 100644 --- a/docker/ui/src/pages/Dashboard.tsx +++ b/docker/ui/src/pages/Dashboard.tsx @@ -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 { diff --git a/docker/ui/src/pages/MetadataPreview.tsx b/docker/ui/src/pages/MetadataPreview.tsx new file mode 100644 index 0000000..4bbc478 --- /dev/null +++ b/docker/ui/src/pages/MetadataPreview.tsx @@ -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('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+
+

Metadaten-Preview

+

+ Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen +

+
+ + {/* Device Selection */} +
+
+ + + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* Results */} + {result && ( +
+ {/* Header */} +
+
+ {getIcon(result.disc_type)({ size: 32 })} + + {result.disc_type.toUpperCase()} + +
+

{result.title}

+ {result.year && ( +

{result.year}

+ )} +
+ + {/* Confidence Score */} +
+
+ Übereinstimmung: + + {Math.round(result.confidence * 100)}% + +
+
+ + {/* Metadata */} +
+ {result.metadata && ( +
+ {result.metadata.overview && ( +
+

Beschreibung

+

{result.metadata.overview}

+
+ )} + + {result.metadata.genres && ( +
+

Genre

+
+ {result.metadata.genres.map((genre, index) => ( + + {genre} + + ))} +
+
+ )} + + {result.metadata.runtime && ( +
+

Laufzeit

+

{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m

+
+ )} +
+ )} + + {/* Tracks */} + {result.tracks && result.tracks.length > 0 && ( +
+

Tracks

+
+ {result.tracks.map((track, index) => ( +
+
+ {track.track_number} + {track.title} +
+ {track.duration && ( + + {Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')} + + )} +
+ ))} +
+
+ )} +
+ + {/* Action */} +
+
+
+ + Bereit zum Ripping +
+ +
+
+
+ )} +
+ ) +}