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"]
|
||||
Reference in New Issue
Block a user