feat(rip): Arbeitsverzeichnis je Rip waehlbar statt global vorgegeben
Ampel / ampel (push) Successful in 28s
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:
@@ -359,6 +359,10 @@ class JobCreateRequest(BaseModel):
|
|||||||
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
|
||||||
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
|
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"
|
MEDIA_ROOT = "/app/media"
|
||||||
@@ -418,6 +422,12 @@ async def create_job(request: JobCreateRequest):
|
|||||||
meta_dict["titles"] = titel_liste
|
meta_dict["titles"] = titel_liste
|
||||||
if request.transcode_node:
|
if request.transcode_node:
|
||||||
meta_dict["transcode_node"] = 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
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export default function DeviceDiscovery() {
|
|||||||
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||||
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
|
||||||
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
|
||||||
|
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
|
||||||
})
|
})
|
||||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import { Button } from './ui/Button'
|
import { Button } from './ui/Button'
|
||||||
@@ -19,6 +19,15 @@ export interface RipOptionen {
|
|||||||
mainFeatureOnly?: boolean
|
mainFeatureOnly?: boolean
|
||||||
titles?: number[]
|
titles?: number[]
|
||||||
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
|
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 {
|
interface WorkerWahl {
|
||||||
@@ -112,6 +121,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
@@ -124,6 +139,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
|||||||
setTitelListe([])
|
setTitelListe([])
|
||||||
setGewaehlt(new Set())
|
setGewaehlt(new Set())
|
||||||
setEncoderNode('')
|
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)
|
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
|
||||||
api.get('/capabilities').then(r => {
|
api.get('/capabilities').then(r => {
|
||||||
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
|
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 => {
|
api.get('/settings').then(r => {
|
||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
setNurHauptfilm(!!s.mainFeatureOnly)
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
|
setStandardArbeitsDir((s.workDir || '').trim())
|
||||||
const basis = s.outputDir || '/app/media'
|
const basis = s.outputDir || '/app/media'
|
||||||
setTargets([
|
setTargets([
|
||||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
{ 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)
|
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
|
||||||
}
|
}
|
||||||
if (encoderNode) optionen.transcodeNode = encoderNode
|
if (encoderNode) optionen.transcodeNode = encoderNode
|
||||||
|
if (arbeitsDir) optionen.workDir = arbeitsDir
|
||||||
const target = targets.find(t => t.type === selectedType)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
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>
|
</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
|
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
|
||||||
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
|
||||||
{selectedType !== 'music' && workers.length >= 2 && (
|
{selectedType !== 'music' && workers.length >= 2 && (
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ export default function SettingsPage() {
|
|||||||
inklusive freiem Platz.
|
inklusive freiem Platz.
|
||||||
*/}
|
*/}
|
||||||
<Select
|
<Select
|
||||||
label="Arbeitsverzeichnis für Roh-Rips"
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||||
>
|
>
|
||||||
@@ -650,10 +650,12 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<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
|
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> — beim
|
||||||
sind bis 100 GB groß und passen selten auf die Container-Platte. Lege das Arbeitsverzeichnis
|
„Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
|
||||||
auf eine Freigabe mit genug Platz — am besten auf dieselbe, auf der auch die fertigen Dateien
|
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
|
||||||
landen. Dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.
|
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 ? (
|
{systemInfo?.plaetze?.length ? (
|
||||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+21
-9
@@ -15,6 +15,7 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
|
|||||||
import glob
|
import glob
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import posixpath
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -99,19 +100,29 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
return os.path.join(RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
def _arbeitsverzeichnis(einstellungen: dict) -> str:
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
||||||
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
|
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||||
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
|
`workDir` → Container-Default /app/temp/raw.
|
||||||
|
|
||||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
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 —
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
|
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()
|
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
||||||
if work_dir:
|
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
||||||
normalisiert = os.path.normpath(work_dir)
|
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
||||||
if normalisiert.startswith(MEDIA_ROOT):
|
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
||||||
return normalisiert
|
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
|
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)
|
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||||
db.update_job(job_id, output_path=final_dir)
|
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
|
# 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).
|
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
|
||||||
|
|||||||
@@ -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 bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht"
|
||||||
assert meldungen[-1][0] == "warning"
|
assert meldungen[-1][0] == "warning"
|
||||||
assert "NICHT aufgehoben" in meldungen[-1][2]
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user