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
@@ -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}