1142 lines
44 KiB
JavaScript
1142 lines
44 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Card } from 'primereact/card';
|
|
import { Tag } from 'primereact/tag';
|
|
import { ProgressBar } from 'primereact/progressbar';
|
|
import { Button } from 'primereact/button';
|
|
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
|
import { api } from '../api/client';
|
|
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
|
|
|
function normalizeTitleId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizePlaylistId(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i);
|
|
return match ? String(match[1]).padStart(5, '0') : null;
|
|
}
|
|
|
|
function formatDurationClock(seconds) {
|
|
const total = Number(seconds || 0);
|
|
if (!Number.isFinite(total) || total <= 0) {
|
|
return null;
|
|
}
|
|
const rounded = Math.max(0, Math.trunc(total));
|
|
const h = Math.floor(rounded / 3600);
|
|
const m = Math.floor((rounded % 3600) / 60);
|
|
const s = rounded % 60;
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
|
}
|
|
|
|
function normalizeTrackId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizeTrackIdList(values) {
|
|
const list = Array.isArray(values) ? values : [];
|
|
const seen = new Set();
|
|
const output = [];
|
|
for (const value of list) {
|
|
const normalized = normalizeTrackId(value);
|
|
if (normalized === null) {
|
|
continue;
|
|
}
|
|
const key = String(normalized);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
output.push(normalized);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function normalizeScriptId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizeScriptIdList(values) {
|
|
const list = Array.isArray(values) ? values : [];
|
|
const seen = new Set();
|
|
const output = [];
|
|
for (const value of list) {
|
|
const normalized = normalizeScriptId(value);
|
|
if (normalized === null) {
|
|
continue;
|
|
}
|
|
const key = String(normalized);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
output.push(normalized);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function normalizeChainId(value) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return null;
|
|
}
|
|
return Math.trunc(parsed);
|
|
}
|
|
|
|
function normalizeMediaProfile(value) {
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
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 (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
|
return 'other';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
|
|
const context = pipeline?.context && typeof pipeline.context === 'object' ? pipeline.context : {};
|
|
const device = context?.device && typeof context.device === 'object' ? context.device : {};
|
|
const review = mediaInfoReview && typeof mediaInfoReview === 'object' ? mediaInfoReview : {};
|
|
const candidates = [
|
|
context?.mediaProfile,
|
|
context?.media_profile,
|
|
review?.mediaProfile,
|
|
review?.media_profile,
|
|
device?.mediaProfile,
|
|
device?.media_profile,
|
|
device?.profile,
|
|
device?.type
|
|
];
|
|
for (const candidate of candidates) {
|
|
const normalized = normalizeMediaProfile(candidate);
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isBurnedSubtitleTrack(track) {
|
|
const flags = Array.isArray(track?.subtitlePreviewFlags)
|
|
? track.subtitlePreviewFlags
|
|
: (Array.isArray(track?.flags) ? track.flags : []);
|
|
const hasBurnedFlag = flags.some((flag) => String(flag || '').trim().toLowerCase() === 'burned');
|
|
const summary = `${track?.subtitlePreviewSummary || ''} ${track?.subtitleActionSummary || ''}`;
|
|
return Boolean(
|
|
track?.subtitlePreviewBurnIn
|
|
|| track?.burnIn
|
|
|| hasBurnedFlag
|
|
|| /burned/i.test(summary)
|
|
);
|
|
}
|
|
|
|
function buildDefaultTrackSelection(review) {
|
|
const titles = Array.isArray(review?.titles) ? review.titles : [];
|
|
const selection = {};
|
|
const reviewEncodeInputTitleId = normalizeTitleId(review?.encodeInputTitleId);
|
|
|
|
for (const title of titles) {
|
|
const titleId = normalizeTitleId(title?.id);
|
|
if (!titleId) {
|
|
continue;
|
|
}
|
|
|
|
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
|
|
const subtitleTracks = Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [];
|
|
const isEncodeInputTitle = Boolean(
|
|
title?.selectedForEncode
|
|
|| title?.encodeInput
|
|
|| (reviewEncodeInputTitleId && reviewEncodeInputTitleId === titleId)
|
|
);
|
|
const audioSelectionSource = isEncodeInputTitle
|
|
? audioTracks.filter((track) => Boolean(track?.selectedForEncode))
|
|
: audioTracks.filter((track) => Boolean(track?.selectedByRule));
|
|
const subtitleSelectionSource = isEncodeInputTitle
|
|
? subtitleTracks.filter((track) => Boolean(track?.selectedForEncode))
|
|
: subtitleTracks.filter((track) => Boolean(track?.selectedByRule));
|
|
|
|
selection[titleId] = {
|
|
audioTrackIds: normalizeTrackIdList(
|
|
audioSelectionSource.map((track) => track?.id)
|
|
),
|
|
subtitleTrackIds: normalizeTrackIdList(
|
|
subtitleSelectionSource
|
|
.filter((track) => !isBurnedSubtitleTrack(track))
|
|
.map((track) => track?.id)
|
|
)
|
|
};
|
|
}
|
|
|
|
return selection;
|
|
}
|
|
|
|
function defaultTrackSelectionForTitle(review, titleId) {
|
|
const defaults = buildDefaultTrackSelection(review);
|
|
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
|
|
}
|
|
|
|
function buildSettingsMap(categories) {
|
|
const map = {};
|
|
const list = Array.isArray(categories) ? categories : [];
|
|
for (const category of list) {
|
|
for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) {
|
|
map[setting.key] = setting.value;
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function buildPresetDisplayMap(options) {
|
|
const map = {};
|
|
const list = Array.isArray(options) ? options : [];
|
|
for (const option of list) {
|
|
if (!option || option.disabled) {
|
|
continue;
|
|
}
|
|
const value = String(option.value || '').trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
const category = String(option.category || '').trim();
|
|
map[value] = category ? `${category}/${value}` : value;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function sanitizeFileName(input) {
|
|
return String(input || 'untitled')
|
|
.replace(/[\\/:*?"<>|]/g, '_')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 180);
|
|
}
|
|
|
|
function renderTemplate(template, values) {
|
|
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
|
|
const value = values[key.trim()];
|
|
if (value === undefined || value === null || value === '') {
|
|
return 'unknown';
|
|
}
|
|
return String(value);
|
|
});
|
|
}
|
|
|
|
function resolveProfiledSetting(settings, key, mediaProfile) {
|
|
const profileKey = mediaProfile ? `${key}_${mediaProfile}` : null;
|
|
if (profileKey && settings?.[profileKey] != null && settings[profileKey] !== '') {
|
|
return settings[profileKey];
|
|
}
|
|
const fallbackProfiles = mediaProfile === 'bluray' ? ['dvd'] : ['bluray'];
|
|
for (const fb of fallbackProfiles) {
|
|
const fbKey = `${key}_${fb}`;
|
|
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
|
return settings[fbKey];
|
|
}
|
|
}
|
|
return settings?.[key] ?? null;
|
|
}
|
|
|
|
function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId = null) {
|
|
const movieDir = String(resolveProfiledSetting(settings, 'movie_dir', mediaProfile) || '').trim();
|
|
if (!movieDir) {
|
|
return null;
|
|
}
|
|
|
|
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
|
const year = metadata?.year || new Date().getFullYear();
|
|
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
|
|
const DEFAULT_TEMPLATE = '${title} (${year})/${title} (${year})';
|
|
const rawTemplate = resolveProfiledSetting(settings, 'output_template', mediaProfile);
|
|
const template = String(rawTemplate || DEFAULT_TEMPLATE).trim() || DEFAULT_TEMPLATE;
|
|
const rendered = renderTemplate(template, { title, year, imdbId });
|
|
const segments = rendered
|
|
.replace(/\\/g, '/')
|
|
.replace(/\/+/g, '/')
|
|
.replace(/^\/+|\/+$/g, '')
|
|
.split('/')
|
|
.map((seg) => sanitizeFileName(seg))
|
|
.filter(Boolean);
|
|
const baseName = segments.length > 0 ? segments[segments.length - 1] : 'untitled';
|
|
const folderParts = segments.slice(0, -1);
|
|
const rawExt = resolveProfiledSetting(settings, 'output_extension', mediaProfile);
|
|
const ext = String(rawExt || 'mkv').trim() || 'mkv';
|
|
const root = movieDir.replace(/\/+$/g, '');
|
|
if (folderParts.length > 0) {
|
|
return `${root}/${folderParts.join('/')}/${baseName}.${ext}`;
|
|
}
|
|
return `${root}/${baseName}.${ext}`;
|
|
}
|
|
|
|
export default function PipelineStatusCard({
|
|
pipeline,
|
|
onAnalyze,
|
|
onReanalyze,
|
|
onOpenMetadata,
|
|
onReassignOmdb,
|
|
onStart,
|
|
onRemoveFromQueue,
|
|
onRestartEncode,
|
|
onRestartReview,
|
|
onConfirmReview,
|
|
onSelectPlaylist,
|
|
onCancel,
|
|
onRetry,
|
|
isQueued = false,
|
|
busy,
|
|
liveJobLog = ''
|
|
}) {
|
|
const state = pipeline?.state || 'IDLE';
|
|
const stateLabel = getStatusLabel(state);
|
|
const progress = Number(pipeline?.progress || 0);
|
|
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
|
|
const retryJobId = pipeline?.context?.jobId;
|
|
const queueLocked = Boolean(isQueued && retryJobId);
|
|
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
|
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
|
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
|
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
|
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
|
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
|
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
|
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
|
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
|
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
|
const [settingsMap, setSettingsMap] = useState({});
|
|
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
|
const [scriptCatalog, setScriptCatalog] = useState([]);
|
|
const [chainCatalog, setChainCatalog] = useState([]);
|
|
// Unified ordered lists: [{type: 'script'|'chain', id: number}]
|
|
const [preEncodeItems, setPreEncodeItems] = useState([]);
|
|
const [postEncodeItems, setPostEncodeItems] = useState([]);
|
|
const [userPresets, setUserPresets] = useState([]);
|
|
const [selectedUserPresetId, setSelectedUserPresetId] = useState(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const loadSettings = async () => {
|
|
try {
|
|
const [settingsResponse, presetsResponse, scriptsResponse, chainsResponse, userPresetsResponse] = await Promise.allSettled([
|
|
api.getSettings(),
|
|
api.getHandBrakePresets(),
|
|
api.getScripts(),
|
|
api.getScriptChains(),
|
|
api.getUserPresets()
|
|
]);
|
|
if (!cancelled) {
|
|
const categories = settingsResponse.status === 'fulfilled'
|
|
? (settingsResponse.value?.categories || [])
|
|
: [];
|
|
setSettingsMap(buildSettingsMap(categories));
|
|
const presetOptions = presetsResponse.status === 'fulfilled'
|
|
? (presetsResponse.value?.options || [])
|
|
: [];
|
|
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
|
|
const scripts = scriptsResponse.status === 'fulfilled'
|
|
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
|
: [];
|
|
setScriptCatalog(
|
|
scripts.map((item) => ({
|
|
id: item?.id,
|
|
name: item?.name
|
|
}))
|
|
);
|
|
const chains = chainsResponse.status === 'fulfilled'
|
|
? (Array.isArray(chainsResponse.value?.chains) ? chainsResponse.value.chains : [])
|
|
: [];
|
|
setChainCatalog(chains.map((item) => ({ id: item?.id, name: item?.name })));
|
|
const allUserPresets = userPresetsResponse.status === 'fulfilled'
|
|
? (Array.isArray(userPresetsResponse.value?.presets) ? userPresetsResponse.value.presets : [])
|
|
: [];
|
|
setUserPresets(allUserPresets);
|
|
}
|
|
} catch (_error) {
|
|
if (!cancelled) {
|
|
setSettingsMap({});
|
|
setPresetDisplayMap({});
|
|
setScriptCatalog([]);
|
|
setChainCatalog([]);
|
|
setUserPresets([]);
|
|
}
|
|
}
|
|
};
|
|
loadSettings();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
|
|
setSelectedEncodeTitleId(fromReview);
|
|
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
|
|
const normChain = (raw) => (Array.isArray(raw) ? raw : []).map(Number).filter((id) => Number.isFinite(id) && id > 0);
|
|
setPreEncodeItems([
|
|
...normalizeScriptIdList(mediaInfoReview?.preEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
|
...normChain(mediaInfoReview?.preEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
|
]);
|
|
setPostEncodeItems([
|
|
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
|
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
|
]);
|
|
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
|
|
setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null);
|
|
}, [
|
|
mediaInfoReview?.encodeInputTitleId,
|
|
mediaInfoReview?.generatedAt,
|
|
mediaInfoReview?.reviewConfirmedAt,
|
|
mediaInfoReview?.prefilledFromPreviousRunAt,
|
|
mediaInfoReview?.userPreset?.id,
|
|
retryJobId
|
|
]);
|
|
|
|
useEffect(() => {
|
|
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
|
|
if (!currentTitleId) {
|
|
return;
|
|
}
|
|
|
|
setTrackSelectionByTitle((prev) => {
|
|
if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) {
|
|
return prev;
|
|
}
|
|
|
|
const defaults = buildDefaultTrackSelection(mediaInfoReview);
|
|
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
|
|
return {
|
|
...prev,
|
|
[currentTitleId]: fallback
|
|
};
|
|
});
|
|
}, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]);
|
|
|
|
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
|
|
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
|
|
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
|
|
|
|
// Filter user presets by job media profile ('all' presets always shown)
|
|
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
|
const presetMediaType = normalizeMediaProfile(p?.mediaType) || 'all';
|
|
if (!jobMediaProfile) {
|
|
return true;
|
|
}
|
|
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
|
|
});
|
|
const canStartReadyJob = isPreRipReview
|
|
? Boolean(retryJobId)
|
|
: Boolean(retryJobId && encodeInputPath);
|
|
const canRestartEncodeFromLastSettings = Boolean(
|
|
(state === 'ERROR' || state === 'CANCELLED')
|
|
&& retryJobId
|
|
&& pipeline?.context?.canRestartEncodeFromLastSettings
|
|
);
|
|
const canRestartReviewFromRaw = Boolean(
|
|
retryJobId
|
|
&& !running
|
|
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
|
);
|
|
|
|
const waitingPlaylistRows = useMemo(() => {
|
|
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
|
? playlistAnalysis.evaluatedCandidates
|
|
: [];
|
|
|
|
const rows = evaluated.length > 0
|
|
? evaluated
|
|
: (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []);
|
|
|
|
const normalized = rows
|
|
.map((item) => {
|
|
const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item);
|
|
if (!playlistId) {
|
|
return null;
|
|
}
|
|
const playlistFile = `${playlistId}.mpls`;
|
|
const score = Number(item?.score);
|
|
const sequenceCoherence = Number(
|
|
item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence
|
|
);
|
|
const handBrakeTitleId = Number(item?.handBrakeTitleId);
|
|
const durationSecondsRaw = Number(item?.durationSeconds ?? item?.duration ?? 0);
|
|
const durationSeconds = Number.isFinite(durationSecondsRaw) && durationSecondsRaw > 0
|
|
? Math.trunc(durationSecondsRaw)
|
|
: 0;
|
|
const durationLabelRaw = String(item?.durationLabel || '').trim();
|
|
const durationLabel = durationLabelRaw || formatDurationClock(durationSeconds);
|
|
return {
|
|
playlistId,
|
|
playlistFile,
|
|
titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null,
|
|
durationSeconds,
|
|
durationLabel: durationLabel || null,
|
|
score: Number.isFinite(score) ? score : null,
|
|
evaluationLabel: item?.evaluationLabel || null,
|
|
segmentCommand: item?.segmentCommand
|
|
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
|
|
segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [],
|
|
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
|
|
recommended: Boolean(item?.recommended),
|
|
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
|
|
? Math.trunc(handBrakeTitleId)
|
|
: null,
|
|
audioSummary: item?.audioSummary || null,
|
|
audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : []
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const dedup = [];
|
|
const seen = new Set();
|
|
for (const row of normalized) {
|
|
if (seen.has(row.playlistId)) {
|
|
continue;
|
|
}
|
|
seen.add(row.playlistId);
|
|
dedup.push(row);
|
|
}
|
|
return dedup;
|
|
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
|
|
|
|
useEffect(() => {
|
|
if (state !== 'WAITING_FOR_USER_DECISION') {
|
|
setSelectedPlaylistId(null);
|
|
return;
|
|
}
|
|
|
|
const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist);
|
|
if (current) {
|
|
setSelectedPlaylistId(current);
|
|
return;
|
|
}
|
|
|
|
const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null;
|
|
const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId);
|
|
const fallback = waitingPlaylistRows[0]?.playlistId || null;
|
|
setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback);
|
|
}, [
|
|
state,
|
|
retryJobId,
|
|
waitingPlaylistRows,
|
|
playlistAnalysis?.recommendation?.playlistId,
|
|
pipeline?.context?.selectedPlaylist
|
|
]);
|
|
|
|
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
|
const commandOutputPath = useMemo(
|
|
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
|
|
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
|
|
);
|
|
const presetDisplayValue = useMemo(() => {
|
|
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
|
if (!preset) {
|
|
return '';
|
|
}
|
|
return presetDisplayMap[preset] || preset;
|
|
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
|
|
const buildSelectedTrackSelectionForCurrentTitle = () => {
|
|
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId)
|
|
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|
|
|| normalizeTitleId(
|
|
(Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []).find((t) => t?.selectedForEncode)?.id
|
|
);
|
|
const selectionEntry = encodeTitleId
|
|
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
|
|
: null;
|
|
const fallbackSelection = encodeTitleId
|
|
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
|
|
: { audioTrackIds: [], subtitleTrackIds: [] };
|
|
const effectiveSelection = selectionEntry || fallbackSelection;
|
|
const encodeTitle = encodeTitleId
|
|
? (Array.isArray(mediaInfoReview?.titles)
|
|
? (mediaInfoReview.titles.find((title) => normalizeTitleId(title?.id) === encodeTitleId) || null)
|
|
: null)
|
|
: null;
|
|
const blockedSubtitleTrackIds = new Set(
|
|
(Array.isArray(encodeTitle?.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
|
.filter((track) => isBurnedSubtitleTrack(track))
|
|
.map((track) => normalizeTrackId(track?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const selectedTrackSelection = encodeTitleId
|
|
? {
|
|
[encodeTitleId]: {
|
|
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
|
|
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
|
|
.filter((id) => !blockedSubtitleTrackIds.has(String(id)))
|
|
}
|
|
}
|
|
: null;
|
|
const selectedPostScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
|
|
const selectedPreScriptIds = preEncodeItems.filter((i) => i.type === 'script').map((i) => i.id);
|
|
const selectedPostChainIds = postEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
|
|
const selectedPreChainIds = preEncodeItems.filter((i) => i.type === 'chain').map((i) => i.id);
|
|
return {
|
|
encodeTitleId,
|
|
selectedTrackSelection,
|
|
selectedPostScriptIds,
|
|
selectedPreScriptIds,
|
|
selectedPostChainIds,
|
|
selectedPreChainIds
|
|
};
|
|
};
|
|
|
|
return (
|
|
<Card title="Pipeline-Status" subTitle="Live-Zustand und Fortschritt">
|
|
<div className="status-row">
|
|
<Tag value={stateLabel} severity={getStatusSeverity(state)} />
|
|
<span>{pipeline?.statusText || 'Bereit'}</span>
|
|
</div>
|
|
|
|
{running && (
|
|
<div className="progress-wrap">
|
|
<ProgressBar value={progress} showValue />
|
|
<small>{pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'}</small>
|
|
</div>
|
|
)}
|
|
|
|
{state === 'FINISHED' && (
|
|
<div className="progress-wrap">
|
|
<ProgressBar value={100} showValue />
|
|
</div>
|
|
)}
|
|
|
|
<div className="actions-row">
|
|
{queueLocked ? (
|
|
<Button
|
|
label="Aus Queue löschen"
|
|
icon="pi pi-times"
|
|
severity="danger"
|
|
outlined
|
|
onClick={() => onRemoveFromQueue?.(retryJobId)}
|
|
loading={busy}
|
|
disabled={typeof onRemoveFromQueue !== 'function'}
|
|
/>
|
|
) : (
|
|
<>
|
|
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
|
|
<Button
|
|
label="Analyse starten"
|
|
icon="pi pi-search"
|
|
onClick={onAnalyze}
|
|
loading={busy}
|
|
/>
|
|
)}
|
|
|
|
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION') && retryJobId && typeof onOpenMetadata === 'function' ? (
|
|
<Button
|
|
label="Metadaten öffnen"
|
|
icon="pi pi-list"
|
|
severity="info"
|
|
onClick={() => onOpenMetadata?.(retryJobId)}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
|
|
{!running && state !== 'METADATA_SELECTION' && state !== 'WAITING_FOR_USER_DECISION' && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId && typeof onReassignOmdb === 'function' ? (
|
|
<Button
|
|
label="OMDb neu zuordnen"
|
|
icon="pi pi-search"
|
|
severity="secondary"
|
|
size="small"
|
|
onClick={() => onReassignOmdb?.(retryJobId)}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
|
|
{state === 'READY_TO_START' && retryJobId ? (
|
|
<Button
|
|
label="Job starten"
|
|
icon="pi pi-play"
|
|
severity="success"
|
|
onClick={() => onStart(retryJobId)}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
|
|
{playlistDecisionRequiredBeforeStart && retryJobId && (
|
|
<Button
|
|
label="Playlist übernehmen"
|
|
icon="pi pi-check"
|
|
severity="warning"
|
|
outlined
|
|
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
|
|
loading={busy}
|
|
disabled={!normalizePlaylistId(selectedPlaylistId)}
|
|
/>
|
|
)}
|
|
|
|
{state === 'READY_TO_ENCODE' && retryJobId ? (
|
|
<Button
|
|
label={isPreRipReview ? 'Backup + Encoding starten' : 'Encoding starten'}
|
|
icon="pi pi-play"
|
|
severity="success"
|
|
onClick={async () => {
|
|
const {
|
|
encodeTitleId,
|
|
selectedTrackSelection,
|
|
selectedPostScriptIds,
|
|
selectedPreScriptIds,
|
|
selectedPostChainIds,
|
|
selectedPreChainIds
|
|
} = buildSelectedTrackSelectionForCurrentTitle();
|
|
await onStart(retryJobId, {
|
|
ensureConfirmed: true,
|
|
selectedEncodeTitleId: encodeTitleId,
|
|
selectedTrackSelection,
|
|
selectedPostEncodeScriptIds: selectedPostScriptIds,
|
|
selectedPreEncodeScriptIds: selectedPreScriptIds,
|
|
selectedPostEncodeChainIds: selectedPostChainIds,
|
|
selectedPreEncodeChainIds: selectedPreChainIds,
|
|
selectedUserPresetId: selectedUserPresetId || null
|
|
});
|
|
}}
|
|
loading={busy}
|
|
disabled={!canStartReadyJob || !canConfirmReview}
|
|
/>
|
|
) : null}
|
|
|
|
{running && (
|
|
<Button
|
|
label="Abbrechen"
|
|
icon="pi pi-stop"
|
|
severity="danger"
|
|
onClick={() => onCancel?.(retryJobId, state)}
|
|
loading={busy}
|
|
/>
|
|
)}
|
|
|
|
{canRestartReviewFromRaw ? (
|
|
<Button
|
|
label="Review neu starten"
|
|
icon="pi pi-refresh"
|
|
severity="info"
|
|
outlined
|
|
onClick={() => onRestartReview?.(retryJobId)}
|
|
loading={busy}
|
|
disabled={!retryJobId}
|
|
/>
|
|
) : null}
|
|
|
|
{canRestartEncodeFromLastSettings ? (
|
|
<Button
|
|
label="Encode neu starten"
|
|
icon="pi pi-play"
|
|
severity="success"
|
|
onClick={() => onRestartEncode?.(retryJobId)}
|
|
loading={busy}
|
|
disabled={!retryJobId}
|
|
/>
|
|
) : null}
|
|
|
|
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && (
|
|
<Button
|
|
label="Retry Rippen"
|
|
icon="pi pi-refresh"
|
|
severity="warning"
|
|
onClick={() => onRetry(retryJobId)}
|
|
loading={busy}
|
|
/>
|
|
)}
|
|
|
|
{(state === 'ERROR' || state === 'CANCELLED') ? (
|
|
<Button
|
|
label="Disk-Analyse neu starten"
|
|
icon="pi pi-search"
|
|
severity="secondary"
|
|
onClick={onReanalyze || onAnalyze}
|
|
loading={busy}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{running ? (
|
|
<div className="live-log-block">
|
|
<h4>Aktueller Job-Log</h4>
|
|
<pre className="log-box">{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}</pre>
|
|
</div>
|
|
) : null}
|
|
|
|
{playlistDecisionRequiredBeforeStart && !queueLocked ? (
|
|
<div className="playlist-decision-block">
|
|
<h3>Playlist-Auswahl erforderlich</h3>
|
|
<small>
|
|
Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden.
|
|
</small>
|
|
{waitingPlaylistRows.length > 0 ? (
|
|
<div className="playlist-decision-list">
|
|
{waitingPlaylistRows.map((row) => (
|
|
<div key={row.playlistId} className="playlist-decision-item">
|
|
<label className="readonly-check-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
|
|
disabled={queueLocked}
|
|
onChange={() => {
|
|
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
|
|
setSelectedPlaylistId(next);
|
|
}}
|
|
/>
|
|
<span>
|
|
{row.playlistFile}
|
|
{row.titleId !== null ? ` | Titel #${row.titleId}` : ''}
|
|
{row.durationLabel ? ` | Dauer ${row.durationLabel}` : ''}
|
|
{row.score !== null ? ` | Score ${row.score}` : ''}
|
|
{row.recommended ? ' | empfohlen' : ''}
|
|
</span>
|
|
</label>
|
|
{row.evaluationLabel ? <small className="track-action-note">{row.evaluationLabel}</small> : null}
|
|
{row.sequenceCoherence !== null ? (
|
|
<small className="track-action-note">Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)}</small>
|
|
) : null}
|
|
{row.handBrakeTitleId !== null ? (
|
|
<small className="track-action-note">HandBrake Titel: -t {row.handBrakeTitleId}</small>
|
|
) : null}
|
|
{row.audioSummary ? (
|
|
<small className="track-action-note">Audio: {row.audioSummary}</small>
|
|
) : null}
|
|
{row.segmentCommand ? <small className="track-action-note">Info: {row.segmentCommand}</small> : null}
|
|
{Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
|
|
<details className="playlist-segment-toggle">
|
|
<summary>Audio-Spuren anzeigen ({row.audioTrackPreview.length})</summary>
|
|
<pre className="playlist-segment-output">{row.audioTrackPreview.join('\n')}</pre>
|
|
</details>
|
|
) : null}
|
|
{Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
|
|
<details className="playlist-segment-toggle">
|
|
<summary>Segment-Dateien anzeigen ({row.segmentFiles.length})</summary>
|
|
<pre className="playlist-segment-output">{row.segmentFiles.join('\n')}</pre>
|
|
</details>
|
|
) : (
|
|
<small className="track-action-note">Keine Segmentliste aus TINFO:26 verfügbar.</small>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<small>Keine Kandidaten gefunden. Bitte Analyse erneut ausführen.</small>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedMetadata ? (
|
|
<div className="pipeline-meta-inline">
|
|
{selectedMetadata.poster ? (
|
|
<img
|
|
src={selectedMetadata.poster}
|
|
alt={selectedMetadata.title || 'Poster'}
|
|
className="poster-large"
|
|
/>
|
|
) : (
|
|
<div className="poster-large poster-fallback">Kein Poster</div>
|
|
)}
|
|
<div className="device-meta">
|
|
<div>
|
|
<strong>Titel:</strong> {selectedMetadata.title || '-'}
|
|
</div>
|
|
<div>
|
|
<strong>Jahr:</strong> {selectedMetadata.year || '-'}
|
|
</div>
|
|
<div>
|
|
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
|
|
</div>
|
|
<div>
|
|
<strong>Status:</strong> {stateLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
|
|
<div className="mediainfo-review-block">
|
|
<h3>Titel-/Spurprüfung</h3>
|
|
{state === 'READY_TO_ENCODE' && !queueLocked ? (
|
|
<small>
|
|
{isPreRipReview
|
|
? 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Backup + Encoding starten" wird automatisch bestätigt und gestartet.'
|
|
: 'Spurauswahl kann direkt übernommen werden. Beim Klick auf "Encoding starten" wird automatisch bestätigt und gestartet.'}
|
|
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
|
</small>
|
|
) : null}
|
|
<MediaInfoReviewPanel
|
|
review={mediaInfoReview}
|
|
presetDisplayValue={presetDisplayValue}
|
|
commandOutputPath={commandOutputPath}
|
|
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
|
allowTitleSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
|
onSelectEncodeTitle={(titleId) => setSelectedEncodeTitleId(normalizeTitleId(titleId))}
|
|
allowTrackSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
|
trackSelectionByTitle={trackSelectionByTitle}
|
|
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
|
|
const normalizedTitleId = normalizeTitleId(titleId);
|
|
const normalizedTrackId = normalizeTrackId(trackId);
|
|
if (!normalizedTitleId || normalizedTrackId === null) {
|
|
return;
|
|
}
|
|
|
|
setTrackSelectionByTitle((prev) => {
|
|
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
|
|
audioTrackIds: [],
|
|
subtitleTrackIds: []
|
|
};
|
|
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
|
|
const existing = normalizeTrackIdList(current?.[key] || []);
|
|
const next = checked
|
|
? normalizeTrackIdList([...existing, normalizedTrackId])
|
|
: existing.filter((id) => id !== normalizedTrackId);
|
|
|
|
return {
|
|
...prev,
|
|
[normalizedTitleId]: {
|
|
...current,
|
|
[key]: next
|
|
}
|
|
};
|
|
});
|
|
}}
|
|
availableScripts={scriptCatalog}
|
|
availableChains={chainCatalog}
|
|
preEncodeItems={preEncodeItems}
|
|
postEncodeItems={postEncodeItems}
|
|
userPresets={filteredUserPresets}
|
|
selectedUserPresetId={selectedUserPresetId}
|
|
onUserPresetChange={(presetId) => setSelectedUserPresetId(presetId)}
|
|
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
|
onAddPreEncodeItem={(itemType) => {
|
|
setPreEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
if (itemType === 'chain') {
|
|
const selectedSet = new Set(
|
|
current
|
|
.filter((item) => item?.type === 'chain')
|
|
.map((item) => normalizeChainId(item?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
|
|
.map((item) => normalizeChainId(item?.id))
|
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
|
if (nextCandidate === undefined || nextCandidate === null) {
|
|
return current;
|
|
}
|
|
return [...current, { type: 'chain', id: nextCandidate }];
|
|
}
|
|
const selectedSet = new Set(
|
|
current
|
|
.filter((item) => item?.type === 'script')
|
|
.map((item) => normalizeScriptId(item?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
|
.map((item) => normalizeScriptId(item?.id))
|
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
|
if (nextCandidate === undefined || nextCandidate === null) {
|
|
return current;
|
|
}
|
|
return [...current, { type: 'script', id: nextCandidate }];
|
|
});
|
|
}}
|
|
onChangePreEncodeItem={(rowIndex, itemType, nextId) => {
|
|
setPreEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
const index = Number(rowIndex);
|
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
return current;
|
|
}
|
|
const type = itemType === 'chain' ? 'chain' : 'script';
|
|
if (type === 'chain') {
|
|
const normalizedId = normalizeChainId(nextId);
|
|
if (normalizedId === null) {
|
|
return current;
|
|
}
|
|
const duplicate = current.some((item, idx) =>
|
|
idx !== index
|
|
&& item?.type === 'chain'
|
|
&& String(normalizeChainId(item?.id)) === String(normalizedId)
|
|
);
|
|
if (duplicate) {
|
|
return current;
|
|
}
|
|
const next = [...current];
|
|
next[index] = { type: 'chain', id: normalizedId };
|
|
return next;
|
|
}
|
|
const normalizedId = normalizeScriptId(nextId);
|
|
if (normalizedId === null) {
|
|
return current;
|
|
}
|
|
const duplicate = current.some((item, idx) =>
|
|
idx !== index
|
|
&& item?.type === 'script'
|
|
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
|
|
);
|
|
if (duplicate) {
|
|
return current;
|
|
}
|
|
const next = [...current];
|
|
next[index] = { type: 'script', id: normalizedId };
|
|
return next;
|
|
});
|
|
}}
|
|
onRemovePreEncodeItem={(rowIndex) => {
|
|
setPreEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
const index = Number(rowIndex);
|
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
return current;
|
|
}
|
|
return current.filter((_, idx) => idx !== index);
|
|
});
|
|
}}
|
|
onReorderPreEncodeItem={(fromIndex, toIndex) => {
|
|
setPreEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
const from = Number(fromIndex);
|
|
const to = Number(toIndex);
|
|
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
|
return current;
|
|
}
|
|
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
|
|
return current;
|
|
}
|
|
const next = [...current];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(to, 0, moved);
|
|
return next;
|
|
});
|
|
}}
|
|
onAddPostEncodeItem={(itemType) => {
|
|
setPostEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
if (itemType === 'chain') {
|
|
const selectedSet = new Set(
|
|
current
|
|
.filter((item) => item?.type === 'chain')
|
|
.map((item) => normalizeChainId(item?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const nextCandidate = (Array.isArray(chainCatalog) ? chainCatalog : [])
|
|
.map((item) => normalizeChainId(item?.id))
|
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
|
if (nextCandidate === undefined || nextCandidate === null) {
|
|
return current;
|
|
}
|
|
return [...current, { type: 'chain', id: nextCandidate }];
|
|
}
|
|
const selectedSet = new Set(
|
|
current
|
|
.filter((item) => item?.type === 'script')
|
|
.map((item) => normalizeScriptId(item?.id))
|
|
.filter((id) => id !== null)
|
|
.map((id) => String(id))
|
|
);
|
|
const nextCandidate = (Array.isArray(scriptCatalog) ? scriptCatalog : [])
|
|
.map((item) => normalizeScriptId(item?.id))
|
|
.find((id) => id !== null && !selectedSet.has(String(id)));
|
|
if (nextCandidate === undefined || nextCandidate === null) {
|
|
return current;
|
|
}
|
|
return [...current, { type: 'script', id: nextCandidate }];
|
|
});
|
|
}}
|
|
onChangePostEncodeItem={(rowIndex, itemType, nextId) => {
|
|
setPostEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
const index = Number(rowIndex);
|
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
return current;
|
|
}
|
|
const type = itemType === 'chain' ? 'chain' : 'script';
|
|
if (type === 'chain') {
|
|
const normalizedId = normalizeChainId(nextId);
|
|
if (normalizedId === null) {
|
|
return current;
|
|
}
|
|
const duplicate = current.some((item, idx) =>
|
|
idx !== index
|
|
&& item?.type === 'chain'
|
|
&& String(normalizeChainId(item?.id)) === String(normalizedId)
|
|
);
|
|
if (duplicate) {
|
|
return current;
|
|
}
|
|
const next = [...current];
|
|
next[index] = { type: 'chain', id: normalizedId };
|
|
return next;
|
|
}
|
|
const normalizedId = normalizeScriptId(nextId);
|
|
if (normalizedId === null) {
|
|
return current;
|
|
}
|
|
const duplicate = current.some((item, idx) =>
|
|
idx !== index
|
|
&& item?.type === 'script'
|
|
&& String(normalizeScriptId(item?.id)) === String(normalizedId)
|
|
);
|
|
if (duplicate) {
|
|
return current;
|
|
}
|
|
const next = [...current];
|
|
next[index] = { type: 'script', id: normalizedId };
|
|
return next;
|
|
});
|
|
}}
|
|
onRemovePostEncodeItem={(rowIndex) => {
|
|
setPostEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
const index = Number(rowIndex);
|
|
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
return current;
|
|
}
|
|
return current.filter((_, idx) => idx !== index);
|
|
});
|
|
}}
|
|
onReorderPostEncodeItem={(fromIndex, toIndex) => {
|
|
setPostEncodeItems((prev) => {
|
|
const current = Array.isArray(prev) ? prev : [];
|
|
const from = Number(fromIndex);
|
|
const to = Number(toIndex);
|
|
if (!Number.isInteger(from) || !Number.isInteger(to)) {
|
|
return current;
|
|
}
|
|
if (from < 0 || to < 0 || from >= current.length || to >= current.length || from === to) {
|
|
return current;
|
|
}
|
|
const next = [...current];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(to, 0, moved);
|
|
return next;
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|