0.11.0-5 multi lw

This commit is contained in:
2026-03-17 21:57:44 +00:00
parent 38fc1778f7
commit d00191324b
12 changed files with 356 additions and 106 deletions
+7 -1
View File
@@ -16,7 +16,13 @@
"Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)", "Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)",
"Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)", "Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)",
"Bash(node --check /home/michael/ripster/backend/src/db/database.js)", "Bash(node --check /home/michael/ripster/backend/src/db/database.js)",
"Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)" "Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)",
"Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
"Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
"Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")",
"Bash(lsblk -o NAME,TYPE,MODEL)",
"Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)"
] ]
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.11.0-4", "version": "0.11.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.11.0-4", "version": "0.11.0-5",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.11.0-4", "version": "0.11.0-5",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+15
View File
@@ -54,6 +54,21 @@ router.post(
}) })
); );
router.post(
'/rescan-drive',
asyncHandler(async (req, res) => {
const devicePath = String(req.body?.devicePath || '').trim();
logger.info('post:rescan-drive', { reqId: req.reqId, devicePath });
if (!devicePath) {
const err = new Error('devicePath ist erforderlich');
err.statusCode = 400;
throw err;
}
const result = await diskDetectionService.rescanDriveAndEmit(devicePath);
res.json({ result });
})
);
router.get( router.get(
'/omdb/search', '/omdb/search',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
+59 -1
View File
@@ -464,7 +464,65 @@ class DiskDetectionService extends EventEmitter {
return results.filter(Boolean); return results.filter(Boolean);
} }
return this.detectAllAuto(); // Auto mode: scan all ROM drives via lsblk
const autoResults = await this.detectAllAuto();
// Fallback: if lsblk found no ROM drives but drive_device is configured, try it directly
if (autoResults.length === 0) {
const legacy = String(settingsMap.drive_device || '').trim();
if (legacy) {
logger.debug('detect:auto:lsblk-empty-fallback-explicit', { legacy });
const legacyResult = await this.detectExplicit(legacy);
if (legacyResult) {
return [legacyResult];
}
}
}
return autoResults;
}
// Rescan a single specific drive and emit the appropriate event
async rescanDriveAndEmit(devicePath) {
const normalized = this.normalizeDevicePath(devicePath);
if (!normalized) {
return { present: false, emitted: 'none', device: null };
}
try {
logger.info('rescan-drive:requested', { devicePath: normalized });
const detected = await this.detectExplicit(normalized);
const previouslyTracked = this.detectedDiscs.has(normalized);
if (detected) {
const previous = this.detectedDiscs.get(normalized);
const changed = previous ? buildSignature(previous) !== buildSignature(detected) : false;
this.detectedDiscs.set(normalized, detected);
this.lastDetected = detected;
this.lastPresent = true;
logger.info('rescan-drive:inserted', { devicePath: normalized, changed });
this.emit('discInserted', detected);
return { present: true, emitted: 'discInserted', device: detected };
}
// No disc found
if (previouslyTracked) {
const old = this.detectedDiscs.get(normalized);
this.detectedDiscs.delete(normalized);
if (this.detectedDiscs.size === 0) {
this.lastDetected = null;
this.lastPresent = false;
}
logger.info('rescan-drive:removed', { devicePath: normalized });
this.emit('discRemoved', old || { path: normalized });
return { present: false, emitted: 'discRemoved', device: null };
}
logger.info('rescan-drive:empty', { devicePath: normalized });
return { present: false, emitted: 'none', device: null };
} catch (error) {
logger.error('rescan-drive:error', { devicePath: normalized, error: errorToMeta(error) });
throw error;
}
} }
// Multi-drive: tracks per-device state and emits insert/remove events for each // Multi-drive: tracks per-device state and emits insert/remove events for each
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.11.0-4", "version": "0.11.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.11.0-4", "version": "0.11.0-5",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.11.0-4", "version": "0.11.0-5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+7
View File
@@ -489,6 +489,13 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']); afterMutationInvalidate(['/history', '/pipeline/queue']);
return result; return result;
}, },
async rescanDrive(devicePath) {
const result = await request('/pipeline/rescan-drive', {
method: 'POST',
body: JSON.stringify({ devicePath })
});
return result;
},
searchOmdb(q) { searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`); return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
}, },
+149 -69
View File
@@ -870,6 +870,8 @@ export default function DashboardPage({
const [activationBytesBusy, setActivationBytesBusy] = useState(false); const [activationBytesBusy, setActivationBytesBusy] = useState(false);
const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set()); const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set());
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false); const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const [knownDrives, setKnownDrives] = useState([]);
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set()); const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] }); const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30); const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
@@ -1062,6 +1064,12 @@ export default function DashboardPage({
void loadDashboardJobs(); void loadDashboardJobs();
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]); }, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]);
useEffect(() => {
api.getDetectedDrives()
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
.catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
const requestedJobId = normalizeJobId(pendingExpandedJobId); const requestedJobId = normalizeJobId(pendingExpandedJobId);
if (!requestedJobId) { if (!requestedJobId) {
@@ -1327,20 +1335,29 @@ export default function DashboardPage({
} }
}; };
const refreshKnownDrives = () => {
api.getDetectedDrives()
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
.catch(() => {});
};
const handleRescan = async () => { const handleRescan = async () => {
setBusy(true); setBusy(true);
try { try {
const response = await api.rescanDisc(); const response = await api.rescanDisc();
refreshKnownDrives();
const allDetected = response?.result?.allDetected || [];
const emitted = response?.result?.emitted || 'none'; const emitted = response?.result?.emitted || 'none';
const count = allDetected.length;
toastRef.current?.show({ toastRef.current?.show({
severity: emitted === 'discInserted' ? 'success' : 'info', severity: count > 0 ? 'success' : 'info',
summary: 'Laufwerk neu gelesen', summary: 'Laufwerke neu gelesen',
detail: detail: count > 0
emitted === 'discInserted' ? `${count} Medium${count > 1 ? 'en' : ''} erkannt.`
? 'Disk-Event wurde neu ausgelöst.'
: 'Kein Medium erkannt.', : 'Kein Medium erkannt.',
life: 2800 life: 2800
}); });
void emitted; // used implicitly via count
await refreshPipeline(); await refreshPipeline();
await loadDashboardJobs(); await loadDashboardJobs();
} catch (error) { } catch (error) {
@@ -1350,6 +1367,32 @@ export default function DashboardPage({
} }
}; };
const handleRescanDrive = async (devicePath) => {
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
try {
const response = await api.rescanDrive(devicePath);
refreshKnownDrives();
const emitted = response?.result?.emitted || 'none';
toastRef.current?.show({
severity: emitted === 'discInserted' ? 'success' : 'info',
summary: devicePath,
detail: emitted === 'discInserted'
? 'Medium erkannt.'
: emitted === 'discRemoved' ? 'Medium entfernt.' : 'Leer.',
life: 2500
});
await refreshPipeline();
} catch (error) {
showError(error);
} finally {
setDriveRescanBusy((prev) => {
const next = new Set(prev);
next.delete(devicePath);
return next;
});
}
};
const handleCancel = async (jobId = null, jobState = null) => { const handleCancel = async (jobId = null, jobState = null) => {
const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId; const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId;
const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null; const cancelledJob = dashboardJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
@@ -1987,6 +2030,23 @@ export default function DashboardPage({
const contextDevice = pipeline?.context?.device; const contextDevice = pipeline?.context?.device;
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null); const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
const cdDriveEntries = Object.entries(pipeline?.cdDrives || {}); const cdDriveEntries = Object.entries(pipeline?.cdDrives || {});
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
const allDrives = useMemo(() => {
const driveMap = new Map();
for (const d of knownDrives) {
driveMap.set(d.path, { path: d.path, model: d.model, discArg: d.discArg });
}
for (const [drivePath, drive] of Object.entries(pipeline?.cdDrives || {})) {
const base = driveMap.get(drivePath) || { path: drivePath };
driveMap.set(drivePath, { ...base, cdDrive: drive });
}
if (device?.path) {
const base = driveMap.get(device.path) || { path: device.path, model: device.model };
driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: state });
}
return Array.from(driveMap.values()).sort((a, b) => (a.path || '').localeCompare(b.path || ''));
}, [knownDrives, pipeline?.cdDrives, device, state]);
const isDriveActive = driveActiveStates.includes(state); const isDriveActive = driveActiveStates.includes(state);
const canRescan = !isDriveActive; const canRescan = !isDriveActive;
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state)); const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
@@ -2229,9 +2289,10 @@ export default function DashboardPage({
</Card> </Card>
<Card title="Disk-Information"> <Card title="Disk-Information">
{/* Global action buttons (non-CD / DVD / Bluray) */}
<div className="actions-row"> <div className="actions-row">
<Button <Button
label="Laufwerk neu lesen" label="Alle neu lesen"
icon="pi pi-refresh" icon="pi pi-refresh"
severity="secondary" severity="secondary"
onClick={handleRescan} onClick={handleRescan}
@@ -2254,42 +2315,79 @@ export default function DashboardPage({
/> />
</div> </div>
{/* Per-drive CD sections */} {/* Compact per-drive list */}
{cdDriveEntries.length > 0 && ( {allDrives.length > 0 ? (
<div className="cd-drives-section"> <div className="drive-list">
{cdDriveEntries.map(([drivePath, drive]) => { {allDrives.map((drv) => {
const driveState = String(drive?.state || '').trim().toUpperCase(); const drivePath = drv.path;
const driveDevice = drive?.device || {}; const cdDrive = drv.cdDrive;
const driveJobId = normalizeJobId(drive?.jobId); const pipelineDevice = drv.pipelineDevice;
const driveCtx = drive?.context && typeof drive.context === 'object' ? drive.context : {}; const isRescanBusy = driveRescanBusy.has(drivePath);
const cdStateSeverity = driveState === 'FINISHED' ? 'success'
: driveState === 'ERROR' || driveState === 'CANCELLED' ? 'danger' // Determine display state
: ['CD_RIPPING', 'CD_ENCODING'].includes(driveState) ? 'warning' let stateLabel = null;
let stateSeverity = 'secondary';
let discInfo = null;
let showProgress = false;
let progressValue = 0;
let statusText = null;
if (cdDrive) {
const s = String(cdDrive.state || '').toUpperCase();
stateLabel = s;
stateSeverity = s === 'FINISHED' ? 'success'
: s === 'ERROR' || s === 'CANCELLED' ? 'danger'
: ['CD_RIPPING', 'CD_ENCODING'].includes(s) ? 'warning'
: 'info'; : 'info';
const driveProgress = Number(drive?.progress ?? 0); const cd = cdDrive.device || {};
const driveStatusText = drive?.statusText || null; discInfo = cd.discLabel || cd.model || null;
const isActiveRip = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(driveState); showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s);
progressValue = Number(cdDrive.progress ?? 0);
statusText = cdDrive.statusText || null;
} else if (pipelineDevice) {
const s = String(drv.pipelineState || '').toUpperCase();
stateLabel = s || 'DISC_DETECTED';
stateSeverity = s === 'FINISHED' ? 'success'
: s === 'ERROR' ? 'danger'
: processingStates.includes(s) ? 'warning'
: 'info';
discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
} else {
stateLabel = 'LEER';
stateSeverity = 'secondary';
}
const driveJobId = normalizeJobId(cdDrive?.jobId);
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
return ( return (
<div key={drivePath} className="cd-drive-item"> <div key={drivePath} className="drive-list-item">
<div className="cd-drive-header"> <div className="drive-list-row">
<code className="cd-drive-path">{drivePath}</code> <div className="drive-list-info">
<Tag value={driveState} severity={cdStateSeverity} /> <code className="drive-list-path">{drivePath}</code>
{drv.model && <span className="drive-list-model">{drv.model}</span>}
{drv.discArg && <span className="drive-list-disc-arg">{drv.discArg}</span>}
</div> </div>
{(driveDevice.discLabel || driveDevice.model) && ( <div className="drive-list-state">
<div className="cd-drive-disc-label"> {stateLabel && <Tag value={stateLabel} severity={stateSeverity} />}
{driveDevice.discLabel || driveDevice.model}
</div> </div>
)} <div className="drive-list-actions">
{isActiveRip && (
<ProgressBar value={driveProgress} style={{ height: '6px', margin: '0.2rem 0' }} showValue={false} />
)}
{isActiveRip && driveStatusText && (
<div className="cd-drive-status-text">{driveStatusText}</div>
)}
<div className="cd-drive-actions">
{driveState === 'DISC_DETECTED' && (
<Button <Button
label="CD analysieren" icon="pi pi-refresh"
text
rounded
size="small"
severity="secondary"
loading={isRescanBusy}
disabled={isRescanBusy || isDriveActive}
onClick={() => handleRescanDrive(drivePath)}
title="Laufwerk neu lesen"
aria-label="Laufwerk neu lesen"
/>
{cdState === 'DISC_DETECTED' && (
<Button
label="Analysieren"
icon="pi pi-search" icon="pi pi-search"
size="small" size="small"
onClick={() => handleAnalyzeForDrive(drivePath)} onClick={() => handleAnalyzeForDrive(drivePath)}
@@ -2297,9 +2395,9 @@ export default function DashboardPage({
disabled={busy} disabled={busy}
/> />
)} )}
{driveState === 'CD_METADATA_SELECTION' && ( {cdState === 'CD_METADATA_SELECTION' && (
<Button <Button
label="Metadaten auswählen" label="Metadaten"
icon="pi pi-list" icon="pi pi-list"
size="small" size="small"
severity="info" severity="info"
@@ -2310,10 +2408,10 @@ export default function DashboardPage({
disabled={!driveJobId} disabled={!driveJobId}
/> />
)} )}
{(driveState === 'ERROR' || driveState === 'CANCELLED') && driveJobId && ( {(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
<Button <Button
label="Erneut analysieren" label="Retry"
icon="pi pi-refresh" icon="pi pi-replay"
size="small" size="small"
severity="warning" severity="warning"
onClick={() => handleAnalyzeForDrive(drivePath)} onClick={() => handleAnalyzeForDrive(drivePath)}
@@ -2323,37 +2421,19 @@ export default function DashboardPage({
)} )}
</div> </div>
</div> </div>
{discInfo && <div className="drive-list-disc-label">{discInfo}</div>}
{showProgress && (
<ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
)}
{showProgress && statusText && (
<div className="drive-list-status-text">{statusText}</div>
)}
</div>
); );
})} })}
</div> </div>
)}
{/* Non-CD / legacy single-drive display */}
<div className="dashboard-drive-state">
{device
? <Tag value="Disk eingelegt" severity="success" icon="pi pi-circle-fill" />
: <Tag value="Laufwerk leer" severity="secondary" icon="pi pi-circle" />}
</div>
{device ? (
<div className="device-meta">
<div>
<strong>Pfad:</strong> {device.path || '-'}
</div>
<div>
<strong>Modell:</strong> {device.model || '-'}
</div>
<div>
<strong>Disk-Label:</strong> {device.discLabel || '-'}
</div>
<div>
<strong>Laufwerks-Label:</strong> {device.label || '-'}
</div>
<div>
<strong>Mount:</strong> {device.mountpoint || '-'}
</div>
</div>
) : ( ) : (
cdDriveEntries.length === 0 ? <p>Aktuell keine Disk erkannt.</p> : null <p className="drive-list-empty">Keine Laufwerke erkannt.</p>
)} )}
</Card> </Card>
+84
View File
@@ -1802,6 +1802,90 @@ body {
} }
/* Per-drive CD sections in Dashboard */ /* Per-drive CD sections in Dashboard */
/* Unified per-drive list (Disk-Information card) */
.drive-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
.drive-list-empty {
font-size: 0.85rem;
color: var(--rip-muted);
margin: 0.5rem 0 0;
}
.drive-list-item {
padding: 0.4rem 0.6rem;
border: 1px solid var(--rip-border);
border-radius: 0.4rem;
background: var(--rip-panel-soft);
}
.drive-list-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.drive-list-info {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
}
.drive-list-path {
font-size: 0.8rem;
color: var(--rip-muted);
white-space: nowrap;
}
.drive-list-model {
font-size: 0.75rem;
color: var(--rip-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drive-list-disc-arg {
font-size: 0.7rem;
color: var(--rip-muted);
opacity: 0.7;
}
.drive-list-state {
flex-shrink: 0;
}
.drive-list-actions {
display: flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
}
.drive-list-disc-label {
font-size: 0.82rem;
margin-top: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drive-list-status-text {
font-size: 0.75rem;
color: var(--rip-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Legacy per-CD-drive section (kept for compat) */
.cd-drives-section { .cd-drives-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.11.0-4", "version": "0.11.0-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.11.0-4", "version": "0.11.0-5",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.11.0-4", "version": "0.11.0-5",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",