Jikan/MAL-Quelle + Job-Titel + Abbrechen-Knopf + Dark-Hover + Ordner-Verwaltung
Ampel / ampel (push) Failing after 38s
Ampel / ampel (push) Failing after 38s
Commander-Befunde (Screenshots 23.07. abends):
- Jikan (MyAnimeList, kostenlos OHNE Key) als Anime-Quelle vor OMDb —
waehlt per Titel-Aehnlichkeit (SequenceMatcher, Schwelle 0.55) statt
blind Treffer 1; damit trifft "Evangelion 2.22" den exakten Film
- Job-Titel: POST /jobs uebernimmt den erkannten Disc-Titel aus dem
DISC_CACHE — Schluss mit "Unbekannt" in der Queue
- Kooperativer Job-Abbruch: POST /jobs/{id}/cancel setzt canceling,
der Worker prueft das Flag bei jedem Fortschritts-Update und killt
den Encoder-Prozess sauber (RipAbbruch); UI-Knopf "Abbrechen" fuer
laufende, Status-Badge "Wird abgebrochen"
- Dark-Mode: Job-Zeilen-Hover war hart hell (hover:bg-slate-50)
- Speicherziele: lokaler Ordner-Browser mit "Ordner anlegen"
(GET /browse + POST /browse/mkdir, nur unter /app/media)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
"""Jikan API Client (MyAnimeList) — kostenlose Anime-Datenbank OHNE API-Key.
|
||||
|
||||
Commander-Fund 23.07.: Für Anime-Discs (häufiger Fall im Heimlab) ist MAL die
|
||||
präziseste Quelle — „Evangelion: 2.22" existiert dort exakt, mit Poster und
|
||||
Beschreibung. Kein Key, Rate-Limit 3 req/s (für Disc-Erkennung irrelevant).
|
||||
Doku: https://docs.api.jikan.moe/
|
||||
"""
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from cache import get, set as cache_set
|
||||
|
||||
JIKAN_BASE_URL = "https://api.jikan.moe/v4"
|
||||
|
||||
|
||||
def titel_aehnlichkeit(a: str, b: str) -> float:
|
||||
"""Normalisierte Titel-Ähnlichkeit 0..1 (pure Funktion, testbar)."""
|
||||
def norm(t: str) -> str:
|
||||
return "".join(c for c in t.lower() if c.isalnum() or c == " ").strip()
|
||||
|
||||
a_n, b_n = norm(a), norm(b)
|
||||
if not a_n or not b_n:
|
||||
return 0.0
|
||||
return SequenceMatcher(None, a_n, b_n).ratio()
|
||||
|
||||
|
||||
class JikanClient:
|
||||
MINDEST_AEHNLICHKEIT = 0.55
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
|
||||
def lookup(self, title: str) -> Optional[Dict]:
|
||||
"""Sucht per Titel; nimmt den ÄHNLICHSTEN Treffer, nicht blind den ersten."""
|
||||
cache_key = f"jikan:{title.lower()}"
|
||||
cached = get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
f"{JIKAN_BASE_URL}/anime",
|
||||
params={"q": title, "limit": 5, "sfw": "true"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
eintraege = response.json().get("data") or []
|
||||
except Exception as e:
|
||||
print(f"Jikan API Error: {e}")
|
||||
return None
|
||||
|
||||
bester, bester_score = None, 0.0
|
||||
for eintrag in eintraege:
|
||||
varianten = [eintrag.get("title") or ""]
|
||||
if eintrag.get("title_english"):
|
||||
varianten.append(eintrag["title_english"])
|
||||
score = max(titel_aehnlichkeit(title, v) for v in varianten)
|
||||
if score > bester_score:
|
||||
bester, bester_score = eintrag, score
|
||||
|
||||
if not bester or bester_score < self.MINDEST_AEHNLICHKEIT:
|
||||
return None
|
||||
|
||||
bilder = (bester.get("images") or {}).get("jpg") or {}
|
||||
ergebnis = {
|
||||
"type": "movie" if bester.get("type") == "Movie" else "tv",
|
||||
"id": f"mal-{bester.get('mal_id', '')}",
|
||||
"title": bester.get("title_english") or bester.get("title") or title,
|
||||
"year": bester.get("year"),
|
||||
"overview": (bester.get("synopsis") or "")[:600],
|
||||
"poster_path": bilder.get("large_image_url") or bilder.get("image_url") or "",
|
||||
"backdrop_path": "",
|
||||
"runtime": 0,
|
||||
"genres": [g.get("name", "") for g in (bester.get("genres") or []) if g.get("name")],
|
||||
"source": "jikan",
|
||||
"match_score": round(bester_score, 2),
|
||||
}
|
||||
cache_set(cache_key, ergebnis)
|
||||
return ergebnis
|
||||
+48
-1
@@ -262,8 +262,15 @@ async def create_job(request: JobCreateRequest):
|
||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||
ziel = _validiere_ziel(request.target_dir)
|
||||
|
||||
# Titel aus der Disc-Erkennung übernehmen — sonst steht im Job "Unbekannt"
|
||||
titel = request.title
|
||||
if not titel:
|
||||
disc = DISC_CACHE.get(device_path)
|
||||
if disc and not disc.get("_laeuft"):
|
||||
titel = disc.get("title")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, request.title, ziel)
|
||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel)
|
||||
await asyncio.to_thread(
|
||||
db.add_log, "info", "api",
|
||||
f"Job {job_id} angelegt für {device_path}" + (f" → {ziel}" if ziel else ""),
|
||||
@@ -350,6 +357,24 @@ async def retry_transcode(job_id: str):
|
||||
return {"id": job_id, "status": "transcoding"}
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/cancel")
|
||||
async def cancel_job(job_id: str):
|
||||
"""Bittet den Worker, den Job abzubrechen (kooperativ über die DB).
|
||||
|
||||
Der Worker prüft das Flag bei jedem Fortschritts-Update und beendet den
|
||||
Encoder-Prozess sauber — kein Celery-Task-ID-Tracking nötig.
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
if job["status"] in ("completed", "failed"):
|
||||
raise HTTPException(status_code=409, detail="Job ist bereits beendet")
|
||||
|
||||
await asyncio.to_thread(db.update_job, job_id, status="canceling")
|
||||
await asyncio.to_thread(db.add_log, "warning", "api", f"Job {job_id}: Abbruch angefordert")
|
||||
return {"id": job_id, "status": "canceling"}
|
||||
|
||||
|
||||
@app.get("/capabilities")
|
||||
async def capabilities():
|
||||
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar?
|
||||
@@ -453,6 +478,28 @@ async def browse(path: str = MEDIA_ROOT):
|
||||
return {"path": normalisiert, "parent": eltern, "dirs": ordner}
|
||||
|
||||
|
||||
class MkdirRequest(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
|
||||
|
||||
@app.post("/browse/mkdir", status_code=201)
|
||||
async def browse_mkdir(request: MkdirRequest):
|
||||
"""Neuen Ordner unter /app/media anlegen (Speicherziele-Verwaltung)."""
|
||||
basis = os.path.normpath(request.path)
|
||||
if not basis.startswith(MEDIA_ROOT):
|
||||
raise HTTPException(status_code=422, detail=f"Nur Pfade unter {MEDIA_ROOT}")
|
||||
name = request.name.strip()
|
||||
if not name or "/" in name or "\\" in name or name.startswith("."):
|
||||
raise HTTPException(status_code=422, detail="Ungültiger Ordnername")
|
||||
ziel = os.path.join(basis, name)
|
||||
try:
|
||||
await asyncio.to_thread(os.makedirs, ziel, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Anlegen fehlgeschlagen: {e}")
|
||||
return {"path": ziel}
|
||||
|
||||
|
||||
@app.get("/setup")
|
||||
async def setup_status():
|
||||
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Dict, List, Optional
|
||||
|
||||
import detection
|
||||
from clients.tmdb import TMDBClient
|
||||
from clients.jikan import JikanClient
|
||||
from clients.musicbrainz import MusicBrainzClient
|
||||
from clients.omdb import OMDbClient
|
||||
from clients.thetvdb import TheTVDBClient
|
||||
@@ -214,6 +215,7 @@ class PreScan:
|
||||
self.musicbrainz = MusicBrainzClient()
|
||||
self.thetvdb = TheTVDBClient()
|
||||
self.omdb = OMDbClient()
|
||||
self.jikan = JikanClient()
|
||||
|
||||
def scan(self, device_path: str) -> PreScanResult:
|
||||
"""Führe Pre-Scan durch."""
|
||||
@@ -421,7 +423,18 @@ class PreScan:
|
||||
if matched:
|
||||
break
|
||||
|
||||
# Fallback 1: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
|
||||
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1
|
||||
if not matched:
|
||||
for kandidat in kandidaten:
|
||||
jikan_treffer = self.jikan.lookup(kandidat)
|
||||
if jikan_treffer:
|
||||
confidence = 0.85
|
||||
metadata = jikan_treffer
|
||||
matched = True
|
||||
break
|
||||
|
||||
# Fallback 2: OMDb (eigene Datenbasis — findet oft, was TMDB nicht
|
||||
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
||||
if not matched:
|
||||
for kandidat in kandidaten:
|
||||
|
||||
Reference in New Issue
Block a user