Speicherziel-Wahl: Rip-Ziel pro Job frei waehlbar (Etappe 16 / Task 16)
Ampel / ampel (push) Successful in 28s

- 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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 16:20:36 +02:00
parent 06cab545da
commit dfef585ec8
7 changed files with 125 additions and 21 deletions
+2 -2
View File
@@ -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) 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).""" """Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
return celery_client.send_task( 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]
) )
+11 -2
View File
@@ -41,6 +41,7 @@ jobs = Table(
Column("status", String(16), nullable=False, server_default="pending"), Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"), Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text), Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text), Column("error", Text),
Column("created_at", DateTime(timezone=True)), Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)), Column("finished_at", DateTime(timezone=True)),
@@ -69,11 +70,18 @@ def utcnow() -> datetime:
def init_db() -> None: def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent).""" """Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach."""
metadata.create_all(engine) 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: with engine.begin() as conn:
conn.execute( conn.execute(
jobs.insert().values( jobs.insert().values(
@@ -81,6 +89,7 @@ def insert_job(job_id: str, device: str, disc_type: str = None, title: str = Non
device=device, device=device,
disc_type=disc_type, disc_type=disc_type,
title=title, title=title,
target_dir=target_dir,
status="pending", status="pending",
progress=0, progress=0,
created_at=utcnow(), created_at=utcnow(),
+60 -4
View File
@@ -6,6 +6,8 @@ from typing import List, Optional, Dict
from pathlib import Path from pathlib import Path
import asyncio import asyncio
import json import json
import os
import shutil
import uuid import uuid
import db import db
@@ -187,6 +189,23 @@ class JobCreateRequest(BaseModel):
device_path: Optional[str] = None device_path: Optional[str] = None
device: Optional[str] = None # Alias, so schickt es das UI device: Optional[str] = None # Alias, so schickt es das UI
title: Optional[str] = None 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) @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") raise HTTPException(status_code=422, detail="device_path fehlt")
if device_path not in device_discovery.list_optical_devices(): if device_path not in device_discovery.list_optical_devices():
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
ziel = _validiere_ziel(request.target_dir)
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
await asyncio.to_thread(db.insert_job, job_id, device_path, None, request.title) 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}") await asyncio.to_thread(
start_rip(device_path, job_id) db.add_log, "info", "api",
return {"id": job_id, "status": "pending", "device": device_path} 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") @app.post("/devices/{name}/eject")
+20 -3
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { useDarkMode } from '../context/ThemeContext'
import RipTargetModal from './RipTargetModal'
interface Device { interface Device {
id: string id: string
@@ -19,6 +20,7 @@ export default function DeviceDiscovery() {
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
const [actionFeedback, setActionFeedback] = useState<string | null>(null) const [actionFeedback, setActionFeedback] = useState<string | null>(null)
const [actionBusy, setActionBusy] = useState(false) const [actionBusy, setActionBusy] = useState(false)
const [modalDevice, setModalDevice] = useState<Device | null>(null)
const { theme } = useDarkMode() const { theme } = useDarkMode()
const refreshDevices = async () => { 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) setActionBusy(true)
setActionFeedback(null) setActionFeedback(null)
try { 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`) setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
} catch (error: any) { } catch (error: any) {
setActionFeedback(`${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`) setActionFeedback(`${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`)
@@ -204,7 +209,7 @@ export default function DeviceDiscovery() {
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-3 pt-1">
<button <button
onClick={() => startRip(device)} onClick={() => setModalDevice(device)}
disabled={actionBusy || device.status !== 'ready'} disabled={actionBusy || device.status !== 'ready'}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
> >
@@ -231,6 +236,18 @@ export default function DeviceDiscovery() {
</div> </div>
)} )}
</div> </div>
{/* Ziel-Auswahl vor dem Rip-Start (Filme/Serien/Musik oder eigener
Pfad — dort eingehängte Shares sind direkt wählbar) */}
<RipTargetModal
isOpen={modalDevice !== null}
onClose={() => setModalDevice(null)}
onSave={(target) => {
if (modalDevice) {
startRip(modalDevice, target.path)
}
}}
/>
</div> </div>
) )
} }
+2 -1
View File
@@ -31,7 +31,8 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
const handleSave = () => { const handleSave = () => {
const target = targets.find(t => t.type === selectedType) const target = targets.find(t => t.type === selectedType)
if (target) { if (target) {
onSave(target) // Eigener Pfad gewinnt — vorher wurde customPath stillschweigend ignoriert
onSave(customPath ? { ...target, path: customPath } : target)
} }
onClose() onClose()
} }
+2 -1
View File
@@ -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) 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).""" """Rippt eine CD mit abcde (FLAC)."""
if not check_abcde_installed(): if not check_abcde_installed():
return { return {
@@ -248,6 +248,7 @@ 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." "error": "cdparanoia ist nicht installiert. Installiere abcde und cdparanoia für CD-Ripping."
} }
if output_dir is None:
output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id) output_dir = os.path.join(RIP_OUTPUT_DIR, "cd", disc_id)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
+27 -7
View File
@@ -27,9 +27,25 @@ from ripping import (
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw") 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") @celery_app.task(bind=True, name="worker.tasks.rip_disc")
def rip_disc(self, device_path: str, job_id: str): 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.""" """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() db.init_db()
disc_type = detect_disc_type(device_path) 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) and einstellungen.get("transcodeEnabled", True)
) )
final_dir = os.path.join(_zielbasis(target_dir, disc_type), job_id)
if disc_type == "cd": 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: elif transcode_an:
# Stufe 1: Roh-Rip nach /app/temp (wird nach der Kompression gelöscht) # Stufe 1: Roh-Rip nach /app/temp (wird nach der Kompression gelöscht)
ergebnis = rip_video( 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), output_dir=os.path.join(RAW_DIR, job_id),
) )
else: 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: 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": if ergebnis.get("status") == "success":
db.update_job( db.update_job(
@@ -102,7 +123,7 @@ def rip_disc(self, device_path: str, job_id: str):
return ergebnis 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. """Stufe 2: HandBrake komprimiert die Roh-MKVs auf Arbeitsgröße.
Erst wenn ALLE Dateien sauber komprimiert sind, wird das Roh-Verzeichnis 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). liegen (kein Datenverlust wie bei ARMs berüchtigtem Move-Bug #1530).
""" """
quellen = rip_ergebnis.get("files", []) quellen = rip_ergebnis.get("files", [])
final_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, job_id)
os.makedirs(final_dir, exist_ok=True) os.makedirs(final_dir, exist_ok=True)
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
original_behalten = einstellungen.get("keepOriginal", False) original_behalten = einstellungen.get("keepOriginal", False)