0.12.0-1 Plugins und diverses
This commit is contained in:
+411
-105
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user