From dfef585ec852e357027247dabd6c9701f5270223 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 23 Jul 2026 16:20:36 +0200 Subject: [PATCH] Speicherziel-Wahl: Rip-Ziel pro Job frei waehlbar (Etappe 16 / Task 16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /jobs nimmt target_dir (validiert unter /app/media, .. fliegt raus) - GET /storage-targets: Verzeichnisse unter /app/media inkl. Mount-Flag und freiem Platz — NFS/SMB-Shares unter /srv/rippy/media erscheinen dank rslave-Bind automatisch - jobs.target_dir (Mini-Migration via ADD COLUMN IF NOT EXISTS) - Worker: rip_disc(target_dir) mit eigener Validierung; CD/Video/ Transcode-Pfade legen im gewaehlten Ziel ab - UI: "Rippen starten" oeffnet den Ziel-Dialog (Filme/Serien/Musik oder eigener Pfad); Modal-Bugfix: customPath wurde still ignoriert Co-Authored-By: Claude Fable 5 --- docker/api/celery_client.py | 4 +- docker/api/db.py | 13 +++- docker/api/main.py | 64 ++++++++++++++++++-- docker/ui/src/components/DeviceDiscovery.tsx | 23 ++++++- docker/ui/src/components/RipTargetModal.tsx | 3 +- docker/worker/ripping.py | 5 +- docker/worker/tasks.py | 34 ++++++++--- 7 files changed, 125 insertions(+), 21 deletions(-) diff --git a/docker/api/celery_client.py b/docker/api/celery_client.py index 7bad835..1da42e4 100644 --- a/docker/api/celery_client.py +++ b/docker/api/celery_client.py @@ -13,8 +13,8 @@ REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL) -def start_rip(device_path: str, job_id: str): +def start_rip(device_path: str, job_id: str, target_dir: str = None): """Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py).""" return celery_client.send_task( - "worker.tasks.rip_disc", args=[device_path, job_id] + "worker.tasks.rip_disc", args=[device_path, job_id, target_dir] ) diff --git a/docker/api/db.py b/docker/api/db.py index ac67465..87a7bdf 100644 --- a/docker/api/db.py +++ b/docker/api/db.py @@ -41,6 +41,7 @@ jobs = Table( Column("status", String(16), nullable=False, server_default="pending"), Column("progress", Integer, nullable=False, server_default="0"), Column("output_path", Text), + Column("target_dir", String(255)), Column("error", Text), Column("created_at", DateTime(timezone=True)), Column("finished_at", DateTime(timezone=True)), @@ -69,11 +70,18 @@ def utcnow() -> datetime: def init_db() -> None: - """Legt fehlende Tabellen an (idempotent).""" + """Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.""" metadata.create_all(engine) + # create_all ändert BESTEHENDE Tabellen nicht — neue Spalten hier nachziehen: + with engine.begin() as conn: + conn.exec_driver_sql( + "ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)" + ) -def insert_job(job_id: str, device: str, disc_type: str = None, title: str = None) -> None: +def insert_job( + job_id: str, device: str, disc_type: str = None, title: str = None, target_dir: str = None +) -> None: with engine.begin() as conn: conn.execute( jobs.insert().values( @@ -81,6 +89,7 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non device=device, disc_type=disc_type, title=title, + target_dir=target_dir, status="pending", progress=0, created_at=utcnow(), diff --git a/docker/api/main.py b/docker/api/main.py index 6c3ca55..ab57f6b 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -6,6 +6,8 @@ from typing import List, Optional, Dict from pathlib import Path import asyncio import json +import os +import shutil import uuid import db @@ -187,6 +189,23 @@ class JobCreateRequest(BaseModel): device_path: Optional[str] = None device: Optional[str] = None # Alias, so schickt es das UI title: Optional[str] = None + target_dir: Optional[str] = None # Ablageziel unter /app/media (frei wählbar) + + +MEDIA_ROOT = "/app/media" + + +def _validiere_ziel(target_dir: Optional[str]) -> Optional[str]: + """Ziel muss unter /app/media liegen — Pfad-Ausbrüche (..) fliegen raus.""" + if not target_dir: + return None + normalisiert = os.path.normpath(target_dir) + if not normalisiert.startswith(MEDIA_ROOT): + raise HTTPException( + status_code=422, + detail=f"Ziel muss unter {MEDIA_ROOT} liegen (Shares dort einhängen)", + ) + return normalisiert @app.post("/jobs", status_code=201) @@ -201,12 +220,49 @@ async def create_job(request: JobCreateRequest): raise HTTPException(status_code=422, detail="device_path fehlt") if device_path not in device_discovery.list_optical_devices(): raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") + ziel = _validiere_ziel(request.target_dir) job_id = str(uuid.uuid4()) - await asyncio.to_thread(db.insert_job, job_id, device_path, None, request.title) - await asyncio.to_thread(db.add_log, "info", "api", f"Job {job_id} angelegt für {device_path}") - start_rip(device_path, job_id) - return {"id": job_id, "status": "pending", "device": device_path} + await asyncio.to_thread(db.insert_job, job_id, device_path, None, request.title, ziel) + await asyncio.to_thread( + db.add_log, "info", "api", + f"Job {job_id} angelegt für {device_path}" + (f" → {ziel}" if ziel else ""), + ) + start_rip(device_path, job_id, ziel) + return {"id": job_id, "status": "pending", "device": device_path, "target_dir": ziel} + + +@app.get("/storage-targets") +async def storage_targets(): + """Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts. + + NFS/SMB-Shares, die auf der VM unter /srv/rippy/media eingehängt werden, + tauchen hier automatisch auf (rslave-Bind in docker-compose). + """ + def sammle(): + ziele = [] + try: + eintraege = sorted(os.listdir(MEDIA_ROOT)) + except OSError: + return ziele + for name in eintraege: + pfad = os.path.join(MEDIA_ROOT, name) + if not os.path.isdir(pfad): + continue + try: + nutzung = shutil.disk_usage(pfad) + frei_gb = round(nutzung.free / 1024**3, 1) + except OSError: + frei_gb = None + ziele.append({ + "name": name, + "path": pfad, + "is_mount": os.path.ismount(pfad), + "free_gb": frei_gb, + }) + return ziele + + return await asyncio.to_thread(sammle) @app.post("/devices/{name}/eject") diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx index 894d4ae..9ec996c 100644 --- a/docker/ui/src/components/DeviceDiscovery.tsx +++ b/docker/ui/src/components/DeviceDiscovery.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { api } from '../lib/api' import { useDarkMode } from '../context/ThemeContext' +import RipTargetModal from './RipTargetModal' interface Device { id: string @@ -19,6 +20,7 @@ export default function DeviceDiscovery() { const [expandedId, setExpandedId] = useState(null) const [actionFeedback, setActionFeedback] = useState(null) const [actionBusy, setActionBusy] = useState(false) + const [modalDevice, setModalDevice] = useState(null) const { theme } = useDarkMode() const refreshDevices = async () => { @@ -32,11 +34,14 @@ export default function DeviceDiscovery() { } } - const startRip = async (device: Device) => { + const startRip = async (device: Device, targetDir?: string) => { setActionBusy(true) setActionFeedback(null) try { - const response = await api.post('/jobs', { device_path: device.path }) + const response = await api.post('/jobs', { + device_path: device.path, + ...(targetDir ? { target_dir: targetDir } : {}), + }) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`) } catch (error: any) { setActionFeedback(`✗ ${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`) @@ -204,7 +209,7 @@ export default function DeviceDiscovery() {
)} + + {/* Ziel-Auswahl vor dem Rip-Start (Filme/Serien/Musik oder eigener + Pfad — dort eingehängte Shares sind direkt wählbar) */} + setModalDevice(null)} + onSave={(target) => { + if (modalDevice) { + startRip(modalDevice, target.path) + } + }} + /> ) } diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index 96e6359..bc58d16 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -31,7 +31,8 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod const handleSave = () => { const target = targets.find(t => t.type === selectedType) if (target) { - onSave(target) + // Eigener Pfad gewinnt — vorher wurde customPath stillschweigend ignoriert + onSave(customPath ? { ...target, path: customPath } : target) } onClose() } diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 2b0d0e0..0f49230 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -235,7 +235,7 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c return run_makemkv(device_path, output_dir, progress_cb=progress_cb) -def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict: +def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict: """Rippt eine CD mit abcde (FLAC).""" if not check_abcde_installed(): return { @@ -248,7 +248,8 @@ def rip_cd(device_path: str, disc_id: str, progress_cb=None) -> dict: "error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping." } - output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id) + if output_dir is None: + output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id) os.makedirs(output_dir, exist_ok=True) def melde(progress: int, message: str = ""): diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py index bd021e3..163606f 100644 --- a/docker/worker/tasks.py +++ b/docker/worker/tasks.py @@ -27,9 +27,25 @@ from ripping import ( RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw") +MEDIA_ROOT = "/app/media" + + +def _zielbasis(target_dir, disc_type: str) -> str: + """Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.""" + if target_dir: + normalisiert = os.path.normpath(target_dir) + if normalisiert.startswith(MEDIA_ROOT): + return normalisiert + return os.path.join(RIP_OUTPUT_DIR, disc_type) + + @celery_app.task(bind=True, name="worker.tasks.rip_disc") -def rip_disc(self, device_path: str, job_id: str): - """Rippt eine Disc basierend auf ihrem Typ; job_id ist die DB-Zeile der API.""" +def rip_disc(self, device_path: str, job_id: str, target_dir: str = None): + """Rippt eine Disc basierend auf ihrem Typ; job_id ist die DB-Zeile der API. + + target_dir (optional): vom Nutzer gewähltes Ablageziel unter /app/media — + dort eingehängte Shares (NFS/SMB) sind damit direkt wählbar. + """ db.init_db() disc_type = detect_disc_type(device_path) @@ -66,8 +82,10 @@ def rip_disc(self, device_path: str, job_id: str): and einstellungen.get("transcodeEnabled", True) ) + final_dir = os.path.join(_zielbasis(target_dir, disc_type), job_id) + if disc_type == "cd": - ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt) + ergebnis = rip_cd(device_path, job_id, progress_cb=fortschritt, output_dir=final_dir) elif transcode_an: # Stufe 1: Roh-Rip nach /app/temp (wird nach der Kompression gelöscht) ergebnis = rip_video( @@ -76,10 +94,13 @@ def rip_disc(self, device_path: str, job_id: str): output_dir=os.path.join(RAW_DIR, job_id), ) else: - ergebnis = rip_video(device_path, job_id, disc_type, progress_cb=fortschritt) + ergebnis = rip_video( + device_path, job_id, disc_type, + progress_cb=fortschritt, output_dir=final_dir, + ) if ergebnis.get("status") == "success" and transcode_an: - ergebnis = _komprimiere(job_id, disc_type, ergebnis, einstellungen) + ergebnis = _komprimiere(job_id, final_dir, ergebnis, einstellungen) if ergebnis.get("status") == "success": db.update_job( @@ -102,7 +123,7 @@ def rip_disc(self, device_path: str, job_id: str): return ergebnis -def _komprimiere(job_id: str, disc_type: str, rip_ergebnis: dict, einstellungen: dict) -> dict: +def _komprimiere(job_id: str, final_dir: str, rip_ergebnis: dict, einstellungen: dict) -> dict: """Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße. Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis @@ -110,7 +131,6 @@ def _komprimiere(job_id: str, disc_type: str, rip_ergebnis: dict, einstellungen: liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530). """ quellen = rip_ergebnis.get("files", []) - final_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, job_id) os.makedirs(final_dir, exist_ok=True) preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET original_behalten = einstellungen.get("keepOriginal", False)