Jikan/MAL-Quelle + Job-Titel + Abbrechen-Knopf + Dark-Hover + Ordner-Verwaltung
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:
Hitonabi
2026-07-23 21:22:57 +02:00
parent 85f352b14f
commit 07be54e62a
8 changed files with 307 additions and 21 deletions
+82
View File
@@ -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
View File
@@ -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?"""
+14 -1
View File
@@ -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:
+74 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
@@ -31,8 +31,35 @@ export default function StorageMounts() {
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const [browsePath, setBrowsePath] = useState('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('')
const { theme } = useDarkMode()
const browsen = async (pfad: string) => {
try {
const r = await api.get('/browse', { params: { path: pfad } })
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
} catch {
setBrowseDirs([])
}
}
const ordnerAnlegen = async () => {
if (!neuerOrdner) return
try {
await api.post('/browse/mkdir', { path: browsePath, name: neuerOrdner })
setNeuerOrdner('')
browsen(browsePath)
laden()
} catch (e: any) {
setFeedback(`${e?.response?.data?.detail || 'Ordner anlegen fehlgeschlagen'}`)
}
}
const laden = async () => {
try {
const [m, t] = await Promise.all([
@@ -46,7 +73,7 @@ export default function StorageMounts() {
}
}
useEffect(() => { laden() }, [])
useEffect(() => { laden(); browsen('/app/media') }, [])
const hinzufuegen = async () => {
setBusy(true)
@@ -116,6 +143,51 @@ export default function StorageMounts() {
)}
</div>
{/* Lokale Ordner durchsuchen + anlegen */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button
onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent}
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
<input
value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner"
className={`w-32 px-2 py-1 text-xs border rounded ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
<button
onClick={ordnerAnlegen}
disabled={!neuerOrdner}
className={`p-1.5 rounded disabled:opacity-30 text-indigo-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-200'}`}
title="Ordner anlegen"
>
<FolderPlus size={16} />
</button>
</div>
<div className="max-h-36 overflow-y-auto">
{browseDirs.length === 0 ? (
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
) : (
browseDirs.map(d => (
<button
key={d.path}
onClick={() => browsen(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))
)}
</div>
</div>
{/* Neues Netzwerk-Ziel */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
+14 -2
View File
@@ -8,7 +8,7 @@ import LiveLogSection from '../components/LiveLogSection'
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray'
status: 'pending' | 'processing' | 'transcoding' | 'completed' | 'failed'
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
device: string
startTime: string
endTime?: string
@@ -32,6 +32,7 @@ function StatusBadge({ status }: { status: string }) {
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20',
canceling: 'bg-orange-900/30 text-orange-400 ring-orange-700/20',
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
}
@@ -39,6 +40,7 @@ function StatusBadge({ status }: { status: string }) {
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20',
canceling: 'bg-orange-100 text-orange-700 ring-orange-600/20',
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
}
@@ -47,6 +49,7 @@ function StatusBadge({ status }: { status: string }) {
pending: 'Wartend',
processing: 'Rippen',
transcoding: 'Komprimieren',
canceling: 'Wird abgebrochen…',
completed: 'Fertig',
failed: 'Fehler',
}
@@ -273,7 +276,7 @@ export default function Dashboard() {
</tr>
) : (
jobs.slice(0, 10).map(job => (
<tr key={job.id} className="hover:bg-slate-50 transition-colors duration-300">
<tr key={job.id} className={`transition-colors duration-300 ${theme === 'dark' ? 'hover:bg-slate-700/40' : 'hover:bg-slate-50'}`}>
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</td>
@@ -304,6 +307,15 @@ export default function Dashboard() {
Neu komprimieren
</button>
)}
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<button
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
title="Job abbrechen (Rohdaten bleiben erhalten)"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-rose-300 hover:bg-slate-600' : 'bg-slate-100 text-rose-700 hover:bg-slate-200'}`}
>
Abbrechen
</button>
)}
</td>
</tr>
))
+11
View File
@@ -116,6 +116,17 @@ def get_settings(key: str = "ui") -> dict:
return {}
def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.status).where(jobs.c.id == job_id)
).first()
return zeile[0] if zeile else ""
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
+20
View File
@@ -20,6 +20,11 @@ import tempfile
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
class RipAbbruch(Exception):
"""Kooperativer Abbruch: vom Fortschritts-Callback geworfen, wenn der
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None
@@ -124,10 +129,15 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
bufsize=1
)
try:
for line in process.stdout:
progress = get_progress_from_line(line)
if progress > 0 and progress_cb:
progress_cb(progress)
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
@@ -185,6 +195,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
)
letzte_meldung = ""
try:
for line in process.stdout:
progress = get_progress_from_prgv(line)
if progress >= 0 and progress_cb:
@@ -194,6 +205,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
teile = line.split(",", 4)
if len(teile) >= 4:
letzte_meldung = teile[3].strip('"')
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
@@ -267,8 +282,13 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
bufsize=1
)
try:
for line in process.stdout:
melde(50, line.strip()[:200])
except RipAbbruch:
process.kill()
process.wait()
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
process.wait()
+29
View File
@@ -22,6 +22,7 @@ from detection import detect_disc_type
from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
RipAbbruch,
rip_cd,
rip_video,
run_handbrake,
@@ -40,8 +41,20 @@ def _zielbasis(target_dir, disc_type: str) -> str:
return os.path.join(RIP_OUTPUT_DIR, disc_type)
def _abbruch_angefordert(job_id: str) -> bool:
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
return db.get_job_status(job_id) == "canceling"
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres."""
if ergebnis.get("status") == "cancelled":
db.update_job(
job_id, status="failed", error="Abgebrochen durch Nutzer",
finished_at=db.utcnow(),
)
db.add_log("warning", "worker", f"Job {job_id}: abgebrochen — Rohdaten bleiben erhalten")
return
if ergebnis.get("status") == "success":
db.update_job(
job_id,
@@ -69,6 +82,11 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
dort eingehängte Shares (NFS/SMB) sind damit direkt wählbar.
"""
db.init_db()
if _abbruch_angefordert(job_id):
_job_abschliessen(job_id, {"status": "cancelled"})
return {"status": "cancelled"}
disc_type = detect_disc_type(device_path)
if disc_type in ("no_disc", "unknown"):
@@ -90,6 +108,8 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
if progress == letzter[0]:
return
letzter[0] = progress
if _abbruch_angefordert(job_id):
raise RipAbbruch()
self.update_state(
state="PROGRESS",
meta={"progress": progress, "status": "ripping", "message": message},
@@ -162,14 +182,23 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
)
anzahl = len(quellen)
letzter = [-1]
for index, quelle in enumerate(quellen):
ziel = os.path.join(final_dir, os.path.basename(quelle))
def datei_fortschritt(p, _index=index):
gesamt = int((_index * 100 + p) / anzahl)
if gesamt == letzter[0]:
return
letzter[0] = gesamt
if _abbruch_angefordert(job_id):
raise RipAbbruch()
db.update_job(job_id, progress=min(99, gesamt))
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
if hb.get("status") == "cancelled":
_job_abschliessen(job_id, hb)
return hb
if hb.get("status") != "success":
ergebnis = {
"status": "error",