Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
Ampel / ampel (push) Successful in 29s
Ampel / ampel (push) Successful in 29s
Volle Titel-Auswahl vor dem Rip (Etappe-12-Rest):
- worker.tasks.scan_tracks: makemkvcon info -> Titel-Liste (Dauer/Groesse/
Kapitel, apdefs-Attr 8/9/11) in die settings-Tabelle 'tracks:<device>';
API: POST /devices/{n}/scan-tracks + GET /devices/{n}/tracks (Polling).
- Rip-Dialog: 'Disc scannen' -> Tabelle mit Checkboxen, Vorauswahl ab
5 min, Groessen-Summe; gewaehlte Titel als meta.titles in den Job.
- rip_titel_auswahl: ein makemkvcon-Aufruf je Titel (mkv kann nur einen
Titel oder all), Fortschritt anteilig. Parser-Tests dabei.
Nativer Windows-Worker OHNE Docker (Commander-Wunsch):
- Die Rippy-Instanz versorgt sich selbst: GET /worker-setup/windows
liefert install.ps1, /worker-setup/paket den Worker-Code als Zip
(api-Dockerfile kopiert docker/worker/*.py als worker_dist/ ins Image).
- install.ps1: Python-Check, venv + requirements, HandBrakeCLI 1.9.2 vom
offiziellen GitHub-Release (URL per HEAD verifiziert), start-worker.bat
mit celery -Q transcode --pool=solo (Celery-Doku: Windows nur solo),
optional -Autostart via schtasks onlogon.
- tasks.py laedt jetzt auch ohne fcntl (Import-Guard) — rip_disc ist auf
solchen Workern hart verriegelt (Klartext-Fehler statt Crash).
- Einstellungen -> Worker: Varianten-Umschalter Linux(Docker)/Windows(nativ)
mit passenden Copy-Paste-Befehlen.
Anleitungs-Tab: Rippy erklaert sich selbst (Disc-Weg, Serien, NAS,
Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,4 +17,10 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY docker/api/ .
|
||||
|
||||
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
|
||||
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
|
||||
# die Zielmaschine braucht weder git noch Docker.
|
||||
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
|
||||
COPY deploy/worker-windows/install.ps1 worker_dist/
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
|
||||
|
||||
@@ -337,6 +337,7 @@ class JobCreateRequest(BaseModel):
|
||||
series: Optional[str] = None # Serien-Flow: Ablage <Serie>/Season NN
|
||||
season: Optional[int] = None
|
||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||
|
||||
|
||||
MEDIA_ROOT = "/app/media"
|
||||
@@ -390,6 +391,10 @@ async def create_job(request: JobCreateRequest):
|
||||
meta_dict["season"] = max(1, int(request.season or 1))
|
||||
if request.main_feature_only is not None:
|
||||
meta_dict["main_feature_only"] = request.main_feature_only
|
||||
if request.titles:
|
||||
titel_liste = sorted({int(t) for t in request.titles if int(t) >= 0})[:200]
|
||||
if titel_liste:
|
||||
meta_dict["titles"] = titel_liste
|
||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
@@ -542,6 +547,34 @@ async def eject_device(name: str):
|
||||
return {"status": "ejected", "device": device_path}
|
||||
|
||||
|
||||
@app.post("/devices/{name}/scan-tracks")
|
||||
async def scan_tracks_starten(name: str):
|
||||
"""Titel-Scan der eingelegten Disc anstoßen (Track-Auswahl-Tabelle).
|
||||
|
||||
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
||||
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
||||
"""
|
||||
device_path = f"/dev/{name}"
|
||||
if device_path not in device_discovery.list_optical_devices():
|
||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
||||
|
||||
await asyncio.to_thread(db.save_settings, {"status": "running"}, f"tracks:{device_path}")
|
||||
celery_client.send_task("worker.tasks.scan_tracks", args=[device_path])
|
||||
return {"status": "scanning"}
|
||||
|
||||
|
||||
@app.get("/devices/{name}/tracks")
|
||||
async def scan_tracks_ergebnis(name: str):
|
||||
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
||||
device_path = f"/dev/{name}"
|
||||
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
||||
if not daten:
|
||||
return {"status": "none"}
|
||||
return daten
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/retry-transcode")
|
||||
async def retry_transcode(job_id: str):
|
||||
"""Stößt die Kompression eines Jobs neu an — OHNE die Disc neu zu rippen.
|
||||
@@ -902,6 +935,50 @@ async def browse_mkdir(request: MkdirRequest):
|
||||
return {"path": ziel}
|
||||
|
||||
|
||||
@app.get("/worker-setup/windows")
|
||||
async def worker_setup_windows():
|
||||
"""Windows-Installer-Skript für den nativen Transcode-Worker.
|
||||
|
||||
Das UI (Einstellungen → Worker → Windows) zeigt den passenden
|
||||
Zwei-Zeilen-Aufruf — alles kommt von dieser Rippy-Instanz selbst.
|
||||
"""
|
||||
pfad = "worker_dist/install.ps1"
|
||||
if not os.path.isfile(pfad):
|
||||
raise HTTPException(status_code=404, detail="Installer nicht im Image — API neu bauen")
|
||||
return FileResponse(pfad, media_type="text/plain", filename="install-rippy-worker.ps1")
|
||||
|
||||
|
||||
@app.get("/worker-setup/paket")
|
||||
async def worker_setup_paket():
|
||||
"""Worker-Quellcode als Zip — der Windows-Installer lädt ihn von hier.
|
||||
|
||||
Kein git, kein Docker auf der Zielmaschine nötig: die Rippy-Instanz
|
||||
versorgt ihre Worker selbst (Dateien liegen via Dockerfile im Image).
|
||||
"""
|
||||
def baue():
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
if not os.path.isdir("worker_dist"):
|
||||
return None
|
||||
puffer = io.BytesIO()
|
||||
with zipfile.ZipFile(puffer, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for name in sorted(os.listdir("worker_dist")):
|
||||
if name.endswith(".py") and not name.startswith(("test_", "conftest")):
|
||||
z.write(os.path.join("worker_dist", name), name)
|
||||
elif name == "requirements.txt":
|
||||
z.write(os.path.join("worker_dist", name), name)
|
||||
return puffer.getvalue()
|
||||
|
||||
inhalt = await asyncio.to_thread(baue)
|
||||
if not inhalt:
|
||||
raise HTTPException(status_code=404, detail="Worker-Paket nicht im Image — API neu bauen")
|
||||
return Response(
|
||||
content=inhalt, media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="rippy-worker.zip"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/system/info")
|
||||
async def system_info():
|
||||
"""System-Selbstauskunft (Einstellungen → System): Werkzeug-Versionen der
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-react'
|
||||
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react'
|
||||
import { api } from './lib/api'
|
||||
import { useDarkMode } from './context/ThemeContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
|
||||
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
|
||||
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
|
||||
type Page = 'dashboard' | 'settings' | 'logs'
|
||||
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
|
||||
|
||||
function App() {
|
||||
const [page, setPage] = useState<Page>('dashboard')
|
||||
@@ -28,6 +29,7 @@ function App() {
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
|
||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
||||
]
|
||||
@@ -86,6 +88,7 @@ function App() {
|
||||
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
{page === 'dashboard' && <Dashboard />}
|
||||
{page === 'anleitung' && <AnleitungPage />}
|
||||
{page === 'logs' && <LogsPage />}
|
||||
{page === 'settings' && <SettingsPage />}
|
||||
</div>
|
||||
|
||||
@@ -359,6 +359,7 @@ export default function DeviceDiscovery() {
|
||||
isOpen={modalDevice !== null}
|
||||
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
||||
discTitle={modalDevice?.disc?.title}
|
||||
deviceId={modalDevice?.id}
|
||||
onClose={() => setModalDevice(null)}
|
||||
onSave={(target, optionen) => {
|
||||
if (modalDevice) {
|
||||
|
||||
@@ -15,16 +15,31 @@ export interface RipOptionen {
|
||||
series?: string
|
||||
season?: number
|
||||
mainFeatureOnly?: boolean
|
||||
titles?: number[] // exakte Titel-Auswahl (Track-Tabelle)
|
||||
}
|
||||
|
||||
interface TitelInfo {
|
||||
nr: number
|
||||
dauer_s: number
|
||||
groesse_bytes: number
|
||||
kapitel: number
|
||||
}
|
||||
|
||||
interface RipTargetModalProps {
|
||||
isOpen: boolean
|
||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
||||
discTitle?: string // Vorschlag für den Serien-Namen
|
||||
deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan
|
||||
onClose: () => void
|
||||
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
||||
}
|
||||
|
||||
function dauerText(s: number): string {
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`
|
||||
}
|
||||
|
||||
interface BrowseDir {
|
||||
name: string
|
||||
path: string
|
||||
@@ -32,11 +47,48 @@ interface BrowseDir {
|
||||
|
||||
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
|
||||
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
|
||||
export default function RipTargetModal({ isOpen, initialType, discTitle, onClose, onSave }: RipTargetModalProps) {
|
||||
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
|
||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||
const [serienName, setSerienName] = useState('')
|
||||
const [staffel, setStaffel] = useState(1)
|
||||
const [nurHauptfilm, setNurHauptfilm] = useState(false)
|
||||
const [scanStatus, setScanStatus] = useState<'idle' | 'running' | 'done' | 'error'>('idle')
|
||||
const [scanFehler, setScanFehler] = useState('')
|
||||
const [titelListe, setTitelListe] = useState<TitelInfo[]>([])
|
||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||
|
||||
const scanStarten = async () => {
|
||||
if (!deviceId) return
|
||||
setScanStatus('running')
|
||||
setScanFehler('')
|
||||
try {
|
||||
await api.post(`/devices/${deviceId}/scan-tracks`)
|
||||
} catch (e: any) {
|
||||
setScanStatus('error')
|
||||
setScanFehler(e?.response?.data?.detail || 'Scan konnte nicht gestartet werden')
|
||||
}
|
||||
}
|
||||
|
||||
// Polling, solange der Titel-Scan läuft (Info-Lauf dauert 20–120 s)
|
||||
useEffect(() => {
|
||||
if (scanStatus !== 'running' || !deviceId) return
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const r = await api.get(`/devices/${deviceId}/tracks`)
|
||||
if (r.data.status === 'done') {
|
||||
const tracks: TitelInfo[] = r.data.tracks || []
|
||||
setTitelListe(tracks)
|
||||
// Vorauswahl: alles ab 5 Minuten (Trailer/Menü-Schnipsel bleiben weg)
|
||||
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
|
||||
setScanStatus('done')
|
||||
} else if (r.data.status === 'error') {
|
||||
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
|
||||
setScanStatus('error')
|
||||
}
|
||||
} catch { /* API kurz weg — weiter pollen */ }
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [scanStatus, deviceId])
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||
@@ -56,6 +108,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
|
||||
setCustomPath('')
|
||||
setSerienName(discTitle || '')
|
||||
setStaffel(1)
|
||||
setScanStatus('idle')
|
||||
setScanFehler('')
|
||||
setTitelListe([])
|
||||
setGewaehlt(new Set())
|
||||
api.get('/settings').then(r => {
|
||||
const s = r.data || {}
|
||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||
@@ -88,6 +144,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
|
||||
optionen.series = serienName.trim()
|
||||
optionen.season = Math.max(1, staffel || 1)
|
||||
}
|
||||
// Explizite Titel-Auswahl schlägt alles andere
|
||||
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||
} else if (scanStatus === 'done' && gewaehlt.size > 0) {
|
||||
// Alle gewählt = normales „all" — keine Einzelaufrufe nötig
|
||||
}
|
||||
const target = targets.find(t => t.type === selectedType)
|
||||
if (customPath) {
|
||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||
@@ -171,8 +233,9 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
|
||||
{selectedType === 'movies' && (
|
||||
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung);
|
||||
eine explizite Titel-Auswahl unten schlägt diese Option */}
|
||||
{selectedType === 'movies' && scanStatus !== 'done' && (
|
||||
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -184,6 +247,74 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, onClose
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */}
|
||||
{deviceId && selectedType !== 'music' && (
|
||||
<div className={`rounded-lg border ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<div className={`flex items-center justify-between px-3 py-2 ${scanStatus === 'done' ? (theme === 'dark' ? 'border-b border-slate-700' : 'border-b border-slate-200') : ''}`}>
|
||||
<div>
|
||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Titel-Auswahl (optional)</span>
|
||||
{scanStatus === 'idle' && (
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{scanStatus === 'running' ? (
|
||||
<span className={`text-xs flex items-center gap-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-indigo-500 border-t-transparent" />
|
||||
Disc wird gelesen (20–120 s)…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={scanStarten}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{scanStatus === 'error' && (
|
||||
<p className="text-xs text-rose-500 px-3 py-2">{scanFehler}</p>
|
||||
)}
|
||||
{scanStatus === 'done' && (
|
||||
<>
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{titelListe.map(t => (
|
||||
<label
|
||||
key={t.nr}
|
||||
className={`flex items-center gap-2.5 px-3 py-1.5 text-sm cursor-pointer ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700/50' : 'text-slate-700 hover:bg-slate-50'}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gewaehlt.has(t.nr)}
|
||||
onChange={e => {
|
||||
const neu = new Set(gewaehlt)
|
||||
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
|
||||
setGewaehlt(neu)
|
||||
}}
|
||||
className="w-4 h-4 accent-indigo-600 flex-shrink-0"
|
||||
/>
|
||||
<span className="font-mono text-xs w-14 flex-shrink-0">Titel {t.nr}</span>
|
||||
<span className="flex-shrink-0">{dauerText(t.dauer_s)}</span>
|
||||
{t.kapitel > 0 && (
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{t.kapitel} Kap.</span>
|
||||
)}
|
||||
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs px-3 py-2 border-t ${theme === 'dark' ? 'border-slate-700 text-slate-500' : 'border-slate-200 text-slate-400'}`}>
|
||||
{gewaehlt.size} von {titelListe.length} Titeln gewählt
|
||||
{' · '}~{([...gewaehlt].reduce((s, nr) => s + (titelListe.find(t => t.nr === nr)?.groesse_bytes || 0), 0) / 1024 ** 3).toFixed(1)} GB
|
||||
{' · '}Vorauswahl: alles ab 5 Minuten
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ordner-Browser */}
|
||||
<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'}`}>
|
||||
|
||||
@@ -40,6 +40,7 @@ function relativeZeit(iso?: string): string {
|
||||
export default function WorkerVerwaltung() {
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [kopiert, setKopiert] = useState(false)
|
||||
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
|
||||
const { theme } = useDarkMode()
|
||||
const { toast } = useToast()
|
||||
|
||||
@@ -59,11 +60,17 @@ export default function WorkerVerwaltung() {
|
||||
}, [])
|
||||
|
||||
const rippyHost = window.location.hostname
|
||||
const installBefehl = [
|
||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||
].join('\n')
|
||||
const installBefehl = variante === 'linux'
|
||||
? [
|
||||
`git clone <rippy-repo-url> rippy && cd rippy`,
|
||||
`RIPPY_HOST=${rippyHost} WORKER_NAME=mein-pc \\`,
|
||||
` docker compose -f deploy/remote-transcode-worker.yml up -d --build`,
|
||||
].join('\n')
|
||||
: [
|
||||
`iwr http://${rippyHost}/api/worker-setup/windows -OutFile install-rippy-worker.ps1`,
|
||||
`powershell -ExecutionPolicy Bypass -File .\\install-rippy-worker.ps1 \``,
|
||||
` -RippyHost ${rippyHost} -WorkerName mein-pc`,
|
||||
].join('\n')
|
||||
|
||||
const kopieren = () => {
|
||||
navigator.clipboard.writeText(installBefehl).then(() => {
|
||||
@@ -131,15 +138,28 @@ export default function WorkerVerwaltung() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Neue Maschine anbinden */}
|
||||
{/* Neue Maschine anbinden — zwei Varianten */}
|
||||
<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-1 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
|
||||
<h3 className={`font-medium mb-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
||||
<button
|
||||
key={wert}
|
||||
onClick={() => setVariante(wert)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
variante === wert
|
||||
? theme === 'dark' ? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-indigo-50 border-indigo-600 text-indigo-700'
|
||||
: theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500' : 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Auf der Zielmaschine (Linux mit Docker, oder Windows mit Docker Desktop) diesen
|
||||
Befehl ausführen — WORKER_NAME frei wählen, unter dem Namen taucht die Maschine
|
||||
oben auf und übernimmt ab dann automatisch Kompressions-Jobs. GPU vorhanden?
|
||||
Wird erkannt und angezeigt. Voraussetzung: die Maschine erreicht diesen
|
||||
Rippy-Host im Netz.
|
||||
{variante === 'linux'
|
||||
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
|
||||
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch von dieser Rippy-Instanz bzw. dem offiziellen HandBrake-Release und erzeugt eine start-worker.bat. Autostart bei Anmeldung: Parameter -Autostart anhängen.'}
|
||||
</p>
|
||||
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
|
||||
{installBefehl}
|
||||
@@ -152,10 +172,8 @@ export default function WorkerVerwaltung() {
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Ehrliche Antwort zu „muss das Docker sein?": aktuell ja — der Worker braucht
|
||||
HandBrake + Celery, und Docker ist der einzige Weg, das auf jeder Maschine
|
||||
identisch und ohne Installations-Roulette auszuliefern. Ein nativer
|
||||
Windows-Dienst (Installer, ohne Docker) steht als Ausbaustufe in der ROADMAP.
|
||||
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier,
|
||||
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { BookOpen, Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
// Anleitungs-Tab (Commander-Wunsch 24.07.): Rippy erklärt sich selbst —
|
||||
// keine externen Wikis, keine README-Suche. Reiner Text, keine API-Aufrufe.
|
||||
|
||||
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
|
||||
const { theme } = useDarkMode()
|
||||
return (
|
||||
<section className={`rounded-xl border p-6 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
||||
<h2 className={`text-lg font-semibold mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<Icon size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{titel}
|
||||
</h2>
|
||||
<div className={`space-y-2 text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AnleitungPage() {
|
||||
const { theme } = useDarkMode()
|
||||
const fett = theme === 'dark' ? 'text-slate-100 font-medium' : 'text-slate-900 font-medium'
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl transition-colors duration-300">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Anleitung</h1>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
So funktioniert Rippy — vom Einlegen bis zum fertigen Film in deinem Media-Server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
|
||||
<p>
|
||||
<span className={fett}>1. Disc einlegen.</span> Rippy erkennt sie automatisch (Titel, Poster,
|
||||
Beschreibung) und zeigt sie oben im Dashboard. Stimmt der Titel nicht → „Nicht korrekt?"
|
||||
klicken und den richtigen wählen — Rippy merkt sich das für diese Disc.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>2. „Rippen starten"</span> und das Ziel wählen: 🎥 Filme, 📺 Serien,
|
||||
🎵 Musik oder ein eigener Ordner (auch NAS-Freigaben). Optional: „Nur Hauptfilm" (längster
|
||||
Titel) oder per <span className={fett}>„Disc scannen"</span> einzelne Titel an-/abwählen.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>3. Warten.</span> Rippy sichert verlustfrei (MakeMKV) und komprimiert
|
||||
danach auf Arbeitsgröße (HandBrake) — alle Tonspuren und Untertitel bleiben erhalten.
|
||||
Fertige Jobs haben einen Download-Knopf; Klick auf den Titel zeigt alle Details.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Vollautomatik:</span> Unter Einstellungen → Ripping einschaltbar —
|
||||
dann startet der Rip ohne Nachfrage, sobald eine Disc erkannt wurde. Bereits gerippte
|
||||
Discs (Fingerabdruck) werden übersprungen.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Tv} titel="Serien rippen">
|
||||
<p>
|
||||
Im „Rippen starten"-Dialog <span className={fett}>„Serien"</span> wählen, Serienname und
|
||||
Staffel angeben. Die Ablage wird automatisch <code>Serie/Season 02/…</code>, und nach dem
|
||||
Rip ordnet Rippy die Episoden über einen Laufzeit-Abgleich mit TMDB zu und benennt sie
|
||||
(„Serie S02E03.mkv"). Das passiert nur bei <span className={fett}>eindeutiger</span> Zuordnung —
|
||||
im Zweifel bleiben die Original-Namen und das Log sagt warum.
|
||||
</p>
|
||||
<p>
|
||||
Eine komplette Staffel auf einer Disc klappt auch, wenn alle Episoden gleich lang sind
|
||||
(Anime-Boxen). Bei Teil-Discs mit lauter gleich langen Episoden ist eine automatische
|
||||
Zuordnung prinzipiell nicht möglich — dann von Hand umbenennen.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={HardDrive} titel="NAS & Speicherziele">
|
||||
<p>
|
||||
Einstellungen → Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
|
||||
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), „Freigaben
|
||||
auflisten", Freigabe wählen, „Einhängen & speichern". Danach taucht das Ziel im
|
||||
„Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
|
||||
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
|
||||
Einstellungen → Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
|
||||
jedem Rip und bricht sonst mit Klartext ab.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Tv} titel="Jellyfin, Emby, Kodi, Plex">
|
||||
<p>
|
||||
Einstellungen → Ripping → Media-Server: Rippy benennt fertige Rips als „Titel (Jahr)" und
|
||||
legt für Jellyfin/Emby/Kodi zusätzlich <code>movie.nfo</code> + <code>poster.jpg</code> dazu —
|
||||
dein Server erkennt alles ohne Nacharbeit (Plex nutzt nur die Benennung).
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Null-Klick-Kette:</span> Bei Jellyfin/Emby zusätzlich Server-URL und
|
||||
API-Key eintragen (Administration → API-Schlüssel) — dann stößt Rippy nach jedem fertigen
|
||||
Rip sofort einen Bibliotheks-Scan an. Disc rein → Film erscheint im Server.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Bell} titel="Benachrichtigungen">
|
||||
<p>
|
||||
Einstellungen → Benachrichtigungen: eine Webhook-URL eintragen (Discord-Webhook, ntfy-Thema
|
||||
für Handy-Push, Slack oder eigenes JSON), „Test senden", speichern. Rippy meldet sich, wenn
|
||||
ein Job fertig ist, fehlschlägt oder abgebrochen wird. Die Schritt-für-Schritt-Anleitung je
|
||||
Dienst steht direkt auf der Einstellungs-Seite.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
|
||||
<p>
|
||||
Die Kompression kann jede Maschine im Netz übernehmen — Einstellungen → Worker zeigt für
|
||||
<span className={fett}> Linux (Docker)</span> und <span className={fett}>Windows (nativ,
|
||||
ohne Docker — nur Python nötig)</span> je einen Copy-Paste-Befehl. Die Maschine taucht
|
||||
danach unter ihrem Namen in der Worker-Liste auf und zieht sich Kompressions-Jobs
|
||||
automatisch. Gerippt wird immer auf der Rippy-Hauptmaschine (dort hängt das Laufwerk).
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key & System">
|
||||
<p>
|
||||
Blu-ray-Ripping braucht den MakeMKV-Beta-Key, der etwa <span className={fett}>monatlich
|
||||
wechselt</span> (MakeMKV-Forum, Thread „MakeMKV is free while in beta"). Neuen Key unter
|
||||
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
|
||||
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={Download} titel="Fertige Dateien herunterladen">
|
||||
<p>
|
||||
Bei fertigen Jobs: „Download" in der Aktion-Spalte (oder Klick auf den Titel) → Dateiliste
|
||||
mit „Alle herunterladen". Die Job-Liste lässt sich per Papierkorb bzw. „Erledigte aufräumen"
|
||||
leeren — <span className={fett}>die gerippten Dateien bleiben dabei immer liegen</span>.
|
||||
„CSV" exportiert die komplette Historie für Excel.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
|
||||
<Abschnitt icon={HelpCircle} titel="Häufige Fragen">
|
||||
<p>
|
||||
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
|
||||
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>4K-UHD schlägt fehl mit „volume key is unknown"?</span> Das Laufwerk
|
||||
liest die Disc (LibreDrive), aber MakeMKV kennt den Schlüssel dieser (zu neuen) Pressung
|
||||
noch nicht. Den automatisch gespeicherten AACS-Dump im MakeMKV-Forum einreichen — mit einem
|
||||
der nächsten Updates ist die Disc rippbar.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
|
||||
erscheint nur, wenn die Rohdaten noch daliegen — bei einem Job, der nie gerippt hat, gäbe
|
||||
es nichts zu komprimieren.
|
||||
</p>
|
||||
<p>
|
||||
<span className={fett}>Kein Laufwerk sichtbar?</span> In einer VM muss das Laufwerk per
|
||||
USB-Passthrough durchgereicht sein (nicht als emuliertes CD-ROM) — Details in der README.
|
||||
</p>
|
||||
</Abschnitt>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -148,6 +148,28 @@ def get_job(job_id: str) -> dict:
|
||||
return dict(zeile) if zeile else None
|
||||
|
||||
|
||||
def save_settings(werte: dict, key: str = "ui") -> None:
|
||||
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
|
||||
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
payload = json.dumps(werte)
|
||||
with engine.begin() as conn:
|
||||
vorhanden = conn.execute(
|
||||
select(settings_table.c.key).where(settings_table.c.key == key)
|
||||
).first()
|
||||
if vorhanden:
|
||||
conn.execute(
|
||||
settings_table.update()
|
||||
.where(settings_table.c.key == key)
|
||||
.values(value=payload)
|
||||
)
|
||||
else:
|
||||
conn.execute(settings_table.insert().values(key=key, value=payload))
|
||||
|
||||
|
||||
def get_job_status(job_id: str) -> str:
|
||||
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
|
||||
from sqlalchemy import select
|
||||
|
||||
@@ -78,6 +78,62 @@ def parse_tinfo_dauern(ausgabe: str) -> dict:
|
||||
return dauern
|
||||
|
||||
|
||||
def parse_titel_info(ausgabe: str) -> list:
|
||||
"""TINFO-Zeilen → volle Titel-Liste für die Auswahl-Tabelle (pure Funktion).
|
||||
|
||||
Attribut-IDs laut MakeMKV apdefs.h (makemkvgui/inc/lgpl/apdefs.h, LGPL):
|
||||
8 = ChapterCount, 9 = Duration ("H:MM:SS"), 11 = DiskSizeBytes.
|
||||
Rückgabe: [{"nr", "dauer_s", "groesse_bytes", "kapitel"}, …] sortiert.
|
||||
"""
|
||||
titel: dict = {}
|
||||
for zeile in ausgabe.splitlines():
|
||||
treffer = re.match(r'TINFO:(\d+),(\d+),\d+,"(.*)"\s*$', zeile.strip())
|
||||
if not treffer:
|
||||
continue
|
||||
nr, attr, wert = int(treffer.group(1)), int(treffer.group(2)), treffer.group(3)
|
||||
eintrag = titel.setdefault(nr, {"nr": nr, "dauer_s": 0, "groesse_bytes": 0, "kapitel": 0})
|
||||
if attr == 9:
|
||||
teile = wert.split(":")
|
||||
if len(teile) == 3 and all(t.isdigit() for t in teile):
|
||||
h, m, s = (int(t) for t in teile)
|
||||
eintrag["dauer_s"] = h * 3600 + m * 60 + s
|
||||
elif attr == 11 and wert.isdigit():
|
||||
eintrag["groesse_bytes"] = int(wert)
|
||||
elif attr == 8 and wert.isdigit():
|
||||
eintrag["kapitel"] = int(wert)
|
||||
return [titel[nr] for nr in sorted(titel)]
|
||||
|
||||
|
||||
def lies_titel_info(device_path: str, timeout: int = 300) -> list:
|
||||
"""Volle Titel-Liste der eingelegten Disc (makemkvcon info, Robot-Mode)."""
|
||||
ergebnis = subprocess.run(
|
||||
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
return parse_titel_info(ergebnis.stdout or "")
|
||||
|
||||
|
||||
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
||||
progress_cb=None) -> dict:
|
||||
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
|
||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||
gesamt = len(titel_liste)
|
||||
alle_dateien = []
|
||||
for index, nr in enumerate(titel_liste):
|
||||
def anteilig(p, _i=index):
|
||||
if progress_cb:
|
||||
progress_cb(int((_i * 100 + p) / gesamt))
|
||||
|
||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr))
|
||||
if ergebnis.get("status") == "cancelled":
|
||||
return ergebnis
|
||||
if ergebnis.get("status") != "success":
|
||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||
return ergebnis
|
||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
||||
|
||||
|
||||
def laengster_titel(dauern: dict):
|
||||
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
||||
if not dauern:
|
||||
@@ -316,18 +372,24 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
||||
|
||||
|
||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||
output_dir: str = None, nur_hauptfilm: bool = False) -> dict:
|
||||
output_dir: str = None, nur_hauptfilm: bool = False,
|
||||
titel_liste: list = None) -> dict:
|
||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||
|
||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||
titel_liste: exakte Titel-Auswahl aus dem UI (schlägt nur_hauptfilm).
|
||||
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
||||
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
||||
"""
|
||||
if output_dir is None:
|
||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||
|
||||
if titel_liste:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb)
|
||||
|
||||
titel = "all"
|
||||
if nur_hauptfilm:
|
||||
dauern = lies_titel_dauern(device_path)
|
||||
|
||||
+51
-5
@@ -23,12 +23,22 @@ import db
|
||||
import medien
|
||||
import notify
|
||||
from celery_app import celery_app
|
||||
from detection import detect_disc_type, disc_size_bytes
|
||||
|
||||
# fcntl gibt es nur unter Linux — der NATIVE Windows-Transcode-Worker
|
||||
# (deploy/worker-windows) lädt dieses Modul auch, bedient aber nur die
|
||||
# transcode-Queue. Rippen ohne detection ist unten hart verriegelt.
|
||||
try:
|
||||
from detection import detect_disc_type, disc_size_bytes
|
||||
except ImportError: # Windows: kein fcntl
|
||||
detect_disc_type = None
|
||||
disc_size_bytes = None
|
||||
|
||||
from ripping import (
|
||||
DEFAULT_HB_PRESET,
|
||||
RIP_OUTPUT_DIR,
|
||||
RipAbbruch,
|
||||
lies_datei_dauer,
|
||||
lies_titel_info,
|
||||
rip_cd,
|
||||
rip_video,
|
||||
run_handbrake,
|
||||
@@ -252,6 +262,16 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
"""
|
||||
db.init_db()
|
||||
|
||||
if detect_disc_type is None:
|
||||
ergebnis = {
|
||||
"status": "error",
|
||||
"error": "Dieser Worker kann nur komprimieren (nativer "
|
||||
"Transcode-Worker ohne Laufwerks-Zugriff) — gerippt "
|
||||
"wird auf der Rippy-Hauptmaschine.",
|
||||
}
|
||||
_job_abschliessen(job_id, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
if _abbruch_angefordert(job_id):
|
||||
_job_abschliessen(job_id, {"status": "cancelled"})
|
||||
return {"status": "cancelled"}
|
||||
@@ -311,12 +331,14 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
job.get("title") or "", meta.get("year"), job_id,
|
||||
)
|
||||
|
||||
# Nur Hauptfilm (längster Titel): pro Rip wählbar, sonst Setting;
|
||||
# bei Serien-Discs sinnlos (dort zählen ALLE Episoden-Titel).
|
||||
# Titel-Wahl: explizite Auswahl aus der Track-Tabelle schlägt alles;
|
||||
# sonst „Nur Hauptfilm" (pro Rip wählbar, Fallback: Setting);
|
||||
# bei Serien-Discs ohne Auswahl zählen ALLE Episoden-Titel.
|
||||
titel_liste = [int(t) for t in (meta.get("titles") or []) if str(t).isdigit()]
|
||||
nur_hauptfilm = meta.get("main_feature_only")
|
||||
if nur_hauptfilm is None:
|
||||
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
||||
if meta.get("series"):
|
||||
if meta.get("series") or titel_liste:
|
||||
nur_hauptfilm = False
|
||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||
db.update_job(job_id, output_path=final_dir)
|
||||
@@ -326,7 +348,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
|
||||
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||
try:
|
||||
disc_bytes = disc_size_bytes(device_path)
|
||||
disc_bytes = disc_size_bytes(device_path) if disc_size_bytes else 0
|
||||
except OSError:
|
||||
disc_bytes = 0
|
||||
marge = 1024**3 # 1 GB Sicherheitsabstand
|
||||
@@ -349,12 +371,14 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
progress_cb=fortschritt,
|
||||
output_dir=raw_dir,
|
||||
nur_hauptfilm=nur_hauptfilm,
|
||||
titel_liste=titel_liste,
|
||||
)
|
||||
else:
|
||||
ergebnis = rip_video(
|
||||
device_path, job_id, disc_type,
|
||||
progress_cb=fortschritt, output_dir=final_dir,
|
||||
nur_hauptfilm=nur_hauptfilm,
|
||||
titel_liste=titel_liste,
|
||||
)
|
||||
|
||||
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
||||
@@ -397,6 +421,28 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
||||
return ergebnis
|
||||
|
||||
|
||||
@celery_app.task(name="worker.tasks.scan_tracks")
|
||||
def scan_tracks(device_path: str):
|
||||
"""Titel-Liste der eingelegten Disc erfassen (für die Auswahl-Tabelle).
|
||||
|
||||
Ergebnis landet in der settings-Tabelle (key 'tracks:<device>') — die
|
||||
API pollt darauf. Läuft auf der Haupt-Queue (nur der Rip-Worker hat das
|
||||
Laufwerk). Ein Info-Lauf dauert je nach Disc 20–120 s.
|
||||
"""
|
||||
db.init_db()
|
||||
key = f"tracks:{device_path}"
|
||||
db.save_settings({"status": "running"}, key)
|
||||
try:
|
||||
titel = lies_titel_info(device_path)
|
||||
db.save_settings({"status": "done", "tracks": titel}, key)
|
||||
db.add_log("info", "worker", f"Titel-Scan {device_path}: {len(titel)} Titel gefunden")
|
||||
return {"status": "done", "anzahl": len(titel)}
|
||||
except Exception as e:
|
||||
db.save_settings({"status": "error", "error": str(e)[:300]}, key)
|
||||
db.add_log("error", "worker", f"Titel-Scan {device_path} fehlgeschlagen: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="worker.tasks.transcode_files")
|
||||
def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
|
||||
"""Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
|
||||
|
||||
@@ -5,6 +5,7 @@ from ripping import (
|
||||
laengster_titel,
|
||||
parse_scan_dauer,
|
||||
parse_tinfo_dauern,
|
||||
parse_titel_info,
|
||||
)
|
||||
|
||||
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
|
||||
@@ -39,6 +40,22 @@ def test_makemkv_cmd_default_bleibt_all():
|
||||
assert cmd[-2:] == ["all", "/out"]
|
||||
|
||||
|
||||
def test_parse_titel_info_volle_tabelle():
|
||||
"""Attr 8=Kapitel, 9=Dauer, 11=Bytes (MakeMKV apdefs.h) → Auswahl-Tabelle."""
|
||||
ausgabe = """
|
||||
TINFO:0,8,0,"24"
|
||||
TINFO:0,9,0,"1:52:29"
|
||||
TINFO:0,11,0,"31234567890"
|
||||
TINFO:1,9,0,"0:03:12"
|
||||
TINFO:1,11,0,"98765432"
|
||||
"""
|
||||
titel = parse_titel_info(ausgabe)
|
||||
assert titel == [
|
||||
{"nr": 0, "dauer_s": 6749, "groesse_bytes": 31234567890, "kapitel": 24},
|
||||
{"nr": 1, "dauer_s": 192, "groesse_bytes": 98765432, "kapitel": 0},
|
||||
]
|
||||
|
||||
|
||||
def test_parse_scan_dauer():
|
||||
ausgabe = "…\n + duration: 01:52:29\n…"
|
||||
assert parse_scan_dauer(ausgabe) == 6749
|
||||
|
||||
Reference in New Issue
Block a user