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 import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from fastapi import WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Dict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
import subprocess
|
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(
|
app = FastAPI(
|
||||||
title="Rippy API",
|
title="Rippy API",
|
||||||
@@ -12,6 +22,15 @@ app = FastAPI(
|
|||||||
version="1.0.0"
|
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
|
# CORS hinzufügen
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -86,3 +105,147 @@ async def get_devices():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
# SSE-Stream für Echtzeit-Updates
|
||||||
|
@app.get("/stream/jobs")
|
||||||
|
async def job_stream():
|
||||||
|
"""SSE-Stream für Job-Updates."""
|
||||||
|
async def event_generator():
|
||||||
|
while True:
|
||||||
|
if sse_connections:
|
||||||
|
# Job-Status aktualisieren
|
||||||
|
jobs = await get_jobs()
|
||||||
|
yield f"data: {json.dumps([j.dict() for j in jobs])}\n\n"
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
# Metadaten-Lookup Endpoints
|
||||||
|
class MetadataLookupRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
year: Optional[int] = None
|
||||||
|
disc_type: str = "dvd"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/metadata/lookup")
|
||||||
|
async def lookup_metadata(request: MetadataLookupRequest):
|
||||||
|
"""Suche Metadaten für Disc."""
|
||||||
|
prescan = PreScan()
|
||||||
|
|
||||||
|
# Dummy device für Pre-Scan
|
||||||
|
device = "/dev/dvd" if request.disc_type in ["dvd", "bluray"] else "/dev/cdrom"
|
||||||
|
|
||||||
|
result = prescan.scan(device)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": result.title,
|
||||||
|
"year": result.year,
|
||||||
|
"confidence": result.confidence,
|
||||||
|
"metadata": result.metadata,
|
||||||
|
"tracks": result.tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/metadata/confirm")
|
||||||
|
async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None):
|
||||||
|
"""Bestätige Metadaten."""
|
||||||
|
# In Cache speichern
|
||||||
|
cache_key = f"confirmed:{title}:{year}"
|
||||||
|
set(cache_key, {"title": title, "year": year, "metadata": metadata or {}})
|
||||||
|
|
||||||
|
return {"status": "confirmed", "key": cache_key}
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-Scan Endpoint
|
||||||
|
class PreScanRequest(BaseModel):
|
||||||
|
device_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/prescan")
|
||||||
|
async def run_prescan(request: PreScanRequest):
|
||||||
|
"""Führe Pre-Scan durch."""
|
||||||
|
try:
|
||||||
|
prescan = PreScan()
|
||||||
|
result = prescan.scan(request.device_path)
|
||||||
|
return result.to_dict()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Jellyfin-Formatierung Endpoints
|
||||||
|
class JellyfinFormatRequest(BaseModel):
|
||||||
|
title: str
|
||||||
|
year: Optional[int]
|
||||||
|
metadata: Dict
|
||||||
|
disc_type: str
|
||||||
|
output_dir: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/jellyfin/format")
|
||||||
|
async def jellyfin_format(request: JellyfinFormatRequest):
|
||||||
|
"""Formatiere für Jellyfin (NFO + Images)."""
|
||||||
|
try:
|
||||||
|
nfo_gen = NFOGenerator()
|
||||||
|
img_downloader = ImageDownloader()
|
||||||
|
|
||||||
|
# Ordnerstruktur erstellen
|
||||||
|
output_path = Path(request.output_dir)
|
||||||
|
|
||||||
|
if request.disc_type in ["dvd", "bluray"]:
|
||||||
|
# Film-Formatierung
|
||||||
|
title = request.metadata.get("title", request.title)
|
||||||
|
year = request.year or request.metadata.get("year")
|
||||||
|
|
||||||
|
# movie.nfo
|
||||||
|
movie_nfo = nfo_gen.generate_movie_nfo(
|
||||||
|
title=title,
|
||||||
|
year=year or 2000,
|
||||||
|
overview=request.metadata.get("overview", ""),
|
||||||
|
rating=request.metadata.get("rating", 0),
|
||||||
|
runtime=request.metadata.get("runtime", 0),
|
||||||
|
genres=request.metadata.get("genres", []),
|
||||||
|
director=request.metadata.get("director", ""),
|
||||||
|
actors=request.metadata.get("actors", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = output_path / "movie.nfo"
|
||||||
|
nfo_gen.save_nfo(movie_nfo, nfo_path)
|
||||||
|
|
||||||
|
# Poster und Fanart
|
||||||
|
img_downloader.download_poster(title, output_path, 500)
|
||||||
|
img_downloader.download_fanart(title, output_path, 1920)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "formatted",
|
||||||
|
"nfo_path": str(nfo_path),
|
||||||
|
"poster_path": str(output_path / "poster.jpg"),
|
||||||
|
"fanart_path": str(output_path / "fanart.jpg")
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Audio-Formatierung
|
||||||
|
artist = request.metadata.get("artist", "Unknown Artist")
|
||||||
|
album = title
|
||||||
|
|
||||||
|
# album.nfo
|
||||||
|
album_nfo = nfo_gen.generate_album_nfo(
|
||||||
|
title=album,
|
||||||
|
artist=artist,
|
||||||
|
year=year or 2000,
|
||||||
|
genres=request.metadata.get("genres", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
nfo_path = output_path / "album.nfo"
|
||||||
|
nfo_gen.save_nfo(album_nfo, nfo_path)
|
||||||
|
|
||||||
|
# Album-Cover
|
||||||
|
img_downloader.download_music_images(artist, album, output_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "formatted",
|
||||||
|
"nfo_path": str(nfo_path),
|
||||||
|
"album_cover_path": str(output_path / "album.jpg")
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""NFO-Generator für Jellyfin (Kodi/NFO-Schema)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from xml.dom.minidom import getDOMImplementation
|
||||||
|
|
||||||
|
|
||||||
|
class NFOGenerator:
|
||||||
|
def __init__(self):
|
||||||
|
self.dom_impl = getDOMImplementation()
|
||||||
|
|
||||||
|
def _create_element(self, doc, name: str, text: str = None) -> None:
|
||||||
|
"""Hilfsfunktion für Element-Erstellung."""
|
||||||
|
element = doc.createElement(name)
|
||||||
|
if text:
|
||||||
|
element.appendChild(doc.createTextNode(str(text)))
|
||||||
|
return element
|
||||||
|
|
||||||
|
def generate_movie_nfo(self, title: str, year: int,
|
||||||
|
overview: str = "", rating: float = 0.0,
|
||||||
|
runtime: int = 0, genres: List[str] = None,
|
||||||
|
director: str = "", writer: str = "",
|
||||||
|
actors: List[str] = None, studio: str = "",
|
||||||
|
premiered: str = "", mpaa: str = "") -> str:
|
||||||
|
"""Generiere movie.nfo für Filme."""
|
||||||
|
doc = self.dom_impl.createDocument(None, "movie", None)
|
||||||
|
root = doc.documentElement
|
||||||
|
|
||||||
|
root.appendChild(self._create_element(doc, "title", title))
|
||||||
|
root.appendChild(self._create_element(doc, "year", year))
|
||||||
|
root.appendChild(self._create_element(doc, "plot", overview))
|
||||||
|
root.appendChild(self._create_element(doc, "rating", rating))
|
||||||
|
root.appendChild(self._create_element(doc, "runtime", runtime))
|
||||||
|
|
||||||
|
if genres:
|
||||||
|
for genre in genres:
|
||||||
|
root.appendChild(self._create_element(doc, "genre", genre))
|
||||||
|
|
||||||
|
if director:
|
||||||
|
root.appendChild(self._create_element(doc, "director", director))
|
||||||
|
|
||||||
|
if writer:
|
||||||
|
root.appendChild(self._create_element(doc, "writer", writer))
|
||||||
|
|
||||||
|
if actors:
|
||||||
|
for actor in actors:
|
||||||
|
actor_node = doc.createElement("actor")
|
||||||
|
actor_node.appendChild(self._create_element(doc, "name", actor))
|
||||||
|
root.appendChild(actor_node)
|
||||||
|
|
||||||
|
if studio:
|
||||||
|
root.appendChild(self._create_element(doc, "studio", studio))
|
||||||
|
|
||||||
|
if premiered:
|
||||||
|
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||||
|
|
||||||
|
if mpaa:
|
||||||
|
root.appendChild(self._create_element(doc, "mpaa", mpaa))
|
||||||
|
|
||||||
|
# Attribution
|
||||||
|
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
||||||
|
|
||||||
|
return doc.toprettyxml(indent=" ")
|
||||||
|
|
||||||
|
def generate_series_nfo(self, title: str, year: int,
|
||||||
|
overview: str = "", rating: float = 0.0,
|
||||||
|
genres: List[str] = None, studio: str = "",
|
||||||
|
premiered: str = "") -> str:
|
||||||
|
"""Generiere series.nfo für Serien."""
|
||||||
|
doc = self.dom_impl.createDocument(None, "tvshow", None)
|
||||||
|
root = doc.documentElement
|
||||||
|
|
||||||
|
root.appendChild(self._create_element(doc, "title", title))
|
||||||
|
root.appendChild(self._create_element(doc, "year", year))
|
||||||
|
root.appendChild(self._create_element(doc, "plot", overview))
|
||||||
|
root.appendChild(self._create_element(doc, "rating", rating))
|
||||||
|
|
||||||
|
if genres:
|
||||||
|
for genre in genres:
|
||||||
|
root.appendChild(self._create_element(doc, "genre", genre))
|
||||||
|
|
||||||
|
if studio:
|
||||||
|
root.appendChild(self._create_element(doc, "studio", studio))
|
||||||
|
|
||||||
|
if premiered:
|
||||||
|
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||||
|
|
||||||
|
# Attribution
|
||||||
|
root.appendChild(self._create_element(doc, "details", "Source: TMDB"))
|
||||||
|
|
||||||
|
return doc.toprettyxml(indent=" ")
|
||||||
|
|
||||||
|
def generate_episode_nfo(self, title: str, season: int, episode: int,
|
||||||
|
overview: str = "", rating: float = 0.0,
|
||||||
|
director: str = "", premiered: str = "",
|
||||||
|
writers: List[str] = None) -> str:
|
||||||
|
"""Generiere episode.nfo für Episoden."""
|
||||||
|
doc = self.dom_impl.createDocument(None, "episodedetails", None)
|
||||||
|
root = doc.documentElement
|
||||||
|
|
||||||
|
root.appendChild(self._create_element(doc, "title", title))
|
||||||
|
root.appendChild(self._create_element(doc, "season", season))
|
||||||
|
root.appendChild(self._create_element(doc, "episode", episode))
|
||||||
|
root.appendChild(self._create_element(doc, "plot", overview))
|
||||||
|
root.appendChild(self._create_element(doc, "rating", rating))
|
||||||
|
|
||||||
|
if director:
|
||||||
|
root.appendChild(self._create_element(doc, "director", director))
|
||||||
|
|
||||||
|
if premiered:
|
||||||
|
root.appendChild(self._create_element(doc, "premiered", premiered))
|
||||||
|
|
||||||
|
if writers:
|
||||||
|
for writer in writers:
|
||||||
|
root.appendChild(self._create_element(doc, "credits", writer))
|
||||||
|
|
||||||
|
return doc.toprettyxml(indent=" ")
|
||||||
|
|
||||||
|
def generate_album_nfo(self, title: str, artist: str, year: int,
|
||||||
|
genres: List[str] = None, rating: float = 0.0,
|
||||||
|
review: str = "") -> str:
|
||||||
|
"""Generiere album.nfo für Musikalben."""
|
||||||
|
doc = self.dom_impl.createDocument(None, "musicalbum", None)
|
||||||
|
root = doc.documentElement
|
||||||
|
|
||||||
|
root.appendChild(self._create_element(doc, "title", title))
|
||||||
|
root.appendChild(self._create_element(doc, "artist", artist))
|
||||||
|
root.appendChild(self._create_element(doc, "year", year))
|
||||||
|
root.appendChild(self._create_element(doc, "rating", rating))
|
||||||
|
root.appendChild(self._create_element(doc, "review", review))
|
||||||
|
|
||||||
|
if genres:
|
||||||
|
for genre in genres:
|
||||||
|
root.appendChild(self._create_element(doc, "genre", genre))
|
||||||
|
|
||||||
|
# Attribution
|
||||||
|
root.appendChild(self._create_element(doc, "details", "Source: MusicBrainz"))
|
||||||
|
|
||||||
|
return doc.toprettyxml(indent=" ")
|
||||||
|
|
||||||
|
def save_nfo(self, content: str, output_path: Path) -> bool:
|
||||||
|
"""Speichere NFO-Datei."""
|
||||||
|
try:
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(output_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"NFO save error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Beispieldaten
|
||||||
|
if __name__ == "__main__":
|
||||||
|
nfo_gen = NFOGenerator()
|
||||||
|
|
||||||
|
# movie.nfo
|
||||||
|
movie_nfo = nfo_gen.generate_movie_nfo(
|
||||||
|
title="Inception",
|
||||||
|
year=2010,
|
||||||
|
overview="A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
|
||||||
|
rating=8.8,
|
||||||
|
runtime=148,
|
||||||
|
genres=["Action", "Sci-Fi", "Thriller"],
|
||||||
|
director="Christopher Nolan",
|
||||||
|
actors=["Leonardo DiCaprio", "Joseph Gordon-Levitt", "Ellen Page"]
|
||||||
|
)
|
||||||
|
|
||||||
|
print(movie_nfo)
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Pre-Scan-Modul: Liest Disc-TOC ohne Ripping."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .clients.tmdb import TMDBClient
|
||||||
|
from .clients.musicbrainz import MusicBrainzClient
|
||||||
|
from .clients.thetvdb import TheTVDBClient
|
||||||
|
from .cache import get, set
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class PreScanResult:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
disc_type: str,
|
||||||
|
title: str,
|
||||||
|
year: int = None,
|
||||||
|
confidence: float = 0.0,
|
||||||
|
metadata: Dict = None,
|
||||||
|
tracks: List[Dict] = None
|
||||||
|
):
|
||||||
|
self.disc_type = disc_type
|
||||||
|
self.title = title
|
||||||
|
self.year = year
|
||||||
|
self.confidence = confidence
|
||||||
|
self.metadata = metadata or {}
|
||||||
|
self.tracks = tracks or []
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
return {
|
||||||
|
"disc_type": self.disc_type,
|
||||||
|
"title": self.title,
|
||||||
|
"year": self.year,
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"metadata": self.metadata,
|
||||||
|
"tracks": self.tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PreScan:
|
||||||
|
def __init__(self):
|
||||||
|
self.tmdb = TMDBClient()
|
||||||
|
self.musicbrainz = MusicBrainzClient()
|
||||||
|
self.thetvdb = TheTVDBClient()
|
||||||
|
|
||||||
|
def scan(self, device_path: str) -> PreScanResult:
|
||||||
|
"""Führe Pre-Scan durch."""
|
||||||
|
# 1. Disc-Typ erkennen
|
||||||
|
disc_type = self._detect_disc_type(device_path)
|
||||||
|
|
||||||
|
# 2. TOC lesen
|
||||||
|
toc = self._read_toc(device_path, disc_type)
|
||||||
|
|
||||||
|
# 3. Metadaten lookup
|
||||||
|
if disc_type == "CD":
|
||||||
|
return self._scan_audio(device_path, toc)
|
||||||
|
else:
|
||||||
|
return self._scan_video(device_path, toc)
|
||||||
|
|
||||||
|
def _detect_disc_type(self, device_path: str) -> str:
|
||||||
|
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["isosize", "-x", device_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ausgabe parsen
|
||||||
|
if result.returncode == 0:
|
||||||
|
size = int(result.stdout.strip())
|
||||||
|
if size < 700 * 1024 * 1024: # < 700MB
|
||||||
|
return "CD"
|
||||||
|
elif size < 15 * 1024 * 1024 * 1024: # < 15GB
|
||||||
|
return "DVD"
|
||||||
|
else:
|
||||||
|
return "Blu-ray"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
return "DVD"
|
||||||
|
|
||||||
|
def _read_toc(self, device_path: str, disc_type: str) -> Dict:
|
||||||
|
"""Lese TOC (Table of Contents)."""
|
||||||
|
toc = {
|
||||||
|
"title": None,
|
||||||
|
"year": None,
|
||||||
|
"tracks": [],
|
||||||
|
"duration": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if disc_type == "CD":
|
||||||
|
# cdparanoia für CD-TOC
|
||||||
|
result = subprocess.run(
|
||||||
|
["cdparanoia", "-Q", device_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse cdparanoia output
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if 'track' in line.lower():
|
||||||
|
# Track-Info parsen
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 4:
|
||||||
|
toc["tracks"].append({
|
||||||
|
"track_number": parts[1],
|
||||||
|
"title": " ".join(parts[3:]) if len(parts) > 3 else "Track",
|
||||||
|
"duration": 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# makemkvcon für DVD/Blu-ray (nur TOC, kein Ripping)
|
||||||
|
result = subprocess.run(
|
||||||
|
["makemkvcon", "--minlength=300", "--progress=off", "info", device_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse makemkvcon output
|
||||||
|
for line in result.stdout.split('\n'):
|
||||||
|
if line.startswith('DRV:'):
|
||||||
|
# Disc-Info
|
||||||
|
parts = line.split(',')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
toc["title"] = parts[3].strip().strip('"')
|
||||||
|
elif line.startswith('TINFO:'):
|
||||||
|
# Titel-Info
|
||||||
|
parts = line.split(',')
|
||||||
|
if len(parts) >= 5:
|
||||||
|
toc["tracks"].append({
|
||||||
|
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
||||||
|
"duration": int(parts[2]) if len(parts) > 2 else 0
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
|
|
||||||
|
return toc
|
||||||
|
|
||||||
|
def _scan_audio(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||||
|
"""Pre-Scan für Audio-CD."""
|
||||||
|
# Cache-Key
|
||||||
|
cache_key = f"prescan:audio:{device_path}"
|
||||||
|
cached = get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return PreScanResult(**cached)
|
||||||
|
|
||||||
|
# Künstler und Album aus Titel extrahieren
|
||||||
|
artist = None
|
||||||
|
album = toc["title"] or "Unknown Album"
|
||||||
|
|
||||||
|
if " - " in album:
|
||||||
|
parts = album.split(" - ", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
artist = parts[0]
|
||||||
|
album = parts[1]
|
||||||
|
|
||||||
|
# MusicBrainz Search
|
||||||
|
if artist:
|
||||||
|
artists = self.musicbrainz.search_artist(artist)
|
||||||
|
if artists:
|
||||||
|
# Best match
|
||||||
|
best_match = artists[0]
|
||||||
|
confidence = 0.8
|
||||||
|
|
||||||
|
# Album suchen
|
||||||
|
releases = self.musicbrainz.search_release(album, artist)
|
||||||
|
if releases:
|
||||||
|
confidence = 0.9
|
||||||
|
release_id = releases[0]["id"]
|
||||||
|
release_info = self.musicbrainz.get_release_info(release_id)
|
||||||
|
|
||||||
|
if release_info:
|
||||||
|
# Tracks aus Release Info
|
||||||
|
for medium in release_info.get("media", []):
|
||||||
|
for track in medium.get("tracks", []):
|
||||||
|
toc["tracks"].append({
|
||||||
|
"track_number": track.get("position", 0),
|
||||||
|
"title": track.get("title", "Unknown"),
|
||||||
|
"duration": track.get("duration", 0) // 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
# TMDB Fallback für bekannte Soundtracks
|
||||||
|
if not artist:
|
||||||
|
movies = self.tmdb.search_movie(album)
|
||||||
|
if movies:
|
||||||
|
confidence = 0.7
|
||||||
|
movie = movies[0]
|
||||||
|
movie_details = self.tmdb.get_movie_details(movie["id"])
|
||||||
|
|
||||||
|
if movie_details:
|
||||||
|
# Soundtrack als "Soundtrack - [Filmname]"
|
||||||
|
artist = album
|
||||||
|
album = f"Soundtrack - {movie_details.get('title', album)}"
|
||||||
|
toc["title"] = album
|
||||||
|
|
||||||
|
# Cache result
|
||||||
|
result = PreScanResult(
|
||||||
|
disc_type="CD",
|
||||||
|
title=album,
|
||||||
|
tracks=toc["tracks"],
|
||||||
|
confidence=confidence if 'confidence' in dir() else 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
set(cache_key, result.to_dict())
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _scan_video(self, device_path: str, toc: Dict) -> PreScanResult:
|
||||||
|
"""Pre-Scan für DVD/Blu-ray."""
|
||||||
|
# Cache-Key
|
||||||
|
cache_key = f"prescan:video:{device_path}"
|
||||||
|
cached = get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return PreScanResult(**cached)
|
||||||
|
|
||||||
|
# Disc-Titel
|
||||||
|
title = toc["title"] or "Unknown Title"
|
||||||
|
|
||||||
|
# TMDB Search
|
||||||
|
confidence = 0.0
|
||||||
|
metadata = {}
|
||||||
|
matched = False
|
||||||
|
|
||||||
|
# Versuche Film-Suche
|
||||||
|
movies = self.tmdb.search_movie(title)
|
||||||
|
if movies:
|
||||||
|
for movie in movies:
|
||||||
|
# Prüfe auf gute Übereinstimmung
|
||||||
|
if movie.get("title", "").lower() == title.lower():
|
||||||
|
confidence = 0.95
|
||||||
|
movie_id = movie["id"]
|
||||||
|
movie_details = self.tmdb.get_movie_details(movie_id)
|
||||||
|
|
||||||
|
if movie_details:
|
||||||
|
metadata = {
|
||||||
|
"type": "movie",
|
||||||
|
"id": movie_id,
|
||||||
|
"title": movie_details.get("title", title),
|
||||||
|
"year": int(movie_details.get("release_date", "0")[:4]) if movie_details.get("release_date") else None,
|
||||||
|
"overview": movie_details.get("overview", ""),
|
||||||
|
"poster_path": movie_details.get("poster_path", ""),
|
||||||
|
"backdrop_path": movie_details.get("backdrop_path", ""),
|
||||||
|
"runtime": movie_details.get("runtime", 0),
|
||||||
|
"genres": [g["name"] for g in movie_details.get("genres", [])]
|
||||||
|
}
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Versuche Serien-Suche
|
||||||
|
if not matched:
|
||||||
|
tv_shows = self.tmdb.search_tv(title)
|
||||||
|
if tv_shows:
|
||||||
|
for show in tv_shows:
|
||||||
|
if show.get("name", "").lower() == title.lower():
|
||||||
|
confidence = 0.9
|
||||||
|
tv_id = show["id"]
|
||||||
|
tv_details = self.tmdb.get_tv_details(tv_id)
|
||||||
|
|
||||||
|
if tv_details:
|
||||||
|
metadata = {
|
||||||
|
"type": "tv",
|
||||||
|
"id": tv_id,
|
||||||
|
"title": tv_details.get("name", title),
|
||||||
|
"year": int(tv_details.get("first_air_date", "0")[:4]) if tv_details.get("first_air_date") else None,
|
||||||
|
"overview": tv_details.get("overview", ""),
|
||||||
|
"poster_path": tv_details.get("poster_path", ""),
|
||||||
|
"backdrop_path": tv_details.get("backdrop_path", ""),
|
||||||
|
"genres": [g["name"] for g in tv_details.get("genres", [])]
|
||||||
|
}
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback auf Titel ohne Lookup
|
||||||
|
if not matched:
|
||||||
|
confidence = 0.3
|
||||||
|
metadata = {
|
||||||
|
"type": "unknown",
|
||||||
|
"title": title,
|
||||||
|
"year": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache result
|
||||||
|
result = PreScanResult(
|
||||||
|
disc_type=toc.get("disc_type", "DVD"),
|
||||||
|
title=title,
|
||||||
|
year=metadata.get("year"),
|
||||||
|
confidence=confidence,
|
||||||
|
metadata=metadata,
|
||||||
|
tracks=toc.get("tracks", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
set(cache_key, result.to_dict())
|
||||||
|
return result
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Pre-Scan package."""
|
||||||
|
|
||||||
|
from .prescan import PreScan, PreScanResult
|
||||||
|
|
||||||
|
__all__ = ["PreScan", "PreScanResult"]
|
||||||
+43
-1
@@ -1,10 +1,52 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import MetadataPreview from './pages/MetadataPreview'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [page, setPage] = useState<'dashboard' | 'metadata'>('dashboard')
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'metadata', label: 'Metadaten-Preview' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import axios from 'axios'
|
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'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
interface Metadata {
|
||||||
|
type: 'movie' | 'tv' | 'music' | 'unknown'
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
backdrop_path?: string
|
||||||
|
runtime?: number
|
||||||
|
genres?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Track {
|
||||||
|
track_number: number
|
||||||
|
title: string
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrescanResult {
|
||||||
|
disc_type: 'cd' | 'dvd' | 'bluray'
|
||||||
|
title: string
|
||||||
|
year?: number
|
||||||
|
confidence: number
|
||||||
|
metadata?: Metadata
|
||||||
|
tracks?: Track[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:8000'
|
||||||
|
|
||||||
|
export default function MetadataPreview() {
|
||||||
|
const [device, setDevice] = useState<string>('')
|
||||||
|
const [result, setResult] = useState<PrescanResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
if (!device) {
|
||||||
|
setError('Bitte wählen Sie ein Gerät aus.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${API_URL}/prescan`, { device_path: device })
|
||||||
|
setResult(response.data)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Pre-Scan fehlgeschlagen.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
setConfirming(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`${API_URL}/metadata/confirm`, {
|
||||||
|
title: result.title,
|
||||||
|
year: result.year,
|
||||||
|
metadata: result.metadata
|
||||||
|
})
|
||||||
|
|
||||||
|
// Job erstellen
|
||||||
|
await axios.post(`${API_URL}/jobs`, {
|
||||||
|
device: device,
|
||||||
|
type: result.disc_type
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError('Bestätigung fehlgeschlagen.')
|
||||||
|
} finally {
|
||||||
|
setConfirming(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getIcon = (type: string): LucideIcon => {
|
||||||
|
switch (type) {
|
||||||
|
case 'cd':
|
||||||
|
return Music
|
||||||
|
case 'dvd':
|
||||||
|
case 'bluray':
|
||||||
|
return Film
|
||||||
|
default:
|
||||||
|
return BookOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number): string => {
|
||||||
|
if (confidence >= 0.9) return 'text-green-600 bg-green-100'
|
||||||
|
if (confidence >= 0.7) return 'text-yellow-600 bg-yellow-100'
|
||||||
|
return 'text-red-600 bg-red-100'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Metadaten-Preview</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Selection */}
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<select
|
||||||
|
value={device}
|
||||||
|
onChange={(e) => setDevice(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Gerät auswählen...</option>
|
||||||
|
<option value="/dev/cdrom">CD-ROM (/dev/cdrom)</option>
|
||||||
|
<option value="/dev/dvd">DVD (/dev/dvd)</option>
|
||||||
|
<option value="/dev/bluray">Blu-ray (/dev/bluray)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleScan}
|
||||||
|
disabled={!device || loading}
|
||||||
|
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white ${
|
||||||
|
!device || loading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
Scannen...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search size={20} />
|
||||||
|
Scan starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3 text-red-700">
|
||||||
|
<AlertCircle size={24} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{getIcon(result.disc_type)({ size: 32 })}
|
||||||
|
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
||||||
|
{result.disc_type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold">{result.title}</h2>
|
||||||
|
{result.year && (
|
||||||
|
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confidence Score */}
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700">Übereinstimmung:</span>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getConfidenceColor(result.confidence)}`}>
|
||||||
|
{Math.round(result.confidence * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="p-6">
|
||||||
|
{result.metadata && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{result.metadata.overview && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Beschreibung</h3>
|
||||||
|
<p className="text-gray-700 leading-relaxed">{result.metadata.overview}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.metadata.genres && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Genre</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{result.metadata.genres.map((genre, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
|
||||||
|
>
|
||||||
|
{genre}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.metadata.runtime && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 uppercase mb-2">Laufzeit</h3>
|
||||||
|
<p className="text-gray-700">{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tracks */}
|
||||||
|
{result.tracks && result.tracks.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500 uppercase mb-3">Tracks</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{result.tracks.map((track, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-8 font-medium text-gray-500">{track.track_number}</span>
|
||||||
|
<span className="text-gray-900">{track.title}</span>
|
||||||
|
</div>
|
||||||
|
{track.duration && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<div className="p-6 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
||||||
|
<CheckCircle className="text-green-600" size={20} />
|
||||||
|
<span>Bereit zum Ripping</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={confirming}
|
||||||
|
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" size={20} />
|
||||||
|
Bestätigen...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle size={20} />
|
||||||
|
Rippen starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user