Files
ripster/frontend/src/pages/ConverterPage.jsx
T
2026-04-29 08:35:24 +00:00

1447 lines
50 KiB
React

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button } from 'primereact/button';
import { Card } from 'primereact/card';
import { Toast } from 'primereact/toast';
import { Badge } from 'primereact/badge';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Divider } from 'primereact/divider';
import { Dialog } from 'primereact/dialog';
import { Dropdown } from 'primereact/dropdown';
import { RadioButton } from 'primereact/radiobutton';
import { api } from '../api/client';
import { useWebSocket } from '../hooks/useWebSocket';
import ConverterFileExplorer from '../components/ConverterFileExplorer';
import ConverterUploadPanel from '../components/ConverterUploadPanel';
import ConverterJobCard from '../components/ConverterJobCard';
import JobDetailDialog from '../components/JobDetailDialog';
import PipelineStatusCard from '../components/PipelineStatusCard';
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
import otherIndicatorIcon from '../assets/media-other.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']);
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.m2ts', '.iso', '.avi', '.mov']);
const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']);
const DEFAULT_CONVERTER_SCAN_EXTENSIONS = [
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
];
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
function parseConverterScanExtensions(value) {
const seen = new Set();
const parsed = String(value || '')
.split(',')
.map((item) => String(item || '').trim().toLowerCase())
.filter((item) => {
if (!item || !DEFAULT_CONVERTER_SCAN_EXTENSIONS.includes(item) || seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
if (parsed.length === 0) {
return [...DEFAULT_CONVERTER_SCAN_EXTENSIONS];
}
return parsed;
}
function isAudioEntry(e) {
if (e.detectedMediaType === 'audio') return true;
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return AUDIO_EXTS.has(p.slice(dot));
}
function isVideoEntry(e) {
if (e.detectedMediaType === 'video' || e.detectedMediaType === 'iso') return true;
const p = (e.relPath || '').toLowerCase();
const dot = p.lastIndexOf('.');
if (dot === -1) return false;
return VIDEO_EXTS.has(p.slice(dot));
}
function normalizeJobId(value) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
}
function parseConverterPlan(job) {
try {
return JSON.parse(job?.encode_plan_json || '{}');
} catch (_err) {
return {};
}
}
function buildConverterPipeline(job, plan, progress = null) {
const metadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {};
const review = plan && typeof plan === 'object' ? plan : {};
const selectedMetadata = {
title: String(job?.title || metadata?.title || job?.detected_title || '').trim() || null,
year: Number.isFinite(Number(job?.year)) ? Math.trunc(Number(job.year)) : (Number.isFinite(Number(metadata?.year)) ? Math.trunc(Number(metadata.year)) : null),
imdbId: String(job?.imdb_id || metadata?.imdbId || '').trim() || null,
poster: String(job?.poster_url || metadata?.poster || '').trim() || null
};
return {
state: String(progress?.state || job?.status || '').trim().toUpperCase() || 'UNKNOWN',
activeJobId: Number(job?.id) || null,
progress: Number.isFinite(Number(progress?.progress)) ? Number(progress.progress) : 0,
eta: progress?.eta || null,
statusText: progress?.statusText || null,
context: {
jobId: Number(job?.id) || null,
mode: 'converter',
mediaProfile: 'converter',
selectedMetadata,
mediaInfoReview: review,
playlistAnalysis: review?.playlistAnalysis || null,
playlistCandidates: Array.isArray(review?.playlistCandidates) ? review.playlistCandidates : [],
handBrakeTitleDecisionRequired: Boolean(review?.handBrakeTitleDecisionRequired),
handBrakeTitleCandidates: Array.isArray(review?.handBrakeTitleCandidates) ? review.handBrakeTitleCandidates : []
}
};
}
export default function ConverterPage() {
const toastRef = useRef(null);
const [jobs, setJobs] = useState([]);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const [jobProgress, setJobProgress] = useState({});
const [loadingJobs, setLoadingJobs] = useState(false);
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
const [selectedEntries, setSelectedEntries] = useState([]);
const [selectionResetToken, setSelectionResetToken] = useState(0);
const [jobModeVisible, setJobModeVisible] = useState(false);
const [audioMode, setAudioMode] = useState('individual');
const [jobModalAction, setJobModalAction] = useState('create');
const [assignTargetJobId, setAssignTargetJobId] = useState(null);
const [creatingJobs, setCreatingJobs] = useState(false);
const [jobEntries, setJobEntries] = useState([]);
const [detailVisible, setDetailVisible] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [selectedJob, setSelectedJob] = useState(null);
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
const [actionBusyJobIds, setActionBusyJobIds] = useState(new Set());
const [logLoadingMode, setLogLoadingMode] = useState(null);
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
const [cancelDetailBusy, setCancelDetailBusy] = useState(false);
const [expandedJobId, setExpandedJobId] = useState(undefined);
const [videoUserPresets, setVideoUserPresets] = useState([]);
const [videoHbPresets, setVideoHbPresets] = useState([]);
const [uploadExtensions, setUploadExtensions] = useState(() => [...DEFAULT_CONVERTER_SCAN_EXTENSIONS]);
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
// die Selection nicht löscht bevor die Jobs erstellt werden
const jobEntriesRef = useRef([]);
const previousJobStatusesRef = useRef(new Map());
const loadUploadExtensions = useCallback(async () => {
try {
const response = await api.getSettings({ forceRefresh: true });
const allSettings = (response?.categories || []).flatMap((category) => category?.settings || []);
const setting = allSettings.find((item) => String(item?.key || '').trim() === 'converter_scan_extensions');
const value = setting?.value ?? setting?.default_value ?? '';
setUploadExtensions(parseConverterScanExtensions(value));
} catch (_error) {
setUploadExtensions([...DEFAULT_CONVERTER_SCAN_EXTENSIONS]);
}
}, []);
useEffect(() => {
let cancelled = false;
const loadPresets = async () => {
try {
const [userResponse, hbResponse] = await Promise.allSettled([
api.getUserPresets('video'),
api.getHandBrakePresets()
]);
if (cancelled) return;
const userPresets = userResponse.status === 'fulfilled'
? (Array.isArray(userResponse.value?.presets) ? userResponse.value.presets : [])
: [];
const hbPresets = hbResponse.status === 'fulfilled'
? (Array.isArray(hbResponse.value?.presets) ? hbResponse.value.presets : [])
: [];
setVideoUserPresets(userPresets);
setVideoHbPresets(hbPresets);
} catch (_error) {
if (!cancelled) {
setVideoUserPresets([]);
setVideoHbPresets([]);
}
}
};
loadPresets();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void loadUploadExtensions();
}, [loadUploadExtensions]);
const loadJobs = useCallback(async () => {
setLoadingJobs(true);
try {
const [jobsResponse, queueResponse] = await Promise.allSettled([
api.getConverterJobs(),
api.getPipelineQueue()
]);
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
? jobsResponse.value.jobs
: [];
if (queueResponse.status === 'fulfilled') {
const queuedRows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
? queueResponse.value.queue.queuedJobs
: [];
setQueuedJobIds(
queuedRows
.map((item) => normalizeJobId(item?.jobId))
.filter(Boolean)
);
} else {
setQueuedJobIds([]);
}
setJobs(nextJobs);
const runningStates = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK']);
setJobProgress((prev) => {
const next = { ...prev };
for (const job of nextJobs) {
const jobId = normalizeJobId(job?.id);
if (!jobId) continue;
const status = String(job?.status || '').trim().toUpperCase();
if (!runningStates.has(status) && next[jobId]) {
delete next[jobId];
}
}
return next;
});
const previousStatuses = previousJobStatusesRef.current;
const nextStatuses = new Map();
let hasStatusChanges = false;
let hasCompletedJobTransitions = false;
for (const job of nextJobs) {
const jobId = normalizeJobId(job?.id);
if (!jobId) continue;
const nextStatus = String(job?.status || '').trim().toUpperCase();
const prevStatus = previousStatuses.get(jobId) || null;
nextStatuses.set(jobId, nextStatus);
if (prevStatus !== nextStatus) {
hasStatusChanges = true;
if (prevStatus && !TERMINAL_JOB_STATUSES.has(prevStatus) && TERMINAL_JOB_STATUSES.has(nextStatus)) {
hasCompletedJobTransitions = true;
}
}
}
if (previousStatuses.size !== nextStatuses.size) {
hasStatusChanges = true;
}
const isFirstSnapshot = previousStatuses.size === 0;
previousJobStatusesRef.current = nextStatuses;
if (!isFirstSnapshot && hasStatusChanges) {
setExplorerRefreshToken((t) => t + 1);
}
if (!isFirstSnapshot && hasCompletedJobTransitions) {
setSelectedEntries([]);
setSelectionResetToken((t) => t + 1);
}
} catch (err) {
console.error('Load converter jobs error:', err);
} finally {
setLoadingJobs(false);
}
}, []);
useEffect(() => {
loadJobs();
const interval = setInterval(loadJobs, 5000);
return () => clearInterval(interval);
}, [loadJobs]);
useWebSocket({
onMessage: (message) => {
if (!message?.type) return;
if (message.type === 'PIPELINE_PROGRESS') {
const payload = message.payload;
const jobId = normalizeJobId(payload?.activeJobId);
if (jobId) {
setJobProgress((prev) => ({
...prev,
[jobId]: {
progress: payload?.progress ?? null,
eta: payload?.eta ?? null,
statusText: payload?.statusText ?? null,
state: payload?.state ?? null
}
}));
}
}
if (
message.type === 'PIPELINE_UPDATE' ||
message.type === 'PIPELINE_STATE_CHANGED' ||
message.type === 'PIPELINE_QUEUE_CHANGED' ||
message.type === 'CONVERTER_SCAN_UPDATE'
) {
loadJobs();
if (message.type === 'CONVERTER_SCAN_UPDATE' || message.type === 'PIPELINE_STATE_CHANGED') {
setExplorerRefreshToken((t) => t + 1);
}
}
if (message.type === 'SETTINGS_UPDATED') {
if (String(message?.payload?.key || '').trim() === 'converter_scan_extensions') {
void loadUploadExtensions();
}
}
if (message.type === 'SETTINGS_BULK_UPDATED') {
const keys = Array.isArray(message?.payload?.keys) ? message.payload.keys : [];
if (keys.includes('converter_scan_extensions')) {
void loadUploadExtensions();
}
}
}
});
const queuedJobIdSet = useMemo(() => {
const next = new Set();
for (const value of queuedJobIds) {
const jobId = normalizeJobId(value);
if (jobId) {
next.add(jobId);
}
}
return next;
}, [queuedJobIds]);
const assignableJobs = useMemo(() => (
jobs
.map((job) => ({ job, plan: parseConverterPlan(job) }))
.filter(({ job, plan }) => {
const status = String(job?.status || '').trim().toUpperCase();
const mediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
return status === 'READY_TO_START' && mediaType === 'audio' && !Boolean(plan?.isFolder);
})
.map(({ job, plan }) => {
const rawTitle = String(job?.title || '').trim();
const title = rawTitle ? `#${job.id} | ${rawTitle}` : `#${job.id}`;
const fileCount = Array.isArray(plan?.inputPaths) && plan.inputPaths.length > 0
? plan.inputPaths.length
: (plan?.inputPath ? 1 : 0);
return {
label: `${title}${fileCount > 0 ? ` (${fileCount} Datei${fileCount !== 1 ? 'en' : ''})` : ''}`,
value: job.id
};
})
), [jobs]);
const handleSelectionChange = (entries) => setSelectedEntries(entries || []);
const handleOpenJobModal = () => {
// Explorer expandiert Ordner bereits zu Einzel-Dateien
const entries = [...selectedEntries];
jobEntriesRef.current = entries;
setJobEntries(entries);
const hasAudio = entries.some(isAudioEntry);
const hasAssignableJobs = assignableJobs.length > 0;
if (!hasAudio && !hasAssignableJobs) {
// Nur Videos oder sonstige Dateien → direkt individual erstellen
handleCreateJobsFromSelection('individual');
return;
}
setAudioMode('individual');
setJobModalAction('create');
setAssignTargetJobId(assignableJobs[0]?.value || null);
setJobModeVisible(true);
};
const handleCreateJobsFromSelection = async (explicitAudioMode) => {
const entries = jobEntriesRef.current;
if (entries.length === 0) return;
const resolvedAudioMode = typeof explicitAudioMode === 'string' ? explicitAudioMode : audioMode;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterCreateJobsFromSelection(relPaths, resolvedAudioMode);
const newJobs = result?.jobs || [];
toastRef.current?.show({
severity: 'success',
summary: 'Jobs erstellt',
detail: `${newJobs.length} Converter-Job${newJobs.length !== 1 ? 's' : ''} erstellt.`,
life: 3500
});
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setSelectionResetToken((t) => t + 1);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Jobs konnten nicht erstellt werden.',
life: 4500
});
} finally {
setCreatingJobs(false);
}
};
const handleAssignSelectionToJob = async (targetJobId) => {
const entries = jobEntriesRef.current;
if (entries.length === 0) return;
const normalizedJobId = Number(targetJobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) return;
setCreatingJobs(true);
setJobModeVisible(false);
try {
const relPaths = entries.map((e) => e.relPath).filter(Boolean);
const result = await api.converterAssignFilesToJob(normalizedJobId, relPaths);
const addedCount = Array.isArray(result?.addedRelPaths) ? result.addedRelPaths.length : 0;
toastRef.current?.show({
severity: 'success',
summary: 'Dateien zugewiesen',
detail: addedCount > 0
? `${addedCount} Datei${addedCount !== 1 ? 'en' : ''} zu Job #${normalizedJobId} hinzugefügt.`
: `Keine neuen Dateien zu Job #${normalizedJobId} hinzugefügt.`,
life: 3600
});
jobEntriesRef.current = [];
setJobEntries([]);
setSelectedEntries([]);
setSelectionResetToken((t) => t + 1);
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
} catch (err) {
toastRef.current?.show({
severity: 'error',
summary: 'Fehler',
detail: err.message || 'Dateien konnten dem Job nicht zugewiesen werden.',
life: 4600
});
} finally {
setCreatingJobs(false);
}
};
const handleJobModeConfirm = async ({ action, audioMode: selectedAudioMode, jobId }) => {
if (action === 'assign') {
await handleAssignSelectionToJob(jobId || assignTargetJobId);
return;
}
await handleCreateJobsFromSelection(selectedAudioMode || audioMode);
};
const handleUploaded = (folders) => {
setExplorerRefreshToken((t) => t + 1);
if (folders?.length > 0) {
setExplorerNavigateTo({ path: folders[0].folderRelPath, ts: Date.now() });
toastRef.current?.show({
severity: 'success',
summary: 'Upload abgeschlossen',
detail: `${folders.length} Ordner hochgeladen. Dateien auswählen und Jobs anlegen.`,
life: 4000
});
}
};
const runJobAction = useCallback(async (jobId, action) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId || typeof action !== 'function') return;
setActionBusyJobIds((prev) => new Set([...prev, normalizedJobId]));
try {
await action(normalizedJobId);
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Aktion fehlgeschlagen',
detail: error?.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setActionBusyJobIds((prev) => {
const next = new Set(prev);
next.delete(normalizedJobId);
return next;
});
}
}, [loadJobs]);
const handleOpenMetadataDialog = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) return;
const row = jobs.find((entry) => normalizeJobId(entry?.id) === normalizedJobId) || null;
if (!row) return;
const plan = parseConverterPlan(row);
const metadata = plan?.metadata && typeof plan.metadata === 'object' ? plan.metadata : {};
setMetadataDialogContext({
jobId: normalizedJobId,
detectedTitle: row?.detected_title || row?.title || metadata?.title || '',
selectedMetadata: {
title: row?.title || metadata?.title || row?.detected_title || '',
year: row?.year || metadata?.year || null,
imdbId: row?.imdb_id || metadata?.imdbId || null,
poster: row?.poster_url || metadata?.poster || null
},
omdbCandidates: []
});
setMetadataDialogVisible(true);
};
const handleMetadataSubmit = async (payload) => {
setMetadataDialogBusy(true);
try {
await api.selectMetadata(payload || {});
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
await loadJobs();
toastRef.current?.show({
severity: 'success',
summary: 'Metadaten übernommen',
detail: `Job #${payload?.jobId} wurde aktualisiert.`,
life: 3200
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Metadaten fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setMetadataDialogBusy(false);
}
};
const handleOmdbSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
return Array.isArray(response?.results) ? response.results : [];
} catch (_error) {
return [];
}
};
const handleStartPipelineJob = async (jobId, options = null) => {
await runJobAction(jobId, async (resolvedJobId) => {
const startOptions = options && typeof options === 'object' ? options : {};
if (startOptions.ensureConfirmed) {
const confirmPayload = {
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
selectedTrackSelection: startOptions.selectedTrackSelection ?? 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(resolvedJobId, confirmPayload);
}
await api.startJob(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job gestartet',
detail: `Job #${resolvedJobId} wurde gestartet.`,
life: 2800
});
});
};
const handleConfirmReview = async (jobId, payload = {}) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.confirmEncodeReview(resolvedJobId, payload || {});
toastRef.current?.show({
severity: 'success',
summary: 'Review bestätigt',
detail: `Encode für Job #${resolvedJobId} ist freigegeben.`,
life: 3000
});
});
};
const handleSelectPlaylist = async (jobId, selectedPlaylist) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.selectMetadata({ jobId: resolvedJobId, selectedPlaylist });
});
};
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleId) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.selectMetadata({ jobId: resolvedJobId, selectedHandBrakeTitleId });
});
};
const handleCancelPipelineJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) return;
const confirmed = await confirmModal({
header: 'Job abbrechen',
message: `Job #${normalizedJobId} abbrechen?`,
acceptLabel: 'Abbrechen',
rejectLabel: 'Zurück',
danger: true
});
if (!confirmed) return;
await runJobAction(normalizedJobId, async (resolvedJobId) => {
await api.cancelPipeline(resolvedJobId);
handleJobCancelledImmediate(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Abbruch gesendet',
detail: `Job #${resolvedJobId} wird abgebrochen.`,
life: 2800
});
});
};
const handleRetryJob = async (jobId) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.retryJob(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Retry gestartet',
detail: `Job #${resolvedJobId} wurde neu eingeplant.`,
life: 3000
});
});
};
const handleDeleteConverterJob = async (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) return;
const confirmed = await confirmModal({
header: 'Job löschen',
message: `Job #${normalizedJobId} wirklich löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) return;
await runJobAction(normalizedJobId, async (resolvedJobId) => {
await api.deleteConverterJob(resolvedJobId);
handleJobDeleted(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Job gelöscht',
detail: `Job #${resolvedJobId} wurde entfernt.`,
life: 3000
});
});
};
const handleRemoveFromQueue = async (jobId) => {
await runJobAction(jobId, async (resolvedJobId) => {
await api.cancelPipeline(resolvedJobId);
handleJobCancelledImmediate(resolvedJobId);
toastRef.current?.show({
severity: 'success',
summary: 'Aus Queue entfernt',
detail: `Job #${resolvedJobId} wurde aus der Queue entfernt.`,
life: 3000
});
});
};
const handleJobStarted = async (jobId) => {
await loadJobs();
toastRef.current?.show({
severity: 'success',
summary: 'Gestartet',
detail: `Converter-Job ${jobId} läuft.`,
life: 3000
});
};
const handleOpenJobDetails = async (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) {
return;
}
setSelectedJob({
...row,
logs: [],
log: '',
logMeta: {
loaded: false,
total: Number(row?.log_count || 0),
returned: 0,
truncated: false
}
});
setDetailVisible(true);
setDetailLoading(true);
try {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Details konnten nicht geladen werden',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setDetailLoading(false);
}
};
const handleLoadDetailLog = async (job, mode = 'tail') => {
const jobId = Number(job?.id || selectedJob?.id || 0);
if (!jobId) {
return;
}
setLogLoadingMode(mode);
try {
const response = await api.getJob(jobId, {
includeLogs: true,
includeAllLogs: mode === 'all',
logTailLines: mode === 'all' ? null : 800
});
setSelectedJob(response.job);
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Log konnte nicht geladen werden',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setLogLoadingMode(null);
}
};
const handleDeleteDetailEntry = async (job) => {
const jobId = Number(job?.id || 0);
if (!jobId) {
return;
}
const confirmed = await confirmModal({
header: 'Historieneintrag löschen',
message: `Historieneintrag Job #${jobId} löschen?`,
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
});
if (!confirmed) {
return;
}
setDeleteEntryBusy(true);
try {
await api.deleteConverterJob(jobId);
handleJobDeleted(jobId);
setDetailVisible(false);
setSelectedJob(null);
toastRef.current?.show({
severity: 'success',
summary: 'Eintrag gelöscht',
detail: `Job #${jobId} wurde entfernt.`,
life: 3200
});
await loadJobs();
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Löschen fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setDeleteEntryBusy(false);
}
};
const handleCancelDetailJob = async (job) => {
const jobId = Number(job?.id || selectedJob?.id || 0);
if (!jobId) {
return;
}
const confirmed = await confirmModal({
header: 'Job abbrechen',
message: `Job #${jobId} abbrechen?`,
acceptLabel: 'Abbrechen',
rejectLabel: 'Zurück',
danger: true
});
if (!confirmed) {
return;
}
setCancelDetailBusy(true);
try {
await api.cancelConverterJob(jobId);
handleJobCancelledImmediate(jobId);
toastRef.current?.show({
severity: 'success',
summary: 'Abbruch gesendet',
detail: `Job #${jobId} wird abgebrochen.`,
life: 2800
});
await loadJobs();
if (detailVisible) {
const response = await api.getJob(jobId, { includeLogs: false, forceRefresh: true });
setSelectedJob(response.job);
}
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Abbruch fehlgeschlagen',
detail: error.message || 'Unbekannter Fehler',
life: 4200
});
} finally {
setCancelDetailBusy(false);
}
};
const handleJobDeleted = (jobId) => {
setJobs((prev) => prev.filter((j) => j.id !== jobId));
setJobProgress((prev) => {
const next = { ...prev };
delete next[jobId];
return next;
});
setExplorerRefreshToken((t) => t + 1);
if (Number(selectedJob?.id || 0) === Number(jobId || 0)) {
setDetailVisible(false);
setSelectedJob(null);
setDetailLoading(false);
setLogLoadingMode(null);
}
};
const handleJobCancelledImmediate = (jobId) => {
const normalizedJobId = normalizeJobId(jobId);
if (!normalizedJobId) {
return;
}
setJobs((prev) => prev.map((job) => (
Number(job?.id) === normalizedJobId
? { ...job, status: 'CANCELLED' }
: job
)));
setJobProgress((prev) => {
const next = { ...prev };
delete next[normalizedJobId];
return next;
});
if (Number(selectedJob?.id || 0) === normalizedJobId) {
setSelectedJob((prev) => (prev ? { ...prev, status: 'CANCELLED' } : prev));
}
};
const handleJobCancelled = () => setTimeout(loadJobs, 1000);
const handleJobInputsChanged = async (_jobId, _payload) => {
setExplorerRefreshToken((t) => t + 1);
await loadJobs();
};
const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR'].includes(String(j.status || '').toUpperCase()));
// Auto-expand: ersten Job aufklappen, wenn keiner expanded ist (außer user hat explizit zugeklappt)
useEffect(() => {
const normalizedExpanded = Number(expandedJobId) || null;
const hasExpanded = activeJobs.some((j) => Number(j.id) === normalizedExpanded);
if (hasExpanded) return;
if (expandedJobId === null) return; // explizit vom User zugeklappt
if (activeJobs.length === 0) return;
setExpandedJobId(Number(activeJobs[0].id));
}, [activeJobs, expandedJobId]);
const jobsCardHeader = (
<div className="converter-card-header">
<div className="converter-card-title">
<span>Converter Jobs</span>
{activeJobs.length > 0 && (
<Badge value={activeJobs.length} severity="info" />
)}
</div>
<Button
icon={loadingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'}
text
rounded
size="small"
onClick={loadJobs}
disabled={loadingJobs}
aria-label="Jobs neu laden"
/>
</div>
);
return (
<div className="ripper-subpage-content">
<Toast ref={toastRef} position="top-right" />
{/* Import-Ordner */}
<Card title="Import-Ordner" subTitle="Dateien aus dem Raw-Ordner auswählen und als Job anlegen">
<ConverterFileExplorer
onSelectionChange={handleSelectionChange}
refreshToken={explorerRefreshToken}
selectionResetToken={selectionResetToken}
navigateToPath={explorerNavigateTo}
onAssignmentChanged={loadJobs}
/>
{selectedEntries.length > 0 && (
<div className="converter-selection-bar">
<span>
<Badge value={selectedEntries.length} severity="info" /> ausgewählt
</span>
<Button
label="Job(s) anlegen"
icon={creatingJobs ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={creatingJobs}
onClick={handleOpenJobModal}
/>
</div>
)}
</Card>
{/* Jobs */}
<Card header={jobsCardHeader}>
{activeJobs.length === 0 ? (
<p className="converter-jobs-empty-hint">
<small>Keine aktiven Converter-Jobs vorhanden. Abgeschlossene Jobs findest du in der Historie.</small>
</p>
) : (
<div className="ripper-job-list converter-jobs-list">
{activeJobs.map((job) => {
const plan = parseConverterPlan(job);
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
const isAudioJob = converterMediaType === 'audio';
if (isAudioJob) {
return (
<ConverterJobCard
key={job.id}
job={job}
jobProgress={jobProgress[job.id] || null}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onStarted={handleJobStarted}
onDeleted={handleJobDeleted}
onCancelled={handleJobCancelled}
onOpenDetails={handleOpenJobDetails}
onInputsChanged={handleJobInputsChanged}
/>
);
}
return (
<ConverterVideoJobCard
key={job.id}
job={job}
plan={plan}
jobProgress={jobProgress[job.id] || null}
isQueued={queuedJobIdSet.has(Number(job.id))}
busy={actionBusyJobIds.has(Number(job.id))}
userPresets={videoUserPresets}
hbPresets={videoHbPresets}
isExpanded={Number(job.id) === Number(expandedJobId)}
onExpand={() => setExpandedJobId(Number(job.id))}
onCollapse={() => setExpandedJobId(null)}
onOpenMetadata={handleOpenMetadataDialog}
onReassignOmdb={handleOpenMetadataDialog}
onStart={handleStartPipelineJob}
onConfirmReview={handleConfirmReview}
onSelectPlaylist={handleSelectPlaylist}
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
onCancel={handleCancelPipelineJob}
onRetry={handleRetryJob}
onDeleteJob={handleDeleteConverterJob}
onRemoveFromQueue={handleRemoveFromQueue}
onInputsChanged={handleJobInputsChanged}
/>
);
})}
</div>
)}
</Card>
{/* Upload */}
<Card title="Datei-Upload" subTitle="Einzelne Dateien oder ganze Ordner (Album) hochladen">
<ConverterUploadPanel
onUploaded={handleUploaded}
allowedExtensions={uploadExtensions}
/>
</Card>
<JobModeDialog
visible={jobModeVisible}
entries={jobEntries}
audioMode={audioMode}
onAudioModeChange={setAudioMode}
action={jobModalAction}
onActionChange={setJobModalAction}
assignableJobs={assignableJobs}
assignJobId={assignTargetJobId}
onAssignJobIdChange={setAssignTargetJobId}
busy={creatingJobs}
onConfirm={handleJobModeConfirm}
onHide={() => setJobModeVisible(false)}
/>
<MetadataSelectionDialog
visible={metadataDialogVisible}
context={metadataDialogContext}
onHide={() => {
if (metadataDialogBusy) return;
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
}}
onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch}
busy={metadataDialogBusy}
/>
<JobDetailDialog
visible={detailVisible}
job={selectedJob}
detailLoading={detailLoading}
onLoadLog={handleLoadDetailLog}
logLoadingMode={logLoadingMode}
onCancel={handleCancelDetailJob}
cancelBusy={cancelDetailBusy}
onDeleteEntry={handleDeleteDetailEntry}
deleteEntryBusy={deleteEntryBusy}
onHide={() => {
setDetailVisible(false);
setSelectedJob(null);
setDetailLoading(false);
setLogLoadingMode(null);
setDeleteEntryBusy(false);
setCancelDetailBusy(false);
}}
/>
</div>
);
}
// ── Job-Modus-Dialog ──────────────────────────────────────────────────────────
function JobModeDialog({
visible,
entries,
audioMode,
onAudioModeChange,
action,
onActionChange,
assignableJobs,
assignJobId,
onAssignJobIdChange,
busy,
onConfirm,
onHide
}) {
const audioEntries = entries.filter(isAudioEntry);
const videoEntries = entries.filter(isVideoEntry);
const otherEntries = entries.filter((e) => !isAudioEntry(e) && !isVideoEntry(e));
const hasAudio = audioEntries.length > 0;
const hasVideo = videoEntries.length > 0;
const hasOther = otherEntries.length > 0;
const hasAssignableJobs = Array.isArray(assignableJobs) && assignableJobs.length > 0;
// Videos immer individual, audio per Modus
const audioJobCount = audioMode === 'shared' ? 1 : audioEntries.length;
const totalJobs = videoEntries.length + otherEntries.length + audioJobCount;
const isAssignMode = action === 'assign';
const confirmLabel = isAssignMode
? 'Dateien zuweisen'
: `${totalJobs} Job${totalJobs !== 1 ? 's' : ''} anlegen`;
const footer = (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button label="Abbrechen" outlined onClick={onHide} disabled={busy} />
<Button
label={confirmLabel}
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-plus'}
disabled={busy || (isAssignMode && !assignJobId)}
onClick={() => onConfirm({ action, audioMode, jobId: assignJobId })}
/>
</div>
);
return (
<Dialog
header="Jobs anlegen"
visible={visible}
onHide={onHide}
footer={footer}
style={{ width: '440px' }}
modal
>
{/* Videos / Sonstige immer individual */}
{(hasVideo || hasOther) && (
<p style={{ marginTop: 0, marginBottom: hasAudio ? 8 : 0, lineHeight: 1.6 }}>
<strong>Videos / Sonstige ({videoEntries.length + otherEntries.length}):</strong> Je eine Datei ein eigener Job.
</p>
)}
{hasAudio && (hasVideo || hasOther) && <Divider style={{ margin: '8px 0' }} />}
{/* Audio: Abfrage */}
{hasAudio && (
<div>
<p style={{ marginTop: 0, marginBottom: 12 }}>
Wie sollen die <strong>{audioEntries.length} Audiodatei{audioEntries.length !== 1 ? 'en' : ''}</strong> verarbeitet werden?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, opacity: isAssignMode ? 0.6 : 1 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="individual"
checked={audioMode === 'individual'}
disabled={isAssignMode}
onChange={() => onAudioModeChange('individual')}
/>
<span>Für jede Datei ein eigener Job <small style={{ color: 'var(--rip-muted)' }}>({audioEntries.length} Jobs)</small></span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<RadioButton
value="shared"
checked={audioMode === 'shared'}
disabled={isAssignMode}
onChange={() => onAudioModeChange('shared')}
/>
<span>Ein gemeinsamer Job für alle Dateien <small style={{ color: 'var(--rip-muted)' }}>(1 Job)</small></span>
</label>
</div>
</div>
)}
{hasAssignableJobs && (
<>
<Divider style={{ margin: '12px 0' }} />
<div>
<p style={{ marginTop: 0, marginBottom: 10 }}>
Optional: Dateien einem <strong>nicht gestarteten Job</strong> direkt zuweisen.
</p>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<RadioButton
value="create"
checked={!isAssignMode}
onChange={() => onActionChange('create')}
/>
<span>Neue Jobs anlegen</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginBottom: 8 }}>
<RadioButton
value="assign"
checked={isAssignMode}
onChange={() => onActionChange('assign')}
/>
<span>Zu bestehendem Job zuweisen</span>
</label>
<Dropdown
value={assignJobId}
options={assignableJobs}
onChange={(e) => onAssignJobIdChange(e.value)}
placeholder="Job auswählen …"
disabled={!isAssignMode}
style={{ width: '100%' }}
/>
</div>
</>
)}
</Dialog>
);
}
function ConverterVideoJobCard({
job,
plan,
jobProgress,
isQueued,
busy,
userPresets,
hbPresets,
isExpanded,
onExpand,
onCollapse,
onOpenMetadata,
onReassignOmdb,
onStart,
onConfirmReview,
onSelectPlaylist,
onSelectHandBrakeTitle,
onCancel,
onRetry,
onDeleteJob,
onRemoveFromQueue,
onInputsChanged
}) {
const jobId = Number(job?.id || 0);
const status = String(job?.status || '').trim().toUpperCase() || 'UNKNOWN';
const title = String(job?.title || job?.detected_title || `Job #${jobId}`).trim() || `Job #${jobId}`;
const state = String(jobProgress?.state || status).trim().toUpperCase() || status;
const pipeline = buildConverterPipeline(job, plan, jobProgress);
const progressValue = Number(jobProgress?.progress);
const hasProgress = Number.isFinite(progressValue);
if (!isExpanded) {
return (
<button
type="button"
className="ripper-job-row"
onClick={onExpand}
>
{job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
)}
<div className="ripper-job-row-content">
<div className="ripper-job-row-main">
<strong className="ripper-job-title-line">
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>{title}</span>
</strong>
<small>#{jobId}</small>
</div>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
</div>
{hasProgress && (
<div className="ripper-job-row-progress">
<ProgressBar value={Math.max(0, Math.min(100, progressValue))} showValue={false} />
</div>
)}
</div>
<i className="pi pi-angle-down" aria-hidden="true" />
</button>
);
}
return (
<div className="ripper-job-expanded">
<div className="ripper-job-expanded-head">
{job?.poster_url && job.poster_url !== 'N/A' ? (
<img src={job.poster_url} alt={title} className="poster-thumb" />
) : (
<div className="poster-thumb ripper-job-poster-fallback">Kein Poster</div>
)}
<div className="ripper-job-expanded-title">
<strong className="ripper-job-title-line">
<i className="pi pi-video media-indicator-icon" style={{ fontSize: '1rem', color: 'var(--rip-muted)' }} />
<span>#{jobId} | {title}</span>
</strong>
<div className="ripper-job-badges">
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
<Tag value="Converter" severity="info" />
</div>
</div>
<Button
label="Einklappen"
icon="pi pi-angle-up"
severity="secondary"
outlined
onClick={onCollapse}
disabled={busy}
/>
</div>
<PipelineStatusCard
jobId={jobId}
jobRow={job}
pipeline={pipeline}
onOpenMetadata={onOpenMetadata}
onReassignOmdb={onReassignOmdb}
onStart={onStart}
onConfirmReview={onConfirmReview}
onSelectPlaylist={onSelectPlaylist}
onSelectHandBrakeTitle={onSelectHandBrakeTitle}
onCancel={onCancel}
onRetry={onRetry}
onDeleteJob={onDeleteJob}
onRemoveFromQueue={onRemoveFromQueue}
onInputsChanged={onInputsChanged}
isQueued={isQueued}
busy={busy}
/>
</div>
);
}
function ConverterVideoConfigPanel({
job,
plan,
userPresets,
hbPresets,
disabled,
onConfigSaved
}) {
const [outputFormat, setOutputFormat] = useState(
String(plan?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv'
);
const [selectedUserPreset, setSelectedUserPreset] = useState(
Number.isFinite(Number(plan?.userPreset?.id)) ? Math.trunc(Number(plan.userPreset.id)) : null
);
const [selectedHbPreset, setSelectedHbPreset] = useState(
Number.isFinite(Number(plan?.userPreset?.id))
? ''
: String(plan?.userPreset?.handbrakePreset || '').trim()
);
const [draftSaving, setDraftSaving] = useState(false);
const [draftError, setDraftError] = useState(null);
const latestSavedRef = useRef(null);
const saveTimeoutRef = useRef(null);
useEffect(() => {
setOutputFormat(String(plan?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv');
if (Number.isFinite(Number(plan?.userPreset?.id))) {
setSelectedUserPreset(Math.trunc(Number(plan.userPreset.id)));
setSelectedHbPreset('');
} else {
setSelectedUserPreset(null);
setSelectedHbPreset(String(plan?.userPreset?.handbrakePreset || '').trim());
}
}, [job?.id, plan?.outputFormat, plan?.userPreset?.id, plan?.userPreset?.handbrakePreset]);
const draftPayload = useMemo(() => {
let userPreset = null;
if (selectedUserPreset) {
const preset = (Array.isArray(userPresets) ? userPresets : [])
.find((p) => Number(p?.id) === Number(selectedUserPreset));
userPreset = preset
? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' }
: { id: selectedUserPreset };
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
return {
converterMediaType: String(plan?.converterMediaType || 'video').trim().toLowerCase() || 'video',
outputFormat,
userPreset
};
}, [outputFormat, plan?.converterMediaType, selectedUserPreset, selectedHbPreset, userPresets]);
const draftSerialized = useMemo(() => JSON.stringify(draftPayload), [draftPayload]);
useEffect(() => {
if (disabled) return;
if (latestSavedRef.current === null) {
latestSavedRef.current = draftSerialized;
return;
}
if (draftSerialized === latestSavedRef.current) return;
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = setTimeout(async () => {
setDraftSaving(true);
setDraftError(null);
try {
await api.converterUpdateJobConfig(job.id, draftPayload);
latestSavedRef.current = draftSerialized;
onConfigSaved?.(job.id, draftPayload);
} catch (err) {
setDraftError(err?.message || 'Konfiguration konnte nicht gespeichert werden.');
} finally {
setDraftSaving(false);
}
}, 650);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
saveTimeoutRef.current = null;
}
};
}, [disabled, draftPayload, draftSerialized, job.id]);
const userPresetOptions = [
{ label: '— Kein User-Preset —', value: null },
...(Array.isArray(userPresets) ? userPresets : []).map((p) => ({ label: p.name, value: p.id }))
];
const hbPresetOptions = [
{ label: '— Kein Preset (Standard) —', value: '' },
...(Array.isArray(hbPresets) ? hbPresets : []).map((p) => ({
label: p.category ? `[${p.category}] ${p.name}` : p.name,
value: p.name
}))
];
return (
<div className="cjc-config" style={{ marginBottom: '0.75rem' }}>
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={VIDEO_OUTPUT_FORMATS}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%' }}
disabled={disabled}
/>
</div>
<div className="cjc-config-field">
<label className="cjc-config-label">User-Preset</label>
<Dropdown
value={selectedUserPreset}
options={userPresetOptions}
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
style={{ width: '100%' }}
disabled={disabled}
/>
</div>
<div className="cjc-config-field">
<label className="cjc-config-label">HandBrake-Preset</label>
<Dropdown
value={selectedHbPreset}
options={hbPresetOptions}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
placeholder="Preset auswählen …"
style={{ width: '100%' }}
disabled={disabled || Boolean(selectedUserPreset)}
/>
{selectedUserPreset ? (
<small style={{ color: 'var(--text-color-secondary)' }}>Deaktiviert, solange ein User-Preset aktiv ist.</small>
) : null}
</div>
</div>
{draftError ? (
<small style={{ color: 'var(--red-600)' }}>{draftError}</small>
) : (
<small style={{ color: 'var(--text-color-secondary)' }}>
{draftSaving ? 'Speichere Konfiguration …' : 'Konfiguration gespeichert'}
</small>
)}
</div>
);
}