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:
Hitonabi
2026-07-21 17:17:35 +02:00
parent 0cf5413ac0
commit 518155051f
15 changed files with 1530 additions and 3 deletions
+118
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
"""Cache package."""
from .cache import init_cache, get, set, delete, clear
__all__ = ["init_cache", "get", "set", "delete", "clear"]
+7
View File
@@ -0,0 +1,7 @@
"""API Clients package."""
from .tmdb import TMDBClient
from .musicbrainz import MusicBrainzClient
from .thetvdb import TheTVDBClient
__all__ = ["TMDBClient", "MusicBrainzClient", "TheTVDBClient"]
+80
View File
@@ -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)
+109
View File
@@ -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:*")
+115
View File
@@ -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:*")
+30
View File
@@ -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()
+112
View File
@@ -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
View File
@@ -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))
+169
View File
@@ -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)
+299
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""Pre-Scan package."""
from .prescan import PreScan, PreScanResult
__all__ = ["PreScan", "PreScanResult"]
+43 -1
View File
@@ -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 -1
View File
@@ -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 {
+273
View File
@@ -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>
)
}