0.12.0-18 Tests and Converter Fixes
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -339,7 +339,13 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
|
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
|
||||||
jobId: job?.id,
|
jobId: job?.id,
|
||||||
progressParser: parseHandBrakeProgress,
|
progressParser: parseHandBrakeProgress,
|
||||||
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `Encoding ${pct}%`),
|
onProgress: (pct, statusText, eta) => ctx.emitProgress(
|
||||||
|
Math.round(pct),
|
||||||
|
statusText || `Encoding ${pct}%`,
|
||||||
|
eta ?? null
|
||||||
|
),
|
||||||
|
appendLogLine: ctx?.extra?.appendLogLine,
|
||||||
|
logSource: 'HANDBRAKE',
|
||||||
isCancelled,
|
isCancelled,
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||||
@@ -451,6 +457,8 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
await _spawnAndWait({
|
await _spawnAndWait({
|
||||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||||
jobId: job?.id,
|
jobId: job?.id,
|
||||||
|
appendLogLine: ctx?.extra?.appendLogLine,
|
||||||
|
logSource: 'FFMPEG',
|
||||||
isCancelled,
|
isCancelled,
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||||
@@ -504,6 +512,8 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
await _spawnAndWait({
|
await _spawnAndWait({
|
||||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||||
jobId: job?.id,
|
jobId: job?.id,
|
||||||
|
appendLogLine: ctx?.extra?.appendLogLine,
|
||||||
|
logSource: 'FFMPEG',
|
||||||
isCancelled,
|
isCancelled,
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||||
@@ -536,6 +546,8 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
await _spawnAndWait({
|
await _spawnAndWait({
|
||||||
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
cmd: encodeConfig.cmd, args: encodeConfig.args,
|
||||||
jobId: job?.id,
|
jobId: job?.id,
|
||||||
|
appendLogLine: ctx?.extra?.appendLogLine,
|
||||||
|
logSource: 'FFMPEG',
|
||||||
isCancelled,
|
isCancelled,
|
||||||
onProcessHandle,
|
onProcessHandle,
|
||||||
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
|
||||||
@@ -591,25 +603,45 @@ class ConverterPlugin extends SourcePlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
|
async function _spawnAndWait({
|
||||||
|
cmd,
|
||||||
|
args,
|
||||||
|
jobId,
|
||||||
|
progressParser,
|
||||||
|
onProgress,
|
||||||
|
appendLogLine,
|
||||||
|
logSource = 'PROCESS',
|
||||||
|
isCancelled,
|
||||||
|
onProcessHandle,
|
||||||
|
context
|
||||||
|
}) {
|
||||||
if (typeof isCancelled === 'function' && isCancelled()) {
|
if (typeof isCancelled === 'function' && isCancelled()) {
|
||||||
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
const err = new Error('Job wurde vom Benutzer abgebrochen.');
|
||||||
err.statusCode = 409;
|
err.statusCode = 409;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleLine = (stream, line) => {
|
||||||
|
if (progressParser && typeof onProgress === 'function') {
|
||||||
|
const progress = progressParser(line);
|
||||||
|
if (progress?.percent != null) {
|
||||||
|
onProgress(progress.percent, progress.detail || null, progress.eta ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof appendLogLine === 'function') {
|
||||||
|
try {
|
||||||
|
appendLogLine(logSource, line, stream);
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore log append errors in process stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handle = spawnTrackedProcess({
|
const handle = spawnTrackedProcess({
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
onStdoutLine: () => {},
|
onStdoutLine: (line) => handleLine('stdout', line),
|
||||||
onStderrLine: (line) => {
|
onStderrLine: (line) => handleLine('stderr', line),
|
||||||
if (progressParser && typeof onProgress === 'function') {
|
|
||||||
const progress = progressParser(line);
|
|
||||||
if (progress?.percent != null) {
|
|
||||||
onProgress(progress.percent, progress.detail || null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
context: context || { jobId }
|
context: context || { jobId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ router.post(
|
|||||||
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
|
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
|
||||||
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
|
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
|
||||||
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
|
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
|
||||||
|
const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null;
|
||||||
logger.info('post:confirm-encode', {
|
logger.info('post:confirm-encode', {
|
||||||
reqId: req.reqId,
|
reqId: req.reqId,
|
||||||
jobId,
|
jobId,
|
||||||
@@ -287,6 +288,7 @@ router.post(
|
|||||||
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
|
||||||
skipPipelineStateUpdate,
|
skipPipelineStateUpdate,
|
||||||
selectedUserPresetId,
|
selectedUserPresetId,
|
||||||
|
selectedHandBrakePreset,
|
||||||
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
|
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
|
||||||
? selectedPostEncodeScriptIds.length
|
? selectedPostEncodeScriptIds.length
|
||||||
: 0,
|
: 0,
|
||||||
@@ -308,7 +310,8 @@ router.post(
|
|||||||
selectedPostEncodeChainIds,
|
selectedPostEncodeChainIds,
|
||||||
selectedPreEncodeChainIds,
|
selectedPreEncodeChainIds,
|
||||||
skipPipelineStateUpdate,
|
skipPipelineStateUpdate,
|
||||||
selectedUserPresetId
|
selectedUserPresetId,
|
||||||
|
selectedHandBrakePreset
|
||||||
});
|
});
|
||||||
res.json({ job });
|
res.json({ job });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function parseJsonSafe(raw, fallback = null) {
|
|||||||
|
|
||||||
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
||||||
const processLogStreams = new Map();
|
const processLogStreams = new Map();
|
||||||
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'other'];
|
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'audiobook', 'converter', 'other'];
|
||||||
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
||||||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
||||||
const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i;
|
const PROCESS_LOG_FILE_PATTERN = /^job-(\d+)\.process\.log$/i;
|
||||||
@@ -329,7 +329,27 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
|
|||||||
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
|
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
|
||||||
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
|
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
|
||||||
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
|
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
|
||||||
if (rawPlanProfile === 'converter' || rawMediaType === 'converter') {
|
const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase();
|
||||||
|
const hasConverterPathHint = (value) => {
|
||||||
|
const normalized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return /(^|\/)converter(\/|$)/.test(normalized);
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
rawPlanProfile === 'converter'
|
||||||
|
|| rawMediaType === 'converter'
|
||||||
|
|| converterMediaTypeHint === 'video'
|
||||||
|
|| converterMediaTypeHint === 'audio'
|
||||||
|
|| converterMediaTypeHint === 'iso'
|
||||||
|
|| hasConverterPathHint(rawPath)
|
||||||
|
|| hasConverterPathHint(encodeInputPath)
|
||||||
|
|| hasConverterPathHint(plan?.inputPath)
|
||||||
|
) {
|
||||||
return 'converter';
|
return 'converter';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -630,12 +650,17 @@ function enrichJobRow(job, settings = null, options = {}) {
|
|||||||
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
const mediainfoInfo = resolvedPaths.mediainfoInfo;
|
||||||
const encodePlan = resolvedPaths.encodePlan;
|
const encodePlan = resolvedPaths.encodePlan;
|
||||||
const mediaType = resolvedPaths.mediaType;
|
const mediaType = resolvedPaths.mediaType;
|
||||||
|
const normalizedJobStatus = String(job?.status || '').trim().toUpperCase();
|
||||||
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
const ripSuccessful = Number(job?.rip_successful || 0) === 1
|
||||||
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
|| String(makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
||||||
const backupSuccess = ripSuccessful;
|
const backupSuccess = ripSuccessful;
|
||||||
|
const finishedWithOutput = normalizedJobStatus === 'FINISHED'
|
||||||
|
&& (includeFsChecks ? Boolean(outputStatus?.exists) : true);
|
||||||
const encodeSuccess = directoryLikeOutput
|
const encodeSuccess = directoryLikeOutput
|
||||||
? (String(job?.status || '').trim().toUpperCase() === 'FINISHED' && Boolean(outputStatus?.exists))
|
? finishedWithOutput
|
||||||
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS';
|
: (mediaType === 'converter'
|
||||||
|
? finishedWithOutput
|
||||||
|
: String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
@@ -3067,7 +3092,33 @@ class HistoryService {
|
|||||||
`
|
`
|
||||||
SELECT *
|
SELECT *
|
||||||
FROM jobs
|
FROM jobs
|
||||||
WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'READY_TO_ENCODE', 'CD_ANALYZING', 'CD_READY_TO_RIP', 'CD_RIPPING', 'CD_ENCODING')
|
WHERE status IN ('ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING')
|
||||||
|
ORDER BY updated_at ASC, id ASC
|
||||||
|
`
|
||||||
|
),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
|
return rows.map((job) => ({
|
||||||
|
...enrichJobRow(job, settings),
|
||||||
|
log_count: hasProcessLogFile(job.id) ? 1 : 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQueueIdleJobs() {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows, settings] = await Promise.all([
|
||||||
|
db.all(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM jobs
|
||||||
|
WHERE status IN (
|
||||||
|
'METADATA_SELECTION',
|
||||||
|
'WAITING_FOR_USER_DECISION',
|
||||||
|
'READY_TO_START',
|
||||||
|
'READY_TO_ENCODE',
|
||||||
|
'CD_METADATA_SELECTION',
|
||||||
|
'CD_READY_TO_RIP'
|
||||||
|
)
|
||||||
ORDER BY updated_at ASC, id ASC
|
ORDER BY updated_at ASC, id ASC
|
||||||
`
|
`
|
||||||
),
|
),
|
||||||
@@ -4041,6 +4092,7 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const artifactMoviePathSet = new Set(artifactMoviePaths);
|
const artifactMoviePathSet = new Set(artifactMoviePaths);
|
||||||
|
const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter';
|
||||||
for (const outputPath of explicitMoviePaths) {
|
for (const outputPath of explicitMoviePaths) {
|
||||||
addCandidate(
|
addCandidate(
|
||||||
movieCandidates,
|
movieCandidates,
|
||||||
@@ -4050,7 +4102,7 @@ class HistoryService {
|
|||||||
movieAllowedPaths
|
movieAllowedPaths
|
||||||
);
|
);
|
||||||
const parentDir = toNormalizedPath(path.dirname(outputPath));
|
const parentDir = toNormalizedPath(path.dirname(outputPath));
|
||||||
if (parentDir && !movieRoots.includes(parentDir)) {
|
if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) {
|
||||||
addCandidate(
|
addCandidate(
|
||||||
movieCandidates,
|
movieCandidates,
|
||||||
'movie',
|
'movie',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button } from 'primereact/button';
|
|||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { ConfirmDialog } from 'primereact/confirmdialog';
|
||||||
import { api } from './api/client';
|
import { api } from './api/client';
|
||||||
import { useWebSocket } from './hooks/useWebSocket';
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
@@ -548,6 +549,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Toast ref={globalToastRef} position="top-right" />
|
<Toast ref={globalToastRef} position="top-right" />
|
||||||
|
<ConfirmDialog />
|
||||||
|
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
|
|||||||
@@ -77,6 +77,45 @@ function afterMutationInvalidate(prefixes = []) {
|
|||||||
invalidateCachedGet(prefixes);
|
invalidateCachedGet(prefixes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildHandBrakePresetList(payload) {
|
||||||
|
const existing = Array.isArray(payload?.presets) ? payload.presets : [];
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const options = Array.isArray(payload?.options) ? payload.options : [];
|
||||||
|
const list = [];
|
||||||
|
for (const option of options) {
|
||||||
|
if (!option || option.disabled) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rawValue = option.value ?? option.name ?? option.label ?? '';
|
||||||
|
const name = String(rawValue || '').trim();
|
||||||
|
if (!name || name.startsWith('__group__')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const category = option.category ? String(option.category).trim() : '';
|
||||||
|
list.push({
|
||||||
|
name,
|
||||||
|
category: category || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHandBrakePresetPayload(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
const existing = Array.isArray(payload.presets) ? payload.presets : [];
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
presets: buildHandBrakePresetList(payload)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function request(path, options = {}) {
|
async function request(path, options = {}) {
|
||||||
const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData;
|
const isFormDataBody = typeof FormData !== 'undefined' && options?.body instanceof FormData;
|
||||||
const mergedHeaders = {
|
const mergedHeaders = {
|
||||||
@@ -324,11 +363,12 @@ export const api = {
|
|||||||
getDetectedDrives() {
|
getDetectedDrives() {
|
||||||
return request('/settings/drives');
|
return request('/settings/drives');
|
||||||
},
|
},
|
||||||
getHandBrakePresets(options = {}) {
|
async getHandBrakePresets(options = {}) {
|
||||||
return requestCachedGet('/settings/handbrake-presets', {
|
const result = await requestCachedGet('/settings/handbrake-presets', {
|
||||||
ttlMs: 10 * 60 * 1000,
|
ttlMs: 10 * 60 * 1000,
|
||||||
forceRefresh: options.forceRefresh
|
forceRefresh: options.forceRefresh
|
||||||
});
|
});
|
||||||
|
return normalizeHandBrakePresetPayload(result);
|
||||||
},
|
},
|
||||||
getScripts(options = {}) {
|
getScripts(options = {}) {
|
||||||
return requestCachedGet('/settings/scripts', {
|
return requestCachedGet('/settings/scripts', {
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ function collectFolderPaths(node, result = []) {
|
|||||||
export default function ConverterFileExplorer({
|
export default function ConverterFileExplorer({
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
selectionResetToken,
|
||||||
navigateToPath,
|
navigateToPath,
|
||||||
onAssignmentChanged
|
onAssignmentChanged
|
||||||
}) {
|
}) {
|
||||||
@@ -204,6 +205,12 @@ export default function ConverterFileExplorer({
|
|||||||
|
|
||||||
useEffect(() => { loadTree(); }, [loadTree, refreshToken]);
|
useEffect(() => { loadTree(); }, [loadTree, refreshToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedPaths([]);
|
||||||
|
setActivePaths([]);
|
||||||
|
setActiveAnchor(null);
|
||||||
|
}, [selectionResetToken]);
|
||||||
|
|
||||||
// Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren
|
// Auto-Navigation nach Upload: Baum neu laden, dann in Zielordner navigieren
|
||||||
// navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst
|
// navigateToPath ist { path: string, ts: number } damit jeder Upload einen neuen Trigger auslöst
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -217,6 +224,17 @@ export default function ConverterFileExplorer({
|
|||||||
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
|
// Auswahl an Eltern melden: Ordner werden zu ihren Datei-Kindern expandiert
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tree) return;
|
if (!tree) return;
|
||||||
|
setSelectedPaths((prev) => {
|
||||||
|
const next = prev.filter((entryPath) => {
|
||||||
|
const node = getNodeByPath(tree, entryPath);
|
||||||
|
if (!node) return false;
|
||||||
|
if (node.type !== 'file') return true;
|
||||||
|
const assignedJobId = Number(node.jobId);
|
||||||
|
return !Number.isFinite(assignedJobId) || assignedJobId <= 0;
|
||||||
|
});
|
||||||
|
return next.length === prev.length ? prev : next;
|
||||||
|
});
|
||||||
|
|
||||||
const rootPaths = getRootSelections(selectedPaths);
|
const rootPaths = getRootSelections(selectedPaths);
|
||||||
const report = [];
|
const report = [];
|
||||||
for (const p of rootPaths) {
|
for (const p of rootPaths) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import MetadataSelectionDialog from './MetadataSelectionDialog';
|
|||||||
import CdMetadataDialog from './CdMetadataDialog';
|
import CdMetadataDialog from './CdMetadataDialog';
|
||||||
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
|
import { CD_FORMATS, CD_FORMAT_SCHEMAS, getDefaultFormatOptions } from '../config/cdFormatSchemas';
|
||||||
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
const VIDEO_OUTPUT_FORMATS = [
|
const VIDEO_OUTPUT_FORMATS = [
|
||||||
{ label: 'MKV', value: 'mkv' },
|
{ label: 'MKV', value: 'mkv' },
|
||||||
@@ -1129,7 +1130,14 @@ export default function ConverterJobCard({
|
|||||||
const confirmText = statusUpper === 'READY_TO_START'
|
const confirmText = statusUpper === 'READY_TO_START'
|
||||||
? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.`
|
? `${jobIdLabel} wirklich abbrechen?\nDer Job wird auf "Abgebrochen" gesetzt.`
|
||||||
: `${jobIdLabel} wirklich abbrechen?`;
|
: `${jobIdLabel} wirklich abbrechen?`;
|
||||||
if (!window.confirm(confirmText)) return;
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Job abbrechen',
|
||||||
|
message: confirmText,
|
||||||
|
acceptLabel: 'Abbrechen',
|
||||||
|
rejectLabel: 'Zurück',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
setCancelBusy(true);
|
setCancelBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.cancelConverterJob(job.id);
|
await api.cancelConverterJob(job.id);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Dropdown } from 'primereact/dropdown';
|
|||||||
import { InputSwitch } from 'primereact/inputswitch';
|
import { InputSwitch } from 'primereact/inputswitch';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -251,7 +252,14 @@ export default function CronJobsTab({ onWsMessage }) {
|
|||||||
// ── Löschen ──────────────────────────────────────────────────────────────────
|
// ── Löschen ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleDelete(job) {
|
async function handleDelete(job) {
|
||||||
if (!window.confirm(`Cronjob "${job.name}" wirklich löschen?`)) return;
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Cronjob löschen',
|
||||||
|
message: `Cronjob "${job.name}" wirklich löschen?`,
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!confirmed) return;
|
||||||
setBusyId(job.id);
|
setBusyId(job.id);
|
||||||
try {
|
try {
|
||||||
await api.deleteCronJob(job.id);
|
await api.deleteCronJob(job.id);
|
||||||
|
|||||||
@@ -1217,7 +1217,8 @@ export default function MediaInfoReviewPanel({
|
|||||||
onReorderPostEncodeItem = null,
|
onReorderPostEncodeItem = null,
|
||||||
userPresets = [],
|
userPresets = [],
|
||||||
selectedUserPresetId = null,
|
selectedUserPresetId = null,
|
||||||
onUserPresetChange = null
|
onUserPresetChange = null,
|
||||||
|
selectedHandBrakePreset = ''
|
||||||
}) {
|
}) {
|
||||||
if (!review) {
|
if (!review) {
|
||||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||||
@@ -1237,14 +1238,20 @@ export default function MediaInfoReviewPanel({
|
|||||||
const selectedUserPreset = selectedUserPresetId
|
const selectedUserPreset = selectedUserPresetId
|
||||||
? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null
|
? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null
|
||||||
: null;
|
: null;
|
||||||
const effectivePresetOverride = selectedUserPreset
|
const hasUserPresets = normalizedUserPresets.length > 0;
|
||||||
|
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
|
||||||
|
const isUserPresetActive = Boolean(selectedUserPreset);
|
||||||
|
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
|
||||||
|
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset);
|
||||||
|
const effectivePresetOverride = isUserPresetActive
|
||||||
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
|
? { handbrakePreset: selectedUserPreset.handbrakePreset || '', extraArgs: selectedUserPreset.extraArgs || '' }
|
||||||
: null;
|
: (isHandBrakePresetActive ? { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' } : null);
|
||||||
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
|
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
|
||||||
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
|
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
|
||||||
: (review?.selectors?.audio || null);
|
: (review?.selectors?.audio || null);
|
||||||
const hasUserPresets = normalizedUserPresets.length > 0;
|
const presetSourceLabel = isUserPresetActive
|
||||||
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
|
? `User-Preset: ${selectedUserPreset?.name || 'Preset'}`
|
||||||
|
: (isHandBrakePresetActive ? 'HandBrake-Preset' : '');
|
||||||
|
|
||||||
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
|
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -1308,23 +1315,28 @@ export default function MediaInfoReviewPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="media-review-meta">
|
<div className="media-review-meta">
|
||||||
<div>
|
<div>
|
||||||
<strong>Preset:</strong>{' '}
|
<strong>Preset:</strong>{' '}
|
||||||
{effectivePresetOverride
|
{effectivePresetOverride
|
||||||
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
|
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
|
||||||
: presetLabel}
|
: presetLabel}
|
||||||
{effectivePresetOverride && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(User-Preset: {selectedUserPreset?.name})</span>}
|
{effectivePresetOverride && presetSourceLabel && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
|
||||||
|
({presetSourceLabel})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Extra Args:</strong>{' '}
|
<strong>Extra Args:</strong>{' '}
|
||||||
{effectivePresetOverride
|
{effectivePresetOverride
|
||||||
? (effectivePresetOverride.extraArgs || '(keine)')
|
? (effectivePresetOverride.extraArgs || '(keine)')
|
||||||
: (review.selectors?.extraArgs || '(keine)')}
|
: (review.selectors?.extraArgs || '(keine)')}
|
||||||
{effectivePresetOverride && !selectedUserPreset?.extraArgs && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>}
|
{effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && (
|
||||||
|
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? 'user-preset' : (review.selectors?.presetProfileSource || '-')}</div>
|
<div><strong>Preset-Profil:</strong> {effectivePresetOverride ? (isUserPresetActive ? 'user-preset' : 'handbrake-preset') : (review.selectors?.presetProfileSource || '-')}</div>
|
||||||
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
<div><strong>MIN_LENGTH_MINUTES:</strong> {review.minLengthMinutes}</div>
|
||||||
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
|
<div><strong>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
|
||||||
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
|
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation';
|
||||||
|
|
||||||
|
const CONVERTER_VIDEO_OUTPUT_FORMATS = [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'MP4', value: 'mp4' },
|
||||||
|
{ label: 'M4V', value: 'm4v' }
|
||||||
|
];
|
||||||
|
|
||||||
function normalizeTitleId(value) {
|
function normalizeTitleId(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -120,6 +127,9 @@ function normalizeMediaProfile(value) {
|
|||||||
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
if (['audiobook', 'audio_book', 'audio book', 'book'].includes(raw)) {
|
||||||
return 'audiobook';
|
return 'audiobook';
|
||||||
}
|
}
|
||||||
|
if (raw === 'converter') {
|
||||||
|
return 'converter';
|
||||||
|
}
|
||||||
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
if (['other', 'sonstiges', 'unknown'].includes(raw)) {
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
@@ -453,6 +463,37 @@ function buildOutputPathPreview(settings, mediaProfile, metadata, fallbackJobId
|
|||||||
return `${root}/${baseName}.${ext}`;
|
return `${root}/${baseName}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildConverterOutputPathPreview(settings, review, metadata, fallbackJobId = null) {
|
||||||
|
const explicitPath = String(review?.outputPath || review?.outputDir || '').trim();
|
||||||
|
if (explicitPath) {
|
||||||
|
return explicitPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const movieDir = String(settings?.converter_movie_dir || '').trim();
|
||||||
|
if (!movieDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
|
||||||
|
const year = metadata?.year || new Date().getFullYear();
|
||||||
|
const template = String(settings?.converter_output_template_video || '{title}').trim() || '{title}';
|
||||||
|
const rendered = renderTemplate(template, { title, year });
|
||||||
|
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 ext = String(review?.outputFormat || '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({
|
export default function PipelineStatusCard({
|
||||||
jobId = null,
|
jobId = null,
|
||||||
pipeline,
|
pipeline,
|
||||||
@@ -470,6 +511,7 @@ export default function PipelineStatusCard({
|
|||||||
onCancel,
|
onCancel,
|
||||||
onRetry,
|
onRetry,
|
||||||
onDeleteJob,
|
onDeleteJob,
|
||||||
|
onInputsChanged,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
busy
|
busy
|
||||||
}) {
|
}) {
|
||||||
@@ -502,6 +544,13 @@ export default function PipelineStatusCard({
|
|||||||
const [postEncodeItems, setPostEncodeItems] = useState([]);
|
const [postEncodeItems, setPostEncodeItems] = useState([]);
|
||||||
const [userPresets, setUserPresets] = useState([]);
|
const [userPresets, setUserPresets] = useState([]);
|
||||||
const [selectedUserPresetId, setSelectedUserPresetId] = useState(null);
|
const [selectedUserPresetId, setSelectedUserPresetId] = useState(null);
|
||||||
|
const [handBrakePresetOptions, setHandBrakePresetOptions] = useState([]);
|
||||||
|
const [selectedHandBrakePreset, setSelectedHandBrakePreset] = useState('');
|
||||||
|
const [selectedOutputFormat, setSelectedOutputFormat] = useState('mkv');
|
||||||
|
const [converterConfigSaving, setConverterConfigSaving] = useState(false);
|
||||||
|
const [converterConfigError, setConverterConfigError] = useState(null);
|
||||||
|
const converterConfigLatestRef = useRef(null);
|
||||||
|
const converterConfigSaveTimeoutRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -512,7 +561,7 @@ export default function PipelineStatusCard({
|
|||||||
api.getHandBrakePresets(),
|
api.getHandBrakePresets(),
|
||||||
api.getScripts(),
|
api.getScripts(),
|
||||||
api.getScriptChains(),
|
api.getScriptChains(),
|
||||||
api.getUserPresets()
|
api.getUserPresets(null, { forceRefresh: true })
|
||||||
]);
|
]);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
const categories = settingsResponse.status === 'fulfilled'
|
const categories = settingsResponse.status === 'fulfilled'
|
||||||
@@ -523,6 +572,7 @@ export default function PipelineStatusCard({
|
|||||||
? (presetsResponse.value?.options || [])
|
? (presetsResponse.value?.options || [])
|
||||||
: [];
|
: [];
|
||||||
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
|
setPresetDisplayMap(buildPresetDisplayMap(presetOptions));
|
||||||
|
setHandBrakePresetOptions(Array.isArray(presetOptions) ? presetOptions : []);
|
||||||
const scripts = scriptsResponse.status === 'fulfilled'
|
const scripts = scriptsResponse.status === 'fulfilled'
|
||||||
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
? (Array.isArray(scriptsResponse.value?.scripts) ? scriptsResponse.value.scripts : [])
|
||||||
: [];
|
: [];
|
||||||
@@ -548,6 +598,7 @@ export default function PipelineStatusCard({
|
|||||||
setScriptCatalog([]);
|
setScriptCatalog([]);
|
||||||
setChainCatalog([]);
|
setChainCatalog([]);
|
||||||
setUserPresets([]);
|
setUserPresets([]);
|
||||||
|
setHandBrakePresetOptions([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -570,14 +621,25 @@ export default function PipelineStatusCard({
|
|||||||
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
...normalizeScriptIdList(mediaInfoReview?.postEncodeScriptIds || []).map((id) => ({ type: 'script', id })),
|
||||||
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
...normChain(mediaInfoReview?.postEncodeChainIds).map((id) => ({ type: 'chain', id }))
|
||||||
]);
|
]);
|
||||||
|
const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv';
|
||||||
|
setSelectedOutputFormat(nextOutputFormat);
|
||||||
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
|
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
|
||||||
setSelectedUserPresetId(Number.isFinite(userPresetId) && userPresetId > 0 ? Math.trunc(userPresetId) : null);
|
if (Number.isFinite(userPresetId) && userPresetId > 0) {
|
||||||
|
setSelectedUserPresetId(Math.trunc(userPresetId));
|
||||||
|
setSelectedHandBrakePreset('');
|
||||||
|
} else {
|
||||||
|
setSelectedUserPresetId(null);
|
||||||
|
const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim();
|
||||||
|
setSelectedHandBrakePreset(hbPresetName);
|
||||||
|
}
|
||||||
}, [
|
}, [
|
||||||
mediaInfoReview?.encodeInputTitleId,
|
mediaInfoReview?.encodeInputTitleId,
|
||||||
mediaInfoReview?.generatedAt,
|
mediaInfoReview?.generatedAt,
|
||||||
mediaInfoReview?.reviewConfirmedAt,
|
mediaInfoReview?.reviewConfirmedAt,
|
||||||
mediaInfoReview?.prefilledFromPreviousRunAt,
|
mediaInfoReview?.prefilledFromPreviousRunAt,
|
||||||
|
mediaInfoReview?.outputFormat,
|
||||||
mediaInfoReview?.userPreset?.id,
|
mediaInfoReview?.userPreset?.id,
|
||||||
|
mediaInfoReview?.userPreset?.handbrakePreset,
|
||||||
retryJobId
|
retryJobId
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -609,6 +671,11 @@ export default function PipelineStatusCard({
|
|||||||
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
|
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
|
||||||
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
|
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
|
||||||
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
|
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
|
||||||
|
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
|
||||||
|
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
|
||||||
|
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
|
||||||
|
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
|
||||||
|
const requiresPresetSelection = showConverterConfig;
|
||||||
|
|
||||||
// Filter user presets by job media profile ('all' presets always shown)
|
// Filter user presets by job media profile ('all' presets always shown)
|
||||||
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
|
||||||
@@ -616,11 +683,17 @@ export default function PipelineStatusCard({
|
|||||||
if (!jobMediaProfile) {
|
if (!jobMediaProfile) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Converter jobs may reuse any user preset; do not restrict by preset media type here.
|
||||||
|
if (jobMediaProfile === 'converter') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
|
return presetMediaType === 'all' || presetMediaType === jobMediaProfile;
|
||||||
});
|
});
|
||||||
const canStartReadyJob = isPreRipReview
|
const effectiveUserPresets = filteredUserPresets;
|
||||||
|
const canStartReadyJob = (isPreRipReview
|
||||||
? Boolean(retryJobId)
|
? Boolean(retryJobId)
|
||||||
: Boolean(retryJobId && encodeInputPath);
|
: Boolean(retryJobId && encodeInputPath))
|
||||||
|
&& (!requiresPresetSelection || hasSelectedPreset);
|
||||||
const canRestartEncodeFromLastSettings = Boolean(
|
const canRestartEncodeFromLastSettings = Boolean(
|
||||||
(state === 'ERROR' || state === 'CANCELLED')
|
(state === 'ERROR' || state === 'CANCELLED')
|
||||||
&& retryJobId
|
&& retryJobId
|
||||||
@@ -631,6 +704,87 @@ export default function PipelineStatusCard({
|
|||||||
&& !running
|
&& !running
|
||||||
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
||||||
);
|
);
|
||||||
|
const allowConverterConfigEdit = showConverterConfig && !queueLocked && state === 'READY_TO_ENCODE';
|
||||||
|
|
||||||
|
const converterConfigPayload = useMemo(() => {
|
||||||
|
if (!showConverterConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let userPreset = null;
|
||||||
|
if (selectedUserPresetId) {
|
||||||
|
const preset = (Array.isArray(userPresets) ? userPresets : [])
|
||||||
|
.find((p) => Number(p?.id) === Number(selectedUserPresetId));
|
||||||
|
userPreset = preset
|
||||||
|
? { id: preset.id, handbrakePreset: preset.handbrake_preset || null, extraArgs: preset.extra_args || '' }
|
||||||
|
: { id: selectedUserPresetId };
|
||||||
|
} else if (trimmedHandBrakePreset) {
|
||||||
|
userPreset = { handbrakePreset: trimmedHandBrakePreset, extraArgs: '' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
converterMediaType,
|
||||||
|
outputFormat: selectedOutputFormat,
|
||||||
|
userPreset
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
showConverterConfig,
|
||||||
|
converterMediaType,
|
||||||
|
selectedOutputFormat,
|
||||||
|
selectedUserPresetId,
|
||||||
|
trimmedHandBrakePreset,
|
||||||
|
userPresets
|
||||||
|
]);
|
||||||
|
|
||||||
|
const converterConfigSerialized = useMemo(
|
||||||
|
() => (converterConfigPayload ? JSON.stringify(converterConfigPayload) : null),
|
||||||
|
[converterConfigPayload]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
converterConfigLatestRef.current = null;
|
||||||
|
setConverterConfigError(null);
|
||||||
|
setConverterConfigSaving(false);
|
||||||
|
}, [retryJobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowConverterConfigEdit || !converterConfigPayload || !retryJobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (converterConfigLatestRef.current === null) {
|
||||||
|
converterConfigLatestRef.current = converterConfigSerialized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (converterConfigSerialized === converterConfigLatestRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (converterConfigSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(converterConfigSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
converterConfigSaveTimeoutRef.current = setTimeout(async () => {
|
||||||
|
setConverterConfigSaving(true);
|
||||||
|
setConverterConfigError(null);
|
||||||
|
try {
|
||||||
|
await api.converterUpdateJobConfig(retryJobId, converterConfigPayload);
|
||||||
|
converterConfigLatestRef.current = converterConfigSerialized;
|
||||||
|
onInputsChanged?.(retryJobId, converterConfigPayload);
|
||||||
|
} catch (error) {
|
||||||
|
setConverterConfigError(error?.message || 'Konfiguration konnte nicht gespeichert werden.');
|
||||||
|
} finally {
|
||||||
|
setConverterConfigSaving(false);
|
||||||
|
}
|
||||||
|
}, 650);
|
||||||
|
return () => {
|
||||||
|
if (converterConfigSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(converterConfigSaveTimeoutRef.current);
|
||||||
|
converterConfigSaveTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
allowConverterConfigEdit,
|
||||||
|
converterConfigPayload,
|
||||||
|
converterConfigSerialized,
|
||||||
|
retryJobId,
|
||||||
|
onInputsChanged
|
||||||
|
]);
|
||||||
|
|
||||||
const waitingPlaylistRows = useMemo(() => {
|
const waitingPlaylistRows = useMemo(() => {
|
||||||
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
|
||||||
@@ -780,10 +934,15 @@ export default function PipelineStatusCard({
|
|||||||
}, [running, retryJobId]);
|
}, [running, retryJobId]);
|
||||||
|
|
||||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired;
|
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired;
|
||||||
const commandOutputPath = useMemo(
|
const commandOutputPath = useMemo(() => {
|
||||||
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
|
if (jobMediaProfile === 'converter') {
|
||||||
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
|
const reviewWithOutput = mediaInfoReview
|
||||||
);
|
? { ...mediaInfoReview, outputFormat: selectedOutputFormat }
|
||||||
|
: { outputFormat: selectedOutputFormat };
|
||||||
|
return buildConverterOutputPathPreview(settingsMap, reviewWithOutput, selectedMetadata, retryJobId);
|
||||||
|
}
|
||||||
|
return buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId);
|
||||||
|
}, [settingsMap, jobMediaProfile, mediaInfoReview, selectedMetadata, retryJobId, selectedOutputFormat]);
|
||||||
const presetDisplayValue = useMemo(() => {
|
const presetDisplayValue = useMemo(() => {
|
||||||
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
const preset = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
@@ -791,6 +950,52 @@ export default function PipelineStatusCard({
|
|||||||
}
|
}
|
||||||
return presetDisplayMap[preset] || preset;
|
return presetDisplayMap[preset] || preset;
|
||||||
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
|
}, [mediaInfoReview?.selectors?.preset, presetDisplayMap]);
|
||||||
|
const converterUserPresetOptions = useMemo(() => {
|
||||||
|
if (!showConverterConfig) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const userOptions = (Array.isArray(effectiveUserPresets) ? effectiveUserPresets : [])
|
||||||
|
.map((preset) => ({
|
||||||
|
label: String(preset?.name || '').trim(),
|
||||||
|
value: Number(preset?.id)
|
||||||
|
}))
|
||||||
|
.filter((option) => option.label && Number.isFinite(option.value));
|
||||||
|
return [{ label: 'Kein User-Preset', value: null }, ...userOptions];
|
||||||
|
}, [showConverterConfig, effectiveUserPresets]);
|
||||||
|
const converterHandBrakePresetOptions = useMemo(() => {
|
||||||
|
if (!showConverterConfig) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const list = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : [];
|
||||||
|
const hbOptions = list
|
||||||
|
.map((option) => {
|
||||||
|
const label = String(option?.label || '').trim();
|
||||||
|
if (!label) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const disabled = Boolean(option?.disabled);
|
||||||
|
const lowerLabel = label.toLowerCase();
|
||||||
|
if (lowerLabel.includes('handbrake cli') || lowerLabel.includes('cli default')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!disabled && (lowerLabel.includes('kein preset') || lowerLabel.includes('cli-parameter'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = disabled
|
||||||
|
? String(option?.value || label)
|
||||||
|
: String(option?.value || '').trim();
|
||||||
|
if (!disabled && !value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
disabled
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
return [{ label: 'Kein HandBrake-Preset', value: '' }, ...hbOptions];
|
||||||
|
}, [showConverterConfig, handBrakePresetOptions]);
|
||||||
const buildSelectedTrackSelectionForCurrentTitle = () => {
|
const buildSelectedTrackSelectionForCurrentTitle = () => {
|
||||||
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId)
|
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId)
|
||||||
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|
|| normalizeTitleId(mediaInfoReview?.encodeInputTitleId)
|
||||||
@@ -862,7 +1067,8 @@ export default function PipelineStatusCard({
|
|||||||
selectedPostScriptIds,
|
selectedPostScriptIds,
|
||||||
selectedPreScriptIds,
|
selectedPreScriptIds,
|
||||||
selectedPostChainIds,
|
selectedPostChainIds,
|
||||||
selectedPreChainIds
|
selectedPreChainIds,
|
||||||
|
selectedHandBrakePreset: trimmedHandBrakePreset
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -908,7 +1114,10 @@ export default function PipelineStatusCard({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION') && retryJobId && typeof onOpenMetadata === 'function' ? (
|
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION')
|
||||||
|
&& retryJobId
|
||||||
|
&& typeof onOpenMetadata === 'function'
|
||||||
|
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
|
||||||
<Button
|
<Button
|
||||||
label="Metadaten öffnen"
|
label="Metadaten öffnen"
|
||||||
icon="pi pi-list"
|
icon="pi pi-list"
|
||||||
@@ -918,7 +1127,24 @@ export default function PipelineStatusCard({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!running && state !== 'METADATA_SELECTION' && state !== 'WAITING_FOR_USER_DECISION' && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId && typeof onReassignOmdb === 'function' ? (
|
{state === 'METADATA_SELECTION' && jobMediaProfile === 'converter' && retryJobId ? (
|
||||||
|
<Button
|
||||||
|
label="Review starten"
|
||||||
|
icon="pi pi-play"
|
||||||
|
severity="info"
|
||||||
|
onClick={() => onStart(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!running
|
||||||
|
&& state !== 'METADATA_SELECTION'
|
||||||
|
&& state !== 'WAITING_FOR_USER_DECISION'
|
||||||
|
&& state !== 'IDLE'
|
||||||
|
&& state !== 'DISC_DETECTED'
|
||||||
|
&& retryJobId
|
||||||
|
&& typeof onReassignOmdb === 'function'
|
||||||
|
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
|
||||||
<Button
|
<Button
|
||||||
label="OMDb neu zuordnen"
|
label="OMDb neu zuordnen"
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
@@ -931,9 +1157,9 @@ export default function PipelineStatusCard({
|
|||||||
|
|
||||||
{state === 'READY_TO_START' && retryJobId ? (
|
{state === 'READY_TO_START' && retryJobId ? (
|
||||||
<Button
|
<Button
|
||||||
label="Job starten"
|
label={jobMediaProfile === 'converter' ? 'Review starten' : 'Job starten'}
|
||||||
icon="pi pi-play"
|
icon="pi pi-play"
|
||||||
severity="success"
|
severity={jobMediaProfile === 'converter' ? 'info' : 'success'}
|
||||||
onClick={() => onStart(retryJobId)}
|
onClick={() => onStart(retryJobId)}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
/>
|
/>
|
||||||
@@ -975,9 +1201,10 @@ export default function PipelineStatusCard({
|
|||||||
selectedPostScriptIds,
|
selectedPostScriptIds,
|
||||||
selectedPreScriptIds,
|
selectedPreScriptIds,
|
||||||
selectedPostChainIds,
|
selectedPostChainIds,
|
||||||
selectedPreChainIds
|
selectedPreChainIds,
|
||||||
|
selectedHandBrakePreset
|
||||||
} = buildSelectedTrackSelectionForCurrentTitle();
|
} = buildSelectedTrackSelectionForCurrentTitle();
|
||||||
await onStart(retryJobId, {
|
const startPayload = {
|
||||||
ensureConfirmed: true,
|
ensureConfirmed: true,
|
||||||
selectedEncodeTitleId: encodeTitleId,
|
selectedEncodeTitleId: encodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
@@ -985,8 +1212,10 @@ export default function PipelineStatusCard({
|
|||||||
selectedPreEncodeScriptIds: selectedPreScriptIds,
|
selectedPreEncodeScriptIds: selectedPreScriptIds,
|
||||||
selectedPostEncodeChainIds: selectedPostChainIds,
|
selectedPostEncodeChainIds: selectedPostChainIds,
|
||||||
selectedPreEncodeChainIds: selectedPreChainIds,
|
selectedPreEncodeChainIds: selectedPreChainIds,
|
||||||
selectedUserPresetId: selectedUserPresetId || null
|
selectedHandBrakePreset
|
||||||
});
|
};
|
||||||
|
startPayload.selectedUserPresetId = selectedUserPresetId || null;
|
||||||
|
await onStart(retryJobId, startPayload);
|
||||||
}}
|
}}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
disabled={!canStartReadyJob || !canConfirmReview}
|
disabled={!canStartReadyJob || !canConfirmReview}
|
||||||
@@ -1203,6 +1432,91 @@ export default function PipelineStatusCard({
|
|||||||
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
||||||
</small>
|
</small>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showConverterConfig ? (
|
||||||
|
<div style={{ marginTop: '0.75rem', marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>Ausgabeformat</label>
|
||||||
|
<Dropdown
|
||||||
|
value={selectedOutputFormat}
|
||||||
|
options={CONVERTER_VIDEO_OUTPUT_FORMATS}
|
||||||
|
onChange={(e) => setSelectedOutputFormat(e.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={!allowConverterConfigEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>User-Preset</label>
|
||||||
|
<Dropdown
|
||||||
|
value={selectedUserPresetId}
|
||||||
|
options={converterUserPresetOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(e) => {
|
||||||
|
const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value;
|
||||||
|
const nextValue = rawValue === null || rawValue === undefined || String(rawValue).trim() === ''
|
||||||
|
? null
|
||||||
|
: Number(rawValue);
|
||||||
|
const normalized = Number.isFinite(nextValue) && nextValue > 0 ? Math.trunc(nextValue) : null;
|
||||||
|
setSelectedUserPresetId(normalized);
|
||||||
|
if (normalized) {
|
||||||
|
setSelectedHandBrakePreset('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="User-Preset auswählen …"
|
||||||
|
showClear
|
||||||
|
disabled={!allowConverterConfigEdit || Boolean(trimmedHandBrakePreset) || converterUserPresetOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
{converterUserPresetOptions.length <= 1 ? (
|
||||||
|
<small style={{ color: 'var(--text-color-secondary)' }}>Keine User-Presets vorhanden.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>HandBrake-Preset</label>
|
||||||
|
<Dropdown
|
||||||
|
value={trimmedHandBrakePreset}
|
||||||
|
options={converterHandBrakePresetOptions}
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
onChange={(e) => {
|
||||||
|
const rawValue = e?.value && typeof e.value === 'object' ? e.value.value : e?.value;
|
||||||
|
const next = String(rawValue || '').trim();
|
||||||
|
setSelectedHandBrakePreset(next);
|
||||||
|
if (next) {
|
||||||
|
setSelectedUserPresetId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="HandBrake-Preset auswählen …"
|
||||||
|
filter
|
||||||
|
optionDisabled="disabled"
|
||||||
|
showClear
|
||||||
|
disabled={!allowConverterConfigEdit || Boolean(selectedUserPresetId) || converterHandBrakePresetOptions.length <= 1}
|
||||||
|
/>
|
||||||
|
{converterHandBrakePresetOptions.length <= 1 ? (
|
||||||
|
<small style={{ color: 'var(--text-color-secondary)' }}>Keine HandBrake-Presets geladen.</small>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{allowConverterConfigEdit && !hasSelectedPreset ? (
|
||||||
|
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
|
||||||
|
Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann.
|
||||||
|
</small>
|
||||||
|
) : null}
|
||||||
|
{allowConverterConfigEdit ? (
|
||||||
|
converterConfigError ? (
|
||||||
|
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--red-600)' }}>
|
||||||
|
{converterConfigError}
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small style={{ display: 'block', marginTop: '0.5rem', color: 'var(--text-color-secondary)' }}>
|
||||||
|
{converterConfigSaving ? 'Speichere Konfiguration …' : 'Konfiguration gespeichert'}
|
||||||
|
</small>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<MediaInfoReviewPanel
|
<MediaInfoReviewPanel
|
||||||
review={mediaInfoReview}
|
review={mediaInfoReview}
|
||||||
presetDisplayValue={presetDisplayValue}
|
presetDisplayValue={presetDisplayValue}
|
||||||
@@ -1292,9 +1606,10 @@ export default function PipelineStatusCard({
|
|||||||
availableChains={chainCatalog}
|
availableChains={chainCatalog}
|
||||||
preEncodeItems={preEncodeItems}
|
preEncodeItems={preEncodeItems}
|
||||||
postEncodeItems={postEncodeItems}
|
postEncodeItems={postEncodeItems}
|
||||||
userPresets={filteredUserPresets}
|
userPresets={effectiveUserPresets}
|
||||||
selectedUserPresetId={selectedUserPresetId}
|
selectedUserPresetId={selectedUserPresetId}
|
||||||
onUserPresetChange={(presetId) => setSelectedUserPresetId(presetId)}
|
onUserPresetChange={showConverterConfig ? null : (presetId) => setSelectedUserPresetId(presetId)}
|
||||||
|
selectedHandBrakePreset={selectedHandBrakePreset}
|
||||||
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
allowEncodeItemSelection={state === 'READY_TO_ENCODE' && !queueLocked}
|
||||||
onAddPreEncodeItem={(itemType) => {
|
onAddPreEncodeItem={(itemType) => {
|
||||||
setPreEncodeItems((prev) => {
|
setPreEncodeItems((prev) => {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Button } from 'primereact/button';
|
|||||||
import { Card } from 'primereact/card';
|
import { Card } from 'primereact/card';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { Badge } from 'primereact/badge';
|
import { Badge } from 'primereact/badge';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Divider } from 'primereact/divider';
|
import { Divider } from 'primereact/divider';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
@@ -13,9 +15,20 @@ import ConverterFileExplorer from '../components/ConverterFileExplorer';
|
|||||||
import ConverterUploadPanel from '../components/ConverterUploadPanel';
|
import ConverterUploadPanel from '../components/ConverterUploadPanel';
|
||||||
import ConverterJobCard from '../components/ConverterJobCard';
|
import ConverterJobCard from '../components/ConverterJobCard';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
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', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
|
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
|
||||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']);
|
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.mov', '.wmv', '.ts', '.m2ts', '.iso', '.m4v', '.flv', '.webm']);
|
||||||
|
const TERMINAL_JOB_STATUSES = new Set(['DONE', 'FINISHED', 'ERROR', 'CANCELLED']);
|
||||||
|
const VIDEO_OUTPUT_FORMATS = [
|
||||||
|
{ label: 'MKV', value: 'mkv' },
|
||||||
|
{ label: 'MP4', value: 'mp4' },
|
||||||
|
{ label: 'M4V', value: 'm4v' }
|
||||||
|
];
|
||||||
|
|
||||||
function isAudioEntry(e) {
|
function isAudioEntry(e) {
|
||||||
if (e.detectedMediaType === 'audio') return true;
|
if (e.detectedMediaType === 'audio') return true;
|
||||||
@@ -46,14 +59,45 @@ function parseConverterPlan(job) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export default function ConverterPage() {
|
||||||
const toastRef = useRef(null);
|
const toastRef = useRef(null);
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
|
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||||
const [jobProgress, setJobProgress] = useState({});
|
const [jobProgress, setJobProgress] = useState({});
|
||||||
const [loadingJobs, setLoadingJobs] = useState(false);
|
const [loadingJobs, setLoadingJobs] = useState(false);
|
||||||
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
|
const [explorerRefreshToken, setExplorerRefreshToken] = useState(0);
|
||||||
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
|
const [explorerNavigateTo, setExplorerNavigateTo] = useState(null);
|
||||||
const [selectedEntries, setSelectedEntries] = useState([]);
|
const [selectedEntries, setSelectedEntries] = useState([]);
|
||||||
|
const [selectionResetToken, setSelectionResetToken] = useState(0);
|
||||||
const [jobModeVisible, setJobModeVisible] = useState(false);
|
const [jobModeVisible, setJobModeVisible] = useState(false);
|
||||||
const [audioMode, setAudioMode] = useState('individual');
|
const [audioMode, setAudioMode] = useState('individual');
|
||||||
const [jobModalAction, setJobModalAction] = useState('create');
|
const [jobModalAction, setJobModalAction] = useState('create');
|
||||||
@@ -63,19 +107,121 @@ export default function ConverterPage() {
|
|||||||
const [detailVisible, setDetailVisible] = useState(false);
|
const [detailVisible, setDetailVisible] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [selectedJob, setSelectedJob] = useState(null);
|
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 [logLoadingMode, setLogLoadingMode] = useState(null);
|
||||||
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
const [deleteEntryBusy, setDeleteEntryBusy] = useState(false);
|
||||||
const [cancelDetailBusy, setCancelDetailBusy] = useState(false);
|
const [cancelDetailBusy, setCancelDetailBusy] = useState(false);
|
||||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||||
|
const [videoUserPresets, setVideoUserPresets] = useState([]);
|
||||||
|
const [videoHbPresets, setVideoHbPresets] = useState([]);
|
||||||
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
|
// Entries werden beim Öffnen des Modals gesichert, damit ein außerhalb-Click
|
||||||
// die Selection nicht löscht bevor die Jobs erstellt werden
|
// die Selection nicht löscht bevor die Jobs erstellt werden
|
||||||
const jobEntriesRef = useRef([]);
|
const jobEntriesRef = useRef([]);
|
||||||
|
const previousJobStatusesRef = useRef(new Map());
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
setLoadingJobs(true);
|
setLoadingJobs(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.getConverterJobs();
|
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
||||||
setJobs(Array.isArray(data?.jobs) ? data.jobs : []);
|
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) {
|
} catch (err) {
|
||||||
console.error('Load converter jobs error:', err);
|
console.error('Load converter jobs error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,6 +258,7 @@ export default function ConverterPage() {
|
|||||||
if (
|
if (
|
||||||
message.type === 'PIPELINE_UPDATE' ||
|
message.type === 'PIPELINE_UPDATE' ||
|
||||||
message.type === 'PIPELINE_STATE_CHANGED' ||
|
message.type === 'PIPELINE_STATE_CHANGED' ||
|
||||||
|
message.type === 'PIPELINE_QUEUE_CHANGED' ||
|
||||||
message.type === 'CONVERTER_SCAN_UPDATE'
|
message.type === 'CONVERTER_SCAN_UPDATE'
|
||||||
) {
|
) {
|
||||||
loadJobs();
|
loadJobs();
|
||||||
@@ -122,6 +269,17 @@ export default function ConverterPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(() => (
|
const assignableJobs = useMemo(() => (
|
||||||
jobs
|
jobs
|
||||||
.map((job) => ({ job, plan: parseConverterPlan(job) }))
|
.map((job) => ({ job, plan: parseConverterPlan(job) }))
|
||||||
@@ -184,6 +342,7 @@ export default function ConverterPage() {
|
|||||||
jobEntriesRef.current = [];
|
jobEntriesRef.current = [];
|
||||||
setJobEntries([]);
|
setJobEntries([]);
|
||||||
setSelectedEntries([]);
|
setSelectedEntries([]);
|
||||||
|
setSelectionResetToken((t) => t + 1);
|
||||||
setExplorerRefreshToken((t) => t + 1);
|
setExplorerRefreshToken((t) => t + 1);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -221,6 +380,7 @@ export default function ConverterPage() {
|
|||||||
jobEntriesRef.current = [];
|
jobEntriesRef.current = [];
|
||||||
setJobEntries([]);
|
setJobEntries([]);
|
||||||
setSelectedEntries([]);
|
setSelectedEntries([]);
|
||||||
|
setSelectionResetToken((t) => t + 1);
|
||||||
setExplorerRefreshToken((t) => t + 1);
|
setExplorerRefreshToken((t) => t + 1);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -256,6 +416,219 @@ export default function ConverterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleJobStarted = async (jobId) => {
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
@@ -331,7 +704,13 @@ export default function ConverterPage() {
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(`Historieneintrag Job #${jobId} löschen?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Historieneintrag löschen',
|
||||||
|
message: `Historieneintrag Job #${jobId} löschen?`,
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -365,13 +744,20 @@ export default function ConverterPage() {
|
|||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(`Job #${jobId} abbrechen?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Job abbrechen',
|
||||||
|
message: `Job #${jobId} abbrechen?`,
|
||||||
|
acceptLabel: 'Abbrechen',
|
||||||
|
rejectLabel: 'Zurück',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCancelDetailBusy(true);
|
setCancelDetailBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.cancelConverterJob(jobId);
|
await api.cancelConverterJob(jobId);
|
||||||
|
handleJobCancelledImmediate(jobId);
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'Abbruch gesendet',
|
summary: 'Abbruch gesendet',
|
||||||
@@ -411,13 +797,33 @@ export default function ConverterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 handleJobCancelled = () => setTimeout(loadJobs, 1000);
|
||||||
const handleJobInputsChanged = async (_jobId, _payload) => {
|
const handleJobInputsChanged = async (_jobId, _payload) => {
|
||||||
setExplorerRefreshToken((t) => t + 1);
|
setExplorerRefreshToken((t) => t + 1);
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeJobs = jobs.filter((j) => !['DONE', 'FINISHED', 'ERROR', 'CANCELLED'].includes(String(j.status || '').toUpperCase()));
|
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)
|
// Auto-expand: ersten Job aufklappen, wenn keiner expanded ist (außer user hat explizit zugeklappt)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -458,6 +864,7 @@ export default function ConverterPage() {
|
|||||||
<ConverterFileExplorer
|
<ConverterFileExplorer
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
refreshToken={explorerRefreshToken}
|
refreshToken={explorerRefreshToken}
|
||||||
|
selectionResetToken={selectionResetToken}
|
||||||
navigateToPath={explorerNavigateTo}
|
navigateToPath={explorerNavigateTo}
|
||||||
onAssignmentChanged={loadJobs}
|
onAssignmentChanged={loadJobs}
|
||||||
/>
|
/>
|
||||||
@@ -484,21 +891,54 @@ export default function ConverterPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="dashboard-job-list converter-jobs-list">
|
<div className="dashboard-job-list converter-jobs-list">
|
||||||
{activeJobs.map((job) => (
|
{activeJobs.map((job) => {
|
||||||
<ConverterJobCard
|
const plan = parseConverterPlan(job);
|
||||||
key={job.id}
|
const converterMediaType = String(plan?.converterMediaType || '').trim().toLowerCase();
|
||||||
job={job}
|
const isAudioJob = converterMediaType === 'audio';
|
||||||
jobProgress={jobProgress[job.id] || null}
|
if (isAudioJob) {
|
||||||
isExpanded={Number(job.id) === Number(expandedJobId)}
|
return (
|
||||||
onExpand={() => setExpandedJobId(Number(job.id))}
|
<ConverterJobCard
|
||||||
onCollapse={() => setExpandedJobId(null)}
|
key={job.id}
|
||||||
onStarted={handleJobStarted}
|
job={job}
|
||||||
onDeleted={handleJobDeleted}
|
jobProgress={jobProgress[job.id] || null}
|
||||||
onCancelled={handleJobCancelled}
|
isExpanded={Number(job.id) === Number(expandedJobId)}
|
||||||
onOpenDetails={handleOpenJobDetails}
|
onExpand={() => setExpandedJobId(Number(job.id))}
|
||||||
onInputsChanged={handleJobInputsChanged}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -523,6 +963,19 @@ export default function ConverterPage() {
|
|||||||
onHide={() => setJobModeVisible(false)}
|
onHide={() => setJobModeVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MetadataSelectionDialog
|
||||||
|
visible={metadataDialogVisible}
|
||||||
|
context={metadataDialogContext}
|
||||||
|
onHide={() => {
|
||||||
|
if (metadataDialogBusy) return;
|
||||||
|
setMetadataDialogVisible(false);
|
||||||
|
setMetadataDialogContext(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleMetadataSubmit}
|
||||||
|
onSearch={handleOmdbSearch}
|
||||||
|
busy={metadataDialogBusy}
|
||||||
|
/>
|
||||||
|
|
||||||
<JobDetailDialog
|
<JobDetailDialog
|
||||||
visible={detailVisible}
|
visible={detailVisible}
|
||||||
job={selectedJob}
|
job={selectedJob}
|
||||||
@@ -675,3 +1128,263 @@ function JobModeDialog({
|
|||||||
</Dialog>
|
</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="dashboard-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 dashboard-job-poster-fallback">Kein Poster</div>
|
||||||
|
)}
|
||||||
|
<div className="dashboard-job-row-content">
|
||||||
|
<div className="dashboard-job-row-main">
|
||||||
|
<strong className="dashboard-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="dashboard-job-badges">
|
||||||
|
<Tag value={getStatusLabel(status, { queued: isQueued })} severity={getStatusSeverity(normalizeStatus(state), { queued: isQueued })} />
|
||||||
|
</div>
|
||||||
|
{hasProgress && (
|
||||||
|
<div className="dashboard-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="dashboard-job-expanded">
|
||||||
|
<div className="dashboard-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 dashboard-job-poster-fallback">Kein Poster</div>
|
||||||
|
)}
|
||||||
|
<div className="dashboard-job-expanded-title">
|
||||||
|
<strong className="dashboard-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="dashboard-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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import discIndicatorIcon from '../assets/media-disc.svg';
|
|||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
|
||||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
|
||||||
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
|
||||||
@@ -326,6 +327,7 @@ function getStorageUsageTone(usagePercent) {
|
|||||||
function normalizeQueue(queue) {
|
function normalizeQueue(queue) {
|
||||||
const payload = queue && typeof queue === 'object' ? queue : {};
|
const payload = queue && typeof queue === 'object' ? queue : {};
|
||||||
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
|
const runningJobs = Array.isArray(payload.runningJobs) ? payload.runningJobs : [];
|
||||||
|
const idleJobs = Array.isArray(payload.idleJobs) ? payload.idleJobs : [];
|
||||||
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
|
const queuedJobs = Array.isArray(payload.queuedJobs) ? payload.queuedJobs : [];
|
||||||
return {
|
return {
|
||||||
maxParallelJobs: Number(payload.maxParallelJobs || 1),
|
maxParallelJobs: Number(payload.maxParallelJobs || 1),
|
||||||
@@ -335,6 +337,8 @@ function normalizeQueue(queue) {
|
|||||||
runningCount: Number(payload.runningCount || runningJobs.length || 0),
|
runningCount: Number(payload.runningCount || runningJobs.length || 0),
|
||||||
runningCdCount: Number(payload.runningCdCount || 0),
|
runningCdCount: Number(payload.runningCdCount || 0),
|
||||||
runningJobs,
|
runningJobs,
|
||||||
|
idleCount: Number(payload.idleCount || idleJobs.length || 0),
|
||||||
|
idleJobs,
|
||||||
queuedJobs,
|
queuedJobs,
|
||||||
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
|
queuedCount: Number(payload.queuedCount || queuedJobs.length || 0),
|
||||||
updatedAt: payload.updatedAt || null
|
updatedAt: payload.updatedAt || null
|
||||||
@@ -1739,6 +1743,9 @@ export default function DashboardPage({
|
|||||||
if (startOptions.selectedUserPresetId !== undefined) {
|
if (startOptions.selectedUserPresetId !== undefined) {
|
||||||
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
|
confirmPayload.selectedUserPresetId = startOptions.selectedUserPresetId;
|
||||||
}
|
}
|
||||||
|
if (startOptions.selectedHandBrakePreset !== undefined) {
|
||||||
|
confirmPayload.selectedHandBrakePreset = startOptions.selectedHandBrakePreset;
|
||||||
|
}
|
||||||
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
|
await api.confirmEncodeReview(normalizedJobId, confirmPayload);
|
||||||
}
|
}
|
||||||
const response = mediaType === 'audiobook'
|
const response = mediaType === 'audiobook'
|
||||||
@@ -1843,16 +1850,25 @@ export default function DashboardPage({
|
|||||||
jobId,
|
jobId,
|
||||||
selectedEncodeTitleId = null,
|
selectedEncodeTitleId = null,
|
||||||
selectedTrackSelection = null,
|
selectedTrackSelection = null,
|
||||||
selectedPostEncodeScriptIds = undefined
|
selectedPostEncodeScriptIds = undefined,
|
||||||
|
selectedUserPresetId = undefined,
|
||||||
|
selectedHandBrakePreset = undefined
|
||||||
) => {
|
) => {
|
||||||
const normalizedJobId = normalizeJobId(jobId);
|
const normalizedJobId = normalizeJobId(jobId);
|
||||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||||
try {
|
try {
|
||||||
await api.confirmEncodeReview(jobId, {
|
const payload = {
|
||||||
selectedEncodeTitleId,
|
selectedEncodeTitleId,
|
||||||
selectedTrackSelection,
|
selectedTrackSelection,
|
||||||
selectedPostEncodeScriptIds
|
selectedPostEncodeScriptIds
|
||||||
});
|
};
|
||||||
|
if (selectedUserPresetId !== undefined) {
|
||||||
|
payload.selectedUserPresetId = selectedUserPresetId;
|
||||||
|
}
|
||||||
|
if (selectedHandBrakePreset !== undefined) {
|
||||||
|
payload.selectedHandBrakePreset = selectedHandBrakePreset;
|
||||||
|
}
|
||||||
|
await api.confirmEncodeReview(jobId, payload);
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
setExpandedJobId(normalizedJobId);
|
setExpandedJobId(normalizedJobId);
|
||||||
@@ -1927,11 +1943,16 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||||
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
||||||
const confirmed = window.confirm(
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Job löschen',
|
||||||
|
message:
|
||||||
`Job #${normalizedJobId} wirklich löschen?\n` +
|
`Job #${normalizedJobId} wirklich löschen?\n` +
|
||||||
`${title}\n\n` +
|
`${title}\n\n` +
|
||||||
'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'
|
'Hinweis: Dateien bleiben erhalten. Das zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.',
|
||||||
);
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1976,10 +1997,14 @@ export default function DashboardPage({
|
|||||||
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
const job = dashboardJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
|
||||||
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
const title = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||||
if (job?.encodeSuccess) {
|
if (job?.encodeSuccess) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Encode neu starten',
|
||||||
|
message:
|
||||||
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
|
`Encode für "${title}" ist bereits erfolgreich abgeschlossen. Wirklich erneut encodieren?\n` +
|
||||||
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.'
|
'Es wird eine neue Datei mit Kollisionsprüfung angelegt.',
|
||||||
);
|
acceptLabel: 'Neu encodieren',
|
||||||
|
rejectLabel: 'Abbrechen'
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2366,11 +2391,13 @@ export default function DashboardPage({
|
|||||||
const canRescan = !isDriveActive;
|
const canRescan = !isDriveActive;
|
||||||
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
const canOpenMetadataModal = Boolean(defaultMetadataDialogContext?.jobId);
|
||||||
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
const queueRunningJobs = Array.isArray(queueState?.runningJobs) ? queueState.runningJobs : [];
|
||||||
|
const queueIdleJobs = Array.isArray(queueState?.idleJobs) ? queueState.idleJobs : [];
|
||||||
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
const queuedJobs = Array.isArray(queueState?.queuedJobs) ? queueState.queuedJobs : [];
|
||||||
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
|
const canReorderQueue = queuedJobs.length > 1 && !queueReorderBusy;
|
||||||
const queueHasJobEntry = queueRunningJobs.some((i) => i.jobId != null)
|
const queueHasJobEntry = queueRunningJobs.some((i) => i.jobId != null)
|
||||||
|| queuedJobs.some((i) => i.jobId != null);
|
|| queuedJobs.some((i) => i.jobId != null);
|
||||||
const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
|
const buildRunningQueueScriptKey = (jobId) => `running-${normalizeJobId(jobId) || '-'}`;
|
||||||
|
const buildIdleQueueScriptKey = (jobId) => `idle-${normalizeJobId(jobId) || '-'}`;
|
||||||
const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
|
const buildQueuedQueueScriptKey = (entryId) => `queued-${Number(entryId) || '-'}`;
|
||||||
const toggleQueueScriptDetails = (key) => {
|
const toggleQueueScriptDetails = (key) => {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -2394,6 +2421,12 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
validKeys.add(buildRunningQueueScriptKey(item?.jobId));
|
validKeys.add(buildRunningQueueScriptKey(item?.jobId));
|
||||||
}
|
}
|
||||||
|
for (const item of queueIdleJobs) {
|
||||||
|
if (!hasQueueScriptSummary(item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validKeys.add(buildIdleQueueScriptKey(item?.jobId));
|
||||||
|
}
|
||||||
for (const item of queuedJobs) {
|
for (const item of queuedJobs) {
|
||||||
if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
|
if (String(item?.type || 'job') !== 'job' || !hasQueueScriptSummary(item)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -2411,8 +2444,8 @@ export default function DashboardPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
});
|
});
|
||||||
}, [queueRunningJobs, queuedJobs]);
|
}, [queueRunningJobs, queueIdleJobs, queuedJobs]);
|
||||||
const queuedJobIdSet = useMemo(() => {
|
const queuedJobIdSet = useMemo(() => {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
for (const item of queuedJobs) {
|
for (const item of queuedJobs) {
|
||||||
@@ -3090,11 +3123,12 @@ export default function DashboardPage({
|
|||||||
<Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
|
<Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
|
||||||
<div className="pipeline-queue-meta">
|
<div className="pipeline-queue-meta">
|
||||||
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||||
<Tag value={`CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
|
<Tag value={`Audio/CD max.: ${queueState?.maxParallelCdEncodes || 2}`} severity="info" />
|
||||||
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
|
<Tag value={`Gesamt max.: ${queueState?.maxTotalEncodes || 3}`} severity="info" />
|
||||||
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio CDs überspringen die Film-Queue-Reihenfolge" />}
|
{queueState?.cdBypassesQueue && <Tag value="CD bypass" severity="secondary" title="Audio/CD-Jobs überspringen die Film-Queue-Reihenfolge" />}
|
||||||
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
|
<Tag value={`Film laufend: ${queueState?.runningCount || 0}`} severity={(queueState?.runningCount || 0) > 0 ? 'warning' : 'success'} />
|
||||||
<Tag value={`CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
|
<Tag value={`Audio/CD laufend: ${queueState?.runningCdCount || 0}`} severity={(queueState?.runningCdCount || 0) > 0 ? 'warning' : 'success'} />
|
||||||
|
<Tag value={`Idle: ${queueState?.idleCount || 0}`} severity={(queueState?.idleCount || 0) > 0 ? 'info' : 'success'} />
|
||||||
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
|
<Tag value={`Wartend: ${queueState?.queuedCount || 0}`} severity={queuedJobs.length > 0 ? 'warning' : 'success'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3143,6 +3177,44 @@ export default function DashboardPage({
|
|||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pipeline-queue-col">
|
||||||
|
<h4>Idle</h4>
|
||||||
|
{queueIdleJobs.length === 0 ? (
|
||||||
|
<small>Keine Idle-Jobs.</small>
|
||||||
|
) : (
|
||||||
|
queueIdleJobs.map((item) => {
|
||||||
|
const hasScriptSummary = hasQueueScriptSummary(item);
|
||||||
|
const detailKey = buildIdleQueueScriptKey(item?.jobId);
|
||||||
|
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
|
||||||
|
return (
|
||||||
|
<div key={`idle-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
||||||
|
<div className="pipeline-queue-item idle queue-job-item">
|
||||||
|
<div className="pipeline-queue-item-main">
|
||||||
|
<strong>
|
||||||
|
#{item.jobId} | {item.title || `Job #${item.jobId}`}
|
||||||
|
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
|
||||||
|
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
|
||||||
|
</strong>
|
||||||
|
<small>{getStatusLabel(item.status)}</small>
|
||||||
|
</div>
|
||||||
|
{hasScriptSummary ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="queue-job-expand-btn"
|
||||||
|
aria-label={detailsExpanded ? 'Skriptdetails ausblenden' : 'Skriptdetails einblenden'}
|
||||||
|
aria-expanded={detailsExpanded}
|
||||||
|
onClick={() => toggleQueueScriptDetails(detailKey)}
|
||||||
|
>
|
||||||
|
<i className={`pi ${detailsExpanded ? 'pi-angle-up' : 'pi-angle-down'}`} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{detailsExpanded ? <QueueJobScriptSummary item={item} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="pipeline-queue-col">
|
<div className="pipeline-queue-col">
|
||||||
<div className="pipeline-queue-col-header">
|
<div className="pipeline-queue-col-header">
|
||||||
<h4>Warteschlange</h4>
|
<h4>Warteschlange</h4>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Tag } from 'primereact/tag';
|
|||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
function formatDetectedMediaType(value) {
|
function formatDetectedMediaType(value) {
|
||||||
const mediaType = String(value || '').trim().toLowerCase();
|
const mediaType = String(value || '').trim().toLowerCase();
|
||||||
@@ -48,7 +49,12 @@ export default function DatabasePage() {
|
|||||||
|
|
||||||
const handleImportOrphanRaw = async (row) => {
|
const handleImportOrphanRaw = async (row) => {
|
||||||
const target = row?.rawPath || row?.folderName || '-';
|
const target = row?.rawPath || row?.folderName || '-';
|
||||||
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Historienjob anlegen',
|
||||||
|
message: `Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`,
|
||||||
|
acceptLabel: 'Job anlegen',
|
||||||
|
rejectLabel: 'Abbrechen'
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Button } from 'primereact/button';
|
|||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
{ label: 'Alle Stati', value: '' },
|
{ label: 'Alle Stati', value: '' },
|
||||||
@@ -164,7 +165,13 @@ export default function DownloadsPage({ refreshToken = 0 }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const label = row?.archiveName || `ZIP ${id}`;
|
const label = row?.archiveName || `ZIP ${id}`;
|
||||||
const confirmed = window.confirm(`"${label}" wirklich loeschen?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'ZIP löschen',
|
||||||
|
message: `"${label}" wirklich loeschen?`,
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
STATUS_FILTER_OPTIONS
|
STATUS_FILTER_OPTIONS
|
||||||
} from '../utils/statusPresentation';
|
} from '../utils/statusPresentation';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
const MEDIA_FILTER_OPTIONS = [
|
const MEDIA_FILTER_OPTIONS = [
|
||||||
{ label: 'Alle Medien', value: '' },
|
{ label: 'Alle Medien', value: '' },
|
||||||
@@ -274,25 +275,50 @@ function resolveAudiobookDetails(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOutputLabelForRow(row) {
|
function resolveConverterMediaType(row) {
|
||||||
const mediaType = resolveMediaType(row);
|
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : {};
|
||||||
if (mediaType === 'cd') {
|
const value = String(
|
||||||
return 'Audio-Dateien';
|
encodePlan?.converterMediaType
|
||||||
|
|| row?.converterMediaType
|
||||||
|
|| ''
|
||||||
|
).trim().toLowerCase();
|
||||||
|
if (value === 'audio') {
|
||||||
|
return 'audio';
|
||||||
}
|
}
|
||||||
if (mediaType === 'audiobook') {
|
if (value === 'video' || value === 'iso') {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAudioOutput(row) {
|
||||||
|
const mediaType = resolveMediaType(row);
|
||||||
|
if (mediaType === 'cd' || mediaType === 'audiobook') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mediaType === 'converter') {
|
||||||
|
return resolveConverterMediaType(row) === 'audio';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutputLabelForRow(row) {
|
||||||
|
if (resolveMediaType(row) === 'audiobook') {
|
||||||
return 'Audiobook-Datei(en)';
|
return 'Audiobook-Datei(en)';
|
||||||
}
|
}
|
||||||
|
if (hasAudioOutput(row)) {
|
||||||
|
return 'Audio-Dateien';
|
||||||
|
}
|
||||||
return 'Movie-Datei(en)';
|
return 'Movie-Datei(en)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOutputShortLabelForRow(row) {
|
function getOutputShortLabelForRow(row) {
|
||||||
const mediaType = resolveMediaType(row);
|
if (resolveMediaType(row) === 'audiobook') {
|
||||||
if (mediaType === 'cd') {
|
|
||||||
return 'Audio';
|
|
||||||
}
|
|
||||||
if (mediaType === 'audiobook') {
|
|
||||||
return 'Audiobook';
|
return 'Audiobook';
|
||||||
}
|
}
|
||||||
|
if (hasAudioOutput(row)) {
|
||||||
|
return 'Audio';
|
||||||
|
}
|
||||||
return 'Movie';
|
return 'Movie';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,7 +743,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
} else if (action === 'restart_review') {
|
} else if (action === 'restart_review') {
|
||||||
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
const confirmed = window.confirm(`Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Review neu starten',
|
||||||
|
message: `Review für "${title}" neu starten?\nDer Job wird erneut analysiert. Spur- und Skriptauswahl kann danach im Dashboard neu getroffen werden.`,
|
||||||
|
acceptLabel: 'Review starten',
|
||||||
|
rejectLabel: 'Abbrechen'
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -743,7 +774,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
} else if (action === 'restart_cd_review') {
|
} else if (action === 'restart_cd_review') {
|
||||||
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
const title = job?.title || job?.detected_title || `Job #${job?.id}`;
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
const confirmed = window.confirm(`CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'CD-Vorprüfung starten',
|
||||||
|
message: `CD-Vorprüfung für "${title}" starten?\nMusicBrainz-Suche, Trackauswahl und Ausgabeeinstellungen werden im Dashboard geöffnet.`,
|
||||||
|
acceptLabel: 'Vorprüfung starten',
|
||||||
|
rejectLabel: 'Abbrechen'
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -848,7 +884,13 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const outputShortLabel = getOutputShortLabelForRow(row);
|
const outputShortLabel = getOutputShortLabelForRow(row);
|
||||||
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
const label = target === 'raw' ? 'RAW-Dateien' : target === 'movie' ? outputLabel : `RAW + ${outputShortLabel}`;
|
||||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||||
const confirmed = window.confirm(`${label} für "${title}" wirklich löschen?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Dateien löschen',
|
||||||
|
message: `${label} für "${title}" wirklich löschen?`,
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -998,7 +1040,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||||
const mediaType = resolveMediaType(row);
|
const mediaType = resolveMediaType(row);
|
||||||
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
||||||
const confirmed = window.confirm(`${actionLabel} für "${title}" neu starten?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: `${actionLabel} neu starten`,
|
||||||
|
message: `${actionLabel} für "${title}" neu starten?`,
|
||||||
|
acceptLabel: 'Neu starten',
|
||||||
|
rejectLabel: 'Abbrechen'
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1415,6 +1462,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const listItem = (row) => {
|
const listItem = (row) => {
|
||||||
const mediaMeta = resolveMediaTypeMeta(row);
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||||
|
const outputIsAudio = hasAudioOutput(row);
|
||||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||||
const subtitle = isCdJob
|
const subtitle = isCdJob
|
||||||
? [
|
? [
|
||||||
@@ -1460,8 +1508,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
{renderPresenceChip(isCdJob || mediaMeta.mediaType === 'audiobook' ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', isCdJob || mediaMeta.mediaType === 'audiobook' ? Boolean(row?.ripSuccessful) : Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||||
@@ -1476,6 +1524,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
const gridItem = (row) => {
|
const gridItem = (row) => {
|
||||||
const mediaMeta = resolveMediaTypeMeta(row);
|
const mediaMeta = resolveMediaTypeMeta(row);
|
||||||
const isCdJob = mediaMeta.mediaType === 'cd';
|
const isCdJob = mediaMeta.mediaType === 'cd';
|
||||||
|
const outputIsAudio = hasAudioOutput(row);
|
||||||
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
|
||||||
const subtitle = isCdJob
|
const subtitle = isCdJob
|
||||||
? [
|
? [
|
||||||
@@ -1523,8 +1572,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
<div className="history-dv-flags-row">
|
<div className="history-dv-flags-row">
|
||||||
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
|
||||||
{renderPresenceChip(isCdJob || mediaMeta.mediaType === 'audiobook' ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
|
||||||
{renderPresenceChip('Encode', isCdJob || mediaMeta.mediaType === 'audiobook' ? Boolean(row?.ripSuccessful) : Boolean(row?.encodeSuccess))}
|
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
<div className="history-dv-ratings-row">{renderSupplementalInfo(row)}</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { InputSwitch } from 'primereact/inputswitch';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
import DynamicSettingsForm from '../components/DynamicSettingsForm';
|
||||||
import CronJobsTab from '../components/CronJobsTab';
|
import CronJobsTab from '../components/CronJobsTab';
|
||||||
|
import { confirmModal } from '../utils/confirmModal';
|
||||||
|
|
||||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||||
|
|
||||||
@@ -689,7 +690,13 @@ export default function SettingsPage() {
|
|||||||
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
if (!Number.isFinite(scriptId) || scriptId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm(`Script "${script?.name || scriptId}" wirklich löschen?`);
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Script löschen',
|
||||||
|
message: `Script "${script?.name || scriptId}" wirklich löschen?`,
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -933,7 +940,14 @@ export default function SettingsPage() {
|
|||||||
if (!Number.isFinite(chainId) || chainId <= 0) {
|
if (!Number.isFinite(chainId) || chainId <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`Skriptkette "${chain?.name || chainId}" wirklich löschen?`)) {
|
const confirmed = await confirmModal({
|
||||||
|
header: 'Skriptkette löschen',
|
||||||
|
message: `Skriptkette "${chain?.name || chainId}" wirklich löschen?`,
|
||||||
|
acceptLabel: 'Löschen',
|
||||||
|
rejectLabel: 'Abbrechen',
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { confirmDialog } from 'primereact/confirmdialog';
|
||||||
|
|
||||||
|
export function confirmModal(options = {}) {
|
||||||
|
const message = String(options?.message || '').trim();
|
||||||
|
if (!message) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = String(options?.header || 'Bitte bestätigen').trim() || 'Bitte bestätigen';
|
||||||
|
const acceptLabel = String(options?.acceptLabel || 'Ja').trim() || 'Ja';
|
||||||
|
const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
|
||||||
|
const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
|
||||||
|
const danger = Boolean(options?.danger);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let finished = false;
|
||||||
|
const settle = (value) => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
resolve(Boolean(value));
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmDialog({
|
||||||
|
header,
|
||||||
|
message,
|
||||||
|
icon,
|
||||||
|
closable: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
draggable: false,
|
||||||
|
dismissableMask: true,
|
||||||
|
acceptLabel,
|
||||||
|
rejectLabel,
|
||||||
|
...(danger ? { acceptClassName: 'p-button-danger' } : {}),
|
||||||
|
accept: () => settle(true),
|
||||||
|
reject: () => settle(false),
|
||||||
|
onHide: () => settle(false)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.12.0-17",
|
"version": "0.12.0-18",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user