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")
|
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||||
ziel = _validiere_ziel(request.target_dir)
|
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())
|
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(
|
await asyncio.to_thread(
|
||||||
db.add_log, "info", "api",
|
db.add_log, "info", "api",
|
||||||
f"Job {job_id} angelegt für {device_path}" + (f" → {ziel}" if ziel else ""),
|
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"}
|
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")
|
@app.get("/capabilities")
|
||||||
async def capabilities():
|
async def capabilities():
|
||||||
"""Welche Encoder sind auf welchen Workern WIRKLICH verfügbar?
|
"""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}
|
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")
|
@app.get("/setup")
|
||||||
async def setup_status():
|
async def setup_status():
|
||||||
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
|
"""First-Run-Erkennung: wurde der Einrichtungs-Assistent abgeschlossen?"""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
import detection
|
import detection
|
||||||
from clients.tmdb import TMDBClient
|
from clients.tmdb import TMDBClient
|
||||||
|
from clients.jikan import JikanClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
from clients.omdb import OMDbClient
|
from clients.omdb import OMDbClient
|
||||||
from clients.thetvdb import TheTVDBClient
|
from clients.thetvdb import TheTVDBClient
|
||||||
@@ -214,6 +215,7 @@ class PreScan:
|
|||||||
self.musicbrainz = MusicBrainzClient()
|
self.musicbrainz = MusicBrainzClient()
|
||||||
self.thetvdb = TheTVDBClient()
|
self.thetvdb = TheTVDBClient()
|
||||||
self.omdb = OMDbClient()
|
self.omdb = OMDbClient()
|
||||||
|
self.jikan = JikanClient()
|
||||||
|
|
||||||
def scan(self, device_path: str) -> PreScanResult:
|
def scan(self, device_path: str) -> PreScanResult:
|
||||||
"""Führe Pre-Scan durch."""
|
"""Führe Pre-Scan durch."""
|
||||||
@@ -421,7 +423,18 @@ class PreScan:
|
|||||||
if matched:
|
if matched:
|
||||||
break
|
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)
|
# exakt trifft; braucht OMDB_API_KEY, sonst überspringt es sich selbst)
|
||||||
if not matched:
|
if not matched:
|
||||||
for kandidat in kandidaten:
|
for kandidat in kandidaten:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
|
||||||
@@ -31,8 +31,35 @@ export default function StorageMounts() {
|
|||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [feedback, setFeedback] = useState<string | null>(null)
|
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 { 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 () => {
|
const laden = async () => {
|
||||||
try {
|
try {
|
||||||
const [m, t] = await Promise.all([
|
const [m, t] = await Promise.all([
|
||||||
@@ -46,7 +73,7 @@ export default function StorageMounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { laden() }, [])
|
useEffect(() => { laden(); browsen('/app/media') }, [])
|
||||||
|
|
||||||
const hinzufuegen = async () => {
|
const hinzufuegen = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -116,6 +143,51 @@ export default function StorageMounts() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* 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'}`}>
|
<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'}`}>
|
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import LiveLogSection from '../components/LiveLogSection'
|
|||||||
interface Job {
|
interface Job {
|
||||||
id: string
|
id: string
|
||||||
type: 'cd' | 'dvd' | 'bluray'
|
type: 'cd' | 'dvd' | 'bluray'
|
||||||
status: 'pending' | 'processing' | 'transcoding' | 'completed' | 'failed'
|
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
|
||||||
device: string
|
device: string
|
||||||
startTime: string
|
startTime: string
|
||||||
endTime?: string
|
endTime?: string
|
||||||
@@ -32,6 +32,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
|
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
|
||||||
processing: 'bg-blue-900/30 text-blue-400 ring-blue-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',
|
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',
|
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
|
||||||
failed: 'bg-rose-900/30 text-rose-400 ring-rose-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',
|
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
|
||||||
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
|
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
|
||||||
transcoding: 'bg-purple-100 text-purple-700 ring-purple-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',
|
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
|
||||||
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
|
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
|
||||||
}
|
}
|
||||||
@@ -47,6 +49,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
pending: 'Wartend',
|
pending: 'Wartend',
|
||||||
processing: 'Rippen',
|
processing: 'Rippen',
|
||||||
transcoding: 'Komprimieren',
|
transcoding: 'Komprimieren',
|
||||||
|
canceling: 'Wird abgebrochen…',
|
||||||
completed: 'Fertig',
|
completed: 'Fertig',
|
||||||
failed: 'Fehler',
|
failed: 'Fehler',
|
||||||
}
|
}
|
||||||
@@ -273,7 +276,7 @@ export default function Dashboard() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
jobs.slice(0, 10).map(job => (
|
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">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<TypeBadge type={job.type} />
|
<TypeBadge type={job.type} />
|
||||||
</td>
|
</td>
|
||||||
@@ -304,6 +307,15 @@ export default function Dashboard() {
|
|||||||
Neu komprimieren
|
Neu komprimieren
|
||||||
</button>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -116,6 +116,17 @@ def get_settings(key: str = "ui") -> dict:
|
|||||||
return {}
|
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:
|
def update_job(job_id: str, **fields) -> None:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
|
||||||
|
|||||||
+35
-15
@@ -20,6 +20,11 @@ import tempfile
|
|||||||
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
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:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob makemkvcon installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("makemkvcon") is not None
|
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
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in process.stdout:
|
try:
|
||||||
progress = get_progress_from_line(line)
|
for line in process.stdout:
|
||||||
if progress > 0 and progress_cb:
|
progress = get_progress_from_line(line)
|
||||||
progress_cb(progress)
|
if progress > 0 and progress_cb:
|
||||||
|
progress_cb(progress)
|
||||||
|
except RipAbbruch:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
||||||
|
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
@@ -185,15 +195,20 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
for line in process.stdout:
|
try:
|
||||||
progress = get_progress_from_prgv(line)
|
for line in process.stdout:
|
||||||
if progress >= 0 and progress_cb:
|
progress = get_progress_from_prgv(line)
|
||||||
progress_cb(progress)
|
if progress >= 0 and progress_cb:
|
||||||
elif line.startswith("MSG:"):
|
progress_cb(progress)
|
||||||
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
elif line.startswith("MSG:"):
|
||||||
teile = line.split(",", 4)
|
# MSG:code,flags,count,"message",... — Klartext ist Feld 4
|
||||||
if len(teile) >= 4:
|
teile = line.split(",", 4)
|
||||||
letzte_meldung = teile[3].strip('"')
|
if len(teile) >= 4:
|
||||||
|
letzte_meldung = teile[3].strip('"')
|
||||||
|
except RipAbbruch:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
return {"status": "cancelled", "error": "Abgebrochen durch Nutzer"}
|
||||||
|
|
||||||
process.wait()
|
process.wait()
|
||||||
|
|
||||||
@@ -267,8 +282,13 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = N
|
|||||||
bufsize=1
|
bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in process.stdout:
|
try:
|
||||||
melde(50, line.strip()[:200])
|
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()
|
process.wait()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from detection import detect_disc_type
|
|||||||
from ripping import (
|
from ripping import (
|
||||||
DEFAULT_HB_PRESET,
|
DEFAULT_HB_PRESET,
|
||||||
RIP_OUTPUT_DIR,
|
RIP_OUTPUT_DIR,
|
||||||
|
RipAbbruch,
|
||||||
rip_cd,
|
rip_cd,
|
||||||
rip_video,
|
rip_video,
|
||||||
run_handbrake,
|
run_handbrake,
|
||||||
@@ -40,8 +41,20 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
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:
|
def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
||||||
"""Schreibt den Endzustand eines Jobs (completed/failed) nach Postgres."""
|
"""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":
|
if ergebnis.get("status") == "success":
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id,
|
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.
|
dort eingehängte Shares (NFS/SMB) sind damit direkt wählbar.
|
||||||
"""
|
"""
|
||||||
db.init_db()
|
db.init_db()
|
||||||
|
|
||||||
|
if _abbruch_angefordert(job_id):
|
||||||
|
_job_abschliessen(job_id, {"status": "cancelled"})
|
||||||
|
return {"status": "cancelled"}
|
||||||
|
|
||||||
disc_type = detect_disc_type(device_path)
|
disc_type = detect_disc_type(device_path)
|
||||||
|
|
||||||
if disc_type in ("no_disc", "unknown"):
|
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]:
|
if progress == letzter[0]:
|
||||||
return
|
return
|
||||||
letzter[0] = progress
|
letzter[0] = progress
|
||||||
|
if _abbruch_angefordert(job_id):
|
||||||
|
raise RipAbbruch()
|
||||||
self.update_state(
|
self.update_state(
|
||||||
state="PROGRESS",
|
state="PROGRESS",
|
||||||
meta={"progress": progress, "status": "ripping", "message": message},
|
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)
|
anzahl = len(quellen)
|
||||||
|
letzter = [-1]
|
||||||
for index, quelle in enumerate(quellen):
|
for index, quelle in enumerate(quellen):
|
||||||
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
ziel = os.path.join(final_dir, os.path.basename(quelle))
|
||||||
|
|
||||||
def datei_fortschritt(p, _index=index):
|
def datei_fortschritt(p, _index=index):
|
||||||
gesamt = int((_index * 100 + p) / anzahl)
|
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))
|
db.update_job(job_id, progress=min(99, gesamt))
|
||||||
|
|
||||||
hb = run_handbrake(quelle, ziel, preset=preset, progress_cb=datei_fortschritt)
|
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":
|
if hb.get("status") != "success":
|
||||||
ergebnis = {
|
ergebnis = {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|||||||
Reference in New Issue
Block a user