diff --git a/docker/api/main.py b/docker/api/main.py index cbe4f34..e6f3208 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -359,6 +359,10 @@ class JobCreateRequest(BaseModel): main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle) transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node) + # Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026: + # beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus + # Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt. + work_dir: Optional[str] = None MEDIA_ROOT = "/app/media" @@ -418,6 +422,12 @@ async def create_job(request: JobCreateRequest): meta_dict["titles"] = titel_liste if request.transcode_node: meta_dict["transcode_node"] = request.transcode_node + # Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss + # unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten + # irgendwohin in den Container schreibt. + arbeits_dir = _validiere_ziel(request.work_dir) + if arbeits_dir: + meta_dict["work_dir"] = arbeits_dir meta_json = json.dumps(meta_dict) if meta_dict else None job_id = str(uuid.uuid4()) diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 0514a33..ceed4f0 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -90,6 +90,7 @@ export default function DeviceDiscovery() { ...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}), ...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}), ...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}), + ...(optionen?.workDir ? { work_dir: optionen.workDir } : {}), }) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`) toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"') diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index 21e6b64..470c3d1 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react' +import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react' import { api } from '../lib/api' import { Modal } from './ui/Modal' import { Button } from './ui/Button' @@ -19,6 +19,15 @@ export interface RipOptionen { mainFeatureOnly?: boolean titles?: number[] transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto + workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung +} + +// Ein Ablageziel aus GET /storage-targets. +interface StorageZiel { + name: string + path: string + is_mount: boolean + free_gb: number | null } interface WorkerWahl { @@ -112,6 +121,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI const [browseDirs, setBrowseDirs] = useState([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [customPath, setCustomPath] = useState('') + // Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar, + // nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch + // bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird. + const [arbeitsZiele, setArbeitsZiele] = useState([]) + const [arbeitsDir, setArbeitsDir] = useState('') + const [standardArbeitsDir, setStandardArbeitsDir] = useState('') useEffect(() => { if (!isOpen) return @@ -124,6 +139,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI setTitelListe([]) setGewaehlt(new Set()) setEncoderNode('') + setArbeitsDir('') + api.get('/storage-targets') + .then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : [])) + .catch(() => setArbeitsZiele([])) // Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar) api.get('/capabilities').then(r => { setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node)) @@ -131,6 +150,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI api.get('/settings').then(r => { const s = r.data || {} setNurHauptfilm(!!s.mainFeatureOnly) + setStandardArbeitsDir((s.workDir || '').trim()) const basis = s.outputDir || '/app/media' setTargets([ { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, @@ -164,6 +184,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI optionen.titles = [...gewaehlt].sort((a, b) => a - b) } if (encoderNode) optionen.transcodeNode = encoderNode + if (arbeitsDir) optionen.workDir = arbeitsDir const target = targets.find(t => t.type === selectedType) if (customPath) { onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen) @@ -245,6 +266,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI )} + {/* + Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026). + Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß + und lag bisher immer auf der Container-Platte — am 25.07. lief sie + damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur + Disc, nicht in eine globale Einstellung. Leer = der Wert aus + Einstellungen → Verarbeitung; genau der greift auch bei + Vollautomatik-Rips, weil dort niemand gefragt wird. + Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe. + */} + {selectedType !== 'music' && ( +
+ +

+ + Bei 4K-UHD bis zu 100 GB — nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben. + Dann muss Rippy am Ende nur umhängen statt zu kopieren. +

+
+ )} + {/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind (sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */} {selectedType !== 'music' && workers.length >= 2 && ( diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 2976450..5df4d5b 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -637,7 +637,7 @@ export default function SettingsPage() { inklusive freiem Platz. */}

- Wichtig für 4K-UHD: Rohdaten - sind bis 100 GB groß und passen selten auf die Container-Platte. Lege das Arbeitsverzeichnis - auf eine Freigabe mit genug Platz — am besten auf dieselbe, auf der auch die fertigen Dateien - landen. Dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren. + Der Standard — beim + „Rippen starten" kannst du für jede Disc etwas anderes wählen. + Läuft die Vollautomatik, fragt dich niemand: dann gilt genau dieser Wert. + Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die + Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die + Rohdatei am Ende nur umhängen statt sie zu kopieren. {systemInfo?.plaetze?.length ? ( <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')} ) : null} diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index 7d0fcaf..bb147e3 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -15,6 +15,7 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in import glob import json import os +import posixpath import shutil import requests @@ -99,19 +100,29 @@ def _zielbasis(target_dir, disc_type: str) -> str: return os.path.join(RIP_OUTPUT_DIR, disc_type) -def _arbeitsverzeichnis(einstellungen: dict) -> str: - """Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine - NAS-Freigabe) schlägt den Container-Default /app/temp/raw. +def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str: + """Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting + `workDir` → Container-Default /app/temp/raw. Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50, aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie — das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein. + + Pro Rip wählbar seit 25.07.2026 (Commander-Wunsch): beim „Rippen starten" + entscheidet man je Disc, wo die Rohdaten landen. Das Setting bleibt der + Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei + denen niemand gefragt wird. """ - work_dir = (einstellungen.get("workDir") or "").strip() - if work_dir: - normalisiert = os.path.normpath(work_dir) - if normalisiert.startswith(MEDIA_ROOT): - return normalisiert + # posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...), + # auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt + # sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows + # Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr. + for kandidat in (job_wahl, einstellungen.get("workDir")): + wert = (kandidat or "").strip() + if wert: + normalisiert = posixpath.normpath(wert) + if normalisiert.startswith(MEDIA_ROOT): + return normalisiert return RAW_DIR @@ -468,7 +479,8 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None): # Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode) db.update_job(job_id, output_path=final_dir) - raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), job_id) + # meta["work_dir"] = die Wahl aus dem Rip-Dialog; leer = Setting/Default. + raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen, meta.get("work_dir")), job_id) # 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). diff --git a/docker/worker/test_medien.py b/docker/worker/test_medien.py index 538f01a..9f471ce 100644 --- a/docker/worker/test_medien.py +++ b/docker/worker/test_medien.py @@ -171,3 +171,23 @@ def test_original_aufheben_warnt_statt_platte_vollzuschreiben(tmp_path, monkeypa assert bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht" assert meldungen[-1][0] == "warning" assert "NICHT aufgehoben" in meldungen[-1][2] + + +def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung(): + """Pro Rip waehlbar (Commander 25.07.2026), Einstellung bleibt Standard. + + Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der + Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort + niemand gefragt wird. + """ + import tasks + + einst = {"workDir": "/app/media/movies"} + assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy" + assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies" + assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR + # Ausbruchsversuche und Pfade ausserhalb /app/media fallen durch + assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR + assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR + # Leere Wahl faellt sauber auf die Einstellung zurueck + assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"