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