diff --git a/README.md b/README.md index b64f2f7..1ec8fbc 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/ROADMAP.md b/ROADMAP.md index e9c978e..c83e7e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 5129700..bfba13c 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -1,6 +1,32 @@ # SAVEPOINT — Rippy -## Aktueller Stand: v3.4 — Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude) +## Aktueller Stand: v3.5 — Track-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:', 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). diff --git a/deploy/worker-windows/install.ps1 b/deploy/worker-windows/install.ps1 new file mode 100644 index 0000000..c0e2797 --- /dev/null +++ b/deploy/worker-windows/install.ps1 @@ -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'." diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 937fcaa..1666b6e 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -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"] diff --git a/docker/api/main.py b/docker/api/main.py index cd0b618..4c07d9e 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -337,6 +337,7 @@ class JobCreateRequest(BaseModel): series: Optional[str] = None # Serien-Flow: Ablage /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 diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx index e9a8e05..7f67f7f 100644 --- a/docker/ui/src/App.tsx +++ b/docker/ui/src/App.tsx @@ -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('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() {
{page === 'dashboard' && } + {page === 'anleitung' && } {page === 'logs' && } {page === 'settings' && }
diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 8f907d4..6853acd 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -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) { diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index b4759f4..b36a1f1 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -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('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([]) + const [gewaehlt, setGewaehlt] = useState>(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([ { 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 )} - {/* 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' && (