Track-Auswahl-Tabelle, nativer Windows-Worker (via UI angeboten), Anleitungs-Tab
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:
Hitonabi
2026-07-24 15:18:08 +02:00
parent 325d95af6f
commit 3a4eb25392
15 changed files with 738 additions and 44 deletions
+11 -4
View File
@@ -118,12 +118,19 @@ steckt im Image: `docker compose build worker && docker compose up -d worker`.
Worker WIRKLICH haben (CPU x264/x265, VAAPI bei AMD/Intel-GPU, NVENC bei
NVIDIA) — jeder Worker meldet seine Fähigkeiten selbst beim Start.
### Optional: GPU-Maschine im Netz als Transcode-Worker
### Optional: weitere Maschinen als Transcode-Worker
Die Kompression läuft als eigener Celery-Task auf der Queue `transcode`
JEDE Maschine im Netz kann sie übernehmen (siehe
`deploy/remote-transcode-worker.yml`). Ohne Zusatz-Worker macht der
eingebaute CPU-Worker alles selbst — Rippy bleibt All-in-one.
JEDE Maschine im Netz kann sie übernehmen. **Einstellungen → Worker** zeigt
für beide Varianten einen Copy-Paste-Befehl:
- **Linux (Docker)**: `deploy/remote-transcode-worker.yml`
- **Windows (nativ, ohne Docker)**: braucht nur Python 3.10+ — der
Installer (`install.ps1`) kommt von der Rippy-Instanz selbst
(`/api/worker-setup/windows`), lädt Worker-Code und HandBrakeCLI
automatisch und erzeugt eine `start-worker.bat` (optional `-Autostart`).
Ohne Zusatz-Worker macht der eingebaute CPU-Worker alles selbst — Rippy
bleibt All-in-one.
## Umgebungsvariablen (.env)
+25 -12
View File
@@ -398,20 +398,33 @@ irrelevant und kann komplett raus."
---
## Etappe 16 (24.07.2026): Track-Tabelle + nativer Windows-Worker + Anleitung
- [x] **Volle Titel-Auswahl vor dem Rip**: scan_tracks-Worker-Task,
Polling-Endpoints, Tabelle (Dauer/Größe/Kapitel, Vorauswahl ≥ 5 min)
im Rip-Dialog, Multi-Titel-Rip (ein makemkvcon-Aufruf je Titel,
Fortschritt anteilig). Parser mit Tests.
- [x] **Nativer Windows-Worker ohne Docker**: install.ps1 + Worker-Code
werden von der Rippy-Instanz selbst serviert (/worker-setup/*);
HandBrakeCLI 1.9.2 vom offiziellen GitHub-Release; celery
--pool=solo -Q transcode; optional Autostart. UI bietet Linux- und
Windows-Variante. E2E auf echtem Windows-PC bewiesen.
- [x] **Anleitungs-Tab**: Rippy erklärt sich selbst (Disc-Weg, Serien,
NAS, Media-Server, Benachrichtigungen, Key, Worker, FAQ).
---
## Ideen-Katalog (Rest) — bewusst offen
1. **Volle Track-Auswahl-Tabelle vor dem Rip** (awaiting_selection):
Scan-Task → Titel-Tabelle im UI → Auswahl → Rip. Der 80%-Fall ist mit
„Nur Hauptfilm" abgedeckt; die Tabelle lohnt für Bonus-Material-Discs.
2. **Nativer Windows-Worker (ohne Docker)**: PyInstaller-Paket (Celery
solo-Pool + HandBrakeCLI.exe gebündelt) als Windows-Dienst; verbindet
sich über die jetzt offenen Ports (6379/5432/8000). Braucht einen
echten Testlauf auf einer Windows-Maschine — nichts ungetestet liefern.
3. **Design 2.0**: `dark:`-Klassen statt Theme-Ternaries — die
Infrastruktur (darkMode: 'class' + html.dark) steht KOMPLETT, es ist
reine mechanische Konvertierung pro Komponente.
4. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
1. **Design 2.0** (kommt in eine frische Session, Commander-Entscheid):
`dark:`-Klassen statt Theme-Ternaries — Infrastruktur steht komplett,
reine mechanische Konvertierung pro Komponente. Der neue Anleitungs-Tab
gehört mit in den Feinschliff.
2. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
5. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
3. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
Jellyfin/Emby.
4. **Windows-Worker als echter Dienst** (statt start-worker.bat/onlogon):
z. B. via NSSM oder PyInstaller-Paket — Komfort-Ausbau der jetzt
funktionierenden nativen Variante.
+27 -1
View File
@@ -1,6 +1,32 @@
# SAVEPOINT — Rippy
## Aktueller Stand: v3.4Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude)
## Aktueller Stand: v3.5Track-Tabelle, nativer Windows-Worker, Anleitungs-Tab (24.07.2026, Claude)
- **Volle Titel-Auswahl vor dem Rip**: „Disc scannen" im Rip-Dialog →
Worker-Task scan_tracks (makemkvcon info; Ergebnis via settings-Tabelle
'tracks:<device>', UI pollt) → Tabelle mit Checkbox/Dauer/Größe/Kapiteln,
Vorauswahl ab 5 min. Gewählte Titel gehen als meta.titles in den Job;
der Worker rippt sie einzeln (makemkvcon kann pro Aufruf nur einen Titel
oder all), Fortschritt anteilig. Parser mit Tests (apdefs-Attr 8/9/11).
- **Nativer Windows-Worker (ohne Docker!)**: Einstellungen → Worker bietet
jetzt ZWEI Varianten — Linux (Docker, wie gehabt) und Windows (nativ,
braucht nur Python). Die Rippy-Instanz versorgt sich selbst:
GET /worker-setup/windows liefert install.ps1, /worker-setup/paket den
Worker-Code als Zip (beides liegt via api-Dockerfile im Image).
install.ps1: venv + Abhängigkeiten, HandBrakeCLI 1.9.2 vom offiziellen
GitHub-Release (URL verifiziert), start-worker.bat (celery -Q transcode
--pool=solo), optional -Autostart (schtasks onlogon). tasks.py lädt auf
Windows ohne fcntl (Import-Guard); rip_disc ist dort hart verriegelt.
**E2E-BEWEIS: von diesem Windows-PC aus installiert, verbunden und ein
echter Transcode-Job gegen die VM gefahren** (Ergebnis siehe unten).
- **Anleitungs-Tab** im UI: kompletter Selbsterklärer (Disc-Weg, Serien,
NAS, Media-Server, Benachrichtigungen, Key, Worker, Downloads, FAQ).
- Design 2.0 kommt bewusst in eine FRISCHE Session (Commander-Entscheid) —
Infrastruktur steht (darkMode 'class'), reine Konvertierungsarbeit.
---
## Vorheriger Stand: v3.4 — Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude)
**Commander-Entscheid: AUTH IST KOMPLETT RAUS** (Heimnetz-only; das UI hatte
nie einen Login, die Endpoints waren Placebo, passlib/bcrypt brach die Ampel).
+103
View File
@@ -0,0 +1,103 @@
# Rippy: Nativer Windows-Transcode-Worker — Installation OHNE Docker.
#
# Was das Skript tut (alles in einen Unterordner "rippy-worker"):
# 1. Prueft Python (3.10+), legt ein venv an, installiert die Abhaengigkeiten
# 2. Laedt den Worker-Code direkt von deiner Rippy-Instanz (/api/worker-setup/paket)
# 3. Laedt HandBrakeCLI (gepinnte Version, offizielles GitHub-Release),
# falls nicht schon im PATH oder im Ordner vorhanden
# 4. Erzeugt start-worker.bat — Doppelklick startet den Worker
#
# Der Worker bedient NUR die Kompressions-Queue (transcode) — gerippt wird
# weiterhin auf der Rippy-Hauptmaschine (dort haengt das Laufwerk).
#
# Aufruf (PowerShell):
# .\install.ps1 -RippyHost 192.168.178.162 [-WorkerName mein-pc] [-Autostart]
#
# -Autostart registriert eine Aufgabe "RippyWorker" (Start bei Anmeldung).
param(
[Parameter(Mandatory = $true)] [string]$RippyHost,
[string]$WorkerName = $env:COMPUTERNAME.ToLower(),
[switch]$Autostart
)
$ErrorActionPreference = "Stop"
$HandBrakeVersion = "1.9.2"
$HandBrakeUrl = "https://github.com/HandBrake/HandBrake/releases/download/$HandBrakeVersion/HandBrakeCLI-$HandBrakeVersion-win-x86_64.zip"
Write-Host "== Rippy Windows-Worker Installation ==" -ForegroundColor Cyan
# 1. Python finden (py-Launcher oder python im PATH)
$python = $null
foreach ($kandidat in @("py -3", "python")) {
try {
$version = Invoke-Expression "$kandidat --version" 2>&1
if ("$version" -match "Python 3\.(\d+)") {
if ([int]$Matches[1] -ge 10) { $python = $kandidat; break }
}
} catch {}
}
if (-not $python) {
Write-Host "FEHLER: Python 3.10+ nicht gefunden." -ForegroundColor Red
Write-Host "Installieren mit: winget install Python.Python.3.12 (dann Skript erneut ausfuehren)"
exit 1
}
Write-Host "Python gefunden: $python"
# 2. Arbeitsordner + Worker-Code von der Rippy-Instanz laden
$ziel = Join-Path (Get-Location) "rippy-worker"
New-Item -ItemType Directory -Force $ziel | Out-Null
Set-Location $ziel
Write-Host "Lade Worker-Code von http://$RippyHost/api/worker-setup/paket ..."
Invoke-WebRequest "http://$RippyHost/api/worker-setup/paket" -OutFile worker.zip -UseBasicParsing
Expand-Archive worker.zip -DestinationPath . -Force
Remove-Item worker.zip
# 3. venv + Abhaengigkeiten
if (-not (Test-Path "venv")) {
Write-Host "Lege Python-Umgebung an ..."
Invoke-Expression "$python -m venv venv"
}
& .\venv\Scripts\python.exe -m pip install --quiet --upgrade pip
& .\venv\Scripts\pip.exe install --quiet -r requirements.txt
Write-Host "Python-Abhaengigkeiten installiert."
# 4. HandBrakeCLI besorgen (falls noetig)
$hb = Get-Command HandBrakeCLI.exe -ErrorAction SilentlyContinue
if (-not $hb -and -not (Test-Path ".\HandBrakeCLI.exe")) {
Write-Host "Lade HandBrakeCLI $HandBrakeVersion (offizielles GitHub-Release) ..."
Invoke-WebRequest $HandBrakeUrl -OutFile hb.zip -UseBasicParsing
Expand-Archive hb.zip -DestinationPath . -Force
Remove-Item hb.zip
}
if (-not (Test-Path ".\HandBrakeCLI.exe") -and -not $hb) {
Write-Host "FEHLER: HandBrakeCLI.exe fehlt." -ForegroundColor Red
exit 1
}
Write-Host "HandBrakeCLI bereit."
# 5. Start-Skript erzeugen
$bat = @"
@echo off
rem Rippy Windows-Transcode-Worker verbindet sich mit $RippyHost
cd /d "%~dp0"
set REDIS_URL=redis://${RippyHost}:6379/0
set DATABASE_URL=postgresql://rippy:rippy@${RippyHost}:5432/rippy
set API_URL=http://${RippyHost}:8000
set WORKER_NAME=$WorkerName
set PATH=%~dp0;%PATH%
venv\Scripts\celery.exe -A celery_app worker --loglevel=info -Q transcode --pool=solo -n ${WorkerName}@%%h
"@
Set-Content -Path "start-worker.bat" -Value $bat -Encoding ASCII
Write-Host "start-worker.bat erzeugt."
# 6. Optional: Autostart bei Anmeldung
if ($Autostart) {
schtasks /create /f /tn "RippyWorker" /tr "`"$ziel\start-worker.bat`"" /sc onlogon | Out-Null
Write-Host "Autostart-Aufgabe 'RippyWorker' registriert (bei Anmeldung)."
}
Write-Host ""
Write-Host "FERTIG. Worker starten mit: $ziel\start-worker.bat" -ForegroundColor Green
Write-Host "Er erscheint danach in Rippy unter Einstellungen -> Worker als '$WorkerName'."
+6
View File
@@ -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"]
+77
View File
@@ -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 20120 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
+5 -2
View File
@@ -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) {
+134 -3
View File
@@ -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 20120 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 (20120 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'}`}>
+34 -16
View File
@@ -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>
+162
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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>
)
}
+22
View File
@@ -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
+63 -1
View File
@@ -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
View File
@@ -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 20120 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.
+17
View File
@@ -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