5508 lines
227 KiB
React
5508 lines
227 KiB
React
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { Toast } from 'primereact/toast';
|
|
import { Card } from 'primereact/card';
|
|
import { Button } from 'primereact/button';
|
|
import { Tag } from 'primereact/tag';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Dialog } from 'primereact/dialog';
|
|
import { InputNumber } from 'primereact/inputnumber';
|
|
import { InputText } from 'primereact/inputtext';
|
|
import { api } from '../api/client';
|
|
import { useWebSocket } from '../hooks/useWebSocket';
|
|
import PipelineStatusCard from '../components/PipelineStatusCard';
|
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
|
import CdMetadataDialog from '../components/CdMetadataDialog';
|
|
import CdRipConfigPanel from '../components/CdRipConfigPanel';
|
|
import AudiobookConfigPanel from '../components/AudiobookConfigPanel';
|
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
|
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
|
import mergeIndicatorIcon from '../assets/media-merge.svg';
|
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
|
import { confirmModal } from '../utils/confirmModal';
|
|
import { isConverterJob, isSeriesVideoJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
|
|
|
|
const processingStates = ['ANALYZING', 'METADATA_LOOKUP', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
|
const activeCdDriveStates = ['CD_ANALYZING', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING'];
|
|
const ripperStatuses = new Set([
|
|
'ANALYZING',
|
|
'METADATA_LOOKUP',
|
|
'METADATA_SELECTION',
|
|
'WAITING_FOR_USER_DECISION',
|
|
'READY_TO_START',
|
|
'MEDIAINFO_CHECK',
|
|
'READY_TO_ENCODE',
|
|
'RIPPING',
|
|
'ENCODING',
|
|
'CANCELLED',
|
|
'ERROR',
|
|
'CD_METADATA_SELECTION',
|
|
'CD_READY_TO_RIP',
|
|
'CD_ANALYZING',
|
|
'CD_RIPPING',
|
|
'CD_ENCODING'
|
|
]);
|
|
const hiddenCancelledReviewOrigins = new Set([
|
|
'READY_TO_START',
|
|
'READY_TO_ENCODE',
|
|
'METADATA_LOOKUP',
|
|
'METADATA_SELECTION',
|
|
'WAITING_FOR_USER_DECISION',
|
|
'CD_METADATA_SELECTION',
|
|
'CD_READY_TO_RIP'
|
|
]);
|
|
|
|
function normalizeJobId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function hasSeriesWorkflowHints(source) {
|
|
const value = source && typeof source === 'object' ? source : {};
|
|
const seasonNumber = Number(
|
|
value?.seasonNumber
|
|
?? value?.seriesLookupHint?.seasonNumber
|
|
?? value?.selectedMetadata?.seasonNumber
|
|
?? null
|
|
);
|
|
const episodeCount = Number(
|
|
value?.episodeCount
|
|
?? value?.selectedMetadata?.episodeCount
|
|
?? 0
|
|
);
|
|
const directEpisodes = Array.isArray(value?.episodes) ? value.episodes : [];
|
|
const nestedEpisodes = Array.isArray(value?.selectedMetadata?.episodes) ? value.selectedMetadata.episodes : [];
|
|
return Boolean(
|
|
(Number.isFinite(seasonNumber) && seasonNumber > 0)
|
|
|| (Number.isFinite(episodeCount) && episodeCount > 0)
|
|
|| directEpisodes.length > 0
|
|
|| nestedEpisodes.length > 0
|
|
|| (value?.seriesAnalysis && typeof value.seriesAnalysis === 'object')
|
|
);
|
|
}
|
|
|
|
function isCdMetadataContext(context) {
|
|
const rawContext = context && typeof context === 'object' ? context : {};
|
|
const state = String(rawContext?.state || rawContext?.lastState || '').trim().toUpperCase();
|
|
if (state.startsWith('CD_')) {
|
|
return true;
|
|
}
|
|
const stage = String(rawContext?.stage || '').trim().toUpperCase();
|
|
if (stage.startsWith('CD_')) {
|
|
return true;
|
|
}
|
|
const mediaProfile = String(rawContext?.mediaProfile || rawContext?.selectedMetadata?.mediaProfile || '').trim().toLowerCase();
|
|
if (mediaProfile === 'cd' || mediaProfile === 'audio_cd' || mediaProfile === 'audio cd') {
|
|
return true;
|
|
}
|
|
// Strong negative signals: never route known non-CD jobs into MusicBrainz.
|
|
if (['bluray', 'dvd', 'audiobook', 'converter', 'other'].includes(mediaProfile)) {
|
|
return false;
|
|
}
|
|
if (String(rawContext?.cdparanoiaCmd || rawContext?.cdparanoiaCommandPreview || '').trim()) {
|
|
return true;
|
|
}
|
|
const hasMusicBrainzSignals = Boolean(
|
|
String(
|
|
rawContext?.selectedMetadata?.mbId
|
|
|| rawContext?.selectedMetadata?.musicBrainzId
|
|
|| rawContext?.selectedMetadata?.musicbrainzId
|
|
|| rawContext?.selectedMetadata?.mbid
|
|
|| ''
|
|
).trim()
|
|
);
|
|
if (hasMusicBrainzSignals) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isCdAnalyzeResponsePayload(payload) {
|
|
const result = payload && typeof payload === 'object' ? payload : {};
|
|
if (isCdMetadataContext(result)) {
|
|
return true;
|
|
}
|
|
const mediaProfile = String(result?.mediaProfile || result?.selectedMetadata?.mediaProfile || '').trim().toLowerCase();
|
|
const hasVideoSignals = ['bluray', 'dvd'].includes(mediaProfile)
|
|
|| hasSeriesWorkflowHints(result)
|
|
|| Boolean(
|
|
String(
|
|
result?.selectedMetadata?.imdbId
|
|
|| result?.selectedMetadata?.tmdbId
|
|
|| result?.selectedMetadata?.providerId
|
|
|| result?.imdbId
|
|
|| result?.tmdbId
|
|
|| ''
|
|
).trim()
|
|
);
|
|
if (hasVideoSignals) {
|
|
return false;
|
|
}
|
|
if (Array.isArray(result?.tracks)) {
|
|
const trackList = result.tracks.filter((track) => track && typeof track === 'object');
|
|
const looksLikeCdTrackList = trackList.length > 0
|
|
&& trackList.every((track) => {
|
|
const position = Number(track?.position);
|
|
return Number.isFinite(position) && position > 0;
|
|
});
|
|
if (looksLikeCdTrackList) {
|
|
return true;
|
|
}
|
|
}
|
|
const status = String(result?.status || result?.state || result?.lastState || '').trim().toUpperCase();
|
|
if (status.startsWith('CD_')) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function normalizeDrivePath(value) {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function getPathBaseName(value) {
|
|
const normalized = String(value || '').trim().replace(/[\\]+/g, '/');
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
const segments = normalized.split('/').filter(Boolean);
|
|
return segments[segments.length - 1] || '';
|
|
}
|
|
|
|
function isIncompleteRawPath(rawPath) {
|
|
const baseName = getPathBaseName(rawPath);
|
|
return /^incomplete_/i.test(baseName);
|
|
}
|
|
|
|
function normalizePathForMatch(value) {
|
|
return String(value || '').trim().replace(/[\\]+/g, '/');
|
|
}
|
|
|
|
function isIncompleteOutputPath(outputPath) {
|
|
const normalized = normalizePathForMatch(outputPath);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return /(^|\/)incomplete_job-\d+(\/|$)/i.test(normalized)
|
|
|| /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
|
|
}
|
|
|
|
function isIncompleteMergeOutputPath(outputPath) {
|
|
const normalized = normalizePathForMatch(outputPath);
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return /(^|\/)incomplete_merge_[^/]+_job_\d+(\/|$)/i.test(normalized);
|
|
}
|
|
|
|
function getIncompleteOutputSelectionPaths(outputPath) {
|
|
const normalized = normalizePathForMatch(outputPath);
|
|
if (!normalized) {
|
|
return [];
|
|
}
|
|
const segments = normalized.split('/').filter(Boolean);
|
|
const incompleteJobIndex = segments.findIndex((segment) => /^incomplete_job-\d+$/i.test(segment));
|
|
if (incompleteJobIndex >= 0) {
|
|
const folderPath = `/${segments.slice(0, incompleteJobIndex + 1).join('/')}`;
|
|
return Array.from(new Set([normalized, folderPath]));
|
|
}
|
|
const incompleteMergeIndex = segments.findIndex((segment) => /^incomplete_merge_[^/]+_job_\d+$/i.test(segment));
|
|
if (incompleteMergeIndex >= 0) {
|
|
const mergeFolderPath = `/${segments.slice(0, incompleteMergeIndex + 1).join('/')}`;
|
|
const normalizedLooksLikeFolder = incompleteMergeIndex === (segments.length - 1);
|
|
return normalizedLooksLikeFolder
|
|
? [mergeFolderPath]
|
|
: [normalized];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return 'n/a';
|
|
}
|
|
return `${Math.trunc(parsed)}%`;
|
|
}
|
|
|
|
function isPlaylistTrackPreparationStatus(value) {
|
|
const normalized = String(value || '').trim().toUpperCase();
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
return normalized.includes('PLAYLIST')
|
|
&& normalized.includes('TRACKDATEN')
|
|
&& normalized.includes('VORBEREIT');
|
|
}
|
|
|
|
function isMediainfoCheckStatus(value) {
|
|
return String(value || '').trim().toUpperCase() === 'MEDIAINFO_CHECK';
|
|
}
|
|
|
|
function formatTemperature(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return 'n/a';
|
|
}
|
|
return `${parsed.toFixed(1)}°C`;
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return 'n/a';
|
|
}
|
|
if (parsed === 0) {
|
|
return '0 B';
|
|
}
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
let unitIndex = 0;
|
|
let current = parsed;
|
|
while (current >= 1024 && unitIndex < units.length - 1) {
|
|
current /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
const digits = unitIndex <= 1 ? 0 : 2;
|
|
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatBytesCompact(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return 'n/a';
|
|
}
|
|
if (parsed === 0) {
|
|
return '0B';
|
|
}
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
let unitIndex = 0;
|
|
let current = parsed;
|
|
while (current >= 1024 && unitIndex < units.length - 1) {
|
|
current /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
return `${Math.round(current)}${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatUsageCompact(usedValue, totalValue) {
|
|
return `${formatBytesCompact(usedValue)}/${formatBytesCompact(totalValue)}`;
|
|
}
|
|
|
|
function formatUpdatedAt(value) {
|
|
if (!value) {
|
|
return '-';
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return '-';
|
|
}
|
|
return date.toLocaleString('de-DE');
|
|
}
|
|
|
|
function formatDurationMs(value) {
|
|
const ms = Number(value);
|
|
if (!Number.isFinite(ms) || ms < 0) {
|
|
return '-';
|
|
}
|
|
if (ms < 1000) {
|
|
return `${Math.round(ms)} ms`;
|
|
}
|
|
const seconds = Math.round(ms / 1000);
|
|
if (seconds < 60) {
|
|
return `${seconds}s`;
|
|
}
|
|
const minutes = Math.floor(seconds / 60);
|
|
const restSeconds = seconds % 60;
|
|
return `${minutes}m ${restSeconds}s`;
|
|
}
|
|
|
|
function normalizeRuntimeActivitiesPayload(rawPayload) {
|
|
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
|
const normalizeItem = (item) => {
|
|
const source = item && typeof item === 'object' ? item : {};
|
|
const parsedId = Number(source.id);
|
|
const id = Number.isFinite(parsedId) && parsedId > 0 ? Math.trunc(parsedId) : null;
|
|
return {
|
|
id,
|
|
type: String(source.type || '').trim().toLowerCase() || 'task',
|
|
name: String(source.name || '').trim() || '-',
|
|
status: String(source.status || '').trim().toLowerCase() || 'running',
|
|
outcome: String(source.outcome || '').trim().toLowerCase() || null,
|
|
source: String(source.source || '').trim() || null,
|
|
message: String(source.message || '').trim() || null,
|
|
errorMessage: String(source.errorMessage || '').trim() || null,
|
|
currentStep: String(source.currentStep || '').trim() || null,
|
|
currentScriptName: String(source.currentScriptName || '').trim() || null,
|
|
output: source.output != null ? String(source.output) : null,
|
|
stdout: source.stdout != null ? String(source.stdout) : null,
|
|
stderr: source.stderr != null ? String(source.stderr) : null,
|
|
outputTruncated: Boolean(source.outputTruncated),
|
|
stdoutTruncated: Boolean(source.stdoutTruncated),
|
|
stderrTruncated: Boolean(source.stderrTruncated),
|
|
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
|
|
startedAt: source.startedAt || null,
|
|
finishedAt: source.finishedAt || null,
|
|
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
|
|
jobId: Number.isFinite(Number(source.jobId)) && Number(source.jobId) > 0 ? Math.trunc(Number(source.jobId)) : null,
|
|
cronJobId: Number.isFinite(Number(source.cronJobId)) && Number(source.cronJobId) > 0 ? Math.trunc(Number(source.cronJobId)) : null,
|
|
chainId: Number.isFinite(Number(source.chainId)) && Number(source.chainId) > 0 ? Math.trunc(Number(source.chainId)) : null,
|
|
scriptId: Number.isFinite(Number(source.scriptId)) && Number(source.scriptId) > 0 ? Math.trunc(Number(source.scriptId)) : null,
|
|
canCancel: Boolean(source.canCancel),
|
|
canNextStep: Boolean(source.canNextStep),
|
|
parentActivityId: Number.isFinite(Number(source.parentActivityId)) && Number(source.parentActivityId) > 0
|
|
? Math.trunc(Number(source.parentActivityId))
|
|
: null,
|
|
stepIndex: Number.isFinite(Number(source.stepIndex)) ? Math.trunc(Number(source.stepIndex)) : null,
|
|
stepTotal: Number.isFinite(Number(source.stepTotal)) && Number(source.stepTotal) > 0
|
|
? Math.trunc(Number(source.stepTotal))
|
|
: null
|
|
};
|
|
};
|
|
const active = (Array.isArray(payload.active) ? payload.active : []).map(normalizeItem);
|
|
const recent = (Array.isArray(payload.recent) ? payload.recent : []).map(normalizeItem);
|
|
return {
|
|
active,
|
|
recent,
|
|
updatedAt: payload.updatedAt || null
|
|
};
|
|
}
|
|
|
|
function runtimeTypeLabel(type) {
|
|
const normalized = String(type || '').trim().toLowerCase();
|
|
if (normalized === 'script') return 'Skript';
|
|
if (normalized === 'chain') return 'Kette';
|
|
if (normalized === 'cron') return 'Cronjob';
|
|
return normalized || 'Task';
|
|
}
|
|
|
|
function runtimeStatusMeta(status) {
|
|
const normalized = String(status || '').trim().toLowerCase();
|
|
if (normalized === 'running') return { label: 'Läuft', severity: 'warning' };
|
|
if (normalized === 'success') return { label: 'Abgeschlossen', severity: 'success' };
|
|
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
|
|
return { label: normalized || '-', severity: 'secondary' };
|
|
}
|
|
|
|
function runtimeOutcomeMeta(outcome, status) {
|
|
const normalized = String(outcome || '').trim().toLowerCase();
|
|
if (normalized === 'success') return { label: 'Erfolg', severity: 'success' };
|
|
if (normalized === 'error') return { label: 'Fehler', severity: 'danger' };
|
|
if (normalized === 'cancelled') return { label: 'Abgebrochen', severity: 'warning' };
|
|
if (normalized === 'skipped') return { label: 'Übersprungen', severity: 'info' };
|
|
return runtimeStatusMeta(status);
|
|
}
|
|
|
|
function driveStateIconMeta(stateLabel) {
|
|
const normalized = String(stateLabel || '').trim().toUpperCase();
|
|
if (!normalized || normalized === 'LEER' || normalized === 'IDLE') {
|
|
return { icon: 'pi-stop', spin: false };
|
|
}
|
|
if (normalized === 'DISC_DETECTED') {
|
|
return { icon: 'pi-check', spin: false };
|
|
}
|
|
if (normalized === 'FINISHED') {
|
|
return { icon: 'pi-check-circle', spin: false };
|
|
}
|
|
if (normalized === 'ERROR' || normalized === 'CANCELLED') {
|
|
return { icon: 'pi-times-circle', spin: false };
|
|
}
|
|
if (processingStates.includes(normalized)) {
|
|
return { icon: 'pi-spinner', spin: true };
|
|
}
|
|
if (
|
|
normalized === 'METADATA_SELECTION'
|
|
|| normalized === 'CD_METADATA_SELECTION'
|
|
|| normalized === 'WAITING_FOR_USER_DECISION'
|
|
) {
|
|
return { icon: 'pi-list', spin: false };
|
|
}
|
|
if (
|
|
normalized === 'READY_TO_START'
|
|
|| normalized === 'READY_TO_ENCODE'
|
|
|| normalized === 'CD_READY_TO_RIP'
|
|
) {
|
|
return { icon: 'pi-play', spin: false };
|
|
}
|
|
return { icon: 'pi-question-circle', spin: false };
|
|
}
|
|
|
|
function hasRuntimeOutputDetails(item) {
|
|
if (!item || typeof item !== 'object') {
|
|
return false;
|
|
}
|
|
const hasRelevantExitCode = Number.isFinite(Number(item.exitCode)) && Number(item.exitCode) !== 0;
|
|
return Boolean(
|
|
String(item.errorMessage || '').trim()
|
|
|| String(item.output || '').trim()
|
|
|| String(item.stdout || '').trim()
|
|
|| String(item.stderr || '').trim()
|
|
|| hasRelevantExitCode
|
|
);
|
|
}
|
|
|
|
function hasRuntimeLogContent(item) {
|
|
if (!item || typeof item !== 'object') {
|
|
return false;
|
|
}
|
|
return Boolean(
|
|
String(item.output || '').trim()
|
|
|| String(item.stdout || '').trim()
|
|
|| String(item.stderr || '').trim()
|
|
);
|
|
}
|
|
|
|
function RuntimeActivityDetails({
|
|
item,
|
|
summary,
|
|
emptyLabel = 'Noch keine Log-Ausgabe vorhanden.'
|
|
}) {
|
|
const hasLogs = hasRuntimeLogContent(item);
|
|
const hasOutput = Boolean(String(item?.output || '').trim());
|
|
const hasStdout = Boolean(String(item?.stdout || '').trim());
|
|
const hasStderr = Boolean(String(item?.stderr || '').trim());
|
|
|
|
return (
|
|
<details className="runtime-activity-details">
|
|
<summary>{summary}</summary>
|
|
{!hasLogs ? <small>{emptyLabel}</small> : null}
|
|
{hasOutput ? (
|
|
<div className="runtime-activity-details-block">
|
|
<small><strong>Ausgabe:</strong>{item?.outputTruncated ? ' (gekürzt)' : ''}</small>
|
|
<pre>{item.output}</pre>
|
|
</div>
|
|
) : null}
|
|
{hasStdout ? (
|
|
<div className="runtime-activity-details-block">
|
|
<small><strong>stdout:</strong>{item?.stdoutTruncated ? ' (gekürzt)' : ''}</small>
|
|
<pre>{item.stdout}</pre>
|
|
</div>
|
|
) : null}
|
|
{hasStderr ? (
|
|
<div className="runtime-activity-details-block">
|
|
<small><strong>stderr:</strong>{item?.stderrTruncated ? ' (gekürzt)' : ''}</small>
|
|
<pre>{item.stderr}</pre>
|
|
</div>
|
|
) : null}
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function normalizeHardwareMonitoringPayload(rawPayload) {
|
|
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
|
return {
|
|
enabled: Boolean(payload.enabled),
|
|
intervalMs: Number(payload.intervalMs || 0),
|
|
updatedAt: payload.updatedAt || null,
|
|
sample: payload.sample && typeof payload.sample === 'object' ? payload.sample : null,
|
|
error: payload.error ? String(payload.error) : null
|
|
};
|
|
}
|
|
|
|
function getStorageUsageTone(usagePercent) {
|
|
const value = Number(usagePercent);
|
|
if (!Number.isFinite(value)) {
|
|
return 'unknown';
|
|
}
|
|
if (value >= 95) {
|
|
return 'critical';
|
|
}
|
|
if (value >= 85) {
|
|
return 'high';
|
|
}
|
|
if (value >= 70) {
|
|
return 'warn';
|
|
}
|
|
return 'ok';
|
|
}
|
|
|
|
function normalizeQueue(queue) {
|
|
const payload = queue && typeof queue === 'object' ? queue : {};
|
|
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
|
|
const idleJobs = Array.isArray(payload.idleJobs) ? payload.idleJobs : [];
|
|
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
|
|
return {
|
|
maxParallelJobs: Number(payload.maxParallelJobs || 1),
|
|
maxParallelCdEncodes: Number(payload.maxParallelCdEncodes || 2),
|
|
maxTotalEncodes: Number(payload.maxTotalEncodes || 3),
|
|
cdBypassesQueue: Boolean(payload.cdBypassesQueue),
|
|
runningCount: Number(payload.runningCount || runningJobs.length || 0),
|
|
runningCdCount: Number(payload.runningCdCount || 0),
|
|
runningJobs,
|
|
idleCount: Number(payload.idleCount || idleJobs.length || 0),
|
|
idleJobs,
|
|
queuedJobs,
|
|
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
|
|
updatedAt: payload.updatedAt || null
|
|
};
|
|
}
|
|
|
|
function buildCdDriveLifecycleKey(cdDrives) {
|
|
if (!cdDrives || typeof cdDrives !== 'object') {
|
|
return '';
|
|
}
|
|
const entries = Object.entries(cdDrives)
|
|
.map(([devicePath, driveState]) => {
|
|
const state = String(driveState?.state || '').trim().toUpperCase();
|
|
const jobId = normalizeJobId(driveState?.jobId || driveState?.context?.jobId) || 0;
|
|
return `${devicePath}|${jobId}|${state}`;
|
|
})
|
|
.sort();
|
|
return entries.join('::');
|
|
}
|
|
|
|
function getQueueActionResult(response) {
|
|
return response?.result && typeof response.result === 'object' ? response.result : {};
|
|
}
|
|
|
|
function showQueuedToast(toastRef, actionLabel, result) {
|
|
if (!toastRef?.current) {
|
|
return;
|
|
}
|
|
const queuePosition = Number(result?.queuePosition || 0);
|
|
const positionText = queuePosition > 0 ? `Position ${queuePosition}` : 'in der Warteschlange';
|
|
toastRef.current.show({
|
|
severity: 'info',
|
|
summary: `${actionLabel} in Queue`,
|
|
detail: `${actionLabel} wurde ${positionText} eingeplant.`,
|
|
life: 3200
|
|
});
|
|
}
|
|
|
|
function reorderQueuedItems(items, draggedEntryId, targetEntryId) {
|
|
const list = Array.isArray(items) ? items : [];
|
|
const from = list.findIndex((item) => Number(item?.entryId) === Number(draggedEntryId));
|
|
const to = list.findIndex((item) => Number(item?.entryId) === Number(targetEntryId));
|
|
if (from < 0 || to < 0 || from === to) {
|
|
return list;
|
|
}
|
|
const next = [...list];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(to, 0, moved);
|
|
return next.map((item, index) => ({
|
|
...item,
|
|
position: index + 1
|
|
}));
|
|
}
|
|
|
|
function queueEntryIcon(type) {
|
|
if (type === 'script') return 'pi pi-code';
|
|
if (type === 'chain') return 'pi pi-link';
|
|
if (type === 'wait') return 'pi pi-clock';
|
|
return 'pi pi-box';
|
|
}
|
|
|
|
function queueEntryLabel(item) {
|
|
if (item.type === 'script') return `Skript: ${item.title}`;
|
|
if (item.type === 'chain') return `Kette: ${item.title}`;
|
|
if (item.type === 'wait') return `Warten: ${item.waitSeconds}s`;
|
|
return item.title || `Job #${item.jobId}`;
|
|
}
|
|
|
|
function isGenericChainQueueLabel(value, chainId = null) {
|
|
const text = String(value || '').trim();
|
|
if (!text) {
|
|
return true;
|
|
}
|
|
const compact = text.toLowerCase().replace(/\s+/g, ' ');
|
|
if (/^kette\s*#?\s*\d+$/.test(compact)) {
|
|
return true;
|
|
}
|
|
const normalizedChainId = Number(chainId);
|
|
if (Number.isFinite(normalizedChainId) && normalizedChainId > 0) {
|
|
const idToken = String(Math.trunc(normalizedChainId));
|
|
if (compact === `kette ${idToken}` || compact === `kette #${idToken}`) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveChainQueueSubtitle(item, chainNameById = new Map()) {
|
|
const chainIdRaw = Number(item?.chainId);
|
|
const chainId = Number.isFinite(chainIdRaw) && chainIdRaw > 0 ? Math.trunc(chainIdRaw) : null;
|
|
const explicitChainName = String(item?.chainName || '').trim();
|
|
if (explicitChainName) {
|
|
return explicitChainName;
|
|
}
|
|
const title = String(item?.title || '').trim();
|
|
if (title && !isGenericChainQueueLabel(title, chainId)) {
|
|
return title;
|
|
}
|
|
if (chainId && chainNameById instanceof Map) {
|
|
const mappedName = String(chainNameById.get(chainId) || '').trim();
|
|
if (mappedName) {
|
|
return mappedName;
|
|
}
|
|
}
|
|
return title || null;
|
|
}
|
|
|
|
function parseSeriesQueueEpisodeText(value) {
|
|
const text = String(value || '').trim();
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
const compactMatch = text.match(/\bS\d{1,3}E\d{1,3}(?:-\d{1,3})?\b/i);
|
|
const spacedMatch = text.match(/\bS\s*(\d{1,3})\s*E\s*(\d{1,3})(?:\s*-\s*(\d{1,3}))?\b/i);
|
|
const xStyleMatch = text.match(/\b(\d{1,3})\s*[xX]\s*(\d{1,3})(?:\s*-\s*(\d{1,3}))?\b/);
|
|
const codeMatch = compactMatch || spacedMatch || xStyleMatch;
|
|
if (!codeMatch) {
|
|
return null;
|
|
}
|
|
|
|
let code = '';
|
|
if (compactMatch) {
|
|
code = String(compactMatch[0] || '').trim().toUpperCase().replace(/\s+/g, '');
|
|
} else {
|
|
const season = Number(codeMatch[1]);
|
|
const episodeStart = Number(codeMatch[2]);
|
|
const episodeEnd = Number(codeMatch[3]);
|
|
const seasonToken = Number.isFinite(season) ? String(Math.trunc(season)).padStart(2, '0') : '??';
|
|
const episodeStartToken = Number.isFinite(episodeStart) ? String(Math.trunc(episodeStart)).padStart(2, '0') : '??';
|
|
const episodeEndToken = Number.isFinite(episodeEnd) ? String(Math.trunc(episodeEnd)).padStart(2, '0') : null;
|
|
code = `S${seasonToken}E${episodeStartToken}${episodeEndToken ? `-${episodeEndToken}` : ''}`;
|
|
}
|
|
|
|
const index = Number(codeMatch.index || 0);
|
|
const prefix = text.slice(0, index).trim().replace(/[-|:–—]+\s*$/u, '').trim();
|
|
const matchedRaw = String(codeMatch[0] || '');
|
|
const trailing = text.slice(index + matchedRaw.length).trim();
|
|
const episodeTitle = trailing.replace(/^[-|:–—]+\s*/u, '').trim() || null;
|
|
return {
|
|
code,
|
|
prefix: prefix || null,
|
|
episodeTitle
|
|
};
|
|
}
|
|
|
|
function resolveSeriesQueueDisplay(item) {
|
|
const titleRaw = String(item?.title || '').trim();
|
|
const subtitleRaw = String(item?.subtitle || '').trim();
|
|
const titleParsed = parseSeriesQueueEpisodeText(titleRaw);
|
|
const subtitleParsed = parseSeriesQueueEpisodeText(subtitleRaw);
|
|
const parsed = subtitleParsed || titleParsed;
|
|
if (!parsed) {
|
|
return {
|
|
isSeriesEpisode: false,
|
|
title: titleRaw || `Job #${item?.jobId || '-'}`,
|
|
subtitle: subtitleRaw || null
|
|
};
|
|
}
|
|
|
|
let baseTitle = titleRaw;
|
|
if (titleParsed?.prefix) {
|
|
baseTitle = titleParsed.prefix;
|
|
} else if (
|
|
subtitleParsed?.prefix
|
|
&& (!baseTitle || baseTitle === subtitleRaw || /\bS\d{1,3}E\d{1,3}/i.test(baseTitle))
|
|
) {
|
|
baseTitle = subtitleParsed.prefix;
|
|
}
|
|
|
|
const resolvedTitle = String(baseTitle || '').trim() || `Job #${item?.jobId || '-'}`;
|
|
const episodeSubtitle = parsed.episodeTitle
|
|
? `${parsed.code} - ${parsed.episodeTitle}`
|
|
: parsed.code;
|
|
return {
|
|
isSeriesEpisode: true,
|
|
title: resolvedTitle,
|
|
subtitle: episodeSubtitle
|
|
};
|
|
}
|
|
|
|
function normalizeQueueNameList(values) {
|
|
const list = Array.isArray(values) ? values : [];
|
|
const seen = new Set();
|
|
const output = [];
|
|
for (const item of list) {
|
|
const name = String(item || '').trim();
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const key = name.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
output.push(name);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function normalizeQueueScriptSummary(item) {
|
|
const source = item?.scriptSummary && typeof item.scriptSummary === 'object' ? item.scriptSummary : {};
|
|
return {
|
|
preScripts: normalizeQueueNameList(source.preScripts),
|
|
postScripts: normalizeQueueNameList(source.postScripts),
|
|
preChains: normalizeQueueNameList(source.preChains),
|
|
postChains: normalizeQueueNameList(source.postChains)
|
|
};
|
|
}
|
|
|
|
function hasQueueScriptSummary(item) {
|
|
const summary = normalizeQueueScriptSummary(item);
|
|
return summary.preScripts.length > 0
|
|
|| summary.postScripts.length > 0
|
|
|| summary.preChains.length > 0
|
|
|| summary.postChains.length > 0;
|
|
}
|
|
|
|
function QueueJobScriptSummary({ item }) {
|
|
const summary = normalizeQueueScriptSummary(item);
|
|
const groups = [
|
|
{ key: 'pre-scripts', icon: 'pi pi-code', label: 'Pre-Encode Skripte', values: summary.preScripts },
|
|
{ key: 'post-scripts', icon: 'pi pi-code', label: 'Post-Encode Skripte', values: summary.postScripts },
|
|
{ key: 'pre-chains', icon: 'pi pi-link', label: 'Pre-Encode Ketten', values: summary.preChains },
|
|
{ key: 'post-chains', icon: 'pi pi-link', label: 'Post-Encode Ketten', values: summary.postChains }
|
|
].filter((group) => group.values.length > 0);
|
|
|
|
if (groups.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="queue-job-script-details">
|
|
{groups.map((group) => {
|
|
const text = group.values.join(' | ');
|
|
return (
|
|
<div key={group.key} className="queue-job-script-group">
|
|
<strong><i className={group.icon} /> {group.label}</strong>
|
|
<small title={text}>{text}</small>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getAnalyzeContext(job) {
|
|
return job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
|
|
? job.makemkvInfo.analyzeContext
|
|
: {};
|
|
}
|
|
|
|
function getEncodePlan(job) {
|
|
if (job?.encodePlan && typeof job.encodePlan === 'object') {
|
|
return job.encodePlan;
|
|
}
|
|
if (job?.encode_plan_json && typeof job.encode_plan_json === 'object') {
|
|
return job.encode_plan_json;
|
|
}
|
|
const rawPlan = String(job?.encode_plan_json || '').trim();
|
|
if (!rawPlan) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(rawPlan);
|
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveJobKindRaw(job) {
|
|
return String(job?.job_kind || job?.jobKind || '').trim().toLowerCase();
|
|
}
|
|
|
|
function isMultipartMergeJob(job) {
|
|
return resolveJobKindRaw(job) === 'multipart_movie_merge';
|
|
}
|
|
|
|
function getMergeAwareStatusLabel(job, status, options = {}) {
|
|
const queued = Boolean(options?.queued);
|
|
const normalizedStatus = normalizeStatus(status);
|
|
if (!queued && isMultipartMergeJob(job) && normalizedStatus === 'ENCODING') {
|
|
return 'Merging';
|
|
}
|
|
return getStatusLabel(status, options);
|
|
}
|
|
|
|
function extractMultipartMergeSources(job) {
|
|
const encodePlan = getEncodePlan(job) || {};
|
|
const sourceItems = Array.isArray(encodePlan?.sourceItems) ? encodePlan.sourceItems : [];
|
|
const normalized = sourceItems
|
|
.map((item) => {
|
|
const sourceJobId = normalizeJobId(item?.jobId || item?.sourceJobId);
|
|
const discNumberRaw = Number(item?.discNumber);
|
|
const discNumber = Number.isFinite(discNumberRaw) && discNumberRaw > 0
|
|
? Math.trunc(discNumberRaw)
|
|
: null;
|
|
const outputPath = String(item?.outputPath || item?.path || '').trim();
|
|
if (!sourceJobId || !outputPath) {
|
|
return null;
|
|
}
|
|
return {
|
|
sourceJobId,
|
|
discNumber,
|
|
outputPath,
|
|
fileName: getPathBaseName(outputPath)
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
if (normalized.length === 0) {
|
|
return [];
|
|
}
|
|
const orderedIds = Array.isArray(encodePlan?.orderedSourceJobIds)
|
|
? encodePlan.orderedSourceJobIds.map((value) => normalizeJobId(value)).filter(Boolean)
|
|
: [];
|
|
const byId = new Map(normalized.map((item) => [item.sourceJobId, item]));
|
|
const ordered = [];
|
|
const seen = new Set();
|
|
for (const sourceJobId of orderedIds) {
|
|
const item = byId.get(sourceJobId);
|
|
if (!item || seen.has(sourceJobId)) {
|
|
continue;
|
|
}
|
|
seen.add(sourceJobId);
|
|
ordered.push(item);
|
|
}
|
|
for (const item of normalized) {
|
|
if (seen.has(item.sourceJobId)) {
|
|
continue;
|
|
}
|
|
seen.add(item.sourceJobId);
|
|
ordered.push(item);
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
function shouldDeleteMultipartMergeInputsAfterSuccess(job) {
|
|
const encodePlan = getEncodePlan(job) || {};
|
|
return Boolean(encodePlan?.deleteInputsAfterMerge);
|
|
}
|
|
|
|
function formatMergeTrackLanguage(language) {
|
|
const normalized = String(language || '').trim().toLowerCase();
|
|
if (!normalized || normalized === 'und') {
|
|
return 'und';
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function formatMergeTrackSummary(track = {}, fallbackLabel = 'Track') {
|
|
const source = track && typeof track === 'object' ? track : {};
|
|
const orderRaw = Number(source.order);
|
|
const order = Number.isFinite(orderRaw) && orderRaw > 0 ? Math.trunc(orderRaw) : null;
|
|
const language = formatMergeTrackLanguage(source.language);
|
|
const codec = String(source.codec || '?').trim() || '?';
|
|
const title = String(source.title || '').trim();
|
|
const channelsRaw = Number(source.channels);
|
|
const channelInfo = Number.isFinite(channelsRaw) && channelsRaw > 0
|
|
? `${Math.trunc(channelsRaw)}ch`
|
|
: null;
|
|
const flags = [];
|
|
if (source.default === true) {
|
|
flags.push('default');
|
|
}
|
|
if (source.forced === true) {
|
|
flags.push('forced');
|
|
}
|
|
const parts = [
|
|
order ? `#${order}` : fallbackLabel,
|
|
language,
|
|
codec
|
|
];
|
|
if (channelInfo) {
|
|
parts.push(channelInfo);
|
|
}
|
|
if (title) {
|
|
parts.push(title);
|
|
}
|
|
if (flags.length > 0) {
|
|
parts.push(`[${flags.join(', ')}]`);
|
|
}
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
function resolveMediaType(job) {
|
|
const centralMediaType = resolveCentralMediaType(job);
|
|
if (centralMediaType && centralMediaType !== 'other') {
|
|
return centralMediaType;
|
|
}
|
|
|
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
|
const candidates = [
|
|
job?.mediaType,
|
|
job?.media_type,
|
|
job?.mediaProfile,
|
|
job?.media_profile,
|
|
encodePlan?.mediaProfile,
|
|
job?.makemkvInfo?.analyzeContext?.mediaProfile,
|
|
job?.makemkvInfo?.mediaProfile,
|
|
job?.mediainfoInfo?.mediaProfile
|
|
];
|
|
for (const candidate of candidates) {
|
|
const raw = String(candidate || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
continue;
|
|
}
|
|
if (['bluray', 'blu-ray', 'blu_ray', 'bd', 'bdmv', 'bdrom', 'bd-rom', 'bd-r', 'bd-re'].includes(raw)) {
|
|
return 'bluray';
|
|
}
|
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
|
return 'dvd';
|
|
}
|
|
if (['cd', 'audio_cd', 'audio cd'].includes(raw)) {
|
|
return 'cd';
|
|
}
|
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
|
return 'audiobook';
|
|
}
|
|
}
|
|
const statusCandidates = [
|
|
job?.status,
|
|
job?.last_state,
|
|
job?.makemkvInfo?.lastState
|
|
];
|
|
if (statusCandidates.some((value) => String(value || '').trim().toUpperCase().startsWith('CD_'))) {
|
|
return 'cd';
|
|
}
|
|
const planFormat = String(encodePlan?.format || '').trim().toLowerCase();
|
|
const hasCdTracksInPlan = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0;
|
|
if (hasCdTracksInPlan && ['flac', 'wav', 'mp3', 'opus', 'ogg'].includes(planFormat)) {
|
|
return 'cd';
|
|
}
|
|
if (String(job?.handbrakeInfo?.mode || '').trim().toLowerCase() === 'cd_rip') {
|
|
return 'cd';
|
|
}
|
|
if (Array.isArray(job?.makemkvInfo?.tracks) && job.makemkvInfo.tracks.length > 0) {
|
|
return 'cd';
|
|
}
|
|
if (['audiobook_encode', 'audiobook_encode_split'].includes(String(job?.handbrakeInfo?.mode || '').trim().toLowerCase())) {
|
|
return 'audiobook';
|
|
}
|
|
if (String(encodePlan?.mode || '').trim().toLowerCase() === 'audiobook') {
|
|
return 'audiobook';
|
|
}
|
|
return centralMediaType || 'other';
|
|
}
|
|
|
|
function mediaIndicatorMeta(job) {
|
|
if (isMultipartMergeJob(job)) {
|
|
return {
|
|
mediaType: 'multipart_merge',
|
|
src: mergeIndicatorIcon,
|
|
alt: 'Merge Job',
|
|
title: 'Multipart Merge Job'
|
|
};
|
|
}
|
|
const mediaType = resolveMediaType(job);
|
|
const isSeriesVideo = (mediaType === 'dvd' || mediaType === 'bluray') && isSeriesVideoJob(job);
|
|
if (mediaType === 'bluray') {
|
|
return {
|
|
mediaType,
|
|
src: blurayIndicatorIcon,
|
|
alt: isSeriesVideo ? 'Blu-ray Serie' : 'Blu-ray',
|
|
title: isSeriesVideo ? 'Blu-ray Serie' : 'Blu-ray',
|
|
isSeriesDvd: isSeriesVideo,
|
|
isSeriesVideo
|
|
};
|
|
}
|
|
if (mediaType === 'dvd') {
|
|
return {
|
|
mediaType,
|
|
src: discIndicatorIcon,
|
|
alt: isSeriesVideo ? 'DVD Serie' : 'DVD',
|
|
title: isSeriesVideo ? 'DVD Serie' : 'DVD',
|
|
isSeriesDvd: isSeriesVideo,
|
|
isSeriesVideo
|
|
};
|
|
}
|
|
if (mediaType === 'cd') {
|
|
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
|
|
}
|
|
if (mediaType === 'audiobook') {
|
|
return { mediaType, src: audiobookIndicatorIcon, alt: 'Audiobook', title: 'Audiobook' };
|
|
}
|
|
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
|
}
|
|
|
|
function normalizeDiscWorkflowKind(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
|
|
return 'series';
|
|
}
|
|
if (['film', 'movie', 'feature'].includes(raw)) {
|
|
return 'film';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveDiscWorkflowKind({
|
|
mediaType,
|
|
selectedMetadata = null,
|
|
analyzeContext = null,
|
|
pipelineContext = null,
|
|
fallbackIsSeries = false
|
|
} = {}) {
|
|
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
|
|
return null;
|
|
}
|
|
const selected = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
|
|
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
|
|
const pipelineCtx = pipelineContext && typeof pipelineContext === 'object' ? pipelineContext : {};
|
|
const workflowCandidates = [
|
|
selected?.workflowKind,
|
|
selected?.kind,
|
|
analyze?.workflowKind,
|
|
pipelineCtx?.workflowKind
|
|
];
|
|
for (const candidate of workflowCandidates) {
|
|
const normalized = normalizeDiscWorkflowKind(candidate);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
}
|
|
const metadataKindCandidates = [
|
|
selected?.metadataKind,
|
|
analyze?.metadataKind
|
|
];
|
|
for (const candidate of metadataKindCandidates) {
|
|
const normalized = normalizeDiscWorkflowKind(candidate);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
}
|
|
if (
|
|
hasSeriesWorkflowHints(selected)
|
|
|| hasSeriesWorkflowHints(analyze)
|
|
|| hasSeriesWorkflowHints(pipelineCtx)
|
|
) {
|
|
return 'series';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getDiscWorkflowBadgeLabel(mediaType, workflowKind) {
|
|
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
|
|
return null;
|
|
}
|
|
const kind = normalizeDiscWorkflowKind(workflowKind);
|
|
if (!kind) {
|
|
return null;
|
|
}
|
|
if (mediaType === 'bluray') {
|
|
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
|
|
}
|
|
return kind === 'series' ? 'DVD-Serie' : 'DVD-Film';
|
|
}
|
|
|
|
function resolveSeriesEpisodeJobIds(job, seriesBatchChildren = []) {
|
|
const ids = [];
|
|
const seen = new Set();
|
|
const pushId = (value) => {
|
|
const normalized = normalizeJobId(value);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
const key = String(normalized);
|
|
if (seen.has(key)) {
|
|
return;
|
|
}
|
|
seen.add(key);
|
|
ids.push(normalized);
|
|
};
|
|
|
|
const plan = getEncodePlan(job) || {};
|
|
const planChildJobIds = Array.isArray(plan?.seriesBatchChildJobIds) ? plan.seriesBatchChildJobIds : [];
|
|
for (const childId of planChildJobIds) {
|
|
pushId(childId);
|
|
}
|
|
|
|
const batchChildren = Array.isArray(seriesBatchChildren) ? seriesBatchChildren : [];
|
|
for (const child of batchChildren) {
|
|
pushId(child?.jobId);
|
|
}
|
|
|
|
const containerChildren = Array.isArray(job?.children) ? job.children : [];
|
|
for (const child of containerChildren) {
|
|
const childId = normalizeJobId(child?.id);
|
|
if (!childId) {
|
|
continue;
|
|
}
|
|
const childPlan = getEncodePlan(child) || {};
|
|
const looksLikeSeriesEpisodeJob = Boolean(
|
|
childPlan?.seriesBatchChild
|
|
|| childPlan?.seriesBatchVirtualEpisode
|
|
|| normalizeJobId(childPlan?.seriesBatchParentJobId)
|
|
|| normalizeJobId(childPlan?.seriesBatchTitleId)
|
|
|| normalizeJobId(childPlan?.seriesBatchChildIndex)
|
|
);
|
|
if (looksLikeSeriesEpisodeJob) {
|
|
pushId(childId);
|
|
}
|
|
}
|
|
|
|
return ids.sort((left, right) => left - right);
|
|
}
|
|
|
|
function JobStepChecks({ backupSuccess, encodeSuccess }) {
|
|
const hasAny = Boolean(backupSuccess || encodeSuccess);
|
|
if (!hasAny) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="job-step-checks">
|
|
{backupSuccess ? (
|
|
<span className="job-step-inline-ok" title="Backup/Rip erfolgreich">
|
|
<i className="pi pi-check-circle" aria-hidden="true" />
|
|
<span>Backup</span>
|
|
</span>
|
|
) : null}
|
|
{encodeSuccess ? (
|
|
<span className="job-step-inline-ok" title="Encode erfolgreich">
|
|
<i className="pi pi-check-circle" aria-hidden="true" />
|
|
<span>Encode</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
|
|
const jobId = normalizeJobId(job?.id);
|
|
const isCurrentSessionJob = Boolean(
|
|
jobId
|
|
&& currentPipelineJobId
|
|
&& jobId === currentPipelineJobId
|
|
&& String(currentPipeline?.state || '').trim().toUpperCase() !== 'IDLE'
|
|
);
|
|
|
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
|
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
|
const resolvedMediaType = resolveMediaType(job);
|
|
const analyzeContext = getAnalyzeContext(job);
|
|
const normalizePlanIdList = (values) => {
|
|
const list = Array.isArray(values) ? values : [];
|
|
const seen = new Set();
|
|
const output = [];
|
|
for (const value of list) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
continue;
|
|
}
|
|
const id = Math.trunc(parsed);
|
|
const key = String(id);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
output.push(id);
|
|
}
|
|
return output;
|
|
};
|
|
const buildNamedSelection = (ids, entries, fallbackLabel) => {
|
|
const source = Array.isArray(entries) ? entries : [];
|
|
const namesById = new Map(
|
|
source
|
|
.map((entry) => {
|
|
const id = Number(entry?.id ?? entry?.scriptId ?? entry?.chainId);
|
|
const normalized = Number.isFinite(id) && id > 0 ? Math.trunc(id) : null;
|
|
const name = String(entry?.name || entry?.scriptName || entry?.chainName || '').trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
return [normalized, name || null];
|
|
})
|
|
.filter(Boolean)
|
|
);
|
|
return ids.map((id) => ({
|
|
id,
|
|
name: namesById.get(id) || `${fallbackLabel} #${id}`
|
|
}));
|
|
};
|
|
const planPreScriptIds = normalizePlanIdList([
|
|
...(Array.isArray(encodePlan?.preEncodeScriptIds) ? encodePlan.preEncodeScriptIds : []),
|
|
...(Array.isArray(encodePlan?.preEncodeScripts) ? encodePlan.preEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
|
|
]);
|
|
const planPostScriptIds = normalizePlanIdList([
|
|
...(Array.isArray(encodePlan?.postEncodeScriptIds) ? encodePlan.postEncodeScriptIds : []),
|
|
...(Array.isArray(encodePlan?.postEncodeScripts) ? encodePlan.postEncodeScripts.map((entry) => entry?.id ?? entry?.scriptId) : [])
|
|
]);
|
|
const planPreChainIds = normalizePlanIdList([
|
|
...(Array.isArray(encodePlan?.preEncodeChainIds) ? encodePlan.preEncodeChainIds : []),
|
|
...(Array.isArray(encodePlan?.preEncodeChains) ? encodePlan.preEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
|
|
]);
|
|
const planPostChainIds = normalizePlanIdList([
|
|
...(Array.isArray(encodePlan?.postEncodeChainIds) ? encodePlan.postEncodeChainIds : []),
|
|
...(Array.isArray(encodePlan?.postEncodeChains) ? encodePlan.postEncodeChains.map((entry) => entry?.id ?? entry?.chainId) : [])
|
|
]);
|
|
const cdRipConfig = encodePlan && typeof encodePlan === 'object'
|
|
? {
|
|
format: String(encodePlan?.format || '').trim().toLowerCase() || null,
|
|
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
|
? encodePlan.formatOptions
|
|
: {},
|
|
selectedTracks: Array.isArray(encodePlan?.selectedTracks)
|
|
? encodePlan.selectedTracks
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value))
|
|
: [],
|
|
preEncodeScriptIds: planPreScriptIds,
|
|
postEncodeScriptIds: planPostScriptIds,
|
|
preEncodeChainIds: planPreChainIds,
|
|
postEncodeChainIds: planPostChainIds,
|
|
preEncodeScripts: buildNamedSelection(planPreScriptIds, encodePlan?.preEncodeScripts, 'Skript'),
|
|
postEncodeScripts: buildNamedSelection(planPostScriptIds, encodePlan?.postEncodeScripts, 'Skript'),
|
|
preEncodeChains: buildNamedSelection(planPreChainIds, encodePlan?.preEncodeChains, 'Kette'),
|
|
postEncodeChains: buildNamedSelection(planPostChainIds, encodePlan?.postEncodeChains, 'Kette'),
|
|
outputTemplate: String(encodePlan?.outputTemplate || '').trim() || null
|
|
}
|
|
: null;
|
|
const cdTracks = Array.isArray(makemkvInfo?.tracks)
|
|
? makemkvInfo.tracks
|
|
.map((track) => {
|
|
const position = Number(track?.position);
|
|
if (!Number.isFinite(position) || position <= 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
...track,
|
|
position: Math.trunc(position),
|
|
selected: track?.selected !== false
|
|
};
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
const cdSelectedMeta = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
|
? makemkvInfo.selectedMetadata
|
|
: {};
|
|
const fallbackCdArtist = cdTracks
|
|
.map((track) => String(track?.artist || '').trim())
|
|
.find(Boolean) || null;
|
|
const resolvedCdMbId = String(
|
|
cdSelectedMeta?.mbId
|
|
|| cdSelectedMeta?.musicBrainzId
|
|
|| cdSelectedMeta?.musicbrainzId
|
|
|| cdSelectedMeta?.mbid
|
|
|| ''
|
|
).trim() || null;
|
|
const resolvedCdCoverUrl = String(
|
|
cdSelectedMeta?.coverUrl
|
|
|| cdSelectedMeta?.poster
|
|
|| cdSelectedMeta?.posterUrl
|
|
|| job?.poster_url
|
|
|| ''
|
|
).trim() || null;
|
|
const cdparanoiaCmd = String(makemkvInfo?.cdparanoiaCmd || 'cdparanoia').trim() || 'cdparanoia';
|
|
const devicePath = String(job?.disc_device || '').trim() || null;
|
|
const firstConfiguredTrack = Array.isArray(encodePlan?.selectedTracks) && encodePlan.selectedTracks.length > 0
|
|
? Number(encodePlan.selectedTracks[0])
|
|
: null;
|
|
const fallbackTrack = cdTracks[0]?.position ? Number(cdTracks[0].position) : null;
|
|
const previewTrackPos = Number.isFinite(firstConfiguredTrack) && firstConfiguredTrack > 0
|
|
? Math.trunc(firstConfiguredTrack)
|
|
: (Number.isFinite(fallbackTrack) && fallbackTrack > 0 ? Math.trunc(fallbackTrack) : null);
|
|
const previewWavPath = previewTrackPos && job?.raw_path
|
|
? `${job.raw_path}/track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`
|
|
: '<temp>/trackNN.cdda.wav';
|
|
const cdparanoiaCommandPreview = `${cdparanoiaCmd} -d ${devicePath || '<device>'} ${previewTrackPos || '<trackNr>'} ${previewWavPath}`;
|
|
const audiobookSelectedMeta = {
|
|
...(makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
|
? makemkvInfo.selectedMetadata
|
|
: {}),
|
|
...(encodePlan?.metadata && typeof encodePlan.metadata === 'object'
|
|
? encodePlan.metadata
|
|
: {})
|
|
};
|
|
const selectedMetadata = resolvedMediaType === 'audiobook'
|
|
? {
|
|
title: audiobookSelectedMeta?.title || job?.title || job?.detected_title || null,
|
|
author: audiobookSelectedMeta?.author || audiobookSelectedMeta?.artist || null,
|
|
narrator: audiobookSelectedMeta?.narrator || null,
|
|
description: audiobookSelectedMeta?.description || null,
|
|
series: audiobookSelectedMeta?.series || null,
|
|
part: audiobookSelectedMeta?.part || null,
|
|
year: audiobookSelectedMeta?.year ?? job?.year ?? null,
|
|
chapters: Array.isArray(audiobookSelectedMeta?.chapters) ? audiobookSelectedMeta.chapters : [],
|
|
durationMs: audiobookSelectedMeta?.durationMs || 0,
|
|
poster: audiobookSelectedMeta?.poster || job?.poster_url || null
|
|
}
|
|
: {
|
|
title: cdSelectedMeta?.title || job?.title || job?.detected_title || null,
|
|
artist: cdSelectedMeta?.artist || fallbackCdArtist || null,
|
|
year: cdSelectedMeta?.year ?? job?.year ?? null,
|
|
mbId: resolvedCdMbId,
|
|
coverUrl: resolvedCdCoverUrl,
|
|
imdbId: job?.imdb_id || null,
|
|
poster: job?.poster_url || resolvedCdCoverUrl || null,
|
|
metadataProvider: analyzeContext?.metadataProvider || 'tmdb',
|
|
providerId: analyzeContext?.selectedMetadata?.providerId || null,
|
|
tmdbId: analyzeContext?.selectedMetadata?.tmdbId || null,
|
|
metadataKind: analyzeContext?.selectedMetadata?.metadataKind || null,
|
|
seasonNumber: analyzeContext?.selectedMetadata?.seasonNumber || analyzeContext?.seriesLookupHint?.seasonNumber || null,
|
|
seasonName: analyzeContext?.selectedMetadata?.seasonName || null,
|
|
episodeCount: analyzeContext?.selectedMetadata?.episodeCount || 0,
|
|
episodes: Array.isArray(analyzeContext?.selectedMetadata?.episodes) ? analyzeContext.selectedMetadata.episodes : [],
|
|
discNumber: analyzeContext?.selectedMetadata?.discNumber || analyzeContext?.seriesLookupHint?.discNumber || null
|
|
};
|
|
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
|
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
|
const inputPath = isPreRip
|
|
? null
|
|
: (job?.encode_input_path || encodePlan?.encodeInputPath || null);
|
|
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
|
const hasEncodableTitle = isPreRip
|
|
? Boolean(encodePlan?.encodeInputTitleId)
|
|
: Boolean(inputPath || job?.raw_path);
|
|
const jobStatus = String(job?.status || job?.last_state || 'IDLE').trim().toUpperCase() || 'IDLE';
|
|
const lastState = String(job?.last_state || '').trim().toUpperCase();
|
|
const errorText = String(job?.error_message || '').trim().toUpperCase();
|
|
const hasOutputPath = Boolean(String(job?.output_path || '').trim());
|
|
const hasEncodePlan = Boolean(encodePlan && Array.isArray(encodePlan?.titles) && encodePlan.titles.length > 0);
|
|
const looksLikeCancelledEncode = (jobStatus === 'ERROR' || jobStatus === 'CANCELLED') && (
|
|
(errorText.includes('ABGEBROCHEN') || errorText.includes('CANCELLED'))
|
|
&& (hasOutputPath || Boolean(job?.encode_input_path) || Boolean(job?.handbrakeInfo))
|
|
);
|
|
const looksLikeEncodingError = (jobStatus === 'ERROR' || jobStatus === 'CANCELLED') && (
|
|
errorText.includes('ENCODING')
|
|
|| errorText.includes('HANDBRAKE')
|
|
|| lastState === 'ENCODING'
|
|
|| Boolean(job?.handbrakeInfo)
|
|
|| looksLikeCancelledEncode
|
|
);
|
|
const canRestartEncodeFromLastSettings = Boolean(
|
|
hasEncodePlan
|
|
&& reviewConfirmed
|
|
&& hasEncodableTitle
|
|
&& (
|
|
jobStatus === 'READY_TO_ENCODE'
|
|
|| jobStatus === 'ENCODING'
|
|
|| jobStatus === 'CANCELLED'
|
|
|| looksLikeEncodingError
|
|
)
|
|
);
|
|
const canRestartReviewFromRaw = Boolean(
|
|
job?.raw_path
|
|
&& !processingStates.includes(jobStatus)
|
|
&& resolvedMediaType !== 'audiobook'
|
|
);
|
|
const cdSkipRipReady = Boolean(
|
|
resolvedMediaType === 'cd'
|
|
&& (
|
|
Boolean(encodePlan?.skipRip)
|
|
|| (
|
|
Number(job?.rip_successful || 0) === 1
|
|
&& Boolean(String(job?.raw_path || '').trim())
|
|
)
|
|
)
|
|
);
|
|
const computedContext = {
|
|
jobId,
|
|
rawPath: job?.raw_path || null,
|
|
outputPath: job?.output_path || null,
|
|
detectedTitle: job?.detected_title || null,
|
|
mediaProfile: resolvedMediaType,
|
|
lastState,
|
|
devicePath,
|
|
cdparanoiaCmd,
|
|
cdparanoiaCommandPreview,
|
|
skipRip: cdSkipRipReady ? true : undefined,
|
|
cdRipConfig,
|
|
tracks: cdTracks,
|
|
inputPath,
|
|
hasEncodableTitle,
|
|
reviewConfirmed,
|
|
mode,
|
|
sourceJobId: encodePlan?.sourceJobId || null,
|
|
selectedMetadata,
|
|
audiobookConfig: resolvedMediaType === 'audiobook'
|
|
? {
|
|
format: String(encodePlan?.format || '').trim().toLowerCase() || 'mp3',
|
|
formatOptions: encodePlan?.formatOptions && typeof encodePlan.formatOptions === 'object'
|
|
? encodePlan.formatOptions
|
|
: {}
|
|
}
|
|
: null,
|
|
mediaInfoReview: encodePlan,
|
|
playlistAnalysis: analyzeContext.playlistAnalysis || null,
|
|
playlistDecisionRequired: Boolean(analyzeContext.playlistDecisionRequired),
|
|
playlistCandidates: Array.isArray(analyzeContext?.playlistAnalysis?.evaluatedCandidates)
|
|
? analyzeContext.playlistAnalysis.evaluatedCandidates
|
|
: [],
|
|
selectedPlaylist: analyzeContext.selectedPlaylist || null,
|
|
selectedTitleId: analyzeContext.selectedTitleId ?? null,
|
|
metadataProvider: analyzeContext.metadataProvider || 'tmdb',
|
|
metadataCandidates: Array.isArray(analyzeContext.metadataCandidates) ? analyzeContext.metadataCandidates : [],
|
|
workflowKind: analyzeContext.workflowKind || null,
|
|
seriesAnalysis: analyzeContext.seriesAnalysis || null,
|
|
seriesLookupHint: analyzeContext.seriesLookupHint || null,
|
|
seriesDecision: analyzeContext.seriesDecision || null,
|
|
canRestartEncodeFromLastSettings,
|
|
canRestartReviewFromRaw
|
|
};
|
|
|
|
if (isCurrentSessionJob) {
|
|
const existingContext = currentPipeline?.context && typeof currentPipeline.context === 'object'
|
|
? currentPipeline.context
|
|
: {};
|
|
const mergedCurrentContext = {
|
|
...computedContext,
|
|
...existingContext,
|
|
rawPath: existingContext.rawPath || computedContext.rawPath,
|
|
outputPath: existingContext.outputPath || computedContext.outputPath,
|
|
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
|
|
? existingContext.tracks
|
|
: computedContext.tracks,
|
|
selectedMetadata: computedContext.selectedMetadata || existingContext.selectedMetadata,
|
|
canRestartEncodeFromLastSettings:
|
|
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
|
|
canRestartReviewFromRaw:
|
|
existingContext.canRestartReviewFromRaw ?? computedContext.canRestartReviewFromRaw
|
|
};
|
|
// The card always represents `job.id`; never let stale live context override it.
|
|
mergedCurrentContext.jobId = jobId;
|
|
return {
|
|
...currentPipeline,
|
|
context: mergedCurrentContext
|
|
};
|
|
}
|
|
|
|
// Use live per-job progress from the backend if available (concurrent jobs).
|
|
const liveJobProgress = currentPipeline?.jobProgress && jobId
|
|
? (currentPipeline.jobProgress[jobId] || null)
|
|
: null;
|
|
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 jobStatusIsRunning = processingStates.includes(jobStatus);
|
|
const ignoreStaleLiveProgress = (
|
|
(isTerminalJobStatus && processingStates.includes(liveState))
|
|
|| (!jobStatusIsRunning && jobStatus !== 'IDLE' && processingStates.includes(liveState))
|
|
);
|
|
const effectiveLiveContext = ignoreStaleLiveProgress ? null : liveContext;
|
|
const mergedContext = effectiveLiveContext
|
|
? {
|
|
...computedContext,
|
|
...effectiveLiveContext,
|
|
// Prefer the DB plan when it has been confirmed but the live (jobProgress) plan hasn't —
|
|
// this happens when confirmEncodeReview is called with skipPipelineStateUpdate=true
|
|
// (always the case for queued jobs), leaving jobProgress with the pre-confirmation plan.
|
|
mediaInfoReview: (computedContext.mediaInfoReview?.reviewConfirmedAt && !effectiveLiveContext.mediaInfoReview?.reviewConfirmedAt)
|
|
? computedContext.mediaInfoReview
|
|
: (effectiveLiveContext.mediaInfoReview || computedContext.mediaInfoReview),
|
|
tracks: (Array.isArray(effectiveLiveContext.tracks) && effectiveLiveContext.tracks.length > 0)
|
|
? effectiveLiveContext.tracks
|
|
: computedContext.tracks,
|
|
selectedMetadata: computedContext.selectedMetadata || effectiveLiveContext.selectedMetadata,
|
|
cdRipConfig: effectiveLiveContext.cdRipConfig || computedContext.cdRipConfig
|
|
}
|
|
: computedContext;
|
|
// The card always represents `job.id`; never let stale live context override it.
|
|
mergedContext.jobId = jobId;
|
|
|
|
return {
|
|
state: ignoreStaleLiveProgress ? jobStatus : (liveJobProgress?.state || jobStatus),
|
|
activeJobId: jobId,
|
|
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
|
|
};
|
|
}
|
|
|
|
export default function RipperPage({
|
|
pipeline,
|
|
hardwareMonitoring,
|
|
lastDiscEvent,
|
|
refreshPipeline,
|
|
jobsRefreshToken,
|
|
downloadSummary = null,
|
|
children = null
|
|
}) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [busy, setBusy] = useState(false);
|
|
const [busyJobIds, setBusyJobIds] = useState(() => new Set());
|
|
const setJobBusy = (jobId, isBusy) => {
|
|
setBusyJobIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (isBusy) {
|
|
next.add(jobId);
|
|
} else {
|
|
next.delete(jobId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
|
const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
|
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
|
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
|
|
const [cancelCleanupDialog, setCancelCleanupDialog] = useState({
|
|
visible: false,
|
|
jobId: null,
|
|
target: null,
|
|
path: null
|
|
});
|
|
const [cancelCleanupBusy, setCancelCleanupBusy] = useState(false);
|
|
const [queueState, setQueueState] = useState(() => normalizeQueue(pipeline?.queue));
|
|
const [queueReorderBusy, setQueueReorderBusy] = useState(false);
|
|
const [draggingQueueEntryId, setDraggingQueueEntryId] = useState(null);
|
|
const [draggingMergeSource, setDraggingMergeSource] = useState(null);
|
|
const [insertQueueDialog, setInsertQueueDialog] = useState({ visible: false, afterEntryId: null });
|
|
const [runtimeActivities, setRuntimeActivities] = useState(() => normalizeRuntimeActivitiesPayload(null));
|
|
const [runtimeLoading, setRuntimeLoading] = useState(false);
|
|
const [runtimeActionBusyKeys, setRuntimeActionBusyKeys] = useState(() => new Set());
|
|
const [runtimeRecentClearing, setRuntimeRecentClearing] = useState(false);
|
|
const [jobsLoading, setJobsLoading] = useState(false);
|
|
const [ripperJobs, setRipperJobs] = useState([]);
|
|
const [multipartMergePreviewByJobId, setMultipartMergePreviewByJobId] = useState({});
|
|
const [multipartMergePreviewLoadingByJobId, setMultipartMergePreviewLoadingByJobId] = useState({});
|
|
const [multipartMergeLiveLogByJobId, setMultipartMergeLiveLogByJobId] = useState({});
|
|
const [multipartMergeLiveLogLoadingByJobId, setMultipartMergeLiveLogLoadingByJobId] = useState({});
|
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
|
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
|
const [activationBytesInput, setActivationBytesInput] = useState('');
|
|
const [activationBytesBusy, setActivationBytesBusy] = useState(false);
|
|
const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set());
|
|
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 [quickAccessCatalog, setQuickAccessCatalog] = useState({ scripts: [], chains: [] });
|
|
const [quickAccessBusyKeys, setQuickAccessBusyKeys] = useState(() => new Set());
|
|
const [insertWaitSeconds, setInsertWaitSeconds] = useState(30);
|
|
const toastRef = useRef(null);
|
|
const dismissedCdMetadataJobIdsRef = useRef(new Set());
|
|
const dismissedActivationDialogKeyRef = useRef(null);
|
|
|
|
const state = String(pipeline?.state || 'IDLE').trim().toUpperCase();
|
|
const currentPipelineJobId = normalizeJobId(pipeline?.activeJobId || pipeline?.context?.jobId);
|
|
const isProcessing = processingStates.includes(state);
|
|
const setQuickAccessBusy = (entryKey, isBusy) => {
|
|
const normalizedKey = String(entryKey || '').trim().toLowerCase();
|
|
if (!normalizedKey) {
|
|
return;
|
|
}
|
|
setQuickAccessBusyKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (isBusy) {
|
|
next.add(normalizedKey);
|
|
} else {
|
|
next.delete(normalizedKey);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
const monitoringState = useMemo(
|
|
() => normalizeHardwareMonitoringPayload(hardwareMonitoring),
|
|
[hardwareMonitoring]
|
|
);
|
|
const cdDriveLifecycleKey = useMemo(
|
|
() => buildCdDriveLifecycleKey(pipeline?.cdDrives),
|
|
[pipeline?.cdDrives]
|
|
);
|
|
// Detects when a background job reaches an interactive review/selection state via
|
|
// PIPELINE_PROGRESS (e.g. METADATA_LOOKUP, METADATA_SELECTION, READY_TO_ENCODE, WAITING_FOR_USER_DECISION),
|
|
// triggering a ripper jobs reload.
|
|
const jobProgressInteractiveSignature = useMemo(() => Object.entries(pipeline?.jobProgress || {})
|
|
.filter(([, v]) => {
|
|
const s = String(v?.state || '').toUpperCase();
|
|
return s === 'METADATA_LOOKUP'
|
|
|| s === 'METADATA_SELECTION'
|
|
|| s === 'READY_TO_ENCODE'
|
|
|| s === 'WAITING_FOR_USER_DECISION';
|
|
})
|
|
.map(([id, value]) => `${id}:${String(value?.state || '').trim().toUpperCase()}`)
|
|
.sort()
|
|
.join(','),
|
|
[pipeline?.jobProgress]);
|
|
const monitoringSample = monitoringState.sample;
|
|
const cpuMetrics = monitoringSample?.cpu || null;
|
|
const memoryMetrics = monitoringSample?.memory || null;
|
|
const gpuMetrics = monitoringSample?.gpu || null;
|
|
const storageMetrics = Array.isArray(monitoringSample?.storage) ? monitoringSample.storage : [];
|
|
const storageGroups = useMemo(() => {
|
|
// Phase 1: group by mountPoint
|
|
const phase1 = [];
|
|
const mountMap = new Map();
|
|
for (const entry of storageMetrics) {
|
|
const groupKey = entry?.mountPoint || `__no_mount_${entry?.key}`;
|
|
if (!mountMap.has(groupKey)) {
|
|
const group = { mountPoint: entry?.mountPoint || null, entries: [], representative: entry };
|
|
mountMap.set(groupKey, group);
|
|
phase1.push(group);
|
|
}
|
|
mountMap.get(groupKey).entries.push(entry);
|
|
}
|
|
|
|
// Phase 2: merge groups with identical disk signature (same physical disk)
|
|
const merged = [];
|
|
const diskSigMap = new Map();
|
|
for (const group of phase1) {
|
|
const totalBytes = Number(group.representative?.totalBytes);
|
|
const freeBytes = Number(group.representative?.freeBytes);
|
|
if (Number.isFinite(totalBytes) && totalBytes > 0 && Number.isFinite(freeBytes)) {
|
|
const sig = `${totalBytes}:${freeBytes}`;
|
|
if (diskSigMap.has(sig)) {
|
|
diskSigMap.get(sig).entries.push(...group.entries);
|
|
} else {
|
|
diskSigMap.set(sig, group);
|
|
merged.push(group);
|
|
}
|
|
} else {
|
|
merged.push(group);
|
|
}
|
|
}
|
|
return merged;
|
|
}, [storageMetrics]);
|
|
const gpuDevices = Array.isArray(gpuMetrics?.devices) ? gpuMetrics.devices : [];
|
|
const primaryGpuMetrics = gpuDevices[0] || null;
|
|
const gpuSummaryUsage = primaryGpuMetrics?.utilizationPercent;
|
|
const gpuSummaryTemp = primaryGpuMetrics?.temperatureC;
|
|
const isRipperMainRoute = location.pathname === '/' || location.pathname === '/ripper';
|
|
const isSubpageRoute = !isRipperMainRoute;
|
|
|
|
const loadRipperJobs = async () => {
|
|
setJobsLoading(true);
|
|
try {
|
|
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
|
api.getJobs({
|
|
statuses: Array.from(ripperStatuses),
|
|
limit: 160,
|
|
lite: true,
|
|
includeChildren: true
|
|
}),
|
|
api.getPipelineQueue()
|
|
]);
|
|
const allJobs = jobsResponse.status === 'fulfilled'
|
|
? (Array.isArray(jobsResponse.value?.jobs) ? jobsResponse.value.jobs : [])
|
|
: [];
|
|
if (queueResponse.status === 'fulfilled') {
|
|
setQueueState(normalizeQueue(queueResponse.value?.queue));
|
|
}
|
|
const shouldDisplayOnRipper = (job) => {
|
|
if (resolveMediaType(job) === 'audiobook') {
|
|
return false;
|
|
}
|
|
if (isConverterJob(job)) {
|
|
return false;
|
|
}
|
|
if (
|
|
String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'
|
|
|| String(job?.job_kind || '').trim().toLowerCase() === 'multipart_movie_container'
|
|
) {
|
|
return false;
|
|
}
|
|
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
|
|
if (!ripperStatuses.has(normalizedStatus)) {
|
|
return false;
|
|
}
|
|
if (isMultipartMergeJob(job)) {
|
|
const mergeToolStatus = String(job?.handbrakeInfo?.status || '').trim().toUpperCase();
|
|
const mergeCompleted = Boolean(
|
|
normalizedStatus === 'FINISHED'
|
|
|| job?.encodeSuccess
|
|
|| mergeToolStatus === 'SUCCESS'
|
|
);
|
|
if (mergeCompleted) {
|
|
return false;
|
|
}
|
|
}
|
|
if (normalizedStatus !== 'CANCELLED') {
|
|
return true;
|
|
}
|
|
const cancelledOrigin = String(job?.last_state || '').trim().toUpperCase();
|
|
return !hiddenCancelledReviewOrigins.has(cancelledOrigin);
|
|
};
|
|
const next = allJobs
|
|
.filter((job) => shouldDisplayOnRipper(job))
|
|
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0));
|
|
|
|
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 && shouldDisplayOnRipper(job)) {
|
|
next.unshift(job);
|
|
}
|
|
}
|
|
}
|
|
|
|
const seen = new Set();
|
|
const deduped = [];
|
|
for (const job of next) {
|
|
const id = normalizeJobId(job?.id);
|
|
if (!id || seen.has(String(id))) {
|
|
continue;
|
|
}
|
|
seen.add(String(id));
|
|
deduped.push(job);
|
|
}
|
|
const filteredJobs = deduped.filter((job) => {
|
|
const isSeriesBatchChild = Boolean(job?.encodePlan?.seriesBatchChild);
|
|
return !isSeriesBatchChild;
|
|
});
|
|
|
|
setRipperJobs(filteredJobs);
|
|
|
|
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
|
|
try {
|
|
const { pending } = await api.getPendingActivation();
|
|
if (Array.isArray(pending) && pending.length > 0) {
|
|
setPendingActivationJobIds(new Set(pending.map((p) => p.jobId)));
|
|
// Modal automatisch öffnen wenn noch nicht sichtbar
|
|
setActivationBytesDialog((prev) => {
|
|
if (prev.visible) return prev;
|
|
const first = pending[0];
|
|
const pendingKey = buildActivationDialogKey(first?.checksum, first?.jobId);
|
|
if (pendingKey && dismissedActivationDialogKeyRef.current === pendingKey) {
|
|
return prev;
|
|
}
|
|
setActivationBytesInput('');
|
|
return { visible: true, checksum: first.checksum, jobId: first.jobId };
|
|
});
|
|
} else {
|
|
setPendingActivationJobIds(new Set());
|
|
dismissedActivationDialogKeyRef.current = null;
|
|
}
|
|
} catch (_err) {
|
|
// ignorieren
|
|
}
|
|
} catch (_error) {
|
|
setRipperJobs([]);
|
|
} finally {
|
|
setJobsLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadQuickAccessFavorites = async () => {
|
|
try {
|
|
const [scriptsRes, chainsRes] = await Promise.allSettled([
|
|
api.getScripts({ forceRefresh: true }),
|
|
api.getScriptChains({ forceRefresh: true })
|
|
]);
|
|
setQuickAccessCatalog({
|
|
scripts: scriptsRes.status === 'fulfilled' && Array.isArray(scriptsRes.value?.scripts)
|
|
? scriptsRes.value.scripts
|
|
: [],
|
|
chains: chainsRes.status === 'fulfilled' && Array.isArray(chainsRes.value?.chains)
|
|
? chainsRes.value.chains
|
|
: []
|
|
});
|
|
} catch (_error) {
|
|
setQuickAccessCatalog({ scripts: [], chains: [] });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!metadataDialogVisible) {
|
|
return;
|
|
}
|
|
if (metadataDialogContext?.jobId) {
|
|
if (isCdMetadataContext(metadataDialogContext)) {
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
}
|
|
return;
|
|
}
|
|
if (pipeline?.state !== 'METADATA_LOOKUP' && pipeline?.state !== 'METADATA_SELECTION' && pipeline?.state !== 'WAITING_FOR_USER_DECISION') {
|
|
setMetadataDialogVisible(false);
|
|
}
|
|
}, [pipeline?.state, metadataDialogVisible, metadataDialogContext, cdMetadataDialogVisible]);
|
|
|
|
// Auto-open CD metadata dialog when any drive enters CD_METADATA_SELECTION
|
|
useEffect(() => {
|
|
// Do not steal focus from active video metadata selection (TMDb dialog).
|
|
if (metadataDialogVisible && !isCdMetadataContext(metadataDialogContext)) {
|
|
return;
|
|
}
|
|
const cdDrives = pipeline?.cdDrives;
|
|
if (!cdDrives || typeof cdDrives !== 'object') return;
|
|
const activeCdMetadataJobIds = new Set();
|
|
for (const drive of Object.values(cdDrives)) {
|
|
const driveState = String(drive?.state || '').trim().toUpperCase();
|
|
const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
|
|
const driveJobId = normalizeJobId(ctx?.jobId || drive?.jobId);
|
|
if (driveState === 'CD_METADATA_SELECTION' && driveJobId) {
|
|
activeCdMetadataJobIds.add(String(driveJobId));
|
|
}
|
|
}
|
|
|
|
if (dismissedCdMetadataJobIdsRef.current.size > 0) {
|
|
const nextDismissed = new Set(
|
|
Array.from(dismissedCdMetadataJobIdsRef.current).filter((jobId) => activeCdMetadataJobIds.has(jobId))
|
|
);
|
|
dismissedCdMetadataJobIdsRef.current = nextDismissed;
|
|
}
|
|
|
|
for (const drive of Object.values(cdDrives)) {
|
|
const driveState = String(drive?.state || '').trim().toUpperCase();
|
|
const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
|
|
const driveJobId = normalizeJobId(ctx?.jobId || drive?.jobId);
|
|
const dismissed = driveJobId ? dismissedCdMetadataJobIdsRef.current.has(String(driveJobId)) : false;
|
|
if (driveState === 'CD_METADATA_SELECTION' && ctx?.jobId && !cdMetadataDialogVisible && !dismissed) {
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
setCdMetadataDialogContext(ctx);
|
|
setCdMetadataDialogVisible(true);
|
|
break;
|
|
}
|
|
if (driveState === 'CD_READY_TO_RIP' && driveJobId) {
|
|
setCdRipPanelJobId(driveJobId);
|
|
}
|
|
}
|
|
}, [pipeline?.cdDrives, metadataDialogVisible, metadataDialogContext]);
|
|
|
|
useEffect(() => {
|
|
if (!cdMetadataDialogVisible) {
|
|
return;
|
|
}
|
|
const dialogJobId = normalizeJobId(cdMetadataDialogContext?.jobId);
|
|
if (!dialogJobId) {
|
|
return;
|
|
}
|
|
const cdDrives = pipeline?.cdDrives;
|
|
if (!cdDrives || typeof cdDrives !== 'object') {
|
|
return;
|
|
}
|
|
|
|
let stillInMetadataSelection = false;
|
|
for (const drive of Object.values(cdDrives)) {
|
|
const driveState = String(drive?.state || '').trim().toUpperCase();
|
|
const ctx = drive?.context && typeof drive.context === 'object' ? drive.context : null;
|
|
const driveJobId = normalizeJobId(ctx?.jobId || drive?.jobId);
|
|
if (driveJobId === dialogJobId && driveState === 'CD_METADATA_SELECTION') {
|
|
stillInMetadataSelection = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!stillInMetadataSelection) {
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
}
|
|
}, [cdMetadataDialogVisible, cdMetadataDialogContext?.jobId, pipeline?.cdDrives]);
|
|
|
|
useEffect(() => {
|
|
setQueueState(normalizeQueue(pipeline?.queue));
|
|
}, [pipeline?.queue]);
|
|
|
|
useEffect(() => {
|
|
void loadRipperJobs();
|
|
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveSignature]);
|
|
|
|
useEffect(() => {
|
|
const normalizedPipelineState = String(pipeline?.state || '').trim().toUpperCase();
|
|
const isReviewState = normalizedPipelineState === 'READY_TO_ENCODE'
|
|
|| normalizedPipelineState === 'WAITING_FOR_USER_DECISION';
|
|
if (!isReviewState || !currentPipelineJobId) {
|
|
return;
|
|
}
|
|
setExpandedJobId(currentPipelineJobId);
|
|
}, [pipeline?.state, currentPipelineJobId]);
|
|
|
|
useEffect(() => {
|
|
api.getDetectedDrives()
|
|
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadQuickAccessFavorites();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const load = async (silent = false) => {
|
|
try {
|
|
const response = await api.getRuntimeActivities();
|
|
if (!cancelled) {
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response));
|
|
if (!silent) {
|
|
setRuntimeLoading(false);
|
|
}
|
|
}
|
|
} catch (_error) {
|
|
if (!cancelled && !silent) {
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(null));
|
|
setRuntimeLoading(false);
|
|
}
|
|
}
|
|
};
|
|
setRuntimeLoading(true);
|
|
void load(false);
|
|
const interval = setInterval(() => {
|
|
void load(true);
|
|
}, 10000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
useWebSocket({
|
|
onMessage: (message) => {
|
|
if (message?.type === 'RUNTIME_ACTIVITY_CHANGED') {
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(message.payload));
|
|
setRuntimeLoading(false);
|
|
return;
|
|
}
|
|
if (message?.type === 'SETTINGS_SCRIPTS_UPDATED' || message?.type === 'SETTINGS_SCRIPT_CHAINS_UPDATED') {
|
|
void loadQuickAccessFavorites();
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
useEffect(() => {
|
|
const normalizedExpanded = normalizeJobId(expandedJobId);
|
|
const hasExpanded = ripperJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
|
if (hasExpanded) {
|
|
return;
|
|
}
|
|
|
|
// Respect explicit user collapse.
|
|
if (expandedJobId === null) {
|
|
return;
|
|
}
|
|
|
|
if (currentPipelineJobId && ripperJobs.some((job) => normalizeJobId(job?.id) === currentPipelineJobId)) {
|
|
setExpandedJobId(currentPipelineJobId);
|
|
return;
|
|
}
|
|
setExpandedJobId(normalizeJobId(ripperJobs[0]?.id));
|
|
}, [ripperJobs, expandedJobId, currentPipelineJobId]);
|
|
|
|
useEffect(() => {
|
|
const normalizedExpanded = normalizeJobId(expandedJobId);
|
|
if (!normalizedExpanded) {
|
|
return;
|
|
}
|
|
const expandedJob = ripperJobs.find((job) => normalizeJobId(job?.id) === normalizedExpanded) || null;
|
|
if (!expandedJob || !isMultipartMergeJob(expandedJob)) {
|
|
return;
|
|
}
|
|
void loadMultipartMergePreview(normalizedExpanded, { force: false, silent: true });
|
|
}, [expandedJobId, ripperJobs]);
|
|
|
|
useEffect(() => {
|
|
const normalizedExpanded = normalizeJobId(expandedJobId);
|
|
if (!normalizedExpanded) {
|
|
return undefined;
|
|
}
|
|
const expandedJob = ripperJobs.find((job) => normalizeJobId(job?.id) === normalizedExpanded) || null;
|
|
if (!expandedJob || !isMultipartMergeJob(expandedJob)) {
|
|
return undefined;
|
|
}
|
|
const expandedStatus = String(expandedJob?.status || expandedJob?.last_state || '').trim().toUpperCase();
|
|
if (expandedStatus !== 'ENCODING') {
|
|
setMultipartMergeLiveLogLoadingByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedExpanded]: false
|
|
}));
|
|
return undefined;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const refresh = async () => {
|
|
setMultipartMergeLiveLogLoadingByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedExpanded]: true
|
|
}));
|
|
try {
|
|
const response = await api.getJob(normalizedExpanded, {
|
|
includeLiveLog: true,
|
|
lite: true,
|
|
logTailLines: 400
|
|
});
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const liveJob = response?.job && typeof response.job === 'object'
|
|
? response.job
|
|
: null;
|
|
setMultipartMergeLiveLogByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedExpanded]: String(liveJob?.log || '').trim()
|
|
}));
|
|
} catch (_error) {
|
|
if (_error?.status === 404) {
|
|
cancelled = true;
|
|
setMultipartMergeLiveLogByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedExpanded]: ''
|
|
}));
|
|
void loadRipperJobs();
|
|
return;
|
|
}
|
|
if (!cancelled) {
|
|
setMultipartMergeLiveLogByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedExpanded]: ''
|
|
}));
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setMultipartMergeLiveLogLoadingByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedExpanded]: false
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
void refresh();
|
|
const interval = setInterval(refresh, 2500);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, [expandedJobId, ripperJobs]);
|
|
|
|
const pipelineByJobId = useMemo(() => {
|
|
const map = new Map();
|
|
for (const job of ripperJobs) {
|
|
const id = normalizeJobId(job?.id);
|
|
if (!id) {
|
|
continue;
|
|
}
|
|
map.set(id, buildPipelineFromJob(job, pipeline, currentPipelineJobId));
|
|
}
|
|
return map;
|
|
}, [ripperJobs, pipeline, currentPipelineJobId]);
|
|
|
|
const buildMetadataContextForJob = (jobId) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return null;
|
|
}
|
|
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
|
const pipelineForJob = pipelineByJobId.get(normalizedJobId) || null;
|
|
const context = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
|
? pipelineForJob.context
|
|
: {};
|
|
const selectedMetadata = context.selectedMetadata && typeof context.selectedMetadata === 'object'
|
|
? context.selectedMetadata
|
|
: {
|
|
title: job?.title || job?.detected_title || context?.detectedTitle || '',
|
|
year: job?.year || null,
|
|
imdbId: job?.imdb_id || null,
|
|
poster: job?.poster_url || null,
|
|
metadataProvider: context?.metadataProvider || 'tmdb',
|
|
seasonNumber: null,
|
|
discNumber: null
|
|
};
|
|
return {
|
|
...context,
|
|
jobId: normalizedJobId,
|
|
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
|
|
selectedMetadata,
|
|
metadataProvider: selectedMetadata?.metadataProvider || context?.metadataProvider || 'tmdb',
|
|
metadataCandidates: Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [],
|
|
seriesAnalysis: context?.seriesAnalysis || null,
|
|
seriesLookupHint: context?.seriesLookupHint || null
|
|
};
|
|
};
|
|
|
|
const defaultMetadataDialogContext = useMemo(() => {
|
|
const currentState = String(pipeline?.state || '').trim().toUpperCase();
|
|
const currentContext = pipeline?.context && typeof pipeline.context === 'object'
|
|
? pipeline.context
|
|
: null;
|
|
const currentContextJobId = normalizeJobId(currentContext?.jobId);
|
|
if (
|
|
(currentState === 'METADATA_LOOKUP' || currentState === 'METADATA_SELECTION' || currentState === 'WAITING_FOR_USER_DECISION')
|
|
&& currentContextJobId
|
|
) {
|
|
return {
|
|
...currentContext,
|
|
jobId: currentContextJobId,
|
|
selectedMetadata: currentContext?.selectedMetadata || {
|
|
title: currentContext?.detectedTitle || '',
|
|
year: null,
|
|
imdbId: null,
|
|
poster: null,
|
|
metadataProvider: currentContext?.metadataProvider || 'tmdb',
|
|
seasonNumber: null,
|
|
discNumber: null
|
|
},
|
|
metadataProvider: currentContext?.selectedMetadata?.metadataProvider || currentContext?.metadataProvider || 'tmdb',
|
|
metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [],
|
|
seriesAnalysis: currentContext?.seriesAnalysis || null,
|
|
seriesLookupHint: currentContext?.seriesLookupHint || null
|
|
};
|
|
}
|
|
|
|
const pendingJob = ripperJobs.find((job) => {
|
|
const normalized = normalizeStatus(job?.status);
|
|
return normalized === 'METADATA_LOOKUP' || normalized === 'METADATA_SELECTION' || normalized === 'WAITING_FOR_USER_DECISION';
|
|
});
|
|
if (!pendingJob) {
|
|
return null;
|
|
}
|
|
return buildMetadataContextForJob(pendingJob.id);
|
|
}, [pipeline, ripperJobs, pipelineByJobId]);
|
|
|
|
const effectiveMetadataDialogContext = metadataDialogContext
|
|
|| defaultMetadataDialogContext
|
|
|| pipeline?.context
|
|
|| {};
|
|
const metadataDialogContextIsCd = isCdMetadataContext(effectiveMetadataDialogContext);
|
|
const metadataDialogShouldBeVisible = metadataDialogVisible
|
|
&& !metadataDialogContextIsCd
|
|
&& !cdMetadataDialogVisible;
|
|
|
|
const showError = (error) => {
|
|
toastRef.current?.show({
|
|
severity: 'error',
|
|
summary: 'Fehler',
|
|
detail: error.message,
|
|
life: 4500
|
|
});
|
|
};
|
|
|
|
const buildActivationDialogKey = (checksum, jobId) => {
|
|
const normalizedJobId = normalizeJobId(jobId) || 0;
|
|
const normalizedChecksum = String(checksum || '').trim().toLowerCase();
|
|
return `${normalizedJobId}:${normalizedChecksum}`;
|
|
};
|
|
|
|
const hideCdMetadataDialog = () => {
|
|
const dialogJobId = normalizeJobId(cdMetadataDialogContext?.jobId);
|
|
if (dialogJobId) {
|
|
dismissedCdMetadataJobIdsRef.current.add(String(dialogJobId));
|
|
}
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
};
|
|
|
|
const hideActivationBytesDialog = () => {
|
|
const key = buildActivationDialogKey(activationBytesDialog?.checksum, activationBytesDialog?.jobId);
|
|
if (key && key !== '0:') {
|
|
dismissedActivationDialogKeyRef.current = key;
|
|
}
|
|
setActivationBytesDialog({ visible: false, checksum: null, jobId: null });
|
|
};
|
|
|
|
const normalizeCdTracks = (tracks) => {
|
|
const source = Array.isArray(tracks) ? tracks : [];
|
|
return source
|
|
.map((track) => {
|
|
const position = Number(track?.position);
|
|
if (!Number.isFinite(position) || position <= 0) {
|
|
return null;
|
|
}
|
|
return {
|
|
...track,
|
|
position: Math.trunc(position),
|
|
selected: track?.selected !== false
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
};
|
|
|
|
const hydrateCdMetadataContext = async (rawContext, fallbackJobId = null) => {
|
|
const context = rawContext && typeof rawContext === 'object' ? rawContext : {};
|
|
const contextJobId = normalizeJobId(context?.jobId) || normalizeJobId(fallbackJobId);
|
|
const existingTracks = normalizeCdTracks(context?.tracks);
|
|
if (!contextJobId) {
|
|
return {
|
|
...context,
|
|
...(contextJobId ? { jobId: contextJobId } : {}),
|
|
tracks: existingTracks
|
|
};
|
|
}
|
|
|
|
try {
|
|
const response = await api.getJob(contextJobId, { forceRefresh: true });
|
|
const job = response?.job && typeof response.job === 'object' ? response.job : null;
|
|
const makemkvInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object' ? job.makemkvInfo : {};
|
|
const encodePlan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
|
|
const hydratedTracks = normalizeCdTracks(makemkvInfo?.tracks || encodePlan?.tracks || []);
|
|
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
|
? makemkvInfo.selectedMetadata
|
|
: {};
|
|
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
|
|
? makemkvInfo.analyzeContext
|
|
: {};
|
|
const detectedTitle = String(
|
|
context?.detectedTitle
|
|
|| analyzeContext?.detectedTitle
|
|
|| job?.detected_title
|
|
|| selectedMetadata?.title
|
|
|| job?.title
|
|
|| ''
|
|
).trim();
|
|
|
|
return {
|
|
...context,
|
|
jobId: contextJobId,
|
|
detectedTitle: detectedTitle || context?.detectedTitle || '',
|
|
mediaProfile: context?.mediaProfile || analyzeContext?.mediaProfile || makemkvInfo?.mediaProfile || 'cd',
|
|
selectedMetadata: context?.selectedMetadata && typeof context.selectedMetadata === 'object'
|
|
? context.selectedMetadata
|
|
: selectedMetadata,
|
|
tracks: hydratedTracks.length > 0 ? hydratedTracks : existingTracks
|
|
};
|
|
} catch (_error) {
|
|
return {
|
|
...context,
|
|
...(contextJobId ? { jobId: contextJobId } : {}),
|
|
tracks: existingTracks
|
|
};
|
|
}
|
|
};
|
|
|
|
const openCdMetadataDialogWithContext = async (rawContext, fallbackJobId = null) => {
|
|
const hydratedContext = await hydrateCdMetadataContext(rawContext, fallbackJobId);
|
|
const hydratedJobId = normalizeJobId(hydratedContext?.jobId);
|
|
if (hydratedJobId) {
|
|
dismissedCdMetadataJobIdsRef.current.delete(String(hydratedJobId));
|
|
}
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
setCdMetadataDialogContext(hydratedContext);
|
|
setCdMetadataDialogVisible(true);
|
|
};
|
|
|
|
const loadMultipartMergePreview = async (jobId, options = {}) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return null;
|
|
}
|
|
const force = options?.force === true;
|
|
const silent = options?.silent !== false;
|
|
if (!force) {
|
|
const existingPreview = multipartMergePreviewByJobId[normalizedJobId];
|
|
if (existingPreview) {
|
|
return existingPreview;
|
|
}
|
|
}
|
|
if (!force && multipartMergePreviewLoadingByJobId[normalizedJobId]) {
|
|
return null;
|
|
}
|
|
setMultipartMergePreviewLoadingByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedJobId]: true
|
|
}));
|
|
try {
|
|
const response = await api.getMultipartMergePreview(normalizedJobId);
|
|
const preview = response?.preview && typeof response.preview === 'object'
|
|
? response.preview
|
|
: null;
|
|
setMultipartMergePreviewByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedJobId]: preview
|
|
}));
|
|
return preview;
|
|
} catch (error) {
|
|
if (!silent) {
|
|
showError(error);
|
|
}
|
|
return null;
|
|
} finally {
|
|
setMultipartMergePreviewLoadingByJobId((prev) => ({
|
|
...prev,
|
|
[normalizedJobId]: false
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleOpenMetadataDialog = async (jobId = null) => {
|
|
const context = jobId ? buildMetadataContextForJob(jobId) : defaultMetadataDialogContext;
|
|
if (!context?.jobId) {
|
|
showError(new Error('Kein Job mit offener Metadaten-Auswahl gefunden.'));
|
|
return;
|
|
}
|
|
if (isCdMetadataContext(context)) {
|
|
await openCdMetadataDialogWithContext(context, jobId);
|
|
return;
|
|
}
|
|
setMetadataDialogReassignMode(false);
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
setMetadataDialogContext(context);
|
|
setMetadataDialogVisible(true);
|
|
};
|
|
|
|
const handleOpenReassignMetadataDialog = async (jobId) => {
|
|
const context = buildMetadataContextForJob(jobId);
|
|
if (!context?.jobId) {
|
|
showError(new Error('Job nicht gefunden.'));
|
|
return;
|
|
}
|
|
if (isCdMetadataContext(context)) {
|
|
await openCdMetadataDialogWithContext(context, jobId);
|
|
return;
|
|
}
|
|
setMetadataDialogReassignMode(true);
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
setMetadataDialogContext(context);
|
|
setMetadataDialogVisible(true);
|
|
};
|
|
|
|
const handleAnalyze = async () => {
|
|
setBusy(true);
|
|
try {
|
|
const response = await api.analyzeDisc();
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
const analyzedJobId = normalizeJobId(response?.result?.jobId);
|
|
const isCdAnalyzeResult = isCdAnalyzeResponsePayload(response?.result);
|
|
if (analyzedJobId) {
|
|
if (isCdAnalyzeResult) {
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
setCdMetadataDialogContext({
|
|
...(pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}),
|
|
jobId: analyzedJobId,
|
|
mediaProfile: 'cd',
|
|
detectedTitle: response?.result?.detectedTitle || '',
|
|
tracks: Array.isArray(response?.result?.tracks) ? response.result.tracks : []
|
|
});
|
|
setCdMetadataDialogVisible(true);
|
|
return;
|
|
}
|
|
setMetadataDialogContext({
|
|
jobId: analyzedJobId,
|
|
detectedTitle: response?.result?.detectedTitle || '',
|
|
workflowKind: response?.result?.workflowKind || null,
|
|
selectedMetadata: {
|
|
title: response?.result?.detectedTitle || '',
|
|
year: null,
|
|
imdbId: null,
|
|
poster: null,
|
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
|
seasonNumber: null
|
|
},
|
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
|
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
|
? response.result.metadataCandidates
|
|
: [],
|
|
seriesAnalysis: response?.result?.seriesAnalysis || null,
|
|
seriesLookupHint: response?.result?.seriesLookupHint || null,
|
|
seriesDecision: response?.result?.seriesDecision || null
|
|
});
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
setMetadataDialogVisible(true);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleReanalyze = async () => {
|
|
await handleAnalyze();
|
|
};
|
|
|
|
const handleAnalyzeForDrive = async (devicePath) => {
|
|
setDriveAnalyzeBusy((prev) => new Set([...prev, devicePath]));
|
|
try {
|
|
const response = await api.analyzeDisc(devicePath);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
const analyzedJobId = normalizeJobId(response?.result?.jobId);
|
|
const isCdAnalyzeResult = isCdAnalyzeResponsePayload(response?.result);
|
|
if (analyzedJobId) {
|
|
if (isCdAnalyzeResult) {
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
setCdMetadataDialogContext({
|
|
...(pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {}),
|
|
jobId: analyzedJobId,
|
|
mediaProfile: 'cd',
|
|
detectedTitle: response?.result?.detectedTitle || '',
|
|
tracks: Array.isArray(response?.result?.tracks) ? response.result.tracks : []
|
|
});
|
|
setCdMetadataDialogVisible(true);
|
|
return;
|
|
}
|
|
setMetadataDialogContext({
|
|
jobId: analyzedJobId,
|
|
detectedTitle: response?.result?.detectedTitle || '',
|
|
workflowKind: response?.result?.workflowKind || null,
|
|
selectedMetadata: {
|
|
title: response?.result?.detectedTitle || '',
|
|
year: null,
|
|
imdbId: null,
|
|
poster: null,
|
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
|
seasonNumber: null
|
|
},
|
|
metadataProvider: response?.result?.metadataProvider || 'tmdb',
|
|
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
|
|
? response.result.metadataCandidates
|
|
: [],
|
|
seriesAnalysis: response?.result?.seriesAnalysis || null,
|
|
seriesLookupHint: response?.result?.seriesLookupHint || null,
|
|
seriesDecision: response?.result?.seriesDecision || null
|
|
});
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
setMetadataDialogVisible(true);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setDriveAnalyzeBusy((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(devicePath);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleAnalyzeAll = async () => {
|
|
const lockedPaths = new Set(
|
|
(Array.isArray(pipeline?.driveLocks) ? pipeline.driveLocks : [])
|
|
.filter((lock) => Number(lock?.count || 0) > 0)
|
|
.map((lock) => normalizeDrivePath(lock?.path))
|
|
.filter(Boolean)
|
|
);
|
|
const drivesToAnalyze = allDrives
|
|
.filter((drv) => {
|
|
if (lockedPaths.has(normalizeDrivePath(drv.path))) {
|
|
return false;
|
|
}
|
|
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 {
|
|
const response = await api.rescanDisc();
|
|
refreshKnownDrives();
|
|
const allDetected = Array.isArray(response?.result?.allDetected) ? response.result.allDetected : [];
|
|
const count = allDetected.length;
|
|
toastRef.current?.show({
|
|
severity: count > 0 ? 'success' : 'info',
|
|
summary: 'Laufwerke neu gelesen',
|
|
detail: count > 0
|
|
? `${count > 1 ? `${count} Medien` : '1 Medium'} erkannt.`
|
|
: 'Kein Medium erkannt.',
|
|
life: 2800
|
|
});
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const refreshKnownDrives = () => {
|
|
api.getDetectedDrives()
|
|
.then((res) => setKnownDrives(Array.isArray(res?.drives) ? res.drives : []))
|
|
.catch(() => {});
|
|
};
|
|
|
|
const handleRescanDrive = async (devicePath) => {
|
|
setDriveRescanBusy((prev) => new Set([...prev, devicePath]));
|
|
try {
|
|
const response = await api.rescanDrive(devicePath);
|
|
refreshKnownDrives();
|
|
const emitted = response?.result?.emitted || 'none';
|
|
toastRef.current?.show({
|
|
severity: emitted === 'discInserted' ? 'success' : 'info',
|
|
summary: devicePath,
|
|
detail: emitted === 'discInserted'
|
|
? 'Medium erkannt.'
|
|
: emitted === 'discRemoved' ? 'Medium entfernt.' : 'Leer.',
|
|
life: 2500
|
|
});
|
|
await refreshPipeline();
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setDriveRescanBusy((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(devicePath);
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCancel = async (jobId = null, jobState = null) => {
|
|
const cancelledJobId = normalizeJobId(jobId) || currentPipelineJobId;
|
|
const cancelledJob = ripperJobs.find((item) => normalizeJobId(item?.id) === cancelledJobId) || null;
|
|
const cancelledState = String(
|
|
jobState
|
|
|| cancelledJob?.status
|
|
|| state
|
|
|| 'IDLE'
|
|
).trim().toUpperCase();
|
|
|
|
if (cancelledJobId) setJobBusy(cancelledJobId, true);
|
|
try {
|
|
await api.cancelPipeline(cancelledJobId);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
const terminalStatuses = new Set(['FINISHED', 'ERROR', 'CANCELLED']);
|
|
const resolveLifecycleStatus = (job) => String(
|
|
job?.status
|
|
|| job?.last_state
|
|
|| ''
|
|
).trim().toUpperCase();
|
|
let latestCancelledJob = null;
|
|
const fetchLatestCancelledJob = async () => {
|
|
if (!cancelledJobId) {
|
|
return null;
|
|
}
|
|
try {
|
|
const latestResponse = await api.getJob(cancelledJobId, { lite: true, forceRefresh: true });
|
|
return latestResponse?.job && typeof latestResponse.job === 'object'
|
|
? latestResponse.job
|
|
: null;
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
};
|
|
latestCancelledJob = await fetchLatestCancelledJob();
|
|
const initialObservedStatus = resolveLifecycleStatus(latestCancelledJob) || cancelledState;
|
|
if (cancelledJobId && !terminalStatuses.has(initialObservedStatus)) {
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const latestStatus = resolveLifecycleStatus(latestCancelledJob);
|
|
if (terminalStatuses.has(latestStatus)) {
|
|
break;
|
|
}
|
|
await wait(300);
|
|
latestCancelledJob = await fetchLatestCancelledJob();
|
|
if ((attempt + 1) % 4 === 0) {
|
|
await refreshPipeline();
|
|
}
|
|
}
|
|
}
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
// No post-cancel cleanup dialog here; encode/rip cleanup is handled by settings or retry flows.
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (cancelledJobId) setJobBusy(cancelledJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteCancelledOutput = async () => {
|
|
const jobId = normalizeJobId(cancelCleanupDialog?.jobId);
|
|
const target = String(cancelCleanupDialog?.target || '').trim().toLowerCase();
|
|
const effectiveTarget = target === 'raw' ? 'raw' : 'movie';
|
|
const jobRow = (Array.isArray(ripperJobs) ? ripperJobs : [])
|
|
.find((row) => normalizeJobId(row?.id) === jobId) || null;
|
|
const includeRelated = isSeriesVideoJob(jobRow);
|
|
if (!jobId) {
|
|
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
|
|
return;
|
|
}
|
|
|
|
setCancelCleanupBusy(true);
|
|
try {
|
|
const response = await api.deleteJobFiles(jobId, effectiveTarget, { includeRelated });
|
|
const summary = response?.summary || {};
|
|
const targetSummary = effectiveTarget === 'raw'
|
|
? (summary.raw || {})
|
|
: (summary.movie || {});
|
|
const deletedFiles = targetSummary.filesDeleted ?? 0;
|
|
const removedDirs = targetSummary.dirsRemoved ?? 0;
|
|
const deletedSomething = Boolean(targetSummary.deleted);
|
|
|
|
if (!deletedSomething) {
|
|
toastRef.current?.show({
|
|
severity: 'warn',
|
|
summary: effectiveTarget === 'raw' ? 'RAW nicht gelöscht' : 'Movie nicht gelöscht',
|
|
detail: targetSummary.reason || 'Keine passenden Dateien/Ordner gefunden.',
|
|
life: 4200
|
|
});
|
|
} else {
|
|
toastRef.current?.show({
|
|
severity: 'success',
|
|
summary: effectiveTarget === 'raw' ? 'RAW gelöscht' : 'Movie gelöscht',
|
|
detail: `Entfernt: ${deletedFiles} Datei(en), ${removedDirs} Ordner.`,
|
|
life: 4000
|
|
});
|
|
}
|
|
await loadRipperJobs();
|
|
await refreshPipeline();
|
|
setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null });
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setCancelCleanupBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleStartJob = async (jobId, options = null) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
|
|
const startOptions = options && typeof options === 'object' ? options : {};
|
|
const startJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
|
const mediaType = resolveMediaType(startJobRow);
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
if (startOptions.ensureConfirmed) {
|
|
const confirmPayload = {
|
|
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
|
|
selectedEncodeTitleIds: startOptions.selectedEncodeTitleIds ?? null,
|
|
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
|
|
episodeAssignments: startOptions.episodeAssignments ?? null,
|
|
skipPipelineStateUpdate: true
|
|
};
|
|
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
|
|
confirmPayload.selectedPostEncodeScriptIds = startOptions.selectedPostEncodeScriptIds;
|
|
}
|
|
if (startOptions.selectedPreEncodeScriptIds !== undefined) {
|
|
confirmPayload.selectedPreEncodeScriptIds = startOptions.selectedPreEncodeScriptIds;
|
|
}
|
|
if (startOptions.selectedPostEncodeChainIds !== undefined) {
|
|
confirmPayload.selectedPostEncodeChainIds = startOptions.selectedPostEncodeChainIds;
|
|
}
|
|
if (startOptions.selectedPreEncodeChainIds !== undefined) {
|
|
confirmPayload.selectedPreEncodeChainIds = startOptions.selectedPreEncodeChainIds;
|
|
}
|
|
if (startOptions.selectedUserPresetId !== undefined) {
|
|
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
|
|
}
|
|
if (startOptions.selectedHandBrakePreset !== undefined) {
|
|
confirmPayload.selectedHandBrakePreset = startOptions.selectedHandBrakePreset;
|
|
}
|
|
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
|
|
}
|
|
const response = mediaType === 'audiobook'
|
|
? await api.startJob(normalizedJobId)
|
|
: await api.startJob(normalizedJobId);
|
|
const result = getQueueActionResult(response);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
if (result.queued) {
|
|
showQueuedToast(toastRef, 'Start', result);
|
|
} else {
|
|
setExpandedJobId(normalizedJobId);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleReorderMultipartMergeSources = async (jobId, orderedSourceJobIds = []) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
const normalizedOrderedIds = Array.isArray(orderedSourceJobIds)
|
|
? orderedSourceJobIds.map((value) => normalizeJobId(value)).filter(Boolean)
|
|
: [];
|
|
if (normalizedOrderedIds.length < 2) {
|
|
return;
|
|
}
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
await api.reorderMultipartMergeSources(normalizedJobId, normalizedOrderedIds);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
await loadMultipartMergePreview(normalizedJobId, { force: true, silent: true });
|
|
setExpandedJobId(normalizedJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateMultipartMergeSettings = async (jobId, settings = {}) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
await api.updateMultipartMergeSettings(normalizedJobId, settings);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId(normalizedJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleSaveActivationBytes = async () => {
|
|
const { checksum, jobId } = activationBytesDialog;
|
|
const bytes = activationBytesInput.trim().toLowerCase();
|
|
setActivationBytesBusy(true);
|
|
try {
|
|
await api.saveActivationBytes(checksum, bytes);
|
|
setPendingActivationJobIds(new Set());
|
|
dismissedActivationDialogKeyRef.current = null;
|
|
setActivationBytesDialog({ visible: false, checksum: null, jobId: null });
|
|
toastRef.current?.show({
|
|
severity: 'success',
|
|
summary: 'Activation Bytes gespeichert',
|
|
detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.',
|
|
life: 4000
|
|
});
|
|
await loadRipperJobs();
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setActivationBytesBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleAudiobookStart = async (jobId, audiobookConfig) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
if (pendingActivationJobIds.has(normalizedJobId)) {
|
|
setActivationBytesInput('');
|
|
const pending = await api.getPendingActivation().catch(() => ({ pending: [] }));
|
|
const entry = (pending?.pending || []).find((p) => p.jobId === normalizedJobId);
|
|
if (entry) {
|
|
dismissedActivationDialogKeyRef.current = null;
|
|
setActivationBytesDialog({ visible: true, checksum: entry.checksum, jobId: normalizedJobId });
|
|
}
|
|
return;
|
|
}
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
|
|
const result = getQueueActionResult(response);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
if (result.queued) {
|
|
showQueuedToast(toastRef, 'Audiobook', result);
|
|
} else {
|
|
setExpandedJobId(normalizedJobId);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleConfirmReview = async (
|
|
jobId,
|
|
selectedEncodeTitleId = null,
|
|
selectedTrackSelection = null,
|
|
selectedPostEncodeScriptIds = undefined,
|
|
selectedUserPresetId = undefined,
|
|
selectedHandBrakePreset = undefined,
|
|
selectedEncodeTitleIds = undefined,
|
|
episodeAssignments = undefined
|
|
) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const payload = {
|
|
selectedEncodeTitleId,
|
|
selectedTrackSelection
|
|
};
|
|
if (selectedEncodeTitleIds !== undefined) {
|
|
payload.selectedEncodeTitleIds = selectedEncodeTitleIds;
|
|
}
|
|
if (selectedPostEncodeScriptIds !== undefined) {
|
|
payload.selectedPostEncodeScriptIds = selectedPostEncodeScriptIds;
|
|
}
|
|
if (episodeAssignments !== undefined) {
|
|
payload.episodeAssignments = episodeAssignments;
|
|
}
|
|
if (selectedUserPresetId !== undefined) {
|
|
payload.selectedUserPresetId = selectedUserPresetId;
|
|
}
|
|
if (selectedHandBrakePreset !== undefined) {
|
|
payload.selectedHandBrakePreset = selectedHandBrakePreset;
|
|
}
|
|
await api.confirmEncodeReview(jobId, payload);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId(normalizedJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleSelectPlaylist = async (jobId, selectedPlaylist = null) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
|
try {
|
|
await api.selectMetadata({
|
|
jobId,
|
|
selectedPlaylist: selectedPlaylist || null
|
|
});
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId(normalizedJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleIds = null) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const normalizedIds = (Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : [selectedHandBrakeTitleIds])
|
|
.map((value) => Number(value))
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
.map((value) => Math.trunc(value));
|
|
const dedupedIds = Array.from(new Set(normalizedIds));
|
|
await api.selectMetadata({
|
|
jobId,
|
|
selectedHandBrakeTitleId: dedupedIds[0] || null,
|
|
selectedHandBrakeTitleIds: dedupedIds
|
|
});
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId(normalizedJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleSubmitRawDecision = async (jobId, decision) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
|
try {
|
|
await api.submitRawDecision(jobId, decision);
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId(normalizedJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleRetry = async (jobId) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
const retryJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
|
const title = retryJobRow?.title || retryJobRow?.detected_title || `Job #${normalizedJobId || jobId}`;
|
|
const mediaType = retryJobRow ? resolveMediaType(retryJobRow) : null;
|
|
const diskLabel = mediaType === 'cd' ? 'CD' : 'Disk';
|
|
const confirmed = await confirmModal({
|
|
header: 'Rip neu starten',
|
|
message: `Bitte lege die korrekte ${diskLabel} für "${title}" ein.\nSoll der Rip jetzt gestartet werden?`,
|
|
acceptLabel: 'Rip starten',
|
|
rejectLabel: 'Abbrechen'
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const response = await api.retryJob(jobId);
|
|
const result = getQueueActionResult(response);
|
|
const retryJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
if (result.queued) {
|
|
showQueuedToast(toastRef, 'Retry', result);
|
|
} else {
|
|
setExpandedJobId(retryJobId);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteRipperJob = async (jobId) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
|
const orphanImportSource = String(
|
|
job?.makemkvInfo?.source
|
|
|| job?.makemkvInfo?.importRecovery?.source
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import';
|
|
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
|
const isMultipartChildJob = jobKindRaw === 'multipart_movie_child'
|
|
|| jobKindRaw === 'multipart_movie_merge'
|
|
|| (Number(job?.is_multipart_movie || 0) === 1 && Boolean(normalizeJobId(job?.parent_job_id)));
|
|
const includeRelatedOnDelete = !isMultipartChildJob;
|
|
const hasAnyOutputPath = Boolean(String(job?.output_path || '').trim());
|
|
const deleteRaw = false;
|
|
let movieSelectionPaths = [];
|
|
let hasCompleteMovieCandidates = false;
|
|
try {
|
|
const deletePreview = await api.getJobDeletePreview(normalizedJobId, { includeRelated: includeRelatedOnDelete });
|
|
const movieCandidates = Array.isArray(deletePreview?.preview?.pathCandidates?.movie)
|
|
? deletePreview.preview.pathCandidates.movie
|
|
: [];
|
|
const existingMovieSelectionPaths = Array.from(new Set(
|
|
movieCandidates
|
|
.filter((candidate) => Boolean(candidate?.exists))
|
|
.map((candidate) => String(candidate?.path || '').trim())
|
|
.filter(Boolean)
|
|
));
|
|
movieSelectionPaths = existingMovieSelectionPaths;
|
|
hasCompleteMovieCandidates = existingMovieSelectionPaths.length > 0;
|
|
const incompleteMovieSelectionPaths = Array.from(new Set(
|
|
movieCandidates
|
|
.filter((candidate) => Boolean(candidate?.exists) && isIncompleteOutputPath(candidate?.path))
|
|
.filter((candidate) => {
|
|
if (!isIncompleteMergeOutputPath(candidate?.path)) {
|
|
return true;
|
|
}
|
|
return Boolean(candidate?.isFile);
|
|
})
|
|
.map((candidate) => String(candidate?.path || '').trim())
|
|
.filter(Boolean)
|
|
));
|
|
if (!hasCompleteMovieCandidates && incompleteMovieSelectionPaths.length > 0) {
|
|
movieSelectionPaths = incompleteMovieSelectionPaths;
|
|
}
|
|
} catch (_error) {
|
|
movieSelectionPaths = [];
|
|
}
|
|
if (movieSelectionPaths.length === 0) {
|
|
movieSelectionPaths = getIncompleteOutputSelectionPaths(job?.output_path);
|
|
}
|
|
if (movieSelectionPaths.length === 0 && hasAnyOutputPath) {
|
|
movieSelectionPaths = [String(job.output_path).trim()].filter(Boolean);
|
|
}
|
|
const deleteMovie = movieSelectionPaths.length > 0;
|
|
const deleteTarget = deleteMovie ? 'movie' : 'none';
|
|
const resetDriveStateOnDelete = !isOrphanRawImportJob;
|
|
const keepDetectedDeviceOnDelete = isOrphanRawImportJob;
|
|
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
|
let deleteHint = 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.';
|
|
if (isOrphanRawImportJob) {
|
|
deleteHint = 'Hinweis: Dieser /database-Job löscht beim Entfernen aus dem Ripper keine RAW-Dateien.';
|
|
}
|
|
if (deleteMovie) {
|
|
deleteHint = 'Hinweis: Zugehörige Encode-Ausgabe (Dateien/Ordner) wird beim Löschen mit entfernt.';
|
|
}
|
|
const confirmed = await confirmModal({
|
|
header: 'Job löschen',
|
|
message:
|
|
`Job #${normalizedJobId} wirklich löschen?\n` +
|
|
`${title}\n\n` +
|
|
deleteHint +
|
|
(includeRelatedOnDelete
|
|
? '\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.'
|
|
: '\nEs wird nur dieser Job entfernt; andere zugehörige Disks bleiben erhalten.') +
|
|
(isOrphanRawImportJob
|
|
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
|
|
: '\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'),
|
|
acceptLabel: 'Löschen',
|
|
rejectLabel: 'Abbrechen',
|
|
danger: true
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const statusBeforeDelete = String(job?.status || '').trim().toUpperCase();
|
|
if (processingStates.includes(statusBeforeDelete)) {
|
|
await api.cancelPipeline(normalizedJobId);
|
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const latest = await api.getJob(normalizedJobId, { lite: true, forceRefresh: true }).catch(() => null);
|
|
const latestStatus = String(latest?.job?.status || latest?.job?.last_state || '').trim().toUpperCase();
|
|
if (!processingStates.includes(latestStatus)) {
|
|
break;
|
|
}
|
|
await wait(250);
|
|
}
|
|
}
|
|
await api.deleteJobEntry(normalizedJobId, deleteTarget, {
|
|
includeRelated: includeRelatedOnDelete,
|
|
...(deleteMovie ? { selectedMoviePaths: movieSelectionPaths } : {}),
|
|
resetDriveState: resetDriveStateOnDelete,
|
|
keepDetectedDevice: keepDetectedDeviceOnDelete,
|
|
preserveRawForImportJobs: true
|
|
});
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId((prev) => (normalizeJobId(prev) === normalizedJobId ? null : prev));
|
|
toastRef.current?.show({
|
|
severity: 'success',
|
|
summary: 'Job gelöscht',
|
|
detail: `Job #${normalizedJobId} wurde entfernt.`,
|
|
life: 3200
|
|
});
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleRestartEncodeWithLastSettings = async (jobId, restartOptions = {}) => {
|
|
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
|
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
|
const restartMode = String(restartOptions?.restartMode || 'all').trim().toLowerCase() === 'from_abort'
|
|
? 'from_abort'
|
|
: 'all';
|
|
if (job?.encodeSuccess) {
|
|
const confirmed = await confirmModal({
|
|
header: 'Encode neu starten',
|
|
message:
|
|
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
|
|
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.',
|
|
acceptLabel: 'Neu encodieren',
|
|
rejectLabel: 'Abbrechen'
|
|
});
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const response = await api.restartEncodeWithLastSettings(jobId, { restartMode });
|
|
const result = getQueueActionResult(response);
|
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
if (result.queued) {
|
|
showQueuedToast(toastRef, 'Encode-Neustart', result);
|
|
} else {
|
|
setExpandedJobId(replacementJobId);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleRestartReviewFromRaw = async (jobId) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId) {
|
|
return;
|
|
}
|
|
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
const response = await api.restartReviewFromRaw(normalizedJobId, { reuseCurrentJob: true });
|
|
const result = getQueueActionResult(response);
|
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setExpandedJobId(replacementJobId);
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
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 loadRipperJobs();
|
|
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);
|
|
if (!targetId || !draggedId || targetId === draggedId || queueReorderBusy) {
|
|
return;
|
|
}
|
|
setQueueState((prev) => {
|
|
const queuedJobs = reorderQueuedItems(prev?.queuedJobs || [], draggedId, targetId);
|
|
return {
|
|
...normalizeQueue(prev),
|
|
queuedJobs,
|
|
queuedCount: queuedJobs.length
|
|
};
|
|
});
|
|
};
|
|
|
|
const handleQueueDrop = async () => {
|
|
const draggedId = Number(draggingQueueEntryId);
|
|
setDraggingQueueEntryId(null);
|
|
if (!draggedId || queueReorderBusy) {
|
|
return;
|
|
}
|
|
|
|
const orderedEntryIds = (Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [])
|
|
.map((item) => Number(item?.entryId))
|
|
.filter(Boolean);
|
|
if (orderedEntryIds.length <= 1) {
|
|
return;
|
|
}
|
|
|
|
setQueueReorderBusy(true);
|
|
try {
|
|
const response = await api.reorderPipelineQueue(orderedEntryIds);
|
|
setQueueState(normalizeQueue(response?.queue));
|
|
} catch (error) {
|
|
showError(error);
|
|
try {
|
|
const latest = await api.getPipelineQueue();
|
|
setQueueState(normalizeQueue(latest?.queue));
|
|
} catch (_reloadError) {
|
|
// ignore reload failures after reorder error
|
|
}
|
|
} finally {
|
|
setQueueReorderBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveQueuedJob = async (jobId) => {
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (!normalizedJobId || queueReorderBusy) {
|
|
return;
|
|
}
|
|
|
|
setQueueReorderBusy(true);
|
|
setJobBusy(normalizedJobId, true);
|
|
try {
|
|
await api.cancelPipeline(normalizedJobId);
|
|
const latest = await api.getPipelineQueue();
|
|
setQueueState(normalizeQueue(latest?.queue));
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setQueueReorderBusy(false);
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveQueueEntry = async (entryId) => {
|
|
if (!entryId || queueReorderBusy) {
|
|
return;
|
|
}
|
|
setQueueReorderBusy(true);
|
|
try {
|
|
const response = await api.removeQueueEntry(entryId);
|
|
setQueueState(normalizeQueue(response?.queue));
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setQueueReorderBusy(false);
|
|
}
|
|
};
|
|
|
|
const openInsertQueueDialog = async (afterEntryId) => {
|
|
const hasJob = (queueState?.runningJobs || []).some((i) => i.jobId != null)
|
|
|| (queueState?.queuedJobs || []).some((i) => i.jobId != null);
|
|
if (!hasJob) {
|
|
return;
|
|
}
|
|
setInsertQueueDialog({ visible: true, afterEntryId: afterEntryId ?? null });
|
|
try {
|
|
const [scriptsRes, chainsRes] = await Promise.allSettled([api.getScripts(), api.getScriptChains()]);
|
|
setQueueCatalog({
|
|
scripts: scriptsRes.status === 'fulfilled' ? (Array.isArray(scriptsRes.value?.scripts) ? scriptsRes.value.scripts : []) : [],
|
|
chains: chainsRes.status === 'fulfilled' ? (Array.isArray(chainsRes.value?.chains) ? chainsRes.value.chains : []) : []
|
|
});
|
|
} catch (_) { /* ignore */ }
|
|
};
|
|
|
|
const handleAddQueueEntry = async (type, params) => {
|
|
const afterEntryId = insertQueueDialog.afterEntryId;
|
|
setInsertQueueDialog({ visible: false, afterEntryId: null });
|
|
try {
|
|
const response = await api.addQueueEntry({ type, ...params, insertAfterEntryId: afterEntryId });
|
|
setQueueState(normalizeQueue(response?.queue));
|
|
} catch (error) {
|
|
showError(error);
|
|
}
|
|
};
|
|
|
|
const syncQueueFromServer = async () => {
|
|
try {
|
|
const latest = await api.getPipelineQueue();
|
|
setQueueState(normalizeQueue(latest?.queue));
|
|
} catch (_error) {
|
|
// ignore sync failures
|
|
}
|
|
};
|
|
|
|
const handleMetadataSearch = async (query, options = {}) => {
|
|
const filterRaw = String(options?.resultFilter || '').trim().toLowerCase();
|
|
const includeMovies = filterRaw !== 'series';
|
|
const includeSeries = filterRaw !== 'movies' && filterRaw !== 'movie';
|
|
const [movieResponse, seriesResponse] = await Promise.all([
|
|
includeMovies ? api.searchTmdbMovie(query) : Promise.resolve({ results: [] }),
|
|
includeSeries ? api.searchTmdbSeries(query) : Promise.resolve({ results: [] })
|
|
]);
|
|
const movieRows = Array.isArray(movieResponse?.results)
|
|
? movieResponse.results.map((row) => ({
|
|
...row,
|
|
workflowKind: 'film',
|
|
metadataKind: 'movie',
|
|
resultType: 'movie'
|
|
}))
|
|
: [];
|
|
const seriesRows = Array.isArray(seriesResponse?.results)
|
|
? seriesResponse.results.map((row) => ({
|
|
...row,
|
|
workflowKind: 'series',
|
|
metadataKind: String(row?.metadataKind || '').trim().toLowerCase() || 'series',
|
|
resultType: 'series'
|
|
}))
|
|
: [];
|
|
return [...movieRows, ...seriesRows];
|
|
};
|
|
|
|
const doSelectMetadata = async (payload, options = {}) => {
|
|
const suppressErrorToast = Boolean(options?.suppressErrorToast);
|
|
const submittedJobId = normalizeJobId(payload?.jobId);
|
|
setBusy(true);
|
|
try {
|
|
let metadataResponse = null;
|
|
if (metadataDialogReassignMode) {
|
|
const duplicateAction = String(payload?.duplicateAction || '').trim().toLowerCase();
|
|
const providerRaw = String(payload?.metadataProvider || '').trim().toLowerCase();
|
|
const nextWorkflowKind = String(
|
|
payload?.workflowKind
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
const reassignJob = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(payload?.jobId)) || null;
|
|
const reassignMediaProfile = String(
|
|
reassignJob?.media_type
|
|
|| reassignJob?.mediaType
|
|
|| effectiveMetadataDialogContext?.mediaProfile
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
const currentWorkflowKind = String(
|
|
reassignJob?.makemkvInfo?.analyzeContext?.selectedMetadata?.workflowKind
|
|
|| reassignJob?.makemkvInfo?.analyzeContext?.workflowKind
|
|
|| reassignJob?.makemkvInfo?.selectedMetadata?.workflowKind
|
|
|| effectiveMetadataDialogContext?.selectedMetadata?.workflowKind
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
const currentProvider = String(
|
|
reassignJob?.makemkvInfo?.analyzeContext?.metadataProvider
|
|
|| reassignJob?.makemkvInfo?.selectedMetadata?.metadataProvider
|
|
|| effectiveMetadataDialogContext?.metadataProvider
|
|
|| ''
|
|
).trim().toLowerCase();
|
|
const shouldRestartReview = Boolean(
|
|
nextWorkflowKind
|
|
&& currentWorkflowKind
|
|
&& nextWorkflowKind !== currentWorkflowKind
|
|
) || Boolean(
|
|
currentProvider
|
|
&& providerRaw
|
|
&& currentProvider !== providerRaw
|
|
);
|
|
const forceFilmMetadataPath = ['dvd', 'bluray'].includes(reassignMediaProfile) && nextWorkflowKind === 'film';
|
|
if (duplicateAction || shouldRestartReview || forceFilmMetadataPath) {
|
|
metadataResponse = await api.selectMetadata(payload);
|
|
} else {
|
|
metadataResponse = await api.assignJobMetadata(payload.jobId, payload);
|
|
}
|
|
} else {
|
|
metadataResponse = await api.selectMetadata(payload);
|
|
}
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
const responseJobId = normalizeJobId(
|
|
metadataResponse?.job?.id
|
|
|| metadataResponse?.result?.jobId
|
|
|| metadataResponse?.jobId
|
|
);
|
|
const expandedTargetJobId = responseJobId || submittedJobId;
|
|
if (expandedTargetJobId) {
|
|
setExpandedJobId(expandedTargetJobId);
|
|
}
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
} catch (error) {
|
|
if (suppressErrorToast) {
|
|
throw error;
|
|
}
|
|
showError(error);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleMetadataSubmit = async (payload) => {
|
|
await doSelectMetadata(payload, { suppressErrorToast: true });
|
|
};
|
|
|
|
const handleMusicBrainzSearch = async (query, options = {}) => {
|
|
try {
|
|
const response = await api.searchMusicBrainz(query, {
|
|
trackCount: Number(options?.trackCount || 0) > 0 ? Math.trunc(Number(options.trackCount)) : null
|
|
});
|
|
return response.results || [];
|
|
} catch (error) {
|
|
showError(error);
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
|
try {
|
|
const response = await api.getMusicBrainzRelease(mbId);
|
|
return response?.release || null;
|
|
} catch (error) {
|
|
showError(error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const handleCdMetadataSubmit = async (payload) => {
|
|
setBusy(true);
|
|
try {
|
|
const response = await api.selectCdMetadata(payload);
|
|
const expandedTargetJobId = normalizeJobId(response?.id) || normalizeJobId(payload?.jobId);
|
|
let startRipError = null;
|
|
if (expandedTargetJobId) {
|
|
try {
|
|
const startResponse = await api.startCdRip(expandedTargetJobId, { skipEncode: true });
|
|
const result = getQueueActionResult(startResponse);
|
|
if (result.queued) {
|
|
showQueuedToast(toastRef, 'Audio CD', result);
|
|
}
|
|
} catch (error) {
|
|
startRipError = error;
|
|
}
|
|
}
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
setCdMetadataDialogVisible(false);
|
|
setCdMetadataDialogContext(null);
|
|
if (expandedTargetJobId) {
|
|
setExpandedJobId(expandedTargetJobId);
|
|
}
|
|
if (startRipError) {
|
|
showError(startRipError);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleCdRipStart = async (jobId, ripConfig) => {
|
|
if (!jobId) {
|
|
return;
|
|
}
|
|
const normalizedJobId = normalizeJobId(jobId);
|
|
if (normalizedJobId) {
|
|
setJobBusy(normalizedJobId, true);
|
|
}
|
|
try {
|
|
const activePipelineForJob = normalizedJobId
|
|
? (pipelineByJobId.get(normalizedJobId) || null)
|
|
: null;
|
|
const shouldForceTwoStep = Boolean(
|
|
activePipelineForJob?.context?.mediaProfile === 'cd'
|
|
&& activePipelineForJob?.context?.skipRip !== true
|
|
);
|
|
const effectiveRipConfig = shouldForceTwoStep
|
|
? {
|
|
...(ripConfig && typeof ripConfig === 'object' ? ripConfig : {}),
|
|
skipEncode: true
|
|
}
|
|
: ripConfig;
|
|
const response = await api.startCdRip(jobId, effectiveRipConfig);
|
|
const result = getQueueActionResult(response);
|
|
if (result.queued) {
|
|
showQueuedToast(toastRef, 'Audio CD', result);
|
|
}
|
|
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
|
|
await refreshPipeline();
|
|
await loadRipperJobs();
|
|
if (replacementJobId) {
|
|
setExpandedJobId(replacementJobId);
|
|
}
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
if (normalizedJobId) {
|
|
setJobBusy(normalizedJobId, false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// CD drives are tracked per-drive in pipeline.cdDrives — exclude them from the single-drive display
|
|
const lastNonCdDiscEvent = lastDiscEvent?.mediaProfile !== 'cd' ? lastDiscEvent : null;
|
|
const contextDevice = pipeline?.context?.device;
|
|
const device = lastNonCdDiscEvent || (contextDevice?.mediaProfile !== 'cd' ? contextDevice : null);
|
|
const driveLocksByPath = useMemo(() => {
|
|
const map = new Map();
|
|
const locks = Array.isArray(pipeline?.driveLocks) ? pipeline.driveLocks : [];
|
|
for (const lock of locks) {
|
|
const path = normalizeDrivePath(lock?.path);
|
|
const count = Number(lock?.count || 0);
|
|
if (!path || !Number.isFinite(count) || count <= 0) {
|
|
continue;
|
|
}
|
|
const owners = Array.isArray(lock?.owners) ? lock.owners : [];
|
|
const owner = owners.length > 0 ? owners[owners.length - 1] : null;
|
|
map.set(path, {
|
|
path,
|
|
count,
|
|
owner,
|
|
owners
|
|
});
|
|
}
|
|
return map;
|
|
}, [pipeline?.driveLocks]);
|
|
|
|
// Unified list of all known drives: merge lsblk drives + CD state + non-CD pipeline state
|
|
const allDrives = useMemo(() => {
|
|
const driveMap = new Map();
|
|
for (const d of knownDrives) {
|
|
driveMap.set(d.path, { path: d.path, model: d.model, discArg: d.discArg });
|
|
}
|
|
for (const [drivePath, drive] of Object.entries(pipeline?.cdDrives || {})) {
|
|
const base = driveMap.get(drivePath) || { path: drivePath };
|
|
driveMap.set(drivePath, { ...base, cdDrive: drive });
|
|
}
|
|
if (device?.path) {
|
|
const base = driveMap.get(device.path) || { path: device.path, model: device.model };
|
|
// When device comes from a recent DISC_DETECTED event, use 'DISC_DETECTED' as the
|
|
// effective state for that drive even if the global pipeline state is ENCODING etc.
|
|
const effectivePipelineState = lastNonCdDiscEvent ? 'DISC_DETECTED' : state;
|
|
driveMap.set(device.path, { ...base, pipelineDevice: device, pipelineState: effectivePipelineState });
|
|
}
|
|
// 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, lastNonCdDiscEvent]);
|
|
const canRescan = !busy;
|
|
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
|
const queueRunningJobsRaw = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
|
const queueIdleJobsRaw = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
|
|
const queuedJobsRaw = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
|
const queueChainNameById = useMemo(() => {
|
|
const map = new Map();
|
|
const chains = Array.isArray(queueCatalog?.chains) ? queueCatalog.chains : [];
|
|
for (const chain of chains) {
|
|
const chainId = Number(chain?.id);
|
|
const chainName = String(chain?.name || '').trim();
|
|
if (!Number.isFinite(chainId) || chainId <= 0 || !chainName) {
|
|
continue;
|
|
}
|
|
map.set(Math.trunc(chainId), chainName);
|
|
}
|
|
return map;
|
|
}, [queueCatalog?.chains]);
|
|
const isVisibleQueueItem = (_item) => true;
|
|
const queueRunningJobs = queueRunningJobsRaw.filter((item) => isVisibleQueueItem(item));
|
|
const queueRunningJobsDisplay = queueRunningJobs;
|
|
const queueIdleJobs = queueIdleJobsRaw.filter((item) => isVisibleQueueItem(item));
|
|
const queuedJobs = queuedJobsRaw.filter((item) => isVisibleQueueItem(item));
|
|
const visibleRunningAudioCdCount = queueRunningJobs.filter(
|
|
(item) => String(item?.poolType || '').trim().toLowerCase() === 'audio'
|
|
).length;
|
|
const visibleRunningFilmCount = Math.max(0, queueRunningJobs.length - visibleRunningAudioCdCount);
|
|
const visibleQueuedCount = queuedJobs.length;
|
|
const visibleIdleCount = queueIdleJobs.length;
|
|
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
|
|
const queueHasJobEntry = queueRunningJobs.some((i) => i.jobId != null)
|
|
|| queuedJobs.some((i) => i.jobId != null);
|
|
const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
|
|
const buildIdleQueueScriptKey = (jobId) => `idle-${normalizeJobId(jobId) || '-'}`;
|
|
const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
|
|
const toggleQueueScriptDetails = (key) => {
|
|
if (!key) {
|
|
return;
|
|
}
|
|
setExpandedQueueScriptKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
useEffect(() => {
|
|
const validKeys = new Set();
|
|
for (const item of queueRunningJobs) {
|
|
if (!hasQueueScriptSummary(item)) {
|
|
continue;
|
|
}
|
|
validKeys.add(buildRunningQueueScriptKey(item?.jobId));
|
|
}
|
|
for (const item of queueIdleJobs) {
|
|
if (!hasQueueScriptSummary(item)) {
|
|
continue;
|
|
}
|
|
validKeys.add(buildIdleQueueScriptKey(item?.jobId));
|
|
}
|
|
for (const item of queuedJobs) {
|
|
if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
|
|
continue;
|
|
}
|
|
validKeys.add(buildQueuedQueueScriptKey(item?.entryId));
|
|
}
|
|
setExpandedQueueScriptKeys((prev) => {
|
|
let changed = false;
|
|
const next = new Set();
|
|
for (const key of prev) {
|
|
if (validKeys.has(key)) {
|
|
next.add(key);
|
|
} else {
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
}, [queueRunningJobs, queueIdleJobs, queuedJobs]);
|
|
const queuedJobIdSet = useMemo(() => {
|
|
const set = new Set();
|
|
for (const item of queuedJobs) {
|
|
const id = normalizeJobId(item?.jobId);
|
|
if (id) {
|
|
set.add(id);
|
|
}
|
|
}
|
|
return set;
|
|
}, [queuedJobs]);
|
|
|
|
const setRuntimeActionBusy = (activityId, action, busyFlag) => {
|
|
const key = `${Number(activityId) || 0}:${String(action || '')}`;
|
|
setRuntimeActionBusyKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (busyFlag) {
|
|
next.add(key);
|
|
} else {
|
|
next.delete(key);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const isRuntimeActionBusy = (activityId, action) => runtimeActionBusyKeys.has(
|
|
`${Number(activityId) || 0}:${String(action || '')}`
|
|
);
|
|
|
|
const handleRuntimeControl = async (item, action) => {
|
|
const activityId = Number(item?.id);
|
|
if (!Number.isFinite(activityId) || activityId <= 0) {
|
|
return;
|
|
}
|
|
const normalizedAction = String(action || '').trim().toLowerCase();
|
|
const actionLabel = normalizedAction === 'next-step' ? 'Nächster Schritt' : 'Abbrechen';
|
|
setRuntimeActionBusy(activityId, normalizedAction, true);
|
|
try {
|
|
const response = normalizedAction === 'next-step'
|
|
? await api.requestRuntimeNextStep(activityId)
|
|
: await api.cancelRuntimeActivity(activityId, { reason: 'Benutzerabbruch via Ripper' });
|
|
if (response?.snapshot) {
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
|
|
} else {
|
|
const fresh = await api.getRuntimeActivities();
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(fresh));
|
|
}
|
|
const accepted = response?.action?.accepted !== false;
|
|
const actionMessage = String(response?.action?.message || '').trim();
|
|
toastRef.current?.show({
|
|
severity: accepted ? 'info' : 'warn',
|
|
summary: actionLabel,
|
|
detail: actionMessage || (accepted ? 'Aktion ausgelöst.' : 'Aktion aktuell nicht möglich.'),
|
|
life: 2600
|
|
});
|
|
} catch (error) {
|
|
toastRef.current?.show({
|
|
severity: 'error',
|
|
summary: actionLabel,
|
|
detail: error?.message || 'Aktion fehlgeschlagen.',
|
|
life: 3200
|
|
});
|
|
} finally {
|
|
setRuntimeActionBusy(activityId, normalizedAction, false);
|
|
}
|
|
};
|
|
|
|
const handleClearRuntimeRecent = async () => {
|
|
if (runtimeRecentClearing || runtimeRecentItems.length === 0) {
|
|
return;
|
|
}
|
|
setRuntimeRecentClearing(true);
|
|
try {
|
|
const response = await api.clearRuntimeRecentActivities();
|
|
if (response?.snapshot) {
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(response.snapshot));
|
|
} else {
|
|
const fresh = await api.getRuntimeActivities();
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(fresh));
|
|
}
|
|
toastRef.current?.show({
|
|
severity: 'success',
|
|
summary: 'Abgeschlossene Liste',
|
|
detail: `Einträge entfernt: ${Number(response?.removed || 0)}`,
|
|
life: 2200
|
|
});
|
|
} catch (error) {
|
|
toastRef.current?.show({
|
|
severity: 'error',
|
|
summary: 'Liste leeren',
|
|
detail: error?.message || 'Leeren fehlgeschlagen.',
|
|
life: 3200
|
|
});
|
|
} finally {
|
|
setRuntimeRecentClearing(false);
|
|
}
|
|
};
|
|
|
|
const quickAccessItems = useMemo(() => {
|
|
const scripts = Array.isArray(quickAccessCatalog?.scripts) ? quickAccessCatalog.scripts : [];
|
|
const chains = Array.isArray(quickAccessCatalog?.chains) ? quickAccessCatalog.chains : [];
|
|
const items = [];
|
|
|
|
for (const script of scripts) {
|
|
const scriptId = Number(script?.id);
|
|
if (!Number.isFinite(scriptId) || scriptId <= 0 || script?.isFavorite !== true) {
|
|
continue;
|
|
}
|
|
items.push({
|
|
type: 'script',
|
|
id: Math.trunc(scriptId),
|
|
name: String(script?.name || '').trim() || `Skript #${scriptId}`
|
|
});
|
|
}
|
|
|
|
for (const chain of chains) {
|
|
const chainId = Number(chain?.id);
|
|
if (!Number.isFinite(chainId) || chainId <= 0 || chain?.isFavorite !== true) {
|
|
continue;
|
|
}
|
|
items.push({
|
|
type: 'chain',
|
|
id: Math.trunc(chainId),
|
|
name: String(chain?.name || '').trim() || `Kette #${chainId}`
|
|
});
|
|
}
|
|
|
|
return items.sort((left, right) => {
|
|
if (left.id !== right.id) {
|
|
return left.id - right.id;
|
|
}
|
|
if (left.type === right.type) {
|
|
return 0;
|
|
}
|
|
return left.type === 'script' ? -1 : 1;
|
|
});
|
|
}, [quickAccessCatalog]);
|
|
|
|
const handleRunQuickAccessItem = async (item) => {
|
|
const type = String(item?.type || '').trim().toLowerCase();
|
|
const id = Number(item?.id);
|
|
if (!Number.isFinite(id) || id <= 0 || (type !== 'script' && type !== 'chain')) {
|
|
return;
|
|
}
|
|
const normalizedId = Math.trunc(id);
|
|
const entryKey = `${type}:${normalizedId}`;
|
|
if (quickAccessRunningKeys.has(entryKey)) {
|
|
return;
|
|
}
|
|
setQuickAccessBusy(entryKey, true);
|
|
try {
|
|
if (type === 'script') {
|
|
const response = await api.testScript(normalizedId);
|
|
const result = response?.result || null;
|
|
toastRef.current?.show({
|
|
severity: result?.success ? 'success' : 'warn',
|
|
summary: 'Favoriten',
|
|
detail: result?.success
|
|
? `Skript "${item?.name || normalizedId}" gestartet.`
|
|
: `Skript "${item?.name || normalizedId}" mit Fehler beendet (exit=${result?.exitCode ?? 'n/a'}).`,
|
|
life: 3000
|
|
});
|
|
} else {
|
|
const response = await api.testScriptChain(normalizedId);
|
|
const result = response?.result || null;
|
|
toastRef.current?.show({
|
|
severity: result?.aborted ? 'warn' : 'success',
|
|
summary: 'Favoriten',
|
|
detail: result?.aborted
|
|
? `Kette "${item?.name || normalizedId}" mit Abbruch beendet.`
|
|
: `Kette "${item?.name || normalizedId}" gestartet.`,
|
|
life: 3200
|
|
});
|
|
}
|
|
const activities = await api.getRuntimeActivities();
|
|
setRuntimeActivities(normalizeRuntimeActivitiesPayload(activities));
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
setQuickAccessBusy(entryKey, false);
|
|
}
|
|
};
|
|
|
|
const runtimeActiveItems = Array.isArray(runtimeActivities?.active) ? runtimeActivities.active : [];
|
|
const quickAccessRunningKeys = useMemo(() => {
|
|
const keys = new Set();
|
|
for (const activity of runtimeActiveItems) {
|
|
const type = String(activity?.type || '').trim().toLowerCase();
|
|
if (type === 'script') {
|
|
const scriptId = Number(activity?.scriptId);
|
|
if (Number.isFinite(scriptId) && scriptId > 0) {
|
|
keys.add(`script:${Math.trunc(scriptId)}`);
|
|
}
|
|
} else if (type === 'chain') {
|
|
const chainId = Number(activity?.chainId);
|
|
if (Number.isFinite(chainId) && chainId > 0) {
|
|
keys.add(`chain:${Math.trunc(chainId)}`);
|
|
}
|
|
}
|
|
}
|
|
return keys;
|
|
}, [runtimeActiveItems]);
|
|
const runtimeRecentItems = Array.isArray(runtimeActivities?.recent)
|
|
? runtimeActivities.recent.slice(0, 8)
|
|
: [];
|
|
|
|
// Group chain activities with their current child script — hide child scripts from top level
|
|
const activeChainChildIds = new Set(
|
|
runtimeActiveItems.filter((i) => i.parentActivityId != null).map((i) => i.id)
|
|
);
|
|
const activeItemsDisplay = runtimeActiveItems.filter((i) => !activeChainChildIds.has(i.id));
|
|
const findActiveChainChild = (chainId) => runtimeActiveItems.find((i) => i.parentActivityId === chainId) || null;
|
|
|
|
const recentChainChildIds = new Set(
|
|
runtimeRecentItems.filter((i) => i.parentActivityId != null).map((i) => i.id)
|
|
);
|
|
const recentItemsDisplay = runtimeRecentItems.filter((i) => !recentChainChildIds.has(i.id));
|
|
|
|
return (
|
|
<div className="page-grid">
|
|
<Toast ref={toastRef} />
|
|
|
|
<div className="ripper-3col-grid">
|
|
<div className="ripper-col ripper-col-left">
|
|
<Card title="Disk-Information">
|
|
{/* Per-drive list */}
|
|
{allDrives.length > 0 ? (
|
|
<div className="drive-list">
|
|
{allDrives.map((drv) => {
|
|
const drivePath = drv.path;
|
|
const cdDrive = drv.cdDrive;
|
|
const pipelineDevice = drv.pipelineDevice;
|
|
const isRescanBusy = driveRescanBusy.has(drivePath);
|
|
const isAnalyzeBusy = driveAnalyzeBusy.has(drivePath);
|
|
|
|
// Determine display state
|
|
let stateLabel = null;
|
|
let stateSeverity = 'secondary';
|
|
let discInfo = null;
|
|
|
|
if (cdDrive) {
|
|
const s = String(cdDrive.state || '').toUpperCase();
|
|
stateLabel = s;
|
|
stateSeverity = s === 'FINISHED' ? 'success'
|
|
: s === 'ERROR' || s === 'CANCELLED' ? 'danger'
|
|
: ['CD_RIPPING', 'CD_ENCODING'].includes(s) ? 'warning'
|
|
: 'info';
|
|
const cd = cdDrive.device || {};
|
|
discInfo = cd.discLabel || cd.model || null;
|
|
} else if (pipelineDevice) {
|
|
const s = String(drv.pipelineState || '').toUpperCase();
|
|
stateLabel = s || 'DISC_DETECTED';
|
|
stateSeverity = s === 'FINISHED' ? 'success'
|
|
: s === 'ERROR' ? 'danger'
|
|
: processingStates.includes(s) ? 'warning'
|
|
: 'info';
|
|
discInfo = pipelineDevice.discLabel || pipelineDevice.label || null;
|
|
} else if (drv.detectedDisc) {
|
|
stateLabel = 'DISC_DETECTED';
|
|
stateSeverity = 'info';
|
|
discInfo = drv.detectedDisc.discLabel || drv.detectedDisc.label || null;
|
|
} else {
|
|
stateLabel = 'LEER';
|
|
stateSeverity = 'secondary';
|
|
}
|
|
|
|
const driveJobId = normalizeJobId(cdDrive?.jobId);
|
|
const driveCtx = cdDrive?.context && typeof cdDrive.context === 'object' ? cdDrive.context : {};
|
|
const cdState = cdDrive ? String(cdDrive.state || '').toUpperCase() : null;
|
|
const pipelineState = String(drv.pipelineState || '').toUpperCase();
|
|
const driveLockEntry = driveLocksByPath.get(normalizeDrivePath(drivePath)) || null;
|
|
const isCdDriveLocked = cdState ? activeCdDriveStates.includes(cdState) : false;
|
|
const isDriveLocked = Boolean(driveLockEntry) || isCdDriveLocked;
|
|
const lockedByJobId = normalizeJobId(driveLockEntry?.owner?.jobId);
|
|
const lockStage = String(driveLockEntry?.owner?.stage || '').trim().toUpperCase();
|
|
const lockTitle = lockedByJobId
|
|
? `Laufwerk ist gesperrt (Job #${lockedByJobId}${lockStage ? `, ${lockStage}` : ''}).`
|
|
: 'Laufwerk ist gesperrt, bis Rip/Encode abgeschlossen ist.';
|
|
const canAnalyzeDrive = cdState
|
|
? cdState === 'DISC_DETECTED'
|
|
: (Boolean(pipelineDevice) && pipelineState === 'DISC_DETECTED') || Boolean(drv.detectedDisc);
|
|
const stateIconMeta = driveStateIconMeta(stateLabel);
|
|
const discArg = String(drv.discArg || '').trim();
|
|
|
|
return (
|
|
<div key={drivePath} className="drive-list-item">
|
|
<div className="drive-list-row">
|
|
<div className="drive-list-info">
|
|
<code className="drive-list-path">{drivePath}</code>
|
|
{drv.model && <span className="drive-list-model">{drv.model}</span>}
|
|
</div>
|
|
<div className="drive-list-state">
|
|
{stateLabel ? (
|
|
<span
|
|
className={`drive-list-status-icon tone-${stateSeverity}`}
|
|
title={getStatusLabel(stateLabel)}
|
|
aria-label={`Status: ${getStatusLabel(stateLabel)}`}
|
|
>
|
|
<i className={`pi ${stateIconMeta.icon}${stateIconMeta.spin ? ' pi-spin' : ''}`} aria-hidden="true" />
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="drive-list-actions">
|
|
{isDriveLocked ? (
|
|
<Button
|
|
icon="pi pi-lock"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="secondary"
|
|
className="drive-list-action-btn"
|
|
disabled
|
|
title={lockTitle}
|
|
aria-label="Laufwerk gesperrt"
|
|
/>
|
|
) : (
|
|
<>
|
|
<Button
|
|
icon="pi pi-refresh"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="secondary"
|
|
className="drive-list-action-btn"
|
|
loading={isRescanBusy}
|
|
disabled={isRescanBusy}
|
|
onClick={() => handleRescanDrive(drivePath)}
|
|
title="Laufwerk neu lesen"
|
|
aria-label="Laufwerk neu lesen"
|
|
/>
|
|
{canAnalyzeDrive && (
|
|
<Button
|
|
icon="pi pi-search"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="secondary"
|
|
className="drive-list-action-btn"
|
|
loading={isAnalyzeBusy}
|
|
disabled={isAnalyzeBusy}
|
|
onClick={() => handleAnalyzeForDrive(drivePath)}
|
|
title="Disk analysieren"
|
|
aria-label="Disk analysieren"
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
{cdState === 'CD_METADATA_SELECTION' && (
|
|
<Button
|
|
icon="pi pi-list"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="info"
|
|
className="drive-list-action-btn"
|
|
onClick={() => {
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
setCdMetadataDialogContext({ ...driveCtx, jobId: driveJobId });
|
|
setCdMetadataDialogVisible(true);
|
|
}}
|
|
disabled={!driveJobId}
|
|
title="Metadaten auswählen"
|
|
aria-label="Metadaten auswählen"
|
|
/>
|
|
)}
|
|
{(cdState === 'ERROR' || cdState === 'CANCELLED') && driveJobId && (
|
|
<Button
|
|
icon="pi pi-replay"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="warning"
|
|
className="drive-list-action-btn"
|
|
onClick={() => handleAnalyzeForDrive(drivePath)}
|
|
loading={isAnalyzeBusy}
|
|
disabled={isAnalyzeBusy}
|
|
title="Erneut analysieren"
|
|
aria-label="Erneut analysieren"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{(discArg || discInfo) ? (
|
|
<div className="drive-list-disc-meta">
|
|
{discArg ? <span className="drive-list-disc-arg">{discArg}</span> : null}
|
|
{discInfo ? <span className="drive-list-disc-label" title={discInfo}>{discInfo}</span> : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<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>
|
|
|
|
{quickAccessItems.length > 0 ? (
|
|
<Card title="Favoriten">
|
|
<div className="quick-access-list">
|
|
{quickAccessItems.map((item) => {
|
|
const entryKey = `${item.type}:${item.id}`;
|
|
const isBusy = quickAccessBusyKeys.has(entryKey);
|
|
const isRunning = quickAccessRunningKeys.has(entryKey);
|
|
return (
|
|
<div key={entryKey} className="quick-access-item">
|
|
<div className="quick-access-item-main">
|
|
<strong className="quick-access-item-title">
|
|
<i
|
|
className={`pi ${item.type === 'script' ? 'pi-code' : 'pi-link'} quick-access-item-title-icon`}
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{item.name}</span>
|
|
</strong>
|
|
</div>
|
|
<Button
|
|
icon="pi pi-play"
|
|
text
|
|
rounded
|
|
size="small"
|
|
severity="success"
|
|
className="quick-access-run-btn"
|
|
loading={isBusy || isRunning}
|
|
disabled={isBusy || isRunning}
|
|
onClick={() => handleRunQuickAccessItem(item)}
|
|
title="Starten"
|
|
aria-label={`${item.type === 'script' ? 'Skript' : 'Kette'} starten`}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Card title="Hardware">
|
|
<div className="hardware-panel-section">
|
|
<h4>Freier Speicher</h4>
|
|
<div className="hardware-storage-list">
|
|
{storageGroups.length === 0 ? (
|
|
<small>Keine Speicherinformationen verfuegbar.</small>
|
|
) : storageGroups.map((group) => {
|
|
const rep = group.representative;
|
|
const tone = getStorageUsageTone(rep?.usagePercent);
|
|
const usagePercent = Number(rep?.usagePercent);
|
|
const barValue = Number.isFinite(usagePercent)
|
|
? Math.max(0, Math.min(100, usagePercent))
|
|
: 0;
|
|
const hasError = group.entries.every((e) => e?.error);
|
|
const groupKey = group.mountPoint || group.entries.map((e) => e?.key).join('-');
|
|
const labels = group.entries.map((e) => e?.label || e?.key || 'Pfad').join(' · ');
|
|
return (
|
|
<div
|
|
key={`storage-group-${groupKey}`}
|
|
className={`hardware-storage-item compact${hasError ? ' has-error' : ''}`}
|
|
>
|
|
<small className="hardware-storage-group-label">{labels}</small>
|
|
{hasError ? (
|
|
<small className="error-text">{rep?.error}</small>
|
|
) : (
|
|
<>
|
|
<div className="hardware-storage-bar-row">
|
|
<div className={`hardware-storage-bar tone-${tone}`}>
|
|
<ProgressBar value={barValue} showValue={false} />
|
|
</div>
|
|
<span className={`hardware-storage-percent tone-${tone}`}>
|
|
{formatPercent(rep?.usagePercent)}
|
|
</span>
|
|
</div>
|
|
<div className="hardware-storage-summary">
|
|
<small>Frei: {formatBytes(rep?.freeBytes)}</small>
|
|
<small>Gesamt: {formatBytes(rep?.totalBytes)}</small>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{monitoringState.enabled ? (
|
|
<>
|
|
<div className="hardware-panel-divider" />
|
|
<div className="hardware-panel-section">
|
|
<div className="hardware-panel-section-head">
|
|
<h4>Hardware-Monitor</h4>
|
|
<Button
|
|
type="button"
|
|
icon="pi pi-link"
|
|
text
|
|
rounded
|
|
size="small"
|
|
className="hardware-panel-link-btn"
|
|
title="Zur Hardware-Seite"
|
|
aria-label="Zur Hardware-Seite"
|
|
onClick={() => navigate('/hardware')}
|
|
/>
|
|
</div>
|
|
{!monitoringSample ? (
|
|
<small>Warte auf erste Messung ...</small>
|
|
) : (
|
|
<div className="hardware-mini-monitor-list">
|
|
<div className="hardware-mini-monitor-item">
|
|
<strong>CPU</strong>
|
|
<small className="hardware-mini-monitor-line">
|
|
<span className="hardware-mini-monitor-metric" title="Gesamtauslastung">
|
|
<i className="pi pi-chart-line" />
|
|
<span>{formatPercent(cpuMetrics?.overallUsagePercent)}</span>
|
|
</span>
|
|
<span className="hardware-mini-monitor-sep">|</span>
|
|
<span className="hardware-mini-monitor-metric" title="Temperatur">
|
|
<i className="pi pi-bolt" />
|
|
<span>{formatTemperature(cpuMetrics?.overallTemperatureC)}</span>
|
|
</span>
|
|
</small>
|
|
</div>
|
|
<div className="hardware-mini-monitor-item">
|
|
<strong>RAM</strong>
|
|
<small className="hardware-mini-monitor-line">
|
|
<span className="hardware-mini-monitor-metric" title="Gesamtauslastung">
|
|
<i className="pi pi-chart-line" />
|
|
<span>{formatPercent(memoryMetrics?.usagePercent)}</span>
|
|
</span>
|
|
<span className="hardware-mini-monitor-sep">|</span>
|
|
<span className="hardware-mini-monitor-metric" title="Nutzung">
|
|
<i className="pi pi-database" />
|
|
<span>{formatUsageCompact(memoryMetrics?.usedBytes, memoryMetrics?.totalBytes)}</span>
|
|
</span>
|
|
</small>
|
|
</div>
|
|
<div className="hardware-mini-monitor-item">
|
|
<strong>GPU</strong>
|
|
<small className="hardware-mini-monitor-line">
|
|
<span className="hardware-mini-monitor-metric" title="Gesamtauslastung">
|
|
<i className="pi pi-chart-line" />
|
|
<span>{formatPercent(gpuSummaryUsage)}</span>
|
|
</span>
|
|
<span className="hardware-mini-monitor-sep">|</span>
|
|
<span className="hardware-mini-monitor-metric" title="Temperatur">
|
|
<i className="pi pi-bolt" />
|
|
<span>{formatTemperature(gpuSummaryTemp)}</span>
|
|
</span>
|
|
<span className="hardware-mini-monitor-sep">|</span>
|
|
<span className="hardware-mini-monitor-metric" title="VRAM Nutzung">
|
|
<i className="pi pi-database" />
|
|
<span>{formatUsageCompact(primaryGpuMetrics?.memoryUsedBytes, primaryGpuMetrics?.memoryTotalBytes)}</span>
|
|
</span>
|
|
</small>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="ripper-col ripper-col-center">
|
|
{isSubpageRoute ? (
|
|
<div className="ripper-subpage-content">
|
|
{children}
|
|
</div>
|
|
) : (
|
|
<Card title="Job Übersicht" subTitle="Kompakte Liste; Klick auf Zeile öffnet die volle Job-Detailansicht mit passenden CTAs">
|
|
{jobsLoading && ripperJobs.length === 0 ? (
|
|
<p>Jobs werden geladen ...</p>
|
|
) : ripperJobs.length === 0 ? (
|
|
<p>Keine relevanten Jobs im Ripper (aktive/fortsetzbare Status).</p>
|
|
) : (
|
|
<div className="ripper-job-list">
|
|
{ripperJobs.map((job) => {
|
|
const jobId = normalizeJobId(job?.id);
|
|
if (!jobId) {
|
|
return null;
|
|
}
|
|
const pipelineForJob = pipelineByJobId.get(jobId) || buildPipelineFromJob(job, pipeline, currentPipelineJobId);
|
|
const effectiveStatus = String(pipelineForJob?.state || job?.status || '').trim().toUpperCase();
|
|
const normalizedStatus = normalizeStatus(effectiveStatus);
|
|
const isQueued = queuedJobIdSet.has(jobId);
|
|
const statusBadgeValue = getMergeAwareStatusLabel(job, effectiveStatus, { queued: isQueued });
|
|
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
|
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
|
const isCurrentSession = jobId === currentPipelineJobId && state !== 'IDLE';
|
|
const reviewConfirmed = Boolean(Number(job?.encode_review_confirmed || 0));
|
|
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
|
const mediaIndicator = mediaIndicatorMeta(job);
|
|
const isResumable = (
|
|
normalizedStatus === 'READY_TO_ENCODE'
|
|
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
|
) && !isCurrentSession;
|
|
const analyzeContext = getAnalyzeContext(job);
|
|
const selectedMetadata = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
|
? pipelineForJob.context.selectedMetadata
|
|
: (analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
|
|
? analyzeContext.selectedMetadata
|
|
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
|
|
? job.makemkvInfo.selectedMetadata
|
|
: {}));
|
|
const seriesSeasonNumber = mediaIndicator.isSeriesDvd
|
|
? (selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null)
|
|
: null;
|
|
const seriesTmdbId = mediaIndicator.isSeriesDvd
|
|
? (selectedMetadata?.tmdbId ?? analyzeContext?.tmdbId ?? null)
|
|
: null;
|
|
const seriesDiscNumber = mediaIndicator.isSeriesDvd
|
|
? (selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null)
|
|
: null;
|
|
const isSeriesContainer = mediaIndicator.isSeriesDvd
|
|
&& String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
|
|
const parentContainerId = normalizeJobId(job?.parent_job_id);
|
|
const seriesTmdbText = String(seriesTmdbId ?? '').trim();
|
|
const seriesSeasonLabel = mediaIndicator.isSeriesDvd && seriesSeasonNumber
|
|
? ` Staffel ${seriesSeasonNumber}`
|
|
: '';
|
|
const seriesDiscLabel = mediaIndicator.isSeriesDvd && seriesDiscNumber
|
|
? ` | Disk ${seriesDiscNumber}`
|
|
: '';
|
|
const seriesContainerLabel = isSeriesContainer
|
|
? ' | Container'
|
|
: (parentContainerId ? ` | Container #${parentContainerId}` : '');
|
|
const seriesTmdbLabel = mediaIndicator.isSeriesDvd && seriesTmdbText && seriesTmdbText !== '0'
|
|
? ` | TMDb ${seriesTmdbId}`
|
|
: '';
|
|
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
|
|
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
|
|
: '';
|
|
const discWorkflowKind = resolveDiscWorkflowKind({
|
|
mediaType: mediaIndicator.mediaType,
|
|
selectedMetadata,
|
|
analyzeContext,
|
|
pipelineContext: pipelineForJob?.context || null,
|
|
fallbackIsSeries: Boolean(mediaIndicator.isSeriesVideo)
|
|
});
|
|
const discWorkflowBadgeLabel = getDiscWorkflowBadgeLabel(mediaIndicator.mediaType, discWorkflowKind);
|
|
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
|
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|
|
|| jobKindRaw === 'multipart_movie_child'
|
|
|| jobKindRaw === 'multipart_movie_container'
|
|
|| jobKindRaw === 'multipart_movie_merge';
|
|
const isMultipartMerge = isMultipartMergeJob(job);
|
|
const multipartDiscNumber = Number(job?.disc_number || selectedMetadata?.discNumber || 0) || null;
|
|
const multipartContainerId = normalizeJobId(job?.parent_job_id);
|
|
const multipartSubtitle = isMultipartMovie
|
|
? (
|
|
`${isMultipartMerge ? ' Merge' : `${multipartDiscNumber ? ` Disc ${multipartDiscNumber}` : ''}`}`
|
|
+ `${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
|
).trim()
|
|
: '';
|
|
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
|
const jobState = effectiveStatus;
|
|
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
|
const pipelineStatusTextRaw = String(pipelineForJob?.statusText || '').trim();
|
|
const pipelineStatusText = pipelineStatusTextRaw.toUpperCase();
|
|
const isCdJob = jobState.startsWith('CD_')
|
|
|| pipelineStage.startsWith('CD_')
|
|
|| mediaProfile === 'cd'
|
|
|| mediaIndicator.mediaType === 'cd'
|
|
|| pipelineStatusText.includes('CD_');
|
|
const isAudiobookJob = mediaProfile === 'audiobook'
|
|
|| mediaIndicator.mediaType === 'audiobook'
|
|
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
|
|
const seriesBatchContext = pipelineForJob?.context?.seriesBatch && typeof pipelineForJob.context.seriesBatch === 'object'
|
|
? pipelineForJob.context.seriesBatch
|
|
: null;
|
|
const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
|
|
const seriesEpisodeJobIds = mediaIndicator.isSeriesDvd
|
|
? resolveSeriesEpisodeJobIds(job, seriesBatchChildren)
|
|
: [];
|
|
const seriesEpisodeJobsLabel = mediaIndicator.isSeriesDvd && seriesEpisodeJobIds.length > 0
|
|
? ` | Episoden #${seriesEpisodeJobIds.join(', #')}`
|
|
: '';
|
|
const seriesCollapsedSubtitle = mediaIndicator.isSeriesDvd
|
|
? `#${jobId}${seriesTitleSuffix}${seriesEpisodeJobsLabel}`
|
|
: '';
|
|
const hasSeriesBatchProgress = seriesBatchChildren.length > 0 || Number(seriesBatchContext?.totalCount || 0) > 0;
|
|
const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
|
|
const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
|
|
? Math.trunc(seriesBatchTotalCountRaw)
|
|
: seriesBatchChildren.length;
|
|
const seriesBatchAggregateProgress = hasSeriesBatchProgress && seriesBatchTotalCount > 0
|
|
? (
|
|
seriesBatchChildren.reduce((sum, child) => {
|
|
const status = String(child?.status || '').trim().toUpperCase();
|
|
if (status === 'FINISHED') {
|
|
return sum + 100;
|
|
}
|
|
const childProgress = Number(child?.progress || 0);
|
|
return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
|
|
}, 0) / seriesBatchTotalCount
|
|
)
|
|
: 0;
|
|
const runningSeriesChild = hasSeriesBatchProgress
|
|
? (
|
|
seriesBatchChildren.find((child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING')
|
|
|| null
|
|
)
|
|
: null;
|
|
const isCancelledState = normalizedStatus === 'CANCELLED' || jobState === 'CANCELLED';
|
|
const rawProgress = isCancelledState
|
|
? 0
|
|
: (hasSeriesBatchProgress
|
|
? seriesBatchAggregateProgress
|
|
: Number(pipelineForJob?.progress ?? 0));
|
|
const clampedProgress = Number.isFinite(rawProgress)
|
|
? Math.max(0, Math.min(100, rawProgress))
|
|
: 0;
|
|
const progressLabel = `${Math.trunc(clampedProgress)}%`;
|
|
const showMediainfoIndeterminateProgress = !hasSeriesBatchProgress
|
|
&& isMediainfoCheckStatus(normalizedStatus);
|
|
const showIndeterminateProgress = showMediainfoIndeterminateProgress || (
|
|
!hasSeriesBatchProgress
|
|
&& isPlaylistTrackPreparationStatus(pipelineStatusTextRaw)
|
|
);
|
|
const indeterminateProgressLabel = showMediainfoIndeterminateProgress
|
|
? 'Mediainfo-Prüfung läuft …'
|
|
: 'Vorbereitung läuft …';
|
|
const etaLabel = isCancelledState ? '' : String(
|
|
hasSeriesBatchProgress
|
|
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
|
: (pipelineForJob?.eta || '')
|
|
).trim();
|
|
|
|
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
|
? pipelineForJob.context.selectedMetadata
|
|
: {};
|
|
const audiobookChapterCount = Array.isArray(audiobookMeta?.chapters) ? audiobookMeta.chapters.length : 0;
|
|
const cdMeta = isCdJob && pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
|
|
? pipelineForJob.context.selectedMetadata
|
|
: {};
|
|
const cdAlbumTitle = isCdJob
|
|
? (String(cdMeta?.title || job?.title || job?.detected_title || `Job #${jobId}`).trim() || `Job #${jobId}`)
|
|
: '';
|
|
const cdArtist = isCdJob ? (String(cdMeta?.artist || '').trim() || null) : null;
|
|
const cdYearValue = isCdJob
|
|
? (cdMeta?.year ?? job?.year ?? null)
|
|
: null;
|
|
const cdYear = isCdJob
|
|
? (String(cdYearValue ?? '').trim() || null)
|
|
: null;
|
|
const cdSubtitle = isCdJob
|
|
? [cdArtist, cdYear].filter(Boolean).join(' | ')
|
|
: '';
|
|
const cdMetaLine = isCdJob
|
|
? `#${jobId}${cdSubtitle ? ` | ${cdSubtitle}` : ''}`
|
|
: '';
|
|
const multipartMergeSources = isMultipartMerge ? extractMultipartMergeSources(job) : [];
|
|
const hasMultipartMergeSources = multipartMergeSources.length > 0;
|
|
const multipartMergePreview = isMultipartMerge
|
|
? (multipartMergePreviewByJobId[jobId] || null)
|
|
: null;
|
|
const multipartMergePreviewLoading = Boolean(
|
|
isMultipartMerge && multipartMergePreviewLoadingByJobId[jobId]
|
|
);
|
|
const multipartMergeLiveLog = String(multipartMergeLiveLogByJobId[jobId] || '').trim();
|
|
const multipartMergeLiveLogLoading = Boolean(
|
|
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
|
|
);
|
|
const isMultipartMergeRunning = isMultipartMerge && normalizedStatus === 'ENCODING';
|
|
const deleteInputsAfterMerge = isMultipartMerge
|
|
? shouldDeleteMultipartMergeInputsAfterSuccess(job)
|
|
: false;
|
|
const canEditMultipartMergeSettings = !busyJobIds.has(jobId)
|
|
&& !processingStates.includes(normalizedStatus)
|
|
&& !isQueued;
|
|
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
|
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
|
? multipartMergePreview.chapters.entries
|
|
: [];
|
|
const multipartMergeAudioTracks = Array.isArray(multipartMergePreview?.media?.audioTracks)
|
|
? multipartMergePreview.media.audioTracks
|
|
: [];
|
|
const multipartMergeSubtitleTracks = Array.isArray(multipartMergePreview?.media?.subtitleTracks)
|
|
? multipartMergePreview.media.subtitleTracks
|
|
: [];
|
|
const multipartMergeMediaAligned = Boolean(multipartMergePreview?.media?.allSourcesAligned);
|
|
const multipartMergeMediaMismatchCount = Array.isArray(multipartMergePreview?.media?.mismatches)
|
|
? multipartMergePreview.media.mismatches.length
|
|
: 0;
|
|
if (isExpanded) {
|
|
return (
|
|
<div key={jobId} className="ripper-job-expanded">
|
|
<div className="ripper-job-expanded-head">
|
|
{(job?.poster_url && job.poster_url !== 'N/A') ? (
|
|
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
|
) : (
|
|
<div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
|
)}
|
|
<div className="ripper-job-expanded-title">
|
|
<strong className="ripper-job-title-line">
|
|
<img
|
|
src={mediaIndicator.src}
|
|
alt={mediaIndicator.alt}
|
|
title={mediaIndicator.title}
|
|
className="media-indicator-icon"
|
|
/>
|
|
<span>{isCdJob ? cdAlbumTitle : `#${jobId} | ${jobTitle}`}</span>
|
|
</strong>
|
|
{isCdJob ? (
|
|
<small className="ripper-job-subtitle">{cdMetaLine || '-'}</small>
|
|
) : null}
|
|
{mediaIndicator.isSeriesDvd ? (
|
|
<small className="ripper-job-subtitle">{seriesTitleSuffix || '-'}</small>
|
|
) : null}
|
|
{!mediaIndicator.isSeriesDvd && isMultipartMovie ? (
|
|
<small className="ripper-job-subtitle">{multipartSubtitle || 'Multipart Movie'}</small>
|
|
) : null}
|
|
<div className="ripper-job-badges">
|
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
|
{discWorkflowBadgeLabel ? <Tag value={discWorkflowBadgeLabel} severity="secondary" /> : null}
|
|
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
|
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
|
{normalizedStatus === 'READY_TO_ENCODE'
|
|
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
|
: null}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
label="Einklappen"
|
|
icon="pi pi-angle-up"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => setExpandedJobId(null)}
|
|
/>
|
|
</div>
|
|
{(() => {
|
|
if (isCdJob) {
|
|
return (
|
|
<>
|
|
{isCdJob ? (
|
|
<CdRipConfigPanel
|
|
pipeline={pipelineForJob}
|
|
onStart={(ripConfig) => handleCdRipStart(jobId, ripConfig)}
|
|
onCancel={() => handleCancel(jobId, jobState)}
|
|
onRetry={() => handleRetry(jobId)}
|
|
onRestartReview={() => handleRestartCdReviewFromRaw(jobId)}
|
|
onDeleteJob={() => handleDeleteRipperJob(jobId)}
|
|
onOpenMetadata={() => {
|
|
const ctx = pipelineForJob?.context && typeof pipelineForJob.context === 'object'
|
|
? pipelineForJob.context
|
|
: pipeline?.context || {};
|
|
void openCdMetadataDialogWithContext({ ...ctx, jobId }, jobId);
|
|
}}
|
|
busy={busyJobIds.has(jobId)}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
if (isAudiobookJob) {
|
|
const needsBytes = pendingActivationJobIds.has(jobId);
|
|
return (
|
|
<>
|
|
{needsBytes && (
|
|
<div style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', background: 'var(--yellow-100)', border: '1px solid var(--yellow-400)', borderRadius: '6px', color: 'var(--yellow-900)', fontSize: '0.875rem' }}>
|
|
<i className="pi pi-lock" style={{ marginRight: '0.5rem' }} />
|
|
<strong>Activation Bytes fehlen.</strong>{' '}
|
|
<button type="button" style={{ background: 'none', border: 'none', color: 'var(--primary-color)', cursor: 'pointer', textDecoration: 'underline', padding: 0 }} onClick={() => handleAudiobookStart(jobId, null)}>
|
|
Jetzt eintragen
|
|
</button>
|
|
</div>
|
|
)}
|
|
<AudiobookConfigPanel
|
|
pipeline={pipelineForJob}
|
|
onStart={(config) => handleAudiobookStart(jobId, config)}
|
|
onCancel={() => handleCancel(jobId, jobState)}
|
|
onRetry={() => handleRetry(jobId)}
|
|
onDeleteJob={() => handleDeleteRipperJob(jobId)}
|
|
busy={busyJobIds.has(jobId) || needsBytes}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
{!isCdJob && !isAudiobookJob ? (
|
|
isMultipartMerge ? (
|
|
<div className="multipart-merge-panel">
|
|
{!isMultipartMergeRunning ? (
|
|
<>
|
|
<div className="multipart-merge-head">
|
|
<strong>Merge-Quellen</strong>
|
|
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
|
|
</div>
|
|
{hasMultipartMergeSources ? (
|
|
<div className="multipart-merge-list">
|
|
{multipartMergeSources.map((source, sourceIndex) => {
|
|
const sourceKey = `${jobId}-${source.sourceJobId}-${sourceIndex}`;
|
|
const canReorder = !busyJobIds.has(jobId)
|
|
&& !processingStates.includes(normalizedStatus)
|
|
&& multipartMergeSources.length > 1;
|
|
return (
|
|
<div
|
|
key={sourceKey}
|
|
className="multipart-merge-item"
|
|
draggable={canReorder}
|
|
onDragStart={() => {
|
|
if (!canReorder) return;
|
|
setDraggingMergeSource({ jobId, sourceJobId: source.sourceJobId });
|
|
}}
|
|
onDragEnd={() => setDraggingMergeSource(null)}
|
|
onDragOver={(event) => {
|
|
if (!canReorder) return;
|
|
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
|
|
event.preventDefault();
|
|
}}
|
|
onDrop={(event) => {
|
|
if (!canReorder) return;
|
|
if (!draggingMergeSource || draggingMergeSource.jobId !== jobId) return;
|
|
event.preventDefault();
|
|
const draggedId = normalizeJobId(draggingMergeSource.sourceJobId);
|
|
const targetId = normalizeJobId(source.sourceJobId);
|
|
if (!draggedId || !targetId || draggedId === targetId) {
|
|
setDraggingMergeSource(null);
|
|
return;
|
|
}
|
|
const currentOrder = multipartMergeSources
|
|
.map((item) => normalizeJobId(item.sourceJobId))
|
|
.filter(Boolean);
|
|
const fromIndex = currentOrder.findIndex((id) => id === draggedId);
|
|
const toIndex = currentOrder.findIndex((id) => id === targetId);
|
|
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
|
setDraggingMergeSource(null);
|
|
return;
|
|
}
|
|
const nextOrder = [...currentOrder];
|
|
const [moved] = nextOrder.splice(fromIndex, 1);
|
|
nextOrder.splice(toIndex, 0, moved);
|
|
setDraggingMergeSource(null);
|
|
handleReorderMultipartMergeSources(jobId, nextOrder);
|
|
}}
|
|
>
|
|
<div className="multipart-merge-item-meta">
|
|
<Tag value={`#${sourceIndex + 1}`} severity="contrast" />
|
|
<Tag value={source.discNumber ? `Disc ${source.discNumber}` : 'Disc ?'} severity="info" />
|
|
<Tag value={`Job #${source.sourceJobId}`} severity="secondary" />
|
|
</div>
|
|
<div className="multipart-merge-item-text">
|
|
<strong>{source.fileName || source.outputPath}</strong>
|
|
<small>{source.outputPath}</small>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="muted-inline">Noch keine Merge-Quellen gefunden.</p>
|
|
)}
|
|
</>
|
|
) : null}
|
|
{isMultipartMergeRunning ? (
|
|
<>
|
|
<div className="status-row multipart-merge-running-head">
|
|
<Tag value="Merging" severity="warning" />
|
|
<span>{String(pipelineForJob?.statusText || '').trim() || 'Merge läuft'}</span>
|
|
</div>
|
|
<div className="progress-wrap multipart-merge-running-progress">
|
|
<ProgressBar
|
|
value={clampedProgress}
|
|
showValue
|
|
displayValueTemplate={() => progressLabel}
|
|
/>
|
|
<small>
|
|
{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}
|
|
</small>
|
|
</div>
|
|
<div className="live-log-block multipart-merge-live-log">
|
|
<h4>Aktueller Merge-Log</h4>
|
|
<pre className="log-box">
|
|
{multipartMergeLiveLog
|
|
|| (multipartMergeLiveLogLoading ? 'Live-Log wird geladen ...' : 'Noch keine Log-Ausgabe vorhanden.')}
|
|
</pre>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="multipart-merge-preview">
|
|
<div className="multipart-merge-preview-head">
|
|
<strong>Merge Vorschau</strong>
|
|
<Button
|
|
icon="pi pi-refresh"
|
|
text
|
|
severity="secondary"
|
|
aria-label="Merge-Vorschau aktualisieren"
|
|
onClick={() => loadMultipartMergePreview(jobId, { force: true, silent: false })}
|
|
disabled={busyJobIds.has(jobId) || multipartMergePreviewLoading}
|
|
/>
|
|
</div>
|
|
{multipartMergePreviewLoading ? (
|
|
<small className="muted-inline">Vorschau wird aktualisiert ...</small>
|
|
) : null}
|
|
<div className="multipart-merge-preview-section">
|
|
<strong>1. CLI-Befehl</strong>
|
|
{multipartMergeCommandPreview ? (
|
|
<code className="multipart-merge-command-preview">{multipartMergeCommandPreview}</code>
|
|
) : (
|
|
<small className="muted-inline">Noch keine Vorschau verfügbar.</small>
|
|
)}
|
|
</div>
|
|
<div className="multipart-merge-preview-section">
|
|
<strong>2. Kapitel (gesamt)</strong>
|
|
{multipartMergeChapterEntries.length > 0 ? (
|
|
<div className="multipart-merge-chapter-list">
|
|
{multipartMergeChapterEntries.map((entry) => (
|
|
<div key={`merge-chapter-${jobId}-${entry.index}`} className="multipart-merge-chapter-item">
|
|
<small>#{entry.index}</small>
|
|
<small>{entry.startClock || entry.start || '-'}</small>
|
|
<span>{entry.title || '-'}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<small className="muted-inline">Keine berechnete Kapitel-Gesamtliste verfügbar.</small>
|
|
)}
|
|
</div>
|
|
<div className="multipart-merge-preview-section">
|
|
<strong>3. Erwartete Medieninfos (nach Merge)</strong>
|
|
<div className="multipart-merge-track-columns">
|
|
<div className="multipart-merge-track-column">
|
|
<small className="multipart-merge-track-heading">Audio</small>
|
|
{multipartMergeAudioTracks.length > 0 ? (
|
|
<div className="multipart-merge-track-list">
|
|
{multipartMergeAudioTracks.map((track) => (
|
|
<small key={`merge-audio-${jobId}-${track.order || track.streamIndex}`}>
|
|
{formatMergeTrackSummary(track, 'Audio')}
|
|
</small>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<small className="muted-inline">Keine Audio-Infos.</small>
|
|
)}
|
|
</div>
|
|
<div className="multipart-merge-track-column">
|
|
<small className="multipart-merge-track-heading">Untertitel</small>
|
|
{multipartMergeSubtitleTracks.length > 0 ? (
|
|
<div className="multipart-merge-track-list">
|
|
{multipartMergeSubtitleTracks.map((track) => (
|
|
<small key={`merge-sub-${jobId}-${track.order || track.streamIndex}`}>
|
|
{formatMergeTrackSummary(track, 'UT')}
|
|
</small>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<small className="muted-inline">Keine Untertitel-Infos.</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{multipartMergePreview?.media?.available ? (
|
|
<small className={multipartMergeMediaAligned ? 'muted-inline' : 'error-text'}>
|
|
{multipartMergeMediaAligned
|
|
? 'Track-Layout über alle Quellen konsistent.'
|
|
: `Achtung: Track-Layout unterscheidet sich zwischen Quellen (${multipartMergeMediaMismatchCount} Abweichung(en)).`}
|
|
</small>
|
|
) : (
|
|
<small className="muted-inline">Stream-Vergleich noch nicht verfügbar.</small>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!isMultipartMergeRunning ? (
|
|
<div className="multipart-merge-settings">
|
|
<label className="multipart-merge-setting-toggle">
|
|
<input
|
|
type="checkbox"
|
|
checked={deleteInputsAfterMerge}
|
|
onChange={(event) => {
|
|
void handleUpdateMultipartMergeSettings(jobId, {
|
|
deleteInputsAfterMerge: Boolean(event.target?.checked)
|
|
});
|
|
}}
|
|
disabled={!canEditMultipartMergeSettings}
|
|
/>
|
|
<span>Input-Dateien nach erfolgreichem Merge löschen</span>
|
|
</label>
|
|
<small className="muted-inline">
|
|
Bei Erfolg werden die Quell-Dateien der Discs aus dem Dateisystem entfernt.
|
|
</small>
|
|
</div>
|
|
) : null}
|
|
<div className="multipart-merge-actions">
|
|
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
|
<Button
|
|
label={isQueued ? 'In Queue' : 'Merge starten'}
|
|
icon="pi pi-play"
|
|
onClick={() => handleStartJob(jobId)}
|
|
disabled={busyJobIds.has(jobId) || isQueued}
|
|
/>
|
|
) : null}
|
|
{processingStates.includes(normalizedStatus) ? (
|
|
<Button
|
|
label="Abbrechen"
|
|
icon="pi pi-stop"
|
|
severity="warning"
|
|
outlined
|
|
onClick={() => handleCancel(jobId, jobState)}
|
|
disabled={busyJobIds.has(jobId)}
|
|
/>
|
|
) : null}
|
|
{(normalizedStatus === 'ERROR' || normalizedStatus === 'CANCELLED') ? (
|
|
<Button
|
|
label="Retry"
|
|
icon="pi pi-refresh"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => handleRetry(jobId)}
|
|
disabled={busyJobIds.has(jobId)}
|
|
/>
|
|
) : null}
|
|
{!isMultipartMergeRunning ? (
|
|
<Button
|
|
label="Job löschen"
|
|
icon="pi pi-trash"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => handleDeleteRipperJob(jobId)}
|
|
disabled={busyJobIds.has(jobId)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<PipelineStatusCard
|
|
jobId={jobId}
|
|
jobRow={job}
|
|
pipeline={pipelineForJob}
|
|
onAnalyze={handleAnalyze}
|
|
onReanalyze={handleReanalyze}
|
|
onOpenMetadata={handleOpenMetadataDialog}
|
|
onReassignMetadata={handleOpenReassignMetadataDialog}
|
|
onStart={handleStartJob}
|
|
onRestartEncode={handleRestartEncodeWithLastSettings}
|
|
onRestartReview={handleRestartReviewFromRaw}
|
|
onConfirmReview={handleConfirmReview}
|
|
onSelectPlaylist={handleSelectPlaylist}
|
|
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
|
onSubmitRawDecision={handleSubmitRawDecision}
|
|
onCancel={handleCancel}
|
|
onRetry={handleRetry}
|
|
onDeleteJob={handleDeleteRipperJob}
|
|
onRemoveFromQueue={handleRemoveQueuedJob}
|
|
isQueued={isQueued}
|
|
busy={busyJobIds.has(jobId)}
|
|
/>
|
|
)
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
key={jobId}
|
|
type="button"
|
|
className="ripper-job-row"
|
|
onClick={() => setExpandedJobId(jobId)}
|
|
>
|
|
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
|
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
|
) : (
|
|
<div className="poster-thumb ripper-job-poster-fallback">{isAudiobookJob ? 'Kein Cover' : 'Kein Poster'}</div>
|
|
)}
|
|
<div className="ripper-job-row-content">
|
|
<div className="ripper-job-row-main">
|
|
<strong className="ripper-job-title-line">
|
|
<img
|
|
src={mediaIndicator.src}
|
|
alt={mediaIndicator.alt}
|
|
title={mediaIndicator.title}
|
|
className="media-indicator-icon"
|
|
/>
|
|
<span>{isCdJob ? cdAlbumTitle : jobTitle}</span>
|
|
</strong>
|
|
<small>
|
|
{isCdJob
|
|
? (cdMetaLine || '-')
|
|
: mediaIndicator.isSeriesDvd
|
|
? (seriesCollapsedSubtitle || '-')
|
|
: (
|
|
`#${jobId}`
|
|
+ (isAudiobookJob
|
|
? (
|
|
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
|
|
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
|
|
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
|
|
)
|
|
: (
|
|
`${job?.year ? ` | ${job.year}` : ''}`
|
|
+ (mediaIndicator.isSeriesDvd ? seriesSeasonLabel : '')
|
|
+ (mediaIndicator.isSeriesDvd ? seriesDiscLabel : '')
|
|
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
|
|
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
|
|
+ (!mediaIndicator.isSeriesDvd && isMultipartMovie
|
|
? `${isMultipartMerge ? ' | Merge' : ''}${multipartDiscNumber ? ` | Disc ${multipartDiscNumber}` : ''}${multipartContainerId ? ` | Container #${multipartContainerId}` : ''}`
|
|
: '')
|
|
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
|
|
))
|
|
)}
|
|
</small>
|
|
</div>
|
|
<div className="ripper-job-badges">
|
|
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
|
{discWorkflowBadgeLabel ? <Tag value={discWorkflowBadgeLabel} severity="secondary" /> : null}
|
|
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
|
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
|
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
|
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
|
|
{normalizedStatus === 'READY_TO_ENCODE'
|
|
? <Tag value={reviewConfirmed ? 'Bestätigt' : 'Unbestätigt'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
|
: null}
|
|
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
|
</div>
|
|
<div
|
|
className="ripper-job-row-progress"
|
|
aria-label={showIndeterminateProgress ? 'Job Fortschritt läuft' : `Job Fortschritt ${progressLabel}`}
|
|
>
|
|
{showIndeterminateProgress ? (
|
|
<ProgressBar mode="indeterminate" showValue={false} />
|
|
) : (
|
|
<ProgressBar value={clampedProgress} showValue={false} />
|
|
)}
|
|
<small>{showIndeterminateProgress ? indeterminateProgressLabel : (etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel)}</small>
|
|
</div>
|
|
</div>
|
|
<i className="pi pi-angle-down" aria-hidden="true" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
<div className="ripper-col ripper-col-right">
|
|
<Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
|
|
<div className="pipeline-queue-meta">
|
|
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
|
<Tag value={`Audio/CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
|
|
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
|
|
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />}
|
|
<Tag value={`Film laufend: ${visibleRunningFilmCount}`} severity={visibleRunningFilmCount > 0 ? 'warning' : 'success'} />
|
|
<Tag value={`Audio/CD laufend: ${visibleRunningAudioCdCount}`} severity={visibleRunningAudioCdCount > 0 ? 'warning' : 'success'} />
|
|
<Tag value={`Wartend: ${visibleQueuedCount}`} severity={visibleQueuedCount > 0 ? 'warning' : 'success'} />
|
|
<Tag value={`Idle: ${visibleIdleCount}`} severity={visibleIdleCount > 0 ? 'info' : 'success'} />
|
|
</div>
|
|
|
|
<div className="pipeline-queue-grid">
|
|
<div className="pipeline-queue-col queue-col-running">
|
|
<h4>Laufende Jobs</h4>
|
|
{queueRunningJobsDisplay.length === 0 ? (
|
|
<small>Keine laufenden Jobs.</small>
|
|
) : (
|
|
queueRunningJobsDisplay.map((item) => {
|
|
const runningJobId = normalizeJobId(item?.jobId);
|
|
const runningQueueDisplay = resolveSeriesQueueDisplay(item);
|
|
const runningTitleBase = runningQueueDisplay.title || item?.title || `Job #${item?.jobId || '-'}`;
|
|
const runningStatusLabel = getMergeAwareStatusLabel(item, item.status);
|
|
const runningMetaLine = `#${runningJobId ?? '-'} | ${runningStatusLabel}`;
|
|
const liveQueueProgressRaw = Number(
|
|
item?.progress
|
|
?? pipeline?.jobProgress?.[runningJobId]?.progress
|
|
);
|
|
const hasLiveQueueProgress = Number.isFinite(liveQueueProgressRaw);
|
|
const clampedQueueProgress = hasLiveQueueProgress
|
|
? Math.max(0, Math.min(100, liveQueueProgressRaw))
|
|
: 0;
|
|
const showQueueIndeterminateProgress = isMediainfoCheckStatus(item?.status) || !hasLiveQueueProgress;
|
|
return (
|
|
<div key={String(item?.entryKey || `running-${item.jobId}`)} className="pipeline-queue-entry-wrap">
|
|
<div className="pipeline-queue-item running queue-job-item">
|
|
<div className="pipeline-queue-item-main">
|
|
<strong>{runningTitleBase}</strong>
|
|
<small>{runningMetaLine}</small>
|
|
<div className="queue-running-progress" aria-label={`Fortschritt Job #${runningJobId ?? '-'}`}>
|
|
{showQueueIndeterminateProgress ? (
|
|
<ProgressBar mode="indeterminate" showValue={false} />
|
|
) : (
|
|
<ProgressBar value={clampedQueueProgress} showValue={false} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<div className="pipeline-queue-col queue-col-idle">
|
|
<h4>Idle</h4>
|
|
{queueIdleJobs.length === 0 ? (
|
|
<small>Keine Idle-Jobs.</small>
|
|
) : (
|
|
queueIdleJobs.map((item) => {
|
|
const hasScriptSummary = hasQueueScriptSummary(item);
|
|
const detailKey = buildIdleQueueScriptKey(item?.jobId);
|
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
|
const idleQueueDisplay = resolveSeriesQueueDisplay(item);
|
|
const idleTitleBase = idleQueueDisplay.title || `Job #${item?.jobId || '-'}`;
|
|
return (
|
|
<div key={`idle-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
|
<div className="pipeline-queue-item idle queue-job-item">
|
|
<div className="pipeline-queue-item-main">
|
|
<strong>
|
|
{`${idleTitleBase} | #${item.jobId || '-'}`}
|
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
|
</strong>
|
|
{idleQueueDisplay.subtitle ? <small>{idleQueueDisplay.subtitle}</small> : null}
|
|
<small>{getMergeAwareStatusLabel(item, item.status)}</small>
|
|
</div>
|
|
{hasScriptSummary ? (
|
|
<button
|
|
type="button"
|
|
className="queue-job-expand-btn"
|
|
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
|
|
aria-expanded={detailsExpanded}
|
|
onClick={() => toggleQueueScriptDetails(detailKey)}
|
|
>
|
|
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
<div className="pipeline-queue-col queue-col-queued">
|
|
<div className="pipeline-queue-col-header">
|
|
<h4>Warteschlange</h4>
|
|
<button
|
|
type="button"
|
|
className="queue-add-entry-btn"
|
|
title={queueHasJobEntry ? 'Skript, Kette oder Wartezeit zur Queue hinzufügen' : 'Nur möglich wenn mind. ein Job in der Queue ist'}
|
|
disabled={!queueHasJobEntry}
|
|
onClick={() => void openInsertQueueDialog(null)}
|
|
>
|
|
<i className="pi pi-plus" /> Hinzufügen
|
|
</button>
|
|
</div>
|
|
{queuedJobs.length === 0 ? (
|
|
<small className="queue-empty-hint">Queue ist leer.</small>
|
|
) : (
|
|
<>
|
|
{queuedJobs.map((item) => {
|
|
const entryId = Number(item?.entryId);
|
|
const isNonJob = item.type && item.type !== 'job';
|
|
const isScriptOrChainNonJob = isNonJob && (item.type === 'script' || item.type === 'chain');
|
|
const isDragging = Number(draggingQueueEntryId) === entryId;
|
|
const hasScriptSummary = !isNonJob && hasQueueScriptSummary(item);
|
|
const queuedQueueDisplay = !isNonJob ? resolveSeriesQueueDisplay(item) : null;
|
|
const detailKey = buildQueuedQueueScriptKey(entryId);
|
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
|
const chainSubtitle = item.type === 'chain'
|
|
? resolveChainQueueSubtitle(item, queueChainNameById)
|
|
: null;
|
|
const scriptOrChainTitle = item.type === 'chain'
|
|
? (chainSubtitle || `Kette #${item.chainId || '-'}`)
|
|
: (item.title || `Skript #${item.scriptId || '-'}`);
|
|
return (
|
|
<div key={`queued-entry-${entryId}`} className="pipeline-queue-entry-wrap">
|
|
<div
|
|
className={`pipeline-queue-item queued${isDragging ? ' dragging' : ''}${isNonJob ? ' non-job' : ''}`}
|
|
draggable={canReorderQueue}
|
|
onDragStart={() => setDraggingQueueEntryId(entryId)}
|
|
onDragEnter={() => handleQueueDragEnter(entryId)}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
void handleQueueDrop();
|
|
}}
|
|
onDragEnd={() => {
|
|
setDraggingQueueEntryId(null);
|
|
void syncQueueFromServer();
|
|
}}
|
|
>
|
|
<span className={`pipeline-queue-drag-handle${canReorderQueue ? '' : ' disabled'}`} title="Reihenfolge ändern">
|
|
<i className="pi pi-bars" />
|
|
</span>
|
|
{!isScriptOrChainNonJob ? (
|
|
<i className={`pipeline-queue-type-icon ${queueEntryIcon(item.type)}`} title={item.type || 'job'} />
|
|
) : null}
|
|
<div className="pipeline-queue-item-main">
|
|
{isNonJob ? (
|
|
isScriptOrChainNonJob ? (
|
|
<small className="queue-nonjob-subtitle">
|
|
<i className={queueEntryIcon(item.type)} aria-hidden="true" />
|
|
<span>{scriptOrChainTitle}</span>
|
|
</small>
|
|
) : (
|
|
<strong>{item.position || '-'}. {queueEntryLabel(item)}</strong>
|
|
)
|
|
) : (
|
|
<>
|
|
<strong>
|
|
{`${queuedQueueDisplay?.title || item.title || `Job #${item.jobId}`} | #${item.jobId || '-'}`}
|
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
|
</strong>
|
|
{queuedQueueDisplay?.subtitle ? <small>{queuedQueueDisplay.subtitle}</small> : null}
|
|
<small>{queuedQueueDisplay?.isSeriesEpisode ? `${item.position || '-'} | ${item.actionLabel || item.action || '-'} | ${getMergeAwareStatusLabel(item, item.status)}` : `${item.position || '-'} | #${item.jobId} | ${item.actionLabel || item.action || '-'} | ${getMergeAwareStatusLabel(item, item.status)}`}</small>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="pipeline-queue-item-actions">
|
|
{hasScriptSummary ? (
|
|
<button
|
|
type="button"
|
|
className="queue-job-expand-btn"
|
|
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
|
|
aria-expanded={detailsExpanded}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
toggleQueueScriptDetails(detailKey);
|
|
}}
|
|
>
|
|
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
|
</button>
|
|
) : null}
|
|
<Button
|
|
icon="pi pi-times"
|
|
severity="danger"
|
|
text
|
|
rounded
|
|
size="small"
|
|
className="pipeline-queue-remove-btn"
|
|
disabled={queueReorderBusy}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (isNonJob) {
|
|
void handleRemoveQueueEntry(entryId);
|
|
} else {
|
|
void handleRemoveQueuedJob(item.jobId);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
|
|
{queueHasJobEntry ? (
|
|
<button
|
|
type="button"
|
|
className="queue-insert-btn"
|
|
title="Eintrag danach einfügen"
|
|
onClick={() => void openInsertQueueDialog(entryId)}
|
|
>
|
|
<i className="pi pi-plus" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card title="Automatisierung">
|
|
<div className="runtime-activity-meta pipeline-queue-meta">
|
|
<Tag value={`Laufend: ${runtimeActiveItems.length}`} severity={runtimeActiveItems.length > 0 ? 'warning' : 'success'} />
|
|
<Tag value={`Zuletzt: ${runtimeRecentItems.length}`} severity="info" />
|
|
<Tag value={`Update: ${formatUpdatedAt(runtimeActivities?.updatedAt)}`} severity="secondary" />
|
|
<Button
|
|
type="button"
|
|
icon="pi pi-trash"
|
|
label="Liste leeren"
|
|
severity="secondary"
|
|
outlined
|
|
size="small"
|
|
onClick={() => {
|
|
void handleClearRuntimeRecent();
|
|
}}
|
|
disabled={runtimeRecentItems.length === 0}
|
|
loading={runtimeRecentClearing}
|
|
/>
|
|
</div>
|
|
|
|
{runtimeLoading && runtimeActiveItems.length === 0 && runtimeRecentItems.length === 0 ? (
|
|
<p>Aktivitäten werden geladen ...</p>
|
|
) : (
|
|
<div className="runtime-activity-grid pipeline-queue-grid">
|
|
<div className="runtime-activity-col pipeline-queue-col">
|
|
<h4>Aktiv</h4>
|
|
{runtimeActiveItems.length === 0 ? (
|
|
<small className="queue-empty-hint">Keine laufenden Skript-/Ketten-/Cron-Ausführungen.</small>
|
|
) : (
|
|
<div className="runtime-activity-list">
|
|
{activeItemsDisplay.map((item, index) => {
|
|
const isChain = String(item?.type || '').trim().toLowerCase() === 'chain';
|
|
const chainChild = isChain ? findActiveChainChild(item?.id) : null;
|
|
const outputItem = chainChild || item;
|
|
const statusMeta = runtimeStatusMeta(item?.status);
|
|
const canCancel = Boolean(item?.canCancel);
|
|
const canNextStep = isChain && Boolean(item?.canNextStep);
|
|
const cancelBusy = isRuntimeActionBusy(item?.id, 'cancel');
|
|
const nextStepBusy = isRuntimeActionBusy(item?.id, 'next-step');
|
|
const stepLabel = item?.stepIndex != null && item?.stepTotal != null
|
|
? `Schritt ${item.stepIndex}/${item.stepTotal}`
|
|
: item?.currentStep
|
|
? 'Schritt'
|
|
: null;
|
|
return (
|
|
<div key={`runtime-active-${item?.id || index}`} className="runtime-activity-item running">
|
|
<div className="runtime-activity-head">
|
|
<strong>{item?.name || '-'}</strong>
|
|
<div className="runtime-activity-tags">
|
|
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
|
|
<Tag value={statusMeta.label} severity={statusMeta.severity} />
|
|
</div>
|
|
</div>
|
|
<small>
|
|
Quelle: {item?.source || '-'}
|
|
{item?.jobId ? ` | Job #${item.jobId}` : ''}
|
|
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
|
|
</small>
|
|
{isChain && (chainChild || item?.currentStep || item?.currentScriptName) ? (
|
|
<div className="runtime-chain-current">
|
|
<small>
|
|
{stepLabel ? <strong>{stepLabel}: </strong> : null}
|
|
{chainChild?.name || item?.currentScriptName || item?.currentStep || null}
|
|
</small>
|
|
{chainChild?.message ? <small>{chainChild.message}</small> : null}
|
|
</div>
|
|
) : null}
|
|
{!isChain && item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
|
|
{!isChain && item?.message ? <small>{item.message}</small> : null}
|
|
<RuntimeActivityDetails
|
|
item={outputItem}
|
|
summary="Live-Ausgabe anzeigen"
|
|
/>
|
|
<small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
|
|
{canCancel || canNextStep ? (
|
|
<div className="runtime-activity-actions">
|
|
{canNextStep ? (
|
|
<Button
|
|
type="button"
|
|
icon="pi pi-step-forward"
|
|
label="Nächster Schritt"
|
|
outlined
|
|
severity="secondary"
|
|
size="small"
|
|
loading={nextStepBusy}
|
|
disabled={cancelBusy}
|
|
onClick={() => {
|
|
void handleRuntimeControl(item, 'next-step');
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canCancel ? (
|
|
<Button
|
|
type="button"
|
|
icon="pi pi-stop"
|
|
label="Abbrechen"
|
|
outlined
|
|
severity="danger"
|
|
size="small"
|
|
loading={cancelBusy}
|
|
disabled={nextStepBusy}
|
|
onClick={() => {
|
|
void handleRuntimeControl(item, 'cancel');
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="runtime-activity-col pipeline-queue-col">
|
|
<h4>Zuletzt abgeschlossen</h4>
|
|
{runtimeRecentItems.length === 0 ? (
|
|
<small className="queue-empty-hint">Keine abgeschlossenen Einträge vorhanden.</small>
|
|
) : (
|
|
<div className="runtime-activity-list">
|
|
{recentItemsDisplay.map((item, index) => {
|
|
const outcomeMeta = runtimeOutcomeMeta(item?.outcome, item?.status);
|
|
return (
|
|
<div key={`runtime-recent-${item?.id || index}`} className="runtime-activity-item done">
|
|
<div className="runtime-activity-head">
|
|
<strong>{item?.name || '-'}</strong>
|
|
<div className="runtime-activity-tags">
|
|
<Tag value={runtimeTypeLabel(item?.type)} severity="info" />
|
|
<Tag value={outcomeMeta.label} severity={outcomeMeta.severity} />
|
|
</div>
|
|
</div>
|
|
<small>
|
|
Quelle: {item?.source || '-'}
|
|
{item?.jobId ? ` | Job #${item.jobId}` : ''}
|
|
{item?.cronJobId ? ` | Cron #${item.cronJobId}` : ''}
|
|
</small>
|
|
{Number.isFinite(Number(item?.exitCode)) ? <small>Exit-Code: {item.exitCode}</small> : null}
|
|
{item?.message ? <small>{item.message}</small> : null}
|
|
{item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
|
|
{hasRuntimeOutputDetails(item) ? (
|
|
<RuntimeActivityDetails
|
|
item={item}
|
|
summary="Details anzeigen"
|
|
/>
|
|
) : null}
|
|
<small>
|
|
Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
|
|
{item?.durationMs != null ? ` | Dauer: ${formatDurationMs(item.durationMs)}` : ''}
|
|
</small>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="ripper-downloads-row">
|
|
<h4>ZIP-Downloads</h4>
|
|
<div className="ripper-downloads-meta">
|
|
{downloadSummary?.activeCount > 0 ? (
|
|
<Tag icon="pi pi-spinner" value={`${downloadSummary.activeCount} aktiv`} severity="warning" />
|
|
) : downloadSummary?.totalCount > 0 ? (
|
|
<Tag icon="pi pi-check" value={downloadSummary.failedCount > 0 ? `Fertig (${downloadSummary.failedCount} fehlgeschlagen)` : `${downloadSummary.totalCount} fertig`} severity={downloadSummary.failedCount > 0 ? 'danger' : 'success'} />
|
|
) : (
|
|
<Tag icon="pi pi-download" value="Keine aktiven Downloads" severity="secondary" />
|
|
)}
|
|
<Button
|
|
label="Downloads öffnen"
|
|
icon="pi pi-arrow-right"
|
|
size="small"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => navigate('/downloads')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<MetadataSelectionDialog
|
|
visible={metadataDialogShouldBeVisible}
|
|
context={effectiveMetadataDialogContext}
|
|
onHide={() => {
|
|
setMetadataDialogVisible(false);
|
|
setMetadataDialogContext(null);
|
|
setMetadataDialogReassignMode(false);
|
|
}}
|
|
onSubmit={handleMetadataSubmit}
|
|
onSearch={handleMetadataSearch}
|
|
busy={busy}
|
|
/>
|
|
|
|
<CdMetadataDialog
|
|
visible={cdMetadataDialogVisible}
|
|
context={cdMetadataDialogContext || {}}
|
|
onHide={hideCdMetadataDialog}
|
|
onSubmit={handleCdMetadataSubmit}
|
|
onSearch={handleMusicBrainzSearch}
|
|
onFetchRelease={handleMusicBrainzReleaseFetch}
|
|
busy={busy}
|
|
/>
|
|
|
|
<Dialog
|
|
header={cancelCleanupDialog?.target === 'raw' ? 'Rip abgebrochen' : 'Encode abgebrochen'}
|
|
visible={Boolean(cancelCleanupDialog.visible)}
|
|
onHide={() => setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null })}
|
|
style={{ width: '32rem', maxWidth: '96vw' }}
|
|
modal
|
|
>
|
|
<p>
|
|
{cancelCleanupDialog?.target === 'raw'
|
|
? 'Soll der bisher erzeugte RAW-Ordner gelöscht werden?'
|
|
: 'Soll die bisher erzeugte Movie-Datei inklusive Job-Ordner im Ausgabeverzeichnis gelöscht werden?'}
|
|
</p>
|
|
{cancelCleanupDialog?.path ? (
|
|
<small className="muted-inline">
|
|
{cancelCleanupDialog?.target === 'raw' ? 'RAW-Pfad' : 'Output-Pfad'}: {cancelCleanupDialog.path}
|
|
</small>
|
|
) : null}
|
|
<div className="dialog-actions">
|
|
<Button
|
|
label="Behalten"
|
|
severity="secondary"
|
|
outlined
|
|
onClick={() => setCancelCleanupDialog({ visible: false, jobId: null, target: null, path: null })}
|
|
disabled={cancelCleanupBusy}
|
|
/>
|
|
<Button
|
|
label={cancelCleanupDialog?.target === 'raw' ? 'RAW löschen' : 'Movie löschen'}
|
|
icon="pi pi-trash"
|
|
severity="danger"
|
|
onClick={handleDeleteCancelledOutput}
|
|
loading={cancelCleanupBusy}
|
|
/>
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
header="Queue-Eintrag einfügen"
|
|
visible={insertQueueDialog.visible}
|
|
onHide={() => setInsertQueueDialog({ visible: false, afterEntryId: null })}
|
|
style={{ width: '28rem', maxWidth: '96vw' }}
|
|
modal
|
|
>
|
|
<div className="queue-insert-dialog-body">
|
|
<p className="queue-insert-dialog-hint">
|
|
{insertQueueDialog.afterEntryId
|
|
? 'Eintrag wird nach dem ausgewählten Element eingefügt.'
|
|
: 'Eintrag wird am Ende der Queue eingefügt.'}
|
|
</p>
|
|
|
|
{queueCatalog.scripts.length > 0 ? (
|
|
<div className="queue-insert-section">
|
|
<span className="queue-insert-section-label"><i className="pi pi-code" /> Skript</span>
|
|
<div className="queue-insert-options">
|
|
{queueCatalog.scripts.map((script) => (
|
|
<button
|
|
key={`qi-script-${script.id}`}
|
|
type="button"
|
|
className="queue-insert-option"
|
|
onClick={() => void handleAddQueueEntry('script', { scriptId: script.id })}
|
|
>
|
|
{script.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{queueCatalog.chains.length > 0 ? (
|
|
<div className="queue-insert-section">
|
|
<span className="queue-insert-section-label"><i className="pi pi-link" /> Skriptkette</span>
|
|
<div className="queue-insert-options">
|
|
{queueCatalog.chains.map((chain) => (
|
|
<button
|
|
key={`qi-chain-${chain.id}`}
|
|
type="button"
|
|
className="queue-insert-option"
|
|
onClick={() => void handleAddQueueEntry('chain', { chainId: chain.id })}
|
|
>
|
|
{chain.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="queue-insert-section">
|
|
<span className="queue-insert-section-label"><i className="pi pi-desktop" /> System</span>
|
|
<div className="queue-insert-options">
|
|
<div className="queue-insert-system-block">
|
|
<span className="queue-insert-system-name"><i className="pi pi-clock" /> Warten</span>
|
|
<InputNumber
|
|
value={insertWaitSeconds}
|
|
onValueChange={(e) => setInsertWaitSeconds(e.value ?? 30)}
|
|
min={1}
|
|
max={3600}
|
|
suffix="s"
|
|
inputStyle={{ width: '4.5rem' }}
|
|
/>
|
|
<Button
|
|
icon="pi pi-check"
|
|
size="small"
|
|
title="Einfügen"
|
|
onClick={() => void handleAddQueueEntry('wait', { waitSeconds: insertWaitSeconds })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{queueCatalog.scripts.length === 0 && queueCatalog.chains.length === 0 ? (
|
|
<small className="muted-inline">Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
|
|
) : null}
|
|
</div>
|
|
</Dialog>
|
|
|
|
<Dialog
|
|
header="Audible Activation Bytes eintragen"
|
|
visible={activationBytesDialog.visible}
|
|
onHide={hideActivationBytesDialog}
|
|
style={{ width: '36rem', maxWidth: '96vw' }}
|
|
modal
|
|
footer={(
|
|
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
|
<Button label="Abbrechen" severity="secondary" outlined onClick={hideActivationBytesDialog} disabled={activationBytesBusy} />
|
|
<Button label="Speichern" icon="pi pi-check" onClick={() => void handleSaveActivationBytes()} loading={activationBytesBusy} disabled={activationBytesInput.trim().length !== 8} />
|
|
</div>
|
|
)}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '0.375rem', fontWeight: 600 }}>Checksum (aus der AAX-Datei)</label>
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
<InputText
|
|
value={activationBytesDialog.checksum || ''}
|
|
readOnly
|
|
style={{ flex: 1, fontFamily: 'monospace', fontSize: '0.85rem' }}
|
|
/>
|
|
<Button
|
|
icon="pi pi-copy"
|
|
severity="secondary"
|
|
outlined
|
|
tooltip="Kopieren"
|
|
onClick={() => navigator.clipboard.writeText(activationBytesDialog.checksum || '')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '0.375rem', fontWeight: 600 }}>Activation Bytes (8 Hex-Zeichen)</label>
|
|
<InputText
|
|
value={activationBytesInput}
|
|
onChange={(e) => setActivationBytesInput(e.target.value)}
|
|
placeholder="z.B. 1a2b3c4d"
|
|
style={{ width: '100%', fontFamily: 'monospace' }}
|
|
maxLength={8}
|
|
/>
|
|
</div>
|
|
<div style={{ background: 'var(--surface-100)', borderRadius: '6px', padding: '0.875rem', fontSize: '0.875rem', lineHeight: 1.6 }}>
|
|
<strong>So bekommst du die Activation Bytes:</strong>
|
|
<ol style={{ margin: '0.5rem 0 0 1.25rem', padding: 0 }}>
|
|
<li>Öffne <strong>audible-tools.kamsker.at</strong></li>
|
|
<li>Aktiviere den <strong>Experten-Modus</strong></li>
|
|
<li>Gib die obige Checksum ein</li>
|
|
<li>Kopiere die zurückgegebenen Activation Bytes hier rein</li>
|
|
</ol>
|
|
<p style={{ margin: '0.5rem 0 0', color: 'var(--text-color-secondary)' }}>
|
|
Die Bytes werden lokal gespeichert und für alle weiteren AAX-Dateien desselben Accounts wiederverwendet.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|