feat(rip): Arbeitsverzeichnis je Rip waehlbar statt global vorgegeben
Ampel / ampel (push) Successful in 28s

Commander-Vorgabe 25.07.2026: "Du sollst es nicht automatisch setzen, es soll
auswaehlbar sein - z. B. beim Rippen starten, oder VORHER wenn Vollautomatik
eingeschaltet ist."

Bisher gab es nur die globale Einstellung workDir, und die war ein freies
Textfeld - man musste den Container-Pfad (/app/media/...) kennen. Beim
Akira-Rip landeten deshalb 74 GB Rohdaten auf der 148-GB-VM-Platte, obwohl
eine NAS-Freigabe mit 2,3 TB eingehaengt war.

- RipTargetModal: neue Auswahl "Arbeitsverzeichnis fuer die Rohdaten",
  gespeist aus /storage-targets (mit Kennzeichnung als Netzwerk-Freigabe und
  freiem Platz je Ziel). Leer = "Standard aus den Einstellungen", der Wert
  wird zur Orientierung mit angezeigt. Bei Musik ausgeblendet - CD-Rips gehen
  direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
- POST /jobs nimmt work_dir entgegen, mit derselben Pfad-Haerte wie das Ziel
  (_validiere_ziel: muss unter /app/media liegen), und legt es in die
  Job-Metadaten.
- _arbeitsverzeichnis(einstellungen, job_wahl) im Worker: Wahl dieses Rips ->
  Setting -> Container-Default. Damit bleibt die Einstellung genau das, was
  bei Vollautomatik-Rips greift, weil dort niemand gefragt wird. Der Text im
  Einstellungen-Tab sagt das jetzt auch so.
- posixpath statt os.path in _arbeitsverzeichnis: das sind immer
  Container-Pfade, auch wenn ein nativer Windows-Worker das Modul laedt (der
  uebersetzt erst spaeter per pfad_lokal). os.path.normpath machte unter
  Windows Backslashes daraus, wodurch die MEDIA_ROOT-Pruefung nicht mehr
  griff - lokal als Testfehler aufgefallen.
- Test deckt die Reihenfolge und die Ausbruchsversuche ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-25 19:59:37 +02:00
parent c065967f4d
commit 8bb075c656
6 changed files with 117 additions and 15 deletions
+10
View File
@@ -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())
@@ -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"')
+58 -1
View File
@@ -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<BrowseDir[]>([])
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<StorageZiel[]>([])
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
</label>
)}
{/*
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' && (
<div>
<Select
label="Arbeitsverzeichnis für die Rohdaten"
value={arbeitsDir}
onChange={e => setArbeitsDir(e.target.value)}
>
<option value="">
Standard aus den Einstellungen
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
</option>
{arbeitsZiele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} />
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.
</p>
</div>
)}
{/* 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 && (
+7 -5
View File
@@ -637,7 +637,7 @@ export default function SettingsPage() {
inklusive freiem Platz.
*/}
<Select
label="Arbeitsverzeichnis für Roh-Rips"
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)}
>
@@ -650,10 +650,12 @@ export default function SettingsPage() {
))}
</Select>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Wichtig für 4K-UHD:</strong> 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.
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim
Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
Läuft die Vollautomatik</strong>, 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}
+19 -7
View File
@@ -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,17 +100,27 @@ 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)
# 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).
+20
View File
@@ -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"