Speicherziel-Wahl: Rip-Ziel pro Job frei waehlbar (Etappe 16 / Task 16)
Ampel / ampel (push) Successful in 28s
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:
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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."
|
"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)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
def melde(progress: int, message: str = ""):
|
def melde(progress: int, message: str = ""):
|
||||||
|
|||||||
+27
-7
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user