0.15.0-1 merger
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.0-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -549,9 +549,10 @@ function resolveAudiobookDetails(job) {
|
||||
};
|
||||
}
|
||||
|
||||
function statusBadgeMeta(status, queued = false) {
|
||||
function statusBadgeMeta(status, queued = false, options = {}) {
|
||||
const normalized = String(status || '').trim().toUpperCase();
|
||||
const label = getStatusLabel(normalized, { queued });
|
||||
const labelOverride = String(options?.label || '').trim();
|
||||
const label = labelOverride || getStatusLabel(normalized, { queued });
|
||||
if (queued) {
|
||||
return { label, icon: 'pi-list', tone: 'info' };
|
||||
}
|
||||
@@ -585,9 +586,42 @@ function statusBadgeMeta(status, queued = false) {
|
||||
if (normalized === 'ENCODING') {
|
||||
return { label, icon: 'pi-cog', tone: 'warning' };
|
||||
}
|
||||
if (normalized === 'MERGING') {
|
||||
return { label, icon: 'pi-spinner pi-spin', tone: 'warning' };
|
||||
}
|
||||
return { label: label || '-', icon: 'pi-info-circle', tone: 'secondary' };
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value) {
|
||||
return String(value || '').trim().replace(/[\\/]+$/, '');
|
||||
}
|
||||
|
||||
function buildMergeToolLogFallback(job = null) {
|
||||
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object'
|
||||
? job.handbrakeInfo
|
||||
: {};
|
||||
const lines = [];
|
||||
const highlights = Array.isArray(handbrakeInfo?.highlights) ? handbrakeInfo.highlights : [];
|
||||
for (const entry of highlights) {
|
||||
const text = String(entry || '').trim();
|
||||
if (text) {
|
||||
lines.push(text);
|
||||
}
|
||||
}
|
||||
const stdoutTail = String(handbrakeInfo?.stdoutTail || '').trim();
|
||||
if (stdoutTail) {
|
||||
lines.push(stdoutTail);
|
||||
}
|
||||
const stderrTail = String(handbrakeInfo?.stderrTail || '').trim();
|
||||
if (stderrTail) {
|
||||
lines.push(stderrTail);
|
||||
}
|
||||
if (lines.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function omdbField(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw || '-';
|
||||
@@ -1185,16 +1219,20 @@ export default function JobDetailDialog({
|
||||
? discIndicatorIcon
|
||||
: otherIndicatorIcon;
|
||||
const mediaTypeAlt = mediaTypeLabel;
|
||||
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
|
||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||
const isMultipartMergeJob = isMultipartMergeChildJob(job);
|
||||
const statusMeta = statusBadgeMeta(
|
||||
isMultipartMergeJob && statusUpper === 'ENCODING' ? 'MERGING' : job?.status,
|
||||
queueLocked,
|
||||
{ label: isMultipartMergeJob && statusUpper === 'ENCODING' ? 'Merging' : '' }
|
||||
);
|
||||
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
|
||||
const metadataContext = resolveJobMetadataContext(job);
|
||||
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
|
||||
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
|
||||
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
|
||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||
const isSeriesContainer = isDvdSeries && jobKindRaw === 'dvd_series_container';
|
||||
const isMultipartContainer = jobKindRaw === 'multipart_movie_container';
|
||||
const isMultipartMergeJob = isMultipartMergeChildJob(job);
|
||||
const isDiskContainer = isSeriesContainer || isMultipartContainer;
|
||||
const allChildJobs = Array.isArray(job?.children)
|
||||
? [...job.children].sort(compareSeriesChildJobsByDisc)
|
||||
@@ -1332,11 +1370,28 @@ export default function JobDetailDialog({
|
||||
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
|
||||
);
|
||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||
const posterUrl = String(
|
||||
job?.poster_url
|
||||
|| metadataContext?.selectedMetadata?.poster
|
||||
|| metadataContext?.selectedMetadata?.coverUrl
|
||||
|| metadataContext?.selectedMetadata?.posterUrl
|
||||
|| job?.encodePlan?.metadata?.poster
|
||||
|| ''
|
||||
).trim() || null;
|
||||
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
|
||||
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
|
||||
const mergeOutputPathCandidates = new Set(
|
||||
[
|
||||
...(isMultipartMergeJob ? [job?.output_path] : []),
|
||||
...(isMultipartContainer ? mergeChildJobs.map((child) => child?.output_path) : [])
|
||||
]
|
||||
.map((value) => normalizePathForCompare(value))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
|
||||
const mergeToolLogFallback = isMultipartMergeJob ? buildMergeToolLogFallback(job) : '';
|
||||
const canShowGeneralEncodeActions = canResumeReady
|
||||
|| typeof onRestartEncode === 'function'
|
||||
|| typeof onRestartReview === 'function'
|
||||
@@ -1407,8 +1462,8 @@ export default function JobDetailDialog({
|
||||
{detailLoading ? <p>Details werden geladen ...</p> : null}
|
||||
|
||||
<div className="job-head-row">
|
||||
{job.poster_url && job.poster_url !== 'N/A' ? (
|
||||
<img src={job.poster_url} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
|
||||
{posterUrl && posterUrl !== 'N/A' ? (
|
||||
<img src={posterUrl} alt={job.title || 'Poster'} className={useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} />
|
||||
) : (
|
||||
<div className={`${useAudioPosterLayout ? 'poster-large-audio' : 'poster-large'} poster-fallback`}>{useAudioPosterLayout ? 'Kein Cover' : 'Kein Poster'}</div>
|
||||
)}
|
||||
@@ -1712,7 +1767,7 @@ export default function JobDetailDialog({
|
||||
<div><strong>Start:</strong> {job.start_time || '-'}</div>
|
||||
<div><strong>Ende:</strong> {job.end_time || '-'}</div>
|
||||
{/* Zeile 3+4: Pfade */}
|
||||
{!isDiskContainer ? (
|
||||
{!isDiskContainer && !isMultipartMergeJob ? (
|
||||
<PathField
|
||||
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
|
||||
value={isCd ? (job.raw_path || job.output_path) : resolvedRawPath}
|
||||
@@ -1745,7 +1800,13 @@ export default function JobDetailDialog({
|
||||
if (!folderPath) {
|
||||
return null;
|
||||
}
|
||||
const label = outputFolders.length > 1 ? `Output ${idx + 1}:` : 'Output:';
|
||||
const normalizedFolderPath = normalizePathForCompare(folderPath);
|
||||
const isMergeOutput = Boolean(
|
||||
(normalizedFolderPath && mergeOutputPathCandidates.has(normalizedFolderPath))
|
||||
|| /_merged\.[^/.]+$/i.test(folderPath)
|
||||
);
|
||||
const outputLabel = outputFolders.length > 1 ? `Output ${idx + 1}` : 'Output';
|
||||
const label = isMergeOutput ? `${outputLabel} (Merge):` : `${outputLabel}:`;
|
||||
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
|
||||
return (
|
||||
<PathField
|
||||
@@ -1760,7 +1821,7 @@ export default function JobDetailDialog({
|
||||
})
|
||||
) : (
|
||||
<PathField
|
||||
label="Output:"
|
||||
label={isMultipartMergeJob ? 'Output (Merge):' : 'Output:'}
|
||||
value={job.output_path}
|
||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||
downloadDisabled={!canDownloadOutput}
|
||||
@@ -2414,6 +2475,23 @@ export default function JobDetailDialog({
|
||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||
</summary>
|
||||
<p>Für Merge-Jobs wird nur das Merge-Tool-Log angezeigt.</p>
|
||||
{typeof onDeleteEntry === 'function' ? (
|
||||
<div className="actions-section">
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Merge-Job löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry?.(child, { includeRelated: false })}
|
||||
loading={deleteEntryBusy}
|
||||
disabled={String(child?.status || '').trim().toUpperCase() === 'ENCODING'}
|
||||
/>
|
||||
<span className="action-desc">Entfernt nur den Merge-Job. Ob die Merge-Datei ebenfalls gelöscht wird, kann im nächsten Schritt gewählt werden.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
))
|
||||
) : (
|
||||
@@ -2523,15 +2601,17 @@ export default function JobDetailDialog({
|
||||
<summary className="series-batch-episode-head">
|
||||
<span className="series-batch-episode-title">{`Merge${child?.id ? ` #${child.id}` : ''}`}</span>
|
||||
</summary>
|
||||
<pre>{child.log || ''}</pre>
|
||||
<pre>{child.log || buildMergeToolLogFallback(child) || ''}</pre>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="log-box">{job.log || ''}</pre>
|
||||
<pre className="log-box">{job.log || (isMultipartMergeJob ? mergeToolLogFallback : '') || ''}</pre>
|
||||
)
|
||||
) : (
|
||||
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||
isMultipartMergeJob && mergeToolLogFallback
|
||||
? <pre className="log-box">{mergeToolLogFallback}</pre>
|
||||
: <p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -2764,14 +2844,18 @@ export default function JobDetailDialog({
|
||||
|
||||
{!queueLocked ? (
|
||||
<div className="action-delete-entry">
|
||||
<span className="action-desc">Entfernt diesen Eintrag aus der Verlaufsliste — Dateien auf der Festplatte bleiben erhalten.</span>
|
||||
<span className="action-desc">
|
||||
{isMultipartMergeJob
|
||||
? 'Entfernt diesen Merge-Job aus der Verlaufsliste. Im nächsten Schritt kann gewählt werden, ob die Merge-Datei ebenfalls gelöscht werden soll.'
|
||||
: 'Entfernt diesen Eintrag aus der Verlaufsliste — Dateien auf der Festplatte bleiben erhalten.'}
|
||||
</span>
|
||||
<Button
|
||||
label="Historieneintrag löschen"
|
||||
label={isMultipartMergeJob ? 'Merge-Job löschen' : 'Historieneintrag löschen'}
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onDeleteEntry?.(job)}
|
||||
onClick={() => onDeleteEntry?.(job, { includeRelated: !isMultipartMergeJob })}
|
||||
loading={deleteEntryBusy}
|
||||
disabled={!canDeleteEntry}
|
||||
/>
|
||||
|
||||
@@ -2510,6 +2510,11 @@ export default function PipelineStatusCard({
|
||||
setLiveJobLog(String(liveJob?.log || '').trim());
|
||||
}
|
||||
} catch (_err) {
|
||||
if (_err?.status === 404) {
|
||||
cancelled = true;
|
||||
setLiveJobLog('');
|
||||
return;
|
||||
}
|
||||
// ignore transient polling errors
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,6 +203,16 @@ function isMultipartContainerHistoryRow(row) {
|
||||
return kind === 'multipart_movie_container';
|
||||
}
|
||||
|
||||
function isMultipartMergeHistoryRow(row) {
|
||||
const kind = String(
|
||||
row?.job_kind
|
||||
|| row?.jobKind
|
||||
|| row?.encodePlan?.jobKind
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
return kind === 'multipart_movie_merge';
|
||||
}
|
||||
|
||||
function isSeriesChildHistoryRow(row) {
|
||||
const kind = String(
|
||||
row?.job_kind
|
||||
@@ -348,6 +358,9 @@ function hasAudioOutput(row) {
|
||||
}
|
||||
|
||||
function getOutputLabelForRow(row) {
|
||||
if (isMultipartMergeHistoryRow(row)) {
|
||||
return 'Merge-Datei(en)';
|
||||
}
|
||||
if (isSeriesVideoJob(row)) {
|
||||
return 'Folgen-Datei(en)';
|
||||
}
|
||||
@@ -361,6 +374,9 @@ function getOutputLabelForRow(row) {
|
||||
}
|
||||
|
||||
function getOutputShortLabelForRow(row) {
|
||||
if (isMultipartMergeHistoryRow(row)) {
|
||||
return 'Merge';
|
||||
}
|
||||
if (isSeriesVideoJob(row)) {
|
||||
return 'Folgen';
|
||||
}
|
||||
@@ -1442,7 +1458,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const includeRelated = options?.includeRelated !== false;
|
||||
const isMergeJob = isMultipartMergeHistoryRow(row);
|
||||
const includeRelated = options?.includeRelated !== undefined
|
||||
? options.includeRelated !== false
|
||||
: !isMergeJob;
|
||||
setDeleteEntryDialogRow(row);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryIncludeRelated(includeRelated);
|
||||
@@ -1695,12 +1714,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (mergeState === 'done') {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merge fertig" severity="success" />
|
||||
<Tag value="Fertig" severity="success" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isMultipartMergeHistoryRow(row) && normalizeStatus(row?.status) === 'ENCODING') {
|
||||
return (
|
||||
<div className="history-status-tag-wrap">
|
||||
<Tag value="Merging" severity="warning" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const normalizedStatus = normalizeStatus(row?.status);
|
||||
const rowId = normalizeJobId(row?.id);
|
||||
const isQueued = Boolean(rowId && queuedJobIdSet.has(rowId));
|
||||
|
||||
+291
-158
@@ -545,6 +545,15 @@ 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 : [];
|
||||
@@ -1158,6 +1167,8 @@ export default function RipperPage({
|
||||
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('');
|
||||
@@ -1270,6 +1281,17 @@ export default function RipperPage({
|
||||
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;
|
||||
}
|
||||
@@ -1506,6 +1528,80 @@ export default function RipperPage({
|
||||
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) {
|
||||
@@ -3154,7 +3250,7 @@ export default function RipperPage({
|
||||
}
|
||||
const normalizedStatus = normalizeStatus(job?.status);
|
||||
const isQueued = queuedJobIdSet.has(jobId);
|
||||
const statusBadgeValue = getStatusLabel(job?.status, { queued: isQueued });
|
||||
const statusBadgeValue = getMergeAwareStatusLabel(job, job?.status, { queued: isQueued });
|
||||
const statusBadgeSeverity = getStatusSeverity(normalizedStatus, { queued: isQueued });
|
||||
const isExpanded = normalizeJobId(expandedJobId) === jobId;
|
||||
const isCurrentSession = jobId === currentPipelineJobId && state !== 'IDLE';
|
||||
@@ -3281,6 +3377,11 @@ export default function RipperPage({
|
||||
const multipartMergePreviewLoading = Boolean(
|
||||
isMultipartMerge && multipartMergePreviewLoadingByJobId[jobId]
|
||||
);
|
||||
const multipartMergeLiveLog = String(multipartMergeLiveLogByJobId[jobId] || '').trim();
|
||||
const multipartMergeLiveLogLoading = Boolean(
|
||||
isMultipartMerge && multipartMergeLiveLogLoadingByJobId[jobId]
|
||||
);
|
||||
const isMultipartMergeRunning = isMultipartMerge && normalizedStatus === 'ENCODING';
|
||||
const multipartMergeCommandPreview = String(multipartMergePreview?.command?.preview || '').trim();
|
||||
const multipartMergeChapterEntries = Array.isArray(multipartMergePreview?.chapters?.entries)
|
||||
? multipartMergePreview.chapters.entries
|
||||
@@ -3396,156 +3497,186 @@ export default function RipperPage({
|
||||
{!isCdJob && !isAudiobookJob ? (
|
||||
isMultipartMerge ? (
|
||||
<div className="multipart-merge-panel">
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
{!isMultipartMergeRunning ? (
|
||||
<>
|
||||
<div className="multipart-merge-head">
|
||||
<strong>Merge-Quellen</strong>
|
||||
<small>Drag-and-Drop ändert die Reihenfolge der Disc-Dateien.</small>
|
||||
</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>
|
||||
{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>
|
||||
) : (
|
||||
<small className="muted-inline">Stream-Vergleich noch nicht verfügbar.</small>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
<div className="multipart-merge-actions">
|
||||
{(normalizedStatus === 'READY_TO_START' || normalizedStatus === 'READY_TO_ENCODE') ? (
|
||||
<Button
|
||||
@@ -3575,14 +3706,16 @@ export default function RipperPage({
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="Job löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteRipperJob(jobId)}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
{!isMultipartMergeRunning ? (
|
||||
<Button
|
||||
label="Job löschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
onClick={() => handleDeleteRipperJob(jobId)}
|
||||
disabled={busyJobIds.has(jobId)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -3753,7 +3886,7 @@ export default function RipperPage({
|
||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||
<small>#{item.jobId} | {getStatusLabel(item.status)}</small>
|
||||
<small>#{item.jobId} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
||||
{clampedQueueProgress > 0 && (
|
||||
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
||||
)}
|
||||
@@ -3794,7 +3927,7 @@ export default function RipperPage({
|
||||
{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>
|
||||
<small>{getStatusLabel(item.status)}</small>
|
||||
<small>{getMergeAwareStatusLabel(item, item.status)}</small>
|
||||
</div>
|
||||
{hasScriptSummary ? (
|
||||
<button
|
||||
@@ -3870,7 +4003,7 @@ export default function RipperPage({
|
||||
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||
</strong>
|
||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||
<small>{item.position || '-'} | #{item.jobId} | {item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
|
||||
<small>{item.position || '-'} | #{item.jobId} | {item.actionLabel || item.action || '-'} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1666,7 +1666,11 @@ body {
|
||||
|
||||
.multipart-merge-chapter-list {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
grid-template-columns: repeat(2, minmax(260px, 1fr));
|
||||
gap: 0.25rem 0.55rem;
|
||||
max-height: 16rem;
|
||||
overflow: auto;
|
||||
padding-right: 0.2rem;
|
||||
}
|
||||
|
||||
.multipart-merge-chapter-item {
|
||||
@@ -1697,6 +1701,15 @@ body {
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.multipart-merge-chapter-list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.playlist-waiting-box {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
|
||||
Reference in New Issue
Block a user