0.12.0-1 Plugins und diverses

This commit is contained in:
2026-03-19 18:57:05 +00:00
parent 0a9cf6969f
commit 24955a956d
33 changed files with 3978 additions and 479 deletions
+41
View File
@@ -120,6 +120,47 @@ function App() {
const location = useLocation();
const navigate = useNavigate();
const globalToastRef = useRef(null);
const prevCdDrivesRef = useRef({});
// When a virtual CD drive is removed (CD encode/rip finished or failed),
// or when a CD drive transitions away from an active job, force both
// Dashboard and History to re-fetch so jobs leave the live list reliably.
useEffect(() => {
const current = pipeline?.cdDrives || {};
const prev = prevCdDrivesRef.current;
const normalizeState = (value) => String(value || '').trim().toUpperCase();
const ACTIVE_CD_STATES = new Set(['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING']);
const TERMINAL_CD_STATES = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
let shouldRefresh = false;
for (const [devicePath, previousDrive] of Object.entries(prev)) {
const previousJobId = normalizeJobId(previousDrive?.jobId);
if (!previousJobId) {
continue;
}
const previousState = normalizeState(previousDrive?.state);
const currentDrive = current?.[devicePath] || null;
const currentJobId = normalizeJobId(currentDrive?.jobId);
const currentState = normalizeState(currentDrive?.state);
const driveRemoved = !currentDrive;
const jobChanged = currentJobId !== previousJobId;
const movedToTerminal = currentJobId === previousJobId
&& TERMINAL_CD_STATES.has(currentState)
&& currentState !== previousState;
const leftActiveLifecycle = ACTIVE_CD_STATES.has(previousState)
&& (driveRemoved || jobChanged || !ACTIVE_CD_STATES.has(currentState));
if (movedToTerminal || leftActiveLifecycle) {
shouldRefresh = true;
break;
}
}
if (shouldRefresh) {
setDashboardJobsRefreshToken((t) => t + 1);
setHistoryJobsRefreshToken((t) => t + 1);
}
prevCdDrivesRef.current = current;
}, [pipeline?.cdDrives]);
const refreshPipeline = async () => {
const response = await api.getPipelineState();
+64 -9
View File
@@ -449,6 +449,14 @@ export const api = {
body: JSON.stringify(payload)
});
},
async recoverMissingCoverArt(payload = {}) {
const result = await request('/settings/coverart/recover', {
method: 'POST',
body: JSON.stringify(payload || {})
});
afterMutationInvalidate(['/history', '/settings']);
return result;
},
getPipelineState() {
return request('/pipeline/state');
},
@@ -594,23 +602,57 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async reencodeJob(jobId) {
async reencodeJob(jobId, options = {}) {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
const result = await request(`/pipeline/reencode/${jobId}`, {
method: 'POST'
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async restartReviewFromRaw(jobId) {
async restartReviewFromRaw(jobId, options = {}) {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
const result = await request(`/pipeline/restart-review/${jobId}`, {
method: 'POST'
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
async restartEncodeWithLastSettings(jobId) {
async restartEncodeWithLastSettings(jobId, options = {}) {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
const result = await request(`/pipeline/restart-encode/${jobId}`, {
method: 'POST'
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
getOutputFolders(jobId) {
return request(`/pipeline/output-folders/${jobId}`);
},
async deleteOutputFolders(jobId, folderPaths = []) {
const result = await request(`/pipeline/delete-output-folders/${jobId}`, {
method: 'POST',
body: JSON.stringify({ folderPaths })
});
afterMutationInvalidate(['/history']);
return result;
},
async restartCdReviewFromRaw(jobId, options = {}) {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
const result = await request(`/pipeline/restart-cd-review/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
@@ -700,17 +742,30 @@ export const api = {
},
async deleteJobEntry(jobId, target = 'none', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const result = await request(`/history/${jobId}/delete`, {
method: 'POST',
body: JSON.stringify({ target, includeRelated })
body: JSON.stringify({
target,
includeRelated,
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
});
afterMutationInvalidate(['/history', '/pipeline/queue']);
return result;
},
requestJobArchive(jobId, target = 'raw') {
requestJobArchive(jobId, target = 'raw', options = {}) {
const outputPath = String(options?.outputPath || '').trim();
return request(`/downloads/history/${jobId}`, {
method: 'POST',
body: JSON.stringify({ target })
body: JSON.stringify({
target,
...(outputPath ? { outputPath } : {})
})
});
},
getDownloads() {
+52 -14
View File
@@ -227,6 +227,7 @@ export default function CdRipConfigPanel({
onStart,
onCancel,
onRetry,
onRestartReview,
onOpenMetadata,
busy
}) {
@@ -523,7 +524,8 @@ export default function CdRipConfigPanel({
});
};
const handleStart = () => {
const handleStart = (options = {}) => {
const forceSkipRip = Boolean(options?.forceSkipRip);
const albumTitle = normalizeTrackText(metaFields?.title)
|| normalizeTrackText(selectedMeta?.title)
|| normalizeTrackText(context?.detectedTitle)
@@ -591,6 +593,7 @@ export default function CdRipConfigPanel({
selectedPostEncodeScriptIds,
selectedPreEncodeChainIds,
selectedPostEncodeChainIds,
skipRip: forceSkipRip || context.skipRip === true ? true : undefined,
metadata: {
title: albumTitle,
artist: albumArtist,
@@ -727,6 +730,19 @@ export default function CdRipConfigPanel({
const completedEncodeCount = selectedTrackRows.filter((track) => track.encodeStatus === 'done').length;
const liveCurrentTrackPosition = normalizePosition(cdLive?.trackPosition);
const lastState = String(context?.lastState || '').trim().toUpperCase();
const failureStage = String(context?.stage || '').trim().toUpperCase();
const normalizedLivePhase = String(cdLive?.phase || '').trim().toLowerCase();
const normalizedStatusText = String(statusText || '').trim().toUpperCase();
const isEncodingFailure = Boolean(
isTerminalFailure
&& (
lastState === 'CD_ENCODING'
|| failureStage === 'CD_ENCODING'
|| context?.skipRip === true
|| normalizedLivePhase === 'encode'
|| normalizedStatusText.includes('CD_ENCODING')
)
);
const preScriptNamesFromConfig = (Array.isArray(cdRipConfig?.preEncodeScripts) ? cdRipConfig.preEncodeScripts : [])
.map((item) => String(item?.name || '').trim())
.filter(Boolean);
@@ -797,22 +813,44 @@ export default function CdRipConfigPanel({
) : null}
{isTerminalFailure ? (
<div className="actions-row">
{jobId ? (
{isEncodingFailure ? (
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
onClick={() => onRetry && onRetry()}
label="Encode starten"
icon="pi pi-play"
severity="success"
onClick={() => handleStart({ forceSkipRip: true })}
loading={busy}
/>
) : (
<>
{jobId ? (
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
onClick={() => onRetry && onRetry()}
loading={busy}
/>
) : null}
<Button
label="Metadaten ändern"
icon="pi pi-pencil"
severity="secondary"
onClick={() => onOpenMetadata && onOpenMetadata()}
loading={busy}
/>
</>
)}
{isEncodingFailure ? (
<Button
label="Review starten"
icon="pi pi-search"
severity="info"
outlined
onClick={() => onRestartReview && onRestartReview()}
loading={busy}
/>
) : null}
<Button
label="Metadaten ändern"
icon="pi pi-pencil"
severity="secondary"
onClick={() => onOpenMetadata && onOpenMetadata()}
loading={busy}
/>
</div>
) : null}
@@ -1267,7 +1305,7 @@ export default function CdRipConfigPanel({
disabled={busy}
/>
<Button
label="Rip starten"
label={context.skipRip === true ? 'Encode starten' : 'Rip starten'}
icon="pi pi-play"
onClick={handleStart}
loading={busy}
+75 -12
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { TabView, TabPanel } from 'primereact/tabview';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { InputText } from 'primereact/inputtext';
@@ -357,6 +357,17 @@ function isNotificationEventToggleSetting(setting) {
return setting?.type === 'boolean' && NOTIFICATION_EVENT_TOGGLE_KEYS.has(normalizeSettingKey(setting?.key));
}
function isReadonlySetting(setting) {
try {
// API gibt validation als parsed object zurück, nicht validation_json als String
const v = setting?.validation;
if (v && typeof v === 'object') return v.readonly === true;
return JSON.parse(setting?.validation_json || '{}')?.readonly === true;
} catch {
return false;
}
}
function SettingField({
setting,
value,
@@ -367,7 +378,8 @@ function SettingField({
ownerError,
ownerDirty,
onChange,
variant = 'default'
variant = 'default',
disabled = false
}) {
const ownerKey = ownerSetting?.key;
const pathHasValue = Boolean(String(value ?? '').trim());
@@ -384,13 +396,15 @@ function SettingField({
<InputSwitch
id={setting.key}
checked={Boolean(value)}
onChange={(event) => onChange?.(setting.key, event.value)}
disabled={disabled}
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
/>
</div>
) : (
<label htmlFor={setting.key}>
<label htmlFor={setting.key} className={disabled ? 'setting-label--readonly' : undefined}>
{setting.label}
{setting.required && <span className="required">*</span>}
{disabled ? <span className="setting-badge--coming-soon"> (bald)</span> : null}
</label>
)}
@@ -424,7 +438,8 @@ function SettingField({
<InputSwitch
id={setting.key}
checked={Boolean(value)}
onChange={(event) => onChange?.(setting.key, event.value)}
disabled={disabled}
onChange={disabled ? undefined : (event) => onChange?.(setting.key, event.value)}
/>
) : null}
@@ -780,10 +795,22 @@ export default function DynamicSettingsForm({
const notificationToggleKeys = new Set(
notificationToggleSettings.map((setting) => normalizeSettingKey(setting?.key))
);
const regularSettings = baseSettings.filter(
(setting) => !notificationToggleKeys.has(normalizeSettingKey(setting?.key))
);
const renderSetting = (setting, variant = 'default') => {
// Abhängigkeitsbaum aufbauen: depends_on → Kinder
const dependentsByParent = new Map();
const rootSettings = [];
for (const setting of baseSettings) {
if (notificationToggleKeys.has(normalizeSettingKey(setting?.key))) continue;
const dep = setting?.depends_on;
if (dep) {
if (!dependentsByParent.has(dep)) dependentsByParent.set(dep, []);
dependentsByParent.get(dep).push(setting);
} else {
rootSettings.push(setting);
}
}
const renderSetting = (setting, variant = 'default', disabled = false, onChangeFn = onChange) => {
const value = values?.[setting.key];
const error = errors?.[setting.key] || null;
const dirty = Boolean(dirtyKeys?.has?.(setting.key));
@@ -805,17 +832,53 @@ export default function DynamicSettingsForm({
ownerValue={ownerValue}
ownerError={ownerError}
ownerDirty={ownerDirty}
onChange={onChange}
onChange={onChangeFn}
variant={variant}
disabled={disabled}
/>
);
};
// Alle Nachkommen eines Keys rekursiv auf false setzen
const cascadeOff = (key) => {
const children = dependentsByParent.get(key) || [];
for (const child of children) {
onChange?.(child.key, false);
cascadeOff(child.key);
}
};
// Rekursiv: Setting + seine Kinder rendern
// Wenn ein Parent auf false geht → alle Kinder werden auf false gesetzt
const renderWithChildren = (setting, depth = 0) => {
const children = dependentsByParent.get(setting.key) || [];
const readonly = depth > 0 && isReadonlySetting(setting);
const active = toBoolean(values?.[setting.key]);
const effectiveOnChange = children.length > 0
? (key, value) => {
onChange?.(key, value);
if (!value) cascadeOff(key);
}
: onChange;
return (
<React.Fragment key={setting.key}>
{renderSetting(setting, 'default', readonly, effectiveOnChange)}
{children.length > 0 && active && !readonly ? (
<div className={`settings-grid settings-grid--depth-${depth + 1}`}>
{children.map((child) => renderWithChildren(child, depth + 1))}
</div>
) : null}
</React.Fragment>
);
};
return (
<>
{regularSettings.length > 0 ? (
{rootSettings.length > 0 ? (
<div className="settings-grid">
{regularSettings.map((setting) => renderSetting(setting))}
{rootSettings.map((setting) => renderWithChildren(setting, 0))}
</div>
) : null}
{pushoverEnabled && notificationToggleSettings.length > 0 ? (
+164 -40
View File
@@ -91,6 +91,21 @@ function normalizePositiveInteger(value) {
return Math.trunc(parsed);
}
function normalizeCdText(value) {
return String(value || '')
.normalize('NFC')
.replace(/\s+/g, ' ')
.trim();
}
function isPlaceholderCdTrackTitle(value) {
const normalized = normalizeCdText(value).toLowerCase();
if (!normalized) {
return true;
}
return /^track\s*\d+$/i.test(normalized);
}
function formatDurationSeconds(totalSeconds) {
const parsed = Number(totalSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -352,7 +367,11 @@ function resolveCdDetails(job) {
selectedMetadata?.mbId
|| selectedMetadata?.musicBrainzId
|| selectedMetadata?.musicbrainzId
|| selectedMetadata?.musicbrainz_id
|| selectedMetadata?.music_brainz_id
|| selectedMetadata?.musicbrainz
|| selectedMetadata?.mbid
|| job?.imdb_id
|| ''
).trim() || null;
@@ -568,11 +587,13 @@ export default function JobDetailDialog({
onResumeReady,
onRestartEncode,
onRestartReview,
onRestartCdReview,
onReencode,
onRetry,
onDeleteFiles,
onDeleteEntry,
onDownloadArchive,
onDownloadOutputFolder,
onRemoveFromQueue,
isQueued = false,
omdbAssignBusy = false,
@@ -580,7 +601,8 @@ export default function JobDetailDialog({
actionBusy = false,
reencodeBusy = false,
deleteEntryBusy = false,
downloadBusyTarget = null
downloadBusyTarget = null,
downloadFolderBusyPath = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
@@ -588,8 +610,76 @@ export default function JobDetailDialog({
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running
&& (isCd || mkDone));
const cdRawPathCandidates = [
job?.raw_path,
job?.makemkvInfo?.rawPath,
job?.makemkvInfo?.importContext?.requestedRawPath,
job?.makemkvInfo?.importContext?.originalRawPath
]
.map((value) => String(value || '').trim())
.filter(Boolean);
const hasCdRawInputCandidate = Boolean(job?.rawStatus?.exists) || cdRawPathCandidates.length > 0;
const hasReencodeRawInput = isCd
? hasCdRawInputCandidate
: Boolean(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true);
const canReencode = !!(hasReencodeRawInput && !running && (isCd || mkDone));
// For CDs: direct re-encode requires track data from the last run
const cdEncodePlan = isCd && job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const cdSelectedMeta = isCd && job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {};
const cdPlanTracks = isCd && Array.isArray(cdEncodePlan?.tracks)
? cdEncodePlan.tracks
: [];
const cdSelectedTrackPositions = isCd
? (
Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0
? cdEncodePlan.selectedTracks
: cdPlanTracks.filter((track) => track?.selected !== false).map((track) => track?.position)
)
.map((value) => normalizePositiveInteger(value))
.filter(Boolean)
: [];
const cdTrackByPosition = new Map(
cdPlanTracks
.map((track) => {
const position = normalizePositiveInteger(track?.position);
if (!position) {
return null;
}
return [position, track];
})
.filter(Boolean)
);
const cdHasCoreMetadata = Boolean(
normalizeCdText(cdSelectedMeta?.title || cdSelectedMeta?.album || job?.title || job?.detected_title)
&& normalizeCdText(cdSelectedMeta?.artist)
);
const cdHasMeaningfulTrackTitles = cdSelectedTrackPositions.some((position) => {
const track = cdTrackByPosition.get(position);
const title = normalizeCdText(track?.title);
return Boolean(title) && !isPlaceholderCdTrackTitle(title);
});
const cdHasPriorRunEvidence = Boolean(
cdEncodePlan?.directReencodeReady
|| (Array.isArray(job?.handbrakeInfo?.tracks) && job.handbrakeInfo.tracks.length > 0)
|| String(job?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
);
const cdHasLastRunData = Boolean(
cdEncodePlan
&& Array.isArray(cdEncodePlan.tracks)
&& cdEncodePlan.tracks.length > 0
&& cdEncodePlan.format
);
const canCdDirectEncode = Boolean(
canReencode
&& cdHasLastRunData
&& cdHasCoreMetadata
&& cdHasMeaningfulTrackTitles
&& cdHasPriorRunEvidence
);
const canCdStartReview = !!(hasCdRawInputCandidate && !running
&& isCd && typeof onRestartCdReview === 'function');
const canResumeReady = Boolean(
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
&& !running
@@ -634,7 +724,9 @@ export default function JobDetailDialog({
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const pluginExecution = resolveJobPluginExecution(job, null);
const pluginExecution = resolveJobPluginExecution(job, null, {
currentStatus: job?.status
});
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -647,6 +739,8 @@ export default function JobDetailDialog({
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
return (
<Dialog
@@ -867,13 +961,34 @@ export default function JobDetailDialog({
downloadDisabled={!canDownloadRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
<PathField
label="Output:"
value={job.output_path}
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
downloadDisabled={!canDownloadOutput}
downloadLoading={downloadBusyTarget === 'output'}
/>
{outputFolders.length > 0 ? (
outputFolders.map((folder, idx) => {
const folderPath = String(folder?.output_path || '').trim();
if (!folderPath) {
return null;
}
const label = outputFolders.length > 1 ? `Output ${idx + 1}:` : 'Output:';
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
return (
<PathField
key={folder.id || folderPath}
label={label}
value={folderPath}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
downloadDisabled={!canDownloadFolder}
downloadLoading={downloadFolderBusyPath === folderPath}
/>
);
})
) : (
<PathField
label="Output:"
value={job.output_path}
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
downloadDisabled={!canDownloadOutput}
downloadLoading={downloadBusyTarget === 'output'}
/>
)}
{job.error_message ? (
<div><strong>Fehler:</strong> {job.error_message}</div>
) : null}
@@ -1003,13 +1118,7 @@ export default function JobDetailDialog({
{tracksRaw.map((track, i) => {
const pos = Number(track.position ?? (i + 1));
const label = String(track.title || track.name || `Track ${pos}`).trim();
const artist = String(track.artist || '').trim();
const durationSec = Number(track.durationMs || 0) > 0
? Number(track.durationMs) / 1000
: Number(track.durationSec || 0);
const durLabel = durationSec > 0
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
: '-';
const artist = String(track.artist || '').trim() || String(job?.makemkvInfo?.selectedMetadata?.artist || '').trim();
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
const encodeResult = encodeResultMap.get(pos);
const outcome = !selected
@@ -1018,8 +1127,8 @@ export default function JobDetailDialog({
? (encodeResult.success ? 'success' : 'error')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
return (
<div key={pos} className="track-item">
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
<div key={pos} className="track-item track-item-inline-status">
<span className="track-item-main">#{pos} {artist ? `${artist} - ` : ''}{label}</span>
<SelectionStateNote selected={selected} outcome={outcome} />
</div>
);
@@ -1070,8 +1179,8 @@ export default function JobDetailDialog({
: stepStatus === 'ERROR' ? 'tone-no'
: '';
return (
<div key={id} className="track-item">
<span>#{id} | {label} | {durLabel}</span>
<div key={id} className={`track-item${isSplitAudiobook ? ' track-item-inline-status' : ''}`}>
<span className={isSplitAudiobook ? 'track-item-main' : undefined}>#{id} | {label} | {durLabel}</span>
{isSplitAudiobook ? (
<SelectionStateNote selected outcome={outcome} />
) : (
@@ -1164,25 +1273,40 @@ export default function JobDetailDialog({
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode || typeof onReencode !== 'function'}
disabled={!canCdDirectEncode || typeof onReencode !== 'function'}
/>
<span className="action-desc">Encodiert die vorhandenen WAV-Rohdaten erneut ohne die CD neu zu lesen. Nützlich wenn sich Format oder Metadaten geändert haben.</span>
<span className="action-desc">
{canCdDirectEncode
? 'Encodiert die vorhandenen WAV-Rohdaten mit den zuletzt bestätigten Einstellungen erneut — ohne die CD neu zu lesen.'
: 'Direktes Encoding ist gesperrt, solange kein bestätigter Vorlauf mit vollständigen Metadaten vorhanden ist. Bitte zuerst "Vorprüfung starten".'}
</span>
</div>
<div className="action-item">
<Button
label="Vorprüfung starten"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onRestartCdReview?.(job)}
loading={actionBusy}
disabled={!canCdStartReview}
/>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen dann Encode aus vorhandenen WAV-Daten starten.</span>
</div>
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-tag"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running || typeof onAssignCdMetadata !== 'function'}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
{typeof onAssignCdMetadata === 'function' ? (
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
) : null}
</div>
) : (
<div className="actions-group">
@@ -1294,7 +1418,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!job.outputStatus?.exists || typeof onDeleteFiles !== 'function'}
disabled={!hasAnyOutputFolder || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
</div>
@@ -0,0 +1,192 @@
import { useState, useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
/**
* Modal zur Konfliktlösung wenn bereits encodierte Ausgabe-Ordner existieren.
*
* Props:
* - visible: boolean
* - onHide: () => void
* - job: object (das Job-Objekt)
* - existingFolders: Array<{ id, output_path, label, created_at }>
* - onKeepBoth: () => void "Beide behalten" geklickt
* - onDeleteSelected: (selectedPaths: string[]) => void "Auswahl löschen & neu encodieren" geklickt
* - busy: boolean
*/
export default function ReencodeConflictModal({
visible = false,
onHide,
job,
existingFolders = [],
mode = 'reencode',
onKeepBoth,
onDeleteSelected,
busy = false
}) {
const [checkedPaths, setCheckedPaths] = useState(new Set());
const isDeleteMode = String(mode || '').trim().toLowerCase() === 'delete';
// Reset on open
useEffect(() => {
if (visible) {
setCheckedPaths(new Set());
}
}, [visible]);
const togglePath = (p) => {
setCheckedPaths((prev) => {
const next = new Set(prev);
if (next.has(p)) {
next.delete(p);
} else {
next.add(p);
}
return next;
});
};
const toggleAll = () => {
if (checkedPaths.size === existingFolders.length) {
setCheckedPaths(new Set());
} else {
setCheckedPaths(new Set(existingFolders.map((f) => f.output_path)));
}
};
const allChecked = existingFolders.length > 0 && checkedPaths.size === existingFolders.length;
const someChecked = checkedPaths.size > 0 && !allChecked;
const noneChecked = checkedPaths.size === 0;
const title = job?.title || job?.detected_title || `Job #${job?.id || ''}`;
const handleDeleteSelected = () => {
const selected = [...checkedPaths];
onDeleteSelected?.(selected);
};
const footer = (
<div className="reencode-conflict-footer">
<Button
label="Abbrechen"
icon="pi pi-times"
severity="secondary"
outlined
size="small"
onClick={onHide}
disabled={busy}
/>
{!isDeleteMode ? (
<Button
label="Beide behalten"
icon="pi pi-copy"
severity="info"
size="small"
onClick={onKeepBoth}
loading={busy}
title="Bestehende Ausgabe belassen neuer Encode erhält eine Nummerierung (_2, _3 …)"
/>
) : null}
<Button
label={isDeleteMode
? (noneChecked ? 'Alle Ordner löschen' : `${checkedPaths.size} Ordner löschen`)
: (noneChecked
? 'Alle löschen & neu encodieren'
: `${checkedPaths.size} Ordner löschen & neu encodieren`)}
icon="pi pi-trash"
severity="danger"
size="small"
onClick={handleDeleteSelected}
loading={busy}
disabled={false}
title={isDeleteMode
? (noneChecked
? 'Alle bestehenden Ausgabe-Ordner löschen'
: 'Ausgewählte Ausgabe-Ordner löschen')
: (noneChecked
? 'Alle bestehenden Ausgabe-Ordner löschen, dann neu encodieren'
: 'Ausgewählte Ausgabe-Ordner löschen, dann neu encodieren')}
/>
</div>
);
return (
<Dialog
header={isDeleteMode ? 'Ausgabe-Ordner löschen' : 'Ausgabe bereits vorhanden'}
visible={visible}
onHide={onHide}
style={{ width: '44rem', maxWidth: '96vw' }}
modal
footer={footer}
className="reencode-conflict-modal"
>
<p className="reencode-conflict-intro">
{isDeleteMode ? (
<>
Für <strong>{title}</strong> wurden mehrere Ausgabe-Ordner erkannt.
Welche Ordner sollen gelöscht werden?
</>
) : (
<>
Für <strong>{title}</strong> existieren bereits encodierte Dateien.
Wie soll mit den vorhandenen Ausgabe-Ordnern umgegangen werden?
</>
)}
</p>
{existingFolders.length > 0 ? (
<div className="reencode-conflict-folders">
<div className="reencode-conflict-folders-header">
<label className="reencode-conflict-check-all">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked; }}
onChange={toggleAll}
/>
<span>Alle auswählen (zum Löschen)</span>
</label>
</div>
{existingFolders.map((folder) => {
const p = folder.output_path;
const checked = checkedPaths.has(p);
const folderName = p.split(/[/\\]/).filter(Boolean).pop() || p;
return (
<label key={p} className={`reencode-conflict-folder-row${checked ? ' is-checked' : ''}`}>
<input
type="checkbox"
checked={checked}
onChange={() => togglePath(p)}
/>
<span className="reencode-conflict-folder-name" title={p}>{folderName}</span>
{folder.created_at ? (
<small className="reencode-conflict-folder-date">
{new Date(folder.created_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
})}
</small>
) : null}
</label>
);
})}
</div>
) : (
<p className="reencode-conflict-no-folders">
{isDeleteMode
? 'Es wurden keine gespeicherten Ausgabe-Ordner gefunden.'
: 'Es wurden keine gespeicherten Ausgabe-Ordner gefunden. Der neue Encode wird ggf. automatisch nummeriert.'}
</p>
)}
{!isDeleteMode ? (
<div className="reencode-conflict-hint">
<strong>Beide behalten:</strong> Bestehende Ordner bleiben. Neuer Encode erhält eine Nummerierung (_2, _3 ).<br />
<strong>Löschen &amp; neu encodieren:</strong> Ausgewählte (oder alle) Ordner werden gelöscht.
Wenn alle gelöscht wurden, erhält der neue Encode keine Nummerierung.
Wenn nur eine Auswahl gelöscht wurde, setzt die Nummerierung fort.
</div>
) : null}
</Dialog>
);
}
+168 -55
View File
@@ -43,6 +43,14 @@ const dashboardStatuses = new Set([
'CD_RIPPING',
'CD_ENCODING'
]);
const hiddenCancelledReviewOrigins = new Set([
'READY_TO_START',
'READY_TO_ENCODE',
'METADATA_SELECTION',
'WAITING_FOR_USER_DECISION',
'CD_METADATA_SELECTION',
'CD_READY_TO_RIP'
]);
function normalizeJobId(value) {
const parsed = Number(value);
@@ -792,6 +800,9 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
const liveContext = liveJobProgress?.context && typeof liveJobProgress.context === 'object'
? liveJobProgress.context
: null;
const liveState = String(liveJobProgress?.state || '').trim().toUpperCase();
const isTerminalJobStatus = jobStatus === 'FINISHED' || jobStatus === 'ERROR' || jobStatus === 'CANCELLED';
const ignoreStaleLiveProgress = isTerminalJobStatus && processingStates.includes(liveState);
const mergedContext = liveContext
? {
...computedContext,
@@ -805,11 +816,15 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
: computedContext;
return {
state: liveJobProgress?.state || jobStatus,
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
activeJobId: jobId,
progress: liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0,
eta: liveJobProgress?.eta || null,
statusText: liveJobProgress?.statusText || job?.error_message || null,
progress: ignoreStaleLiveProgress
? 0
: (liveJobProgress != null ? Number(liveJobProgress.progress ?? 0) : 0),
eta: ignoreStaleLiveProgress ? null : (liveJobProgress?.eta || null),
statusText: ignoreStaleLiveProgress
? (job?.error_message || null)
: (liveJobProgress?.statusText || job?.error_message || null),
context: mergedContext
};
}
@@ -876,6 +891,7 @@ export default function DashboardPage({
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
const [knownDrives, setKnownDrives] = useState([]);
const [driveRescanBusy, setDriveRescanBusy] = useState(() => new Set());
const [driveAnalyzeBusy, setDriveAnalyzeBusy] = useState(() => new Set());
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
@@ -978,18 +994,45 @@ export default function DashboardPage({
if (queueResponse.status === 'fulfilled') {
setQueueState(normalizeQueue(queueResponse.value?.queue));
}
const shouldDisplayOnDashboard = (job) => {
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
if (!dashboardStatuses.has(normalizedStatus)) {
return false;
}
if (normalizedStatus !== 'CANCELLED') {
return true;
}
const cancelledOrigin = String(job?.last_state || '').trim().toUpperCase();
return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
};
const next = allJobs
.filter((job) => dashboardStatuses.has(String(job?.status || '').trim().toUpperCase()))
.filter((job) => shouldDisplayOnDashboard(job))
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
if (currentPipelineJobId && !next.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
try {
const active = await api.getJob(currentPipelineJobId, { lite: true });
if (active?.job) {
next.unshift(active.job);
const pinnedJobIds = new Set();
if (currentPipelineJobId) {
pinnedJobIds.add(currentPipelineJobId);
}
for (const driveState of Object.values(pipeline?.cdDrives || {})) {
const driveJobId = normalizeJobId(driveState?.jobId);
if (driveJobId) {
pinnedJobIds.add(driveJobId);
}
}
const missingPinnedJobIds = Array.from(pinnedJobIds)
.filter((jobId) => !next.some((job) => normalizeJobId(job?.id) === jobId));
if (missingPinnedJobIds.length > 0) {
const pinnedResults = await Promise.allSettled(
missingPinnedJobIds.map((jobId) => api.getJob(jobId, { lite: true, forceRefresh: true }))
);
for (const result of pinnedResults) {
if (result.status !== 'fulfilled') {
continue;
}
const job = result.value?.job;
if (job && shouldDisplayOnDashboard(job)) {
next.unshift(job);
}
} catch (_error) {
// ignore; dashboard still shows available rows
}
}
@@ -1067,7 +1110,7 @@ export default function DashboardPage({
useEffect(() => {
void loadDashboardJobs();
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, jobsRefreshToken]);
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, pipeline?.cdDrives, jobsRefreshToken]);
useEffect(() => {
api.getDetectedDrives()
@@ -1328,7 +1371,7 @@ export default function DashboardPage({
};
const handleAnalyzeForDrive = async (devicePath) => {
setBusy(true);
setDriveAnalyzeBusy((prev) => new Set([...prev, devicePath]));
try {
await api.analyzeDisc(devicePath);
await refreshPipeline();
@@ -1336,10 +1379,27 @@ export default function DashboardPage({
} catch (error) {
showError(error);
} finally {
setBusy(false);
setDriveAnalyzeBusy((prev) => {
const next = new Set(prev);
next.delete(devicePath);
return next;
});
}
};
const handleAnalyzeAll = async () => {
const drivesToAnalyze = allDrives
.filter((drv) => {
const cdState = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
if (cdState) return cdState === 'DISC_DETECTED';
if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return true;
return Boolean(drv.detectedDisc);
})
.map((drv) => drv.path);
if (drivesToAnalyze.length === 0) return;
await Promise.all(drivesToAnalyze.map((path) => handleAnalyzeForDrive(path)));
};
const handleRescan = async () => {
setBusy(true);
try {
@@ -1351,7 +1411,7 @@ export default function DashboardPage({
severity: count > 0 ? 'success' : 'info',
summary: 'Laufwerke neu gelesen',
detail: count > 0
? `${count} Medium${count > 1 ? 'en' : ''} erkannt.`
? `${count > 1 ? `${count} Medien` : '1 Medium'} erkannt.`
: 'Kein Medium erkannt.',
life: 2800
});
@@ -1771,6 +1831,31 @@ export default function DashboardPage({
}
};
const handleRestartCdReviewFromRaw = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setJobBusy(normalizedJobId, true);
try {
const response = await api.restartCdReviewFromRaw(normalizedJobId);
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
await loadDashboardJobs();
if (result.queued) {
showQueuedToast(toastRef, 'CD-Vorprüfung', result);
} else {
setExpandedJobId(replacementJobId);
}
} catch (error) {
showError(error);
} finally {
setJobBusy(normalizedJobId, false);
}
};
const handleQueueDragEnter = (targetEntryId) => {
const targetId = Number(targetEntryId);
const draggedId = Number(draggingQueueEntryId);
@@ -2047,11 +2132,20 @@ export default function DashboardPage({
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]);
// For drives that the detection service found but aren't tracked by cdDrives or the global
// state machine (e.g. a second non-CD disc), show DISC_DETECTED using detectedDiscs.
for (const [drivePath, discDevice] of Object.entries(pipeline?.detectedDiscs || {})) {
const existing = driveMap.get(drivePath);
if (existing && !existing.cdDrive && !existing.pipelineDevice) {
driveMap.set(drivePath, { ...existing, detectedDisc: discDevice });
}
}
return Array.from(driveMap.values())
.filter((drv) => !String(drv.path || '').startsWith('__virtual__'))
.sort((a, b) => (a.path || '').localeCompare(b.path || ''));
}, [knownDrives, pipeline?.cdDrives, pipeline?.detectedDiscs, device, state]);
const isDriveActive = driveActiveStates.includes(state);
const canRescan = !isDriveActive;
const canReanalyze = !isDriveActive && (state === 'ENCODING' ? Boolean(device) : !processingStates.includes(state));
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
@@ -2291,33 +2385,7 @@ export default function DashboardPage({
</Card>
<Card title="Disk-Information">
{/* Global action buttons (non-CD / DVD / Bluray) */}
<div className="actions-row">
<Button
label="Alle neu lesen"
icon="pi pi-refresh"
severity="secondary"
onClick={handleRescan}
loading={busy}
disabled={!canRescan}
/>
<Button
label="Disk neu analysieren"
icon="pi pi-search"
severity="warning"
onClick={handleReanalyze}
loading={busy}
disabled={!canReanalyze}
/>
<Button
label="Metadaten-Modal öffnen"
icon="pi pi-list"
onClick={() => handleOpenMetadataDialog()}
disabled={!canOpenMetadataModal}
/>
</div>
{/* Compact per-drive list */}
{/* Per-drive list */}
{allDrives.length > 0 ? (
<div className="drive-list">
{allDrives.map((drv) => {
@@ -2325,6 +2393,7 @@ export default function DashboardPage({
const cdDrive = drv.cdDrive;
const pipelineDevice = drv.pipelineDevice;
const isRescanBusy = driveRescanBusy.has(drivePath);
const isAnalyzeBusy = driveAnalyzeBusy.has(drivePath);
// Determine display state
let stateLabel = null;
@@ -2354,6 +2423,10 @@ export default function DashboardPage({
: processingStates.includes(s) ? 'warning'
: 'info';
discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
} else if (drv.detectedDisc) {
stateLabel = 'DISC_DETECTED';
stateSeverity = 'info';
discInfo = drv.detectedDisc.discLabel || drv.detectedDisc.label || null;
} else {
stateLabel = 'LEER';
stateSeverity = 'secondary';
@@ -2362,7 +2435,11 @@ export default function DashboardPage({
const driveJobId = normalizeJobId(cdDrive?.jobId);
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
const pipelineState = String(drv.pipelineState || '').toUpperCase();
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
const canAnalyzeDrive = cdState
? cdState === 'DISC_DETECTED'
: (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc);
return (
<div key={drivePath} className="drive-list-item">
@@ -2373,7 +2450,7 @@ export default function DashboardPage({
{drv.discArg && <span className="drive-list-disc-arg">{drv.discArg}</span>}
</div>
<div className="drive-list-state">
{stateLabel && <Tag value={stateLabel} severity={stateSeverity} />}
{stateLabel && <Tag value={stateLabel} severity={stateSeverity} className="drive-list-status-tag" />}
</div>
<div className="drive-list-actions">
<Button
@@ -2388,14 +2465,18 @@ export default function DashboardPage({
title="Laufwerk neu lesen"
aria-label="Laufwerk neu lesen"
/>
{cdState === 'DISC_DETECTED' && (
{canAnalyzeDrive && (
<Button
label="Analysieren"
icon="pi pi-search"
text
rounded
size="small"
severity="secondary"
loading={isAnalyzeBusy}
disabled={isAnalyzeBusy}
onClick={() => handleAnalyzeForDrive(drivePath)}
loading={busy}
disabled={busy}
title="Disk analysieren"
aria-label="Disk analysieren"
/>
)}
{cdState === 'CD_METADATA_SELECTION' && (
@@ -2413,13 +2494,16 @@ export default function DashboardPage({
)}
{(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
<Button
label="Retry"
icon="pi pi-replay"
text
rounded
size="small"
severity="warning"
onClick={() => handleAnalyzeForDrive(drivePath)}
loading={busy}
disabled={busy}
loading={isAnalyzeBusy}
disabled={isAnalyzeBusy}
title="Erneut analysieren"
aria-label="Erneut analysieren"
/>
)}
</div>
@@ -2438,6 +2522,32 @@ export default function DashboardPage({
) : (
<p className="drive-list-empty">Keine Laufwerke erkannt.</p>
)}
{/* Global action buttons — below drive list, side by side */}
<div className="drive-list-global-actions">
<Button
label="Alle neu lesen"
icon="pi pi-refresh"
severity="secondary"
size="small"
onClick={handleRescan}
loading={busy}
disabled={!canRescan}
/>
<Button
label="Alle analysieren"
icon="pi pi-search"
severity="warning"
size="small"
onClick={handleAnalyzeAll}
disabled={allDrives.every((drv) => {
const cs = drv.cdDrive ? String(drv.cdDrive.state || '').toUpperCase() : null;
if (cs) return cs !== 'DISC_DETECTED';
if (Boolean(drv.pipelineDevice) && String(drv.pipelineState || '').toUpperCase() === 'DISC_DETECTED') return false;
return !drv.detectedDisc;
})}
/>
</div>
</Card>
<Card title="Freier Speicher">
@@ -2538,7 +2648,9 @@ export default function DashboardPage({
? pipelineForJob.context.selectedMetadata
: {};
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context);
const pluginExecution = resolveJobPluginExecution(job, pipelineForJob?.context, {
currentStatus: jobState
});
if (isExpanded) {
return (
@@ -2589,6 +2701,7 @@ export default function DashboardPage({
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
onCancel={() => handleCancel(jobId, jobState)}
onRetry={() => handleRetry(jobId)}
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
onOpenMetadata={() => {
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
? pipelineForJob.context
+411 -105
View File
@@ -12,6 +12,7 @@ import { api } from '../api/client';
import JobDetailDialog from '../components/JobDetailDialog';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import CdMetadataDialog from '../components/CdMetadataDialog';
import ReencodeConflictModal from '../components/ReencodeConflictModal';
import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
@@ -32,10 +33,10 @@ const MEDIA_FILTER_OPTIONS = [
];
const SORT_OPTIONS = [
{ label: 'Startzeit: Neu -> Alt', value: '!start_time' },
{ label: 'Startzeit: Alt -> Neu', value: 'start_time' },
{ label: 'Endzeit: Neu -> Alt', value: '!end_time' },
{ label: 'Endzeit: Alt -> Neu', value: 'end_time' },
{ label: 'Startzeit: Neu -> Alt', value: '!sortStartTime' },
{ label: 'Startzeit: Alt -> Neu', value: 'sortStartTime' },
{ label: 'Endzeit: Neu -> Alt', value: '!sortEndTime' },
{ label: 'Endzeit: Alt -> Neu', value: 'sortEndTime' },
{ label: 'Titel: A -> Z', value: 'sortTitle' },
{ label: 'Titel: Z -> A', value: '!sortTitle' },
{ label: 'Medium: A -> Z', value: 'sortMediaType' },
@@ -222,7 +223,11 @@ function resolveCdDetails(row) {
selectedMetadata?.mbId
|| selectedMetadata?.musicBrainzId
|| selectedMetadata?.musicbrainzId
|| selectedMetadata?.musicbrainz_id
|| selectedMetadata?.music_brainz_id
|| selectedMetadata?.musicbrainz
|| selectedMetadata?.mbid
|| row?.imdb_id
|| ''
).trim() || null;
@@ -349,6 +354,24 @@ function formatDateTime(value) {
});
}
function parseSortableTimestamp(value) {
if (!value) {
return null;
}
const ts = Date.parse(value);
return Number.isFinite(ts) ? ts : null;
}
function resolveSortTimestamp(...candidates) {
for (const candidate of candidates) {
const ts = parseSortableTimestamp(candidate);
if (ts != null) {
return ts;
}
}
return 0;
}
export default function HistoryPage({ refreshToken = 0 }) {
const location = useLocation();
const navigate = useNavigate();
@@ -357,8 +380,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [status, setStatus] = useState('');
const [mediumFilter, setMediumFilter] = useState('');
const [layout, setLayout] = useState('grid');
const [sortKey, setSortKey] = useState('!start_time');
const [sortField, setSortField] = useState('start_time');
const [sortKey, setSortKey] = useState('!sortStartTime');
const [sortField, setSortField] = useState('sortStartTime');
const [sortOrder, setSortOrder] = useState(-1);
const [selectedJob, setSelectedJob] = useState(null);
const [detailVisible, setDetailVisible] = useState(false);
@@ -372,7 +395,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
const [downloadFolderBusyPath, setDownloadFolderBusyPath] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
@@ -381,6 +406,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const [conflictModalVisible, setConflictModalVisible] = useState(false);
const [conflictModalJob, setConflictModalJob] = useState(null);
const [conflictModalFolders, setConflictModalFolders] = useState([]);
const [conflictModalAction, setConflictModalAction] = useState(null); // 'reencode' | 'restart_encode' | 'restart_review' | 'restart_cd_review' | 'delete_output'
const [conflictModalMode, setConflictModalMode] = useState('reencode'); // 'reencode' | 'delete'
const [conflictModalBusy, setConflictModalBusy] = useState(false);
const toastRef = useRef(null);
const queuedJobIdSet = useMemo(() => {
@@ -398,7 +429,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
() => jobs.map((job) => ({
...job,
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
sortMediaType: resolveMediaType(job)
sortMediaType: resolveMediaType(job),
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
})),
[jobs]
);
@@ -470,8 +503,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
const onSortChange = (event) => {
const value = String(event.value || '').trim();
if (!value) {
setSortKey('!start_time');
setSortField('start_time');
setSortKey('!sortStartTime');
setSortField('sortStartTime');
setSortOrder(-1);
return;
}
@@ -507,7 +540,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
setDetailLoading(true);
try {
const response = await api.getJob(jobId, { includeLogs: false });
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Fehler', detail: error.message });
@@ -541,11 +574,242 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!detailVisible || Number(selectedJob?.id || 0) !== Number(jobId || 0)) {
return;
}
const response = await api.getJob(jobId, { includeLogs: false });
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
};
// ── Conflict Modal Helpers ─────────────────────────────────────────────────
const mergeOutputFoldersForJob = (job, folders = []) => {
const normalizedJobId = Number(job?.id || 0);
const merged = [];
const seen = new Set();
const addFolder = (folder) => {
const outputPath = String(folder?.output_path || '').trim();
if (!outputPath || seen.has(outputPath)) {
return;
}
seen.add(outputPath);
merged.push({
id: folder?.id ?? 0,
job_id: folder?.job_id ?? (normalizedJobId || null),
output_path: outputPath,
label: folder?.label || null,
created_at: folder?.created_at || null
});
};
for (const folder of (Array.isArray(folders) ? folders : [])) {
addFolder(folder);
}
const currentPath = String(job?.output_path || '').trim();
if (currentPath && !seen.has(currentPath) && Boolean(job?.outputStatus?.exists)) {
addFolder({
id: 0,
job_id: normalizedJobId,
output_path: currentPath,
label: 'Aktuelle Ausgabe',
created_at: null
});
}
return merged;
};
const loadOutputFoldersForJob = async (job) => {
if (!job?.id) {
return [];
}
try {
const response = await api.getOutputFolders(job.id);
return mergeOutputFoldersForJob(job, response?.folders);
} catch (_error) {
return mergeOutputFoldersForJob(job, []);
}
};
const openConflictModal = async (job, action, mode = 'reencode', preloadedFolders = null) => {
const folders = Array.isArray(preloadedFolders)
? mergeOutputFoldersForJob(job, preloadedFolders)
: await loadOutputFoldersForJob(job);
if (folders.length === 0) {
return false;
}
setConflictModalJob(job);
setConflictModalAction(action);
setConflictModalMode(mode);
setConflictModalFolders(folders);
setConflictModalVisible(true);
return true;
};
const closeConflictModal = () => {
if (conflictModalBusy) return;
setConflictModalVisible(false);
setConflictModalJob(null);
setConflictModalAction(null);
setConflictModalMode('reencode');
setConflictModalFolders([]);
};
const executeHistoryAction = async (job, action, options = {}, executionOptions = {}) => {
const skipConfirm = Boolean(executionOptions?.skipConfirm);
if (action === 'reencode') {
setReencodeBusyJobId(job.id);
try {
await api.reencodeJob(job.id, options);
toastRef.current?.show({ severity: 'success', summary: 'Re-Encode gestartet', detail: 'Job wurde in die Mediainfo-Prüfung gesetzt.', life: 3500 });
await load();
await refreshDetailIfOpen(job.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setReencodeBusyJobId(null);
}
} else if (action === 'restart_encode') {
setActionBusy(true);
try {
const response = await api.restartEncodeWithLastSettings(job.id, options);
const result = getQueueActionResult(response);
if (result.queued) {
toastRef.current?.show({ severity: 'info', summary: 'Encode-Neustart in Queue', detail: result.queuePosition > 0 ? `Position ${result.queuePosition}` : 'In der Warteschlange eingeplant.', life: 3500 });
} else {
toastRef.current?.show({ severity: 'success', summary: 'Encode-Neustart gestartet', detail: 'Letzte bestätigte Einstellungen werden verwendet.', life: 3500 });
}
await load();
await refreshDetailIfOpen(job.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
} else if (action === 'restart_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
if (!skipConfirm) {
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
if (!confirmed) {
return;
}
}
setActionBusy(true);
try {
await api.restartReviewFromRaw(job.id, options);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
life: 3500
});
await load();
await refreshDetailIfOpen(job.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
} else if (action === 'restart_cd_review') {
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
if (!skipConfirm) {
const confirmed = window.confirm(`CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`);
if (!confirmed) {
return;
}
}
setActionBusy(true);
try {
await api.restartCdReviewFromRaw(job.id, options);
toastRef.current?.show({
severity: 'success',
summary: 'CD-Vorprüfung gestartet',
detail: 'Job ist jetzt im Dashboard verfügbar — bitte Metadaten und Einstellungen wählen.',
life: 4000
});
await load();
await refreshDetailIfOpen(job.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'CD-Vorprüfung fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
} else if (action === 'delete_output') {
const deleteFolders = Array.isArray(options?.deleteFolders)
? options.deleteFolders.filter((value) => typeof value === 'string' && value.trim())
: [];
if (deleteFolders.length === 0) {
toastRef.current?.show({
severity: 'warn',
summary: 'Keine Ordner gewählt',
detail: 'Es wurden keine Output-Ordner zum Löschen erkannt.',
life: 3500
});
return;
}
setActionBusy(true);
try {
const response = await api.deleteOutputFolders(job.id, deleteFolders);
const result = response?.result && typeof response.result === 'object' ? response.result : {};
const deletedCount = Array.isArray(result.deleted) ? result.deleted.length : 0;
const failedCount = Array.isArray(result.failed) ? result.failed.length : 0;
toastRef.current?.show({
severity: failedCount > 0 ? 'warn' : 'success',
summary: failedCount > 0 ? 'Output teilweise gelöscht' : 'Output gelöscht',
detail: failedCount > 0
? `${deletedCount} Ordner gelöscht, ${failedCount} mit Fehler.`
: `${deletedCount} Ordner gelöscht.`,
life: 4000
});
await load();
await refreshDetailIfOpen(job.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Output löschen fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
}
}
};
const handleConflictKeepBoth = async () => {
const job = conflictModalJob;
const action = conflictModalAction;
setConflictModalBusy(true);
try {
await executeHistoryAction(job, action, { keepBoth: true }, { skipConfirm: true });
closeConflictModal();
} finally {
setConflictModalBusy(false);
}
};
const handleConflictDeleteSelected = async (selectedPaths) => {
const job = conflictModalJob;
const action = conflictModalAction;
const selected = Array.isArray(selectedPaths)
? selectedPaths.filter((value) => typeof value === 'string' && value.trim())
: [];
const allKnownPaths = conflictModalFolders
.map((folder) => String(folder?.output_path || '').trim())
.filter(Boolean);
const deleteFolders = selected.length > 0 ? selected : allKnownPaths;
setConflictModalBusy(true);
try {
const options = deleteFolders.length > 0 ? { deleteFolders } : {};
await executeHistoryAction(job, action, options, { skipConfirm: true });
closeConflictModal();
} finally {
setConflictModalBusy(false);
}
};
// ── File deletion ──────────────────────────────────────────────────────────
const handleDeleteFiles = async (row, target) => {
if (target === 'movie') {
const outputFolders = await loadOutputFoldersForJob(row);
if (outputFolders.length > 1) {
await openConflictModal(row, 'delete_output', 'delete', outputFolders);
return;
}
}
const outputLabel = getOutputLabelForRow(row);
const outputShortLabel = getOutputShortLabelForRow(row);
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
@@ -608,97 +872,64 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleDownloadOutputFolder = async (row, folderPath) => {
const jobId = Number(row?.id || selectedJob?.id || 0);
if (!jobId || !folderPath) return;
setDownloadFolderBusyPath(folderPath);
try {
const response = await api.requestJobArchive(jobId, 'output', { outputPath: folderPath });
const item = response?.item && typeof response.item === 'object' ? response.item : null;
const isReady = String(item?.status || '').trim().toLowerCase() === 'ready';
toastRef.current?.show({
severity: isReady ? 'success' : 'info',
summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt',
detail: isReady
? 'Output-ZIP ist bereits auf der Downloads-Seite verfügbar.'
: 'Output-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.',
life: 4000
});
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Download fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setDownloadFolderBusyPath(null);
}
};
const maybeOpenOutputConflictModal = async (row, action) => {
const outputFolders = await loadOutputFoldersForJob(row);
if (outputFolders.length === 0) {
return false;
}
await openConflictModal(row, action, 'reencode', outputFolders);
return true;
};
const handleReencode = async (row) => {
const title = row.title || row.detected_title || `Job #${row.id}`;
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
if (!confirmed) {
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
return;
}
setReencodeBusyJobId(row.id);
try {
await api.reencodeJob(row.id);
toastRef.current?.show({
severity: 'success',
summary: 'Re-Encode gestartet',
detail: 'Job wurde in die Mediainfo-Prüfung gesetzt.',
life: 3500
});
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Re-Encode fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setReencodeBusyJobId(null);
}
await executeHistoryAction(row, 'reencode');
};
const handleRestartEncode = async (row) => {
const title = row.title || row.detected_title || `Job #${row.id}`;
if (row?.encodeSuccess) {
const confirmed = window.confirm(
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n`
+ 'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
);
if (!confirmed) {
return;
}
}
setActionBusy(true);
try {
const response = await api.restartEncodeWithLastSettings(row.id);
const result = getQueueActionResult(response);
if (result.queued) {
const queuePosition = Number(result?.queuePosition || 0);
toastRef.current?.show({
severity: 'info',
summary: 'Encode-Neustart in Queue',
detail: queuePosition > 0
? `Job wurde auf Position ${queuePosition} eingeplant.`
: 'Job wurde in die Warteschlange eingeplant.',
life: 3500
});
} else {
toastRef.current?.show({
severity: 'success',
summary: 'Encode-Neustart gestartet',
detail: 'Letzte bestätigte Einstellungen werden verwendet.',
life: 3500
});
}
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Encode-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
if (await maybeOpenOutputConflictModal(row, 'restart_encode')) {
return;
}
await executeHistoryAction(row, 'restart_encode');
};
const handleRestartReview = async (row) => {
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
if (!confirmed) {
if (await maybeOpenOutputConflictModal(row, 'restart_review')) {
return;
}
await executeHistoryAction(row, 'restart_review');
};
setActionBusy(true);
try {
await api.restartReviewFromRaw(row.id);
toastRef.current?.show({
severity: 'success',
summary: 'Review-Neustart',
detail: 'Analyse gestartet. Job ist jetzt im Dashboard verfügbar.',
life: 3500
});
await load();
await refreshDetailIfOpen(row.id);
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'Review-Neustart fehlgeschlagen', detail: error.message, life: 4500 });
} finally {
setActionBusy(false);
const handleRestartCdReview = async (row) => {
if (await maybeOpenOutputConflictModal(row, 'restart_cd_review')) {
return;
}
await executeHistoryAction(row, 'restart_cd_review');
};
const handleRetry = async (row) => {
@@ -725,7 +956,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
});
await load();
if (replacementJobId) {
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false });
const detailResponse = await api.getJob(replacementJobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(detailResponse.job);
setDetailVisible(true);
} else {
@@ -850,6 +1081,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
setDeleteEntryPreview(null);
setDeleteEntryPreviewLoading(false);
setDeleteEntryTargetBusy(null);
setDeleteEntrySelectedMoviePaths([]);
};
const handleDeleteEntry = async (row) => {
@@ -859,12 +1091,20 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
setDeleteEntryDialogRow(row);
setDeleteEntryPreview(null);
setDeleteEntrySelectedMoviePaths([]);
setDeleteEntryDialogVisible(true);
setDeleteEntryPreviewLoading(true);
setDeleteEntryBusy(true);
try {
const response = await api.getJobDeletePreview(jobId, { includeRelated: true });
setDeleteEntryPreview(response?.preview || null);
const preview = response?.preview || null;
setDeleteEntryPreview(preview);
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
const defaultSelectedMoviePaths = movieCandidates
.filter((item) => Boolean(item?.exists))
.map((item) => String(item?.path || '').trim())
.filter(Boolean);
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
} catch (error) {
toastRef.current?.show({
severity: 'error',
@@ -890,11 +1130,27 @@ export default function HistoryPage({ refreshToken = 0 }) {
if (!jobId) {
return;
}
if (
(normalizedTarget === 'movie' || normalizedTarget === 'both')
&& previewMovieExisting.length > 0
&& deleteEntrySelectedMoviePaths.length === 0
) {
toastRef.current?.show({
severity: 'warn',
summary: `${deleteEntryOutputShortLabel}-Ordner auswählen`,
detail: `Bitte mindestens einen ${deleteEntryOutputShortLabel}-Ordner zum Löschen auswählen.`,
life: 3500
});
return;
}
setDeleteEntryBusy(true);
setDeleteEntryTargetBusy(normalizedTarget);
try {
const response = await api.deleteJobEntry(jobId, normalizedTarget, { includeRelated: true });
const response = await api.deleteJobEntry(jobId, normalizedTarget, {
includeRelated: true,
selectedMoviePaths: deleteEntrySelectedMoviePaths
});
const deletedJobIds = Array.isArray(response?.deletedJobIds) ? response.deletedJobIds : [];
const fileSummary = response?.fileSummary || {};
const rawFiles = Number(fileSummary?.raw?.filesDeleted || 0);
@@ -929,6 +1185,24 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const toggleDeleteMoviePathSelection = (moviePath, checked) => {
const normalizedPath = String(moviePath || '').trim();
if (!normalizedPath) {
return;
}
setDeleteEntrySelectedMoviePaths((previous) => {
const nextSet = new Set((Array.isArray(previous) ? previous : []).map((item) => String(item || '').trim()).filter(Boolean));
if (checked) {
nextSet.add(normalizedPath);
} else {
nextSet.delete(normalizedPath);
}
return previewMoviePaths
.map((item) => String(item?.path || '').trim())
.filter((itemPath) => itemPath && nextSet.has(itemPath));
});
};
const handleRemoveFromQueue = async (row) => {
const jobId = normalizeJobId(row?.id || row);
if (!jobId) {
@@ -1202,6 +1476,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
const previewRawDisplay = previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1);
const previewMovieDisplay = previewMovieExisting.length > 0 ? previewMovieExisting : previewMoviePaths.slice(0, 1);
const selectedDeleteMoviePathSet = useMemo(
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
[deleteEntrySelectedMoviePaths]
);
const movieDeleteSelectionRequired = previewMovieExisting.length > 0 && deleteEntrySelectedMoviePaths.length === 0;
const deleteTargetActionsDisabled = deleteEntryPreviewLoading || Boolean(deleteEntryTargetBusy) || !deleteEntryPreview;
const deleteEntryOutputLabel = getOutputLabelForRow(deleteEntryDialogRow);
const deleteEntryOutputShortLabel = getOutputShortLabelForRow(deleteEntryDialogRow);
@@ -1281,6 +1562,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
logLoadingMode={logLoadingMode}
onRestartEncode={handleRestartEncode}
onRestartReview={handleRestartReview}
onRestartCdReview={handleRestartCdReview}
onReencode={handleReencode}
onRetry={handleRetry}
onAssignOmdb={handleAssignOmdb}
@@ -1288,6 +1570,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onDownloadArchive={handleDownloadArchive}
onDownloadOutputFolder={handleDownloadOutputFolder}
onRemoveFromQueue={handleRemoveFromQueue}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
actionBusy={actionBusy}
@@ -1296,11 +1579,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
deleteEntryBusy={deleteEntryBusy}
downloadBusyTarget={downloadBusyTarget}
downloadFolderBusyPath={downloadFolderBusyPath}
onHide={() => {
setDetailVisible(false);
setDetailLoading(false);
setLogLoadingMode(null);
setDownloadBusyTarget(null);
setDownloadFolderBusyPath(null);
}}
/>
@@ -1344,12 +1629,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div>
<h4>RAW</h4>
{previewRawPaths.length > 0 ? (() => {
const display = previewRawPaths.filter(p => p.exists).length > 0
? previewRawPaths.filter(p => p.exists)
: previewRawPaths.slice(0, 1);
return (
<ul className="history-delete-preview-list">
{display.map((item) => (
{previewRawDisplay.map((item) => (
<li key={`delete-raw-${item.path}`}>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
@@ -1367,17 +1649,30 @@ export default function HistoryPage({ refreshToken = 0 }) {
<div>
<h4>{deleteEntryOutputShortLabel}</h4>
{previewMoviePaths.length > 0 ? (() => {
const display = previewMoviePaths.filter(p => p.exists).length > 0
? previewMoviePaths.filter(p => p.exists)
: previewMoviePaths.slice(0, 1);
return (
<ul className="history-delete-preview-list">
{display.map((item) => (
{previewMovieDisplay.map((item) => (
<li key={`delete-movie-${item.path}`}>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
{' '}| {item.path}
{item.exists ? (
<label className="history-delete-preview-checkbox-row">
<input
type="checkbox"
checked={selectedDeleteMoviePathSet.has(String(item.path || '').trim())}
onChange={(event) => toggleDeleteMoviePathSelection(item.path, Boolean(event.target.checked))}
/>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
<span>| {item.path}</span>
</label>
) : (
<>
<span className={item.exists ? 'exists-yes' : 'exists-no'}>
{item.exists ? 'vorhanden' : 'nicht gefunden'}
</span>
{' '}| {item.path}
</>
)}
</li>
))}
</ul>
@@ -1406,7 +1701,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
outlined
onClick={() => confirmDeleteEntry('movie')}
loading={deleteEntryTargetBusy === 'movie'}
disabled={deleteTargetActionsDisabled}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
<Button
label="Beides löschen"
@@ -1414,7 +1709,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
severity="danger"
onClick={() => confirmDeleteEntry('both')}
loading={deleteEntryTargetBusy === 'both'}
disabled={deleteTargetActionsDisabled}
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
/>
<Button
label="Nur Eintrag löschen"
@@ -1459,6 +1754,17 @@ export default function HistoryPage({ refreshToken = 0 }) {
onFetchRelease={handleMusicBrainzReleaseFetch}
busy={cdMetadataAssignBusy}
/>
<ReencodeConflictModal
visible={conflictModalVisible}
onHide={closeConflictModal}
job={conflictModalJob}
existingFolders={conflictModalFolders}
mode={conflictModalMode}
onKeepBoth={handleConflictKeepBoth}
onDeleteSelected={handleConflictDeleteSelected}
busy={conflictModalBusy}
/>
</div>
);
}
+34
View File
@@ -152,6 +152,7 @@ export default function SettingsPage() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testingPushover, setTestingPushover] = useState(false);
const [coverArtRecoveryBusy, setCoverArtRecoveryBusy] = useState(false);
const [updatingExpertMode, setUpdatingExpertMode] = useState(false);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const [initialValues, setInitialValues] = useState({});
@@ -535,6 +536,31 @@ export default function SettingsPage() {
}
};
const handleCoverArtRecovery = async () => {
setCoverArtRecoveryBusy(true);
try {
const response = await api.recoverMissingCoverArt({});
const result = response?.result || {};
const recovered = Number(result?.recovered || 0);
const failed = Number(result?.failed || 0);
const scanned = Number(result?.scannedJobs || 0);
toastRef.current?.show({
severity: failed > 0 ? 'warn' : 'success',
summary: 'Coverart-Prüfung',
detail: `Geprüft: ${scanned} | Nachgeladen: ${recovered} | Fehlgeschlagen: ${failed}`,
life: 5000
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Coverart-Prüfung fehlgeschlagen',
detail: error.message
});
} finally {
setCoverArtRecoveryBusy(false);
}
};
const handleScriptEditorChange = (key, value) => {
setScriptEditor((prev) => ({
...prev,
@@ -1035,6 +1061,14 @@ export default function SettingsPage() {
loading={testingPushover}
disabled={saving || updatingExpertMode}
/>
<Button
label="Coverarts prüfen"
icon="pi pi-images"
severity="help"
onClick={handleCoverArtRecovery}
loading={coverArtRecoveryBusy}
disabled={saving || updatingExpertMode || testingPushover}
/>
<div className="settings-expert-toggle">
<span>Expertenmodus</span>
<InputSwitch
+205 -5
View File
@@ -1665,6 +1665,44 @@ body {
gap: 0.5rem;
}
/* Hierarchische Settings — Baumstruktur */
.settings-grid--depth-1 {
margin-left: 1.5rem;
padding-left: 0.9rem;
border-left: 3px solid var(--rip-amber, #f59e0b);
margin-top: 0.2rem;
margin-bottom: 0.1rem;
background: rgba(245, 158, 11, 0.04);
border-radius: 0 0.35rem 0.35rem 0;
}
.settings-grid--depth-2 {
margin-left: 1.25rem;
padding-left: 0.75rem;
border-left: 2px solid var(--rip-border, #e5e7eb);
margin-top: 0.15rem;
border-radius: 0 0.25rem 0.25rem 0;
}
/* Label für readonly/not-yet-implemented Toggles */
.setting-label--readonly {
opacity: 0.55;
}
/* Readonly-Zeile leicht ausgrauen */
.settings-grid--depth-1 .setting-row:has(.p-inputswitch.p-disabled),
.settings-grid--depth-2 .setting-row:has(.p-inputswitch.p-disabled) {
opacity: 0.6;
}
.setting-badge--coming-soon {
font-size: 0.68rem;
color: var(--rip-muted, #9ca3af);
font-weight: 400;
font-style: italic;
margin-left: 0.3rem;
}
.settings-tabview {
margin-top: 0.75rem;
}
@@ -1841,24 +1879,28 @@ body {
}
.drive-list-row {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.drive-list-info {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
flex: 1;
min-width: 0;
}
.drive-list-path {
display: inline-block;
font-size: 0.8rem;
color: var(--rip-muted);
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.drive-list-model {
@@ -1876,14 +1918,24 @@ body {
}
.drive-list-state {
flex-shrink: 0;
justify-self: start;
}
.drive-list-status-tag.p-tag {
max-width: 10rem;
padding: 0.15rem 0.45rem;
font-size: 0.68rem;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.drive-list-actions {
display: flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
justify-self: end;
}
.drive-list-disc-label {
@@ -1902,6 +1954,38 @@ body {
text-overflow: ellipsis;
}
.drive-list-global-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.75rem;
}
.drive-list-global-actions .p-button {
width: 100%;
}
@media (max-width: 860px) {
.drive-list-row {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"info info"
"state actions";
}
.drive-list-info {
grid-area: info;
}
.drive-list-state {
grid-area: state;
}
.drive-list-actions {
grid-area: actions;
}
}
/* Legacy per-CD-drive section (kept for compat) */
.cd-drives-section {
display: flex;
@@ -2678,6 +2762,17 @@ body {
word-break: break-word;
}
.history-delete-preview-checkbox-row {
display: inline-flex;
align-items: flex-start;
gap: 0.35rem;
cursor: pointer;
}
.history-delete-preview-checkbox-row input[type="checkbox"] {
margin-top: 0.15rem;
}
.history-delete-preview-list .exists-yes {
color: #176635;
font-weight: 600;
@@ -3393,6 +3488,21 @@ body {
font-size: 0.85rem;
}
.track-item-inline-status {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.track-item-inline-status .track-item-main {
min-width: 0;
}
.track-item-inline-status .track-action-note {
margin-left: 0;
}
.media-title-block {
border: 1px solid var(--rip-border);
border-radius: 0.45rem;
@@ -4591,3 +4701,93 @@ body {
grid-template-columns: 1fr;
}
}
/* ── ReencodeConflictModal ──────────────────────────────────────────────── */
.reencode-conflict-modal .p-dialog-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.reencode-conflict-intro {
margin: 0;
font-size: 0.95rem;
}
.reencode-conflict-folders {
display: flex;
flex-direction: column;
gap: 0.4rem;
border: 1px solid var(--surface-border, #d8d3c6);
border-radius: 8px;
padding: 0.75rem;
background: var(--surface-ground, #f7f7f7);
}
.reencode-conflict-folders-header {
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--surface-border, #d8d3c6);
margin-bottom: 0.25rem;
}
.reencode-conflict-check-all {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.reencode-conflict-folder-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.3rem 0.25rem;
border-radius: 4px;
transition: background 0.15s;
}
.reencode-conflict-folder-row:hover {
background: var(--surface-hover, #eeebe4);
}
.reencode-conflict-folder-row.is-checked {
background: var(--red-50, #fff5f5);
}
.reencode-conflict-folder-name {
flex: 1;
font-size: 0.875rem;
word-break: break-all;
}
.reencode-conflict-folder-date {
color: var(--text-color-secondary, #6c757d);
white-space: nowrap;
font-size: 0.78rem;
}
.reencode-conflict-hint {
font-size: 0.82rem;
color: var(--text-color-secondary, #6c757d);
line-height: 1.5;
padding: 0.5rem 0.75rem;
border-left: 3px solid var(--primary-color, #a89f91);
background: var(--surface-ground, #f7f7f7);
border-radius: 0 4px 4px 0;
}
.reencode-conflict-no-folders {
margin: 0;
font-size: 0.875rem;
color: var(--text-color-secondary, #6c757d);
}
.reencode-conflict-footer {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
}
+72 -12
View File
@@ -3,6 +3,57 @@ function normalizeStage(value) {
return stage || null;
}
function normalizeStatus(value) {
return String(value || '').trim().toUpperCase() || null;
}
function inferExpectedPluginStage({ currentStatus = null, currentStage = null } = {}) {
const explicitStage = normalizeStage(currentStage);
if (explicitStage) {
return explicitStage;
}
const status = normalizeStatus(currentStatus);
if (!status) {
return null;
}
if (
status === 'ANALYZING'
|| status === 'CD_ANALYZING'
|| status === 'METADATA_SELECTION'
|| status === 'CD_METADATA_SELECTION'
|| status === 'CD_READY_TO_RIP'
) {
return 'analyze';
}
if (status === 'RIPPING' || status === 'CD_RIPPING') {
return 'rip';
}
if (status === 'MEDIAINFO_CHECK' || status === 'WAITING_FOR_USER_DECISION' || status === 'READY_TO_ENCODE') {
return 'review';
}
if (status === 'ENCODING' || status === 'CD_ENCODING') {
return 'encode';
}
return null;
}
function matchesExpectedStage(executionState, expectedStage) {
if (!executionState) {
return false;
}
if (!expectedStage) {
return true;
}
const stages = Array.isArray(executionState.stages) ? executionState.stages : [];
if (stages.length === 0) {
return true;
}
return stages.includes(expectedStage);
}
const STAGE_LABELS = {
analyze: 'Analyse',
rip: 'Rip',
@@ -57,21 +108,30 @@ export function normalizePluginExecutionState(rawState) {
};
}
export function resolveJobPluginExecution(job = null, liveContext = null) {
export function resolveJobPluginExecution(job = null, liveContext = null, options = {}) {
const liveExecution = normalizePluginExecutionState(liveContext?.pluginExecution);
if (liveExecution) {
return liveExecution;
}
const makemkvExecution = normalizePluginExecutionState(job?.makemkvInfo?.pluginExecution);
if (makemkvExecution) {
return makemkvExecution;
}
const handbrakeExecution = normalizePluginExecutionState(job?.handbrakeInfo?.pluginExecution);
if (handbrakeExecution) {
return handbrakeExecution;
const jobExecution = normalizePluginExecutionState(job?.pluginExecution);
const expectedStage = inferExpectedPluginStage(options);
const candidates = [liveExecution, makemkvExecution, handbrakeExecution, jobExecution].filter(Boolean);
if (candidates.length === 0) {
return null;
}
return normalizePluginExecutionState(job?.pluginExecution);
if (!expectedStage) {
return candidates[0];
}
const stageMatch = candidates.find((state) => matchesExpectedStage(state, expectedStage));
if (stageMatch) {
return stageMatch;
}
return null;
}
export function inferPluginStageFromStatus(status) {
return inferExpectedPluginStage({ currentStatus: status });
}