0.16.1-5 Ebcode Fix / presets
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -532,6 +532,9 @@ router.post(
|
||||
'/confirm-encode/:jobId',
|
||||
asyncHandler(async (req, res) => {
|
||||
const jobId = Number(req.params.jobId);
|
||||
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const hasSelectedUserPresetId = Object.prototype.hasOwnProperty.call(body, 'selectedUserPresetId');
|
||||
const hasSelectedHandBrakePreset = Object.prototype.hasOwnProperty.call(body, 'selectedHandBrakePreset');
|
||||
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
|
||||
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
|
||||
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
|
||||
@@ -541,8 +544,12 @@ router.post(
|
||||
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
|
||||
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
|
||||
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
|
||||
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
|
||||
const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null;
|
||||
const selectedUserPresetId = hasSelectedUserPresetId
|
||||
? body.selectedUserPresetId
|
||||
: undefined;
|
||||
const selectedHandBrakePreset = hasSelectedHandBrakePreset
|
||||
? body.selectedHandBrakePreset
|
||||
: undefined;
|
||||
logger.info('post:confirm-encode', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
|
||||
@@ -8,6 +8,7 @@ const pipelineService = require('../services/pipelineService');
|
||||
const wsService = require('../services/websocketService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const userPresetService = require('../services/userPresetService');
|
||||
const userPresetDefaultsService = require('../services/userPresetDefaultsService');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
|
||||
@@ -277,6 +278,72 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
// ── User Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const mediaType = req.query.media_type || null;
|
||||
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
|
||||
const presets = await userPresetService.listPresets(mediaType);
|
||||
res.json({ presets });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/user-preset-defaults',
|
||||
asyncHandler(async (req, res) => {
|
||||
logger.debug('get:user-preset-defaults', { reqId: req.reqId });
|
||||
const defaults = await userPresetDefaultsService.listDefaults();
|
||||
res.json({ defaults });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-preset-defaults',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-preset-defaults:update', { reqId: req.reqId });
|
||||
const defaults = await userPresetDefaultsService.updateDefaults(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'defaults-updated' });
|
||||
res.json({ defaults });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
|
||||
const preset = await userPresetService.createPreset(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
|
||||
res.status(201).json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
|
||||
const preset = await userPresetService.updatePreset(presetId, payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
|
||||
res.json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
|
||||
const removed = await userPresetService.deletePreset(presetId);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:key',
|
||||
asyncHandler(async (req, res) => {
|
||||
@@ -408,52 +475,6 @@ router.put(
|
||||
})
|
||||
);
|
||||
|
||||
// ── User Presets ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const mediaType = req.query.media_type || null;
|
||||
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
|
||||
const presets = await userPresetService.listPresets(mediaType);
|
||||
res.json({ presets });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/user-presets',
|
||||
asyncHandler(async (req, res) => {
|
||||
const payload = req.body || {};
|
||||
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
|
||||
const preset = await userPresetService.createPreset(payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
|
||||
res.status(201).json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
const payload = req.body || {};
|
||||
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
|
||||
const preset = await userPresetService.updatePreset(presetId, payload);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
|
||||
res.json({ preset });
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/user-presets/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const presetId = Number(req.params.id);
|
||||
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
|
||||
const removed = await userPresetService.deletePreset(presetId);
|
||||
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
|
||||
res.json({ removed });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/pushover/test',
|
||||
asyncHandler(async (req, res) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const { buildMediainfoReview, refreshAudioTrackActionsForPlanTitles } = require(
|
||||
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
const userPresetService = require('./userPresetService');
|
||||
const userPresetDefaultsService = require('./userPresetDefaultsService');
|
||||
const thumbnailService = require('./thumbnailService');
|
||||
const activationBytesService = require('./activationBytesService');
|
||||
const { toBoolean } = require('../utils/validators');
|
||||
@@ -8529,6 +8530,119 @@ function normalizeUserPresetForPlan(rawPreset) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveUserPresetDefaultSlotKey(mediaProfile, isSeriesSelection = false) {
|
||||
const normalizedProfile = normalizeMediaProfile(mediaProfile);
|
||||
if (normalizedProfile !== 'dvd' && normalizedProfile !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
return `${normalizedProfile}_${isSeriesSelection ? 'series' : 'movie'}`;
|
||||
}
|
||||
|
||||
async function applyConfiguredDefaultUserPresetToReviewPlan(reviewPlan, options = {}) {
|
||||
const plan = reviewPlan && typeof reviewPlan === 'object'
|
||||
? reviewPlan
|
||||
: null;
|
||||
if (!plan) {
|
||||
return {
|
||||
plan: reviewPlan,
|
||||
applied: false,
|
||||
slotKey: null,
|
||||
presetId: null,
|
||||
presetName: null
|
||||
};
|
||||
}
|
||||
|
||||
const existingUserPreset = normalizeUserPresetForPlan(plan?.userPreset || null);
|
||||
if (existingUserPreset) {
|
||||
return {
|
||||
plan,
|
||||
applied: false,
|
||||
slotKey: null,
|
||||
presetId: existingUserPreset.id || null,
|
||||
presetName: existingUserPreset.name || null
|
||||
};
|
||||
}
|
||||
|
||||
const slotKey = resolveUserPresetDefaultSlotKey(options?.mediaProfile || null, Boolean(options?.isSeriesSelection));
|
||||
if (!slotKey) {
|
||||
return {
|
||||
plan,
|
||||
applied: false,
|
||||
slotKey: null,
|
||||
presetId: null,
|
||||
presetName: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultsMap = await userPresetDefaultsService.listDefaults();
|
||||
const rawPresetId = defaultsMap && typeof defaultsMap === 'object'
|
||||
? defaultsMap[slotKey]
|
||||
: null;
|
||||
const presetId = normalizePositiveInteger(rawPresetId);
|
||||
if (!presetId) {
|
||||
return {
|
||||
plan,
|
||||
applied: false,
|
||||
slotKey,
|
||||
presetId: null,
|
||||
presetName: null
|
||||
};
|
||||
}
|
||||
|
||||
const preset = await userPresetService.getPresetById(presetId);
|
||||
const normalizedPreset = normalizeUserPresetForPlan(preset);
|
||||
if (!normalizedPreset) {
|
||||
return {
|
||||
plan,
|
||||
applied: false,
|
||||
slotKey,
|
||||
presetId,
|
||||
presetName: null
|
||||
};
|
||||
}
|
||||
|
||||
const presetMediaType = String(preset?.mediaType || '').trim().toLowerCase();
|
||||
const normalizedProfile = normalizeMediaProfile(options?.mediaProfile || null);
|
||||
const mediaTypeAllowed = presetMediaType === 'all'
|
||||
|| !presetMediaType
|
||||
|| presetMediaType === normalizedProfile;
|
||||
if (!mediaTypeAllowed) {
|
||||
return {
|
||||
plan,
|
||||
applied: false,
|
||||
slotKey,
|
||||
presetId,
|
||||
presetName: normalizedPreset.name || null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: {
|
||||
...plan,
|
||||
userPreset: normalizedPreset
|
||||
},
|
||||
applied: true,
|
||||
slotKey,
|
||||
presetId: normalizedPreset.id || presetId || null,
|
||||
presetName: normalizedPreset.name || null
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('review:user-preset-default:resolve-failed', {
|
||||
mediaProfile: options?.mediaProfile || null,
|
||||
isSeriesSelection: Boolean(options?.isSeriesSelection),
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
return {
|
||||
plan,
|
||||
applied: false,
|
||||
slotKey,
|
||||
presetId: null,
|
||||
presetName: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildScriptDescriptorList(scriptIds, sourceScripts = []) {
|
||||
const normalizedIds = normalizeScriptIdList(scriptIds);
|
||||
if (normalizedIds.length === 0) {
|
||||
@@ -18257,6 +18371,11 @@ class PipelineService extends EventEmitter {
|
||||
options?.multipartSettingsLock || null
|
||||
);
|
||||
review = multipartLockResult.plan;
|
||||
const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(review, {
|
||||
mediaProfile,
|
||||
isSeriesSelection: true
|
||||
});
|
||||
review = reviewDefaultUserPresetResult.plan;
|
||||
resolvedSelectedTitleIds = normalizeReviewTitleIdList(review.selectedTitleIds || []);
|
||||
resolvedPrimaryTitleId = normalizeReviewTitleId(review.encodeInputTitleId)
|
||||
|| resolvedSelectedTitleIds[0]
|
||||
@@ -18931,6 +19050,11 @@ class PipelineService extends EventEmitter {
|
||||
options?.multipartSettingsLock || null
|
||||
);
|
||||
review = multipartLockResult.plan;
|
||||
const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(review, {
|
||||
mediaProfile,
|
||||
isSeriesSelection: isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext)
|
||||
});
|
||||
review = reviewDefaultUserPresetResult.plan;
|
||||
|
||||
if (!Array.isArray(review.titles) || review.titles.length === 0) {
|
||||
const error = new Error('Titel-/Spurprüfung aus RAW lieferte keine Titel.');
|
||||
@@ -22014,29 +22138,26 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
// Resolve user preset: explicit payload wins, otherwise preserve currently selected preset from encode plan.
|
||||
const hasExplicitUserPresetSelection = !multipartSettingsLocked
|
||||
&& Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId');
|
||||
const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked
|
||||
&& Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset');
|
||||
const rawHandBrakePreset = hasExplicitHandBrakePresetSelection
|
||||
const hasUserPresetIdField = Object.prototype.hasOwnProperty.call(options || {}, 'selectedUserPresetId');
|
||||
const hasHandBrakePresetField = Object.prototype.hasOwnProperty.call(options || {}, 'selectedHandBrakePreset');
|
||||
const explicitUserPresetId = hasUserPresetIdField
|
||||
? normalizePositiveInteger(options?.selectedUserPresetId)
|
||||
: null;
|
||||
const rawHandBrakePreset = hasHandBrakePresetField
|
||||
? String(options?.selectedHandBrakePreset || '').trim()
|
||||
: '';
|
||||
const hasExplicitUserPresetSelection = !multipartSettingsLocked && explicitUserPresetId !== null;
|
||||
const hasExplicitHandBrakePresetSelection = !multipartSettingsLocked && Boolean(rawHandBrakePreset);
|
||||
let resolvedUserPreset = null;
|
||||
if (hasExplicitUserPresetSelection) {
|
||||
const rawUserPresetId = options?.selectedUserPresetId;
|
||||
const userPresetId = rawUserPresetId !== null && rawUserPresetId !== undefined && String(rawUserPresetId).trim() !== ''
|
||||
? Number(rawUserPresetId)
|
||||
: null;
|
||||
if (Number.isFinite(userPresetId) && userPresetId > 0) {
|
||||
resolvedUserPreset = await userPresetService.getPresetById(userPresetId);
|
||||
resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId);
|
||||
if (!resolvedUserPreset) {
|
||||
const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`);
|
||||
const error = new Error(`User-Preset ${explicitUserPresetId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ((!resolvedUserPreset || !hasExplicitUserPresetSelection) && hasExplicitHandBrakePresetSelection) {
|
||||
if (!resolvedUserPreset && hasExplicitHandBrakePresetSelection) {
|
||||
resolvedUserPreset = rawHandBrakePreset
|
||||
? {
|
||||
name: `HandBrake: ${rawHandBrakePreset}`,
|
||||
@@ -22045,8 +22166,10 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (!hasExplicitUserPresetSelection && !hasExplicitHandBrakePresetSelection) {
|
||||
resolvedUserPreset = normalizeUserPresetForPlan(encodePlan?.userPreset || null);
|
||||
if (!resolvedUserPreset) {
|
||||
resolvedUserPreset = normalizeUserPresetForPlan(
|
||||
planForConfirm?.userPreset || encodePlan?.userPreset || null
|
||||
);
|
||||
}
|
||||
|
||||
const confirmedPlan = {
|
||||
@@ -22070,24 +22193,49 @@ class PipelineService extends EventEmitter {
|
||||
const readyMediaProfile = this.resolveMediaProfileForJob(job, {
|
||||
encodePlan: confirmedPlan
|
||||
});
|
||||
if (readyMediaProfile === 'converter' && !confirmedPlan.userPreset) {
|
||||
const error = new Error('Bestätigung nicht möglich: Bitte ein User- oder HandBrake-Preset auswählen.');
|
||||
const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
||||
const confirmedConverterMediaType = String(
|
||||
confirmedPlan?.converterMediaType || encodePlan?.converterMediaType || ''
|
||||
).trim().toLowerCase();
|
||||
const requiresExplicitPreset = readyMediaProfile === 'dvd'
|
||||
|| readyMediaProfile === 'bluray'
|
||||
|| (readyMediaProfile === 'converter' && confirmedConverterMediaType !== 'audio');
|
||||
const planPresetName = String(
|
||||
planForConfirm?.userPreset?.handbrakePreset
|
||||
|| encodePlan?.userPreset?.handbrakePreset
|
||||
|| planForConfirm?.selectors?.preset
|
||||
|| encodePlan?.selectors?.preset
|
||||
|| ''
|
||||
).trim();
|
||||
const planExtraArgs = String(
|
||||
planForConfirm?.userPreset?.extraArgs
|
||||
|| encodePlan?.userPreset?.extraArgs
|
||||
|| planForConfirm?.selectors?.extraArgs
|
||||
|| encodePlan?.selectors?.extraArgs
|
||||
|| ''
|
||||
).trim();
|
||||
const effectivePresetName = String(
|
||||
resolvedUserPreset?.handbrakePreset
|
||||
|| rawHandBrakePreset
|
||||
|| planPresetName
|
||||
|| confirmSettings?.handbrake_preset
|
||||
|| ''
|
||||
).trim();
|
||||
const effectiveExtraArgs = String(
|
||||
resolvedUserPreset?.extraArgs
|
||||
|| planExtraArgs
|
||||
|| confirmSettings?.handbrake_extra_args
|
||||
|| ''
|
||||
).trim();
|
||||
if (requiresExplicitPreset && !effectivePresetName && !effectiveExtraArgs) {
|
||||
const error = new Error('Bestätigung nicht möglich: Kein Preset oder Extra-Args gesetzt. Bitte User-/HandBrake-Preset wählen oder passende Settings hinterlegen.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
||||
const actionDisplaySettings = {
|
||||
...(confirmSettings && typeof confirmSettings === 'object' ? confirmSettings : {}),
|
||||
handbrake_preset: String(
|
||||
resolvedUserPreset?.handbrakePreset
|
||||
|| confirmSettings?.handbrake_preset
|
||||
|| ''
|
||||
).trim() || null,
|
||||
handbrake_extra_args: String(
|
||||
resolvedUserPreset?.extraArgs
|
||||
|| confirmSettings?.handbrake_extra_args
|
||||
|| ''
|
||||
).trim()
|
||||
handbrake_preset: effectivePresetName || null,
|
||||
handbrake_extra_args: effectiveExtraArgs
|
||||
};
|
||||
confirmedPlan.titles = refreshAudioTrackActionsForPlanTitles(
|
||||
confirmedPlan.titles,
|
||||
@@ -23315,6 +23463,11 @@ class PipelineService extends EventEmitter {
|
||||
options?.multipartSettingsLock || null
|
||||
);
|
||||
enrichedReview = multipartLockResult.plan;
|
||||
const reviewDefaultUserPresetResult = await applyConfiguredDefaultUserPresetToReviewPlan(enrichedReview, {
|
||||
mediaProfile,
|
||||
isSeriesSelection: effectiveIsSeriesDvdReview
|
||||
});
|
||||
enrichedReview = reviewDefaultUserPresetResult.plan;
|
||||
const previousEncodePlan = options?.previousEncodePlan && typeof options.previousEncodePlan === 'object'
|
||||
? options.previousEncodePlan
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
const { getDb } = require('../db/database');
|
||||
const logger = require('./logger').child('USER_PRESET_DEFAULTS');
|
||||
|
||||
const SLOT_KEYS = Object.freeze([
|
||||
'bluray_movie',
|
||||
'bluray_series',
|
||||
'dvd_movie',
|
||||
'dvd_series'
|
||||
]);
|
||||
const SLOT_KEY_SET = new Set(SLOT_KEYS);
|
||||
const SLOT_ALLOWED_MEDIA_TYPES = Object.freeze({
|
||||
bluray_movie: new Set(['bluray', 'all']),
|
||||
bluray_series: new Set(['bluray', 'all']),
|
||||
dvd_movie: new Set(['dvd', 'all']),
|
||||
dvd_series: new Set(['dvd', 'all'])
|
||||
});
|
||||
|
||||
function normalizePresetId(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
function buildDefaultsMap(rows = []) {
|
||||
const map = Object.fromEntries(SLOT_KEYS.map((key) => [key, null]));
|
||||
for (const row of rows) {
|
||||
const slotKey = String(row?.slot_key || '').trim();
|
||||
if (!SLOT_KEY_SET.has(slotKey)) {
|
||||
continue;
|
||||
}
|
||||
const presetId = normalizePresetId(row?.preset_id);
|
||||
map[slotKey] = presetId;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function listDefaults() {
|
||||
const db = await getDb();
|
||||
const rows = await db.all(
|
||||
`
|
||||
SELECT slot_key, preset_id
|
||||
FROM user_preset_defaults
|
||||
WHERE slot_key IN (${SLOT_KEYS.map(() => '?').join(', ')})
|
||||
ORDER BY slot_key ASC
|
||||
`,
|
||||
SLOT_KEYS
|
||||
);
|
||||
return buildDefaultsMap(rows);
|
||||
}
|
||||
|
||||
async function updateDefaults(payload = {}) {
|
||||
const defaults = payload && typeof payload === 'object'
|
||||
? (payload.defaults && typeof payload.defaults === 'object' ? payload.defaults : payload)
|
||||
: {};
|
||||
const updates = [];
|
||||
|
||||
for (const [slotKey, rawPresetId] of Object.entries(defaults)) {
|
||||
if (!SLOT_KEY_SET.has(slotKey)) {
|
||||
const error = new Error(`Ungültiger Slot: ${slotKey}`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
updates.push({ slotKey, presetId: normalizePresetId(rawPresetId) });
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return listDefaults();
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
for (const update of updates) {
|
||||
if (update.presetId === null) {
|
||||
continue;
|
||||
}
|
||||
const exists = await db.get(
|
||||
`SELECT id, media_type FROM user_presets WHERE id = ? LIMIT 1`,
|
||||
[update.presetId]
|
||||
);
|
||||
if (!exists) {
|
||||
const error = new Error(`Preset ${update.presetId} nicht gefunden.`);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
const mediaType = String(exists.media_type || '').trim().toLowerCase();
|
||||
const allowedMediaTypes = SLOT_ALLOWED_MEDIA_TYPES[update.slotKey] || new Set(['all']);
|
||||
if (!allowedMediaTypes.has(mediaType)) {
|
||||
const error = new Error(
|
||||
`Preset ${update.presetId} (${mediaType || 'unbekannt'}) ist für ${update.slotKey} nicht zulässig.`
|
||||
);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await db.exec('BEGIN');
|
||||
try {
|
||||
for (const update of updates) {
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO user_preset_defaults (slot_key, preset_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(slot_key) DO UPDATE SET preset_id = excluded.preset_id
|
||||
`,
|
||||
[update.slotKey, update.presetId]
|
||||
);
|
||||
}
|
||||
await db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info('update', {
|
||||
updates: updates.map((entry) => ({
|
||||
slotKey: entry.slotKey,
|
||||
presetId: entry.presetId
|
||||
}))
|
||||
});
|
||||
return listDefaults();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SLOT_KEYS,
|
||||
listDefaults,
|
||||
updateDefaults
|
||||
};
|
||||
@@ -186,12 +186,25 @@ CREATE TABLE user_presets (
|
||||
|
||||
CREATE INDEX idx_user_presets_media_type ON user_presets(media_type);
|
||||
|
||||
CREATE TABLE user_preset_defaults (
|
||||
slot_key TEXT PRIMARY KEY,
|
||||
preset_id INTEGER,
|
||||
FOREIGN KEY (preset_id) REFERENCES user_presets(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_preset_defaults_preset_id ON user_preset_defaults(preset_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aax_activation_bytes (
|
||||
checksum TEXT PRIMARY KEY,
|
||||
activation_bytes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_movie', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('bluray_series', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_movie', NULL);
|
||||
INSERT OR IGNORE INTO user_preset_defaults (slot_key, preset_id) VALUES ('dvd_series', NULL);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_prefs (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
|
||||
@@ -6,14 +6,6 @@ Frontend: React + PrimeReact + Vite.
|
||||
|
||||
## Hauptseiten
|
||||
|
||||
### `JobsInboxPage.jsx`
|
||||
|
||||
Zentrale Job-Inbox:
|
||||
|
||||
- einheitliche Liste über Ripper-, Converter- und Audiobook-Jobs
|
||||
- View-/Status-Filter, Suche, Paginierung
|
||||
- Schnellsprung in passende Detailseite (`Ripper`/`Converter`)
|
||||
|
||||
### `RipperPage.jsx`
|
||||
|
||||
Pipeline-Steuerung:
|
||||
|
||||
+5
-7
@@ -6,7 +6,6 @@ Dieses Kapitel ist das Bedienhandbuch für die Oberflächen im Alltag.
|
||||
|
||||
| Seite | Rolle im Betrieb | Typischer Einsatz |
|
||||
|---|---|---|
|
||||
| [Jobs](jobs.md) | zentrale Inbox | Gesamtlage prüfen und in Fachseite springen |
|
||||
| [Ripper](ripper.md) | Live-Steuerung der Pipeline | Disc-Flow starten, Queue steuern, Review freigeben |
|
||||
| [Audiobooks](audiobooks.md) | AAX-Spezialflow | Upload, Konfiguration und Encode von Audiobooks |
|
||||
| [Settings](settings.md) | Konfiguration/Automatisierung | Pfade, Tools, Queue-Limits, Skripte, Cron |
|
||||
@@ -17,17 +16,16 @@ Dieses Kapitel ist das Bedienhandbuch für die Oberflächen im Alltag.
|
||||
|
||||
## Empfohlener Tagesablauf
|
||||
|
||||
1. `Jobs`: Statuslage und Auffälligkeiten prüfen.
|
||||
2. `Ripper` oder `Converter`/`Audiobooks`: neue Arbeit starten.
|
||||
3. `Historie`: Ergebnisse validieren und ggf. nachbearbeiten.
|
||||
4. `Downloads`: Exporte für externe Übergabe erzeugen.
|
||||
5. `Settings`: Regeln und Limits nur kontrolliert anpassen.
|
||||
1. `Ripper` oder `Converter`/`Audiobooks`: neue Arbeit starten.
|
||||
2. `Historie`: Ergebnisse validieren und ggf. nachbearbeiten.
|
||||
3. `Downloads`: Exporte für externe Übergabe erzeugen.
|
||||
4. `Settings`: Regeln und Limits nur kontrolliert anpassen.
|
||||
|
||||
## Navigation
|
||||
|
||||
Direkt in der Top-Navigation:
|
||||
|
||||
- `Jobs`, `Ripper`, `Converter`, `Audiobooks`, `Settings`, `Historie`, `Downloads`
|
||||
- `Ripper`, `Converter`, `Audiobooks`, `Settings`, `Historie`, `Downloads`
|
||||
|
||||
Nur per URL:
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Jobs
|
||||
|
||||
`Jobs` ist die zentrale Inbox über alle Jobarten und dient als schneller Einstieg in den Tagesbetrieb.
|
||||
|
||||
## Zweck der Seite
|
||||
|
||||
Die Liste vereinheitlicht:
|
||||
|
||||
- Disc-/Ripper-Jobs
|
||||
- Converter-Jobs
|
||||
- Audiobook-Jobs
|
||||
|
||||
Dadurch kannst du unabhängig von der Fachseite zuerst den Gesamtzustand prüfen.
|
||||
|
||||
## Filterlogik
|
||||
|
||||
### View-Filter
|
||||
|
||||
- `Alle Jobs`
|
||||
- `Ripper-View`
|
||||
- `Converter-View`
|
||||
- `Audiobooks`
|
||||
|
||||
### Status-Filter
|
||||
|
||||
- `Alle`
|
||||
- `Aktiv`
|
||||
- `Fehler/Abbruch`
|
||||
|
||||
Zusätzlich:
|
||||
|
||||
- Freitextsuche
|
||||
- Paginierung
|
||||
|
||||
## Was ein Jobeintrag zeigt
|
||||
|
||||
- Job-ID und Titel
|
||||
- Statusbadge
|
||||
- Jobtypbadge (z. B. Blu-ray, DVD, Converter Audio, Audiobook)
|
||||
- Aktualisierungszeit
|
||||
|
||||
Die Ansicht wird periodisch aktualisiert und ergänzt sich mit WebSocket-Events.
|
||||
|
||||
## Schnellaktion pro Zeile
|
||||
|
||||
- `Im Ripper öffnen` für Disc-/Audiobook-nahe Jobs
|
||||
- `Im Converter öffnen` für Converter-Jobs
|
||||
|
||||
So kommst du ohne Umwege in die Oberfläche mit den passenden Detailaktionen.
|
||||
|
||||
## Empfohlener Einsatz
|
||||
|
||||
1. In `Jobs` Störungen und Auslastung identifizieren.
|
||||
2. Danach gezielt in `Ripper`, `Converter` oder `Historie` springen.
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+33
-5
@@ -13,7 +13,6 @@ import HistoryPage from './pages/HistoryPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
import DownloadsPage from './pages/DownloadsPage';
|
||||
import ConverterPage from './pages/ConverterPage';
|
||||
import JobsInboxPage from './pages/JobsInboxPage';
|
||||
import AudiobooksPage from './pages/AudiobooksPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
@@ -150,6 +149,14 @@ function App() {
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
const prevCdDrivesRef = useRef({});
|
||||
const hardwareWarmupRef = useRef({ timer: null, attempts: 0 });
|
||||
|
||||
const clearHardwareWarmupRetry = () => {
|
||||
if (hardwareWarmupRef.current?.timer) {
|
||||
clearTimeout(hardwareWarmupRef.current.timer);
|
||||
hardwareWarmupRef.current.timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// When a virtual CD drive is removed (CD encode/rip finished or failed),
|
||||
// or when a CD drive transitions away from an active job, force both
|
||||
@@ -194,7 +201,23 @@ function App() {
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
setPipeline(response.pipeline);
|
||||
setHardwareMonitoring(response?.hardwareMonitoring || null);
|
||||
const monitoringPayload = response?.hardwareMonitoring || null;
|
||||
setHardwareMonitoring(monitoringPayload);
|
||||
const monitoringEnabled = Boolean(monitoringPayload?.enabled);
|
||||
const hasSample = Boolean(monitoringPayload?.sample && typeof monitoringPayload.sample === 'object');
|
||||
if (monitoringEnabled && !hasSample) {
|
||||
const nextAttempts = Number(hardwareWarmupRef.current?.attempts || 0) + 1;
|
||||
hardwareWarmupRef.current.attempts = nextAttempts;
|
||||
if (nextAttempts <= 10 && !hardwareWarmupRef.current.timer) {
|
||||
hardwareWarmupRef.current.timer = setTimeout(() => {
|
||||
hardwareWarmupRef.current.timer = null;
|
||||
refreshPipeline().catch(() => null);
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
hardwareWarmupRef.current.attempts = 0;
|
||||
clearHardwareWarmupRetry();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -310,10 +333,17 @@ function App() {
|
||||
setExpertMode(val === 'true' || val === true);
|
||||
})
|
||||
.catch(() => null);
|
||||
return () => {
|
||||
clearHardwareWarmupRetry();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
if (message.type === 'WS_CONNECTED') {
|
||||
refreshPipeline().catch(() => null);
|
||||
}
|
||||
|
||||
if (message.type === 'PIPELINE_STATE_CHANGED') {
|
||||
setPipeline(message.payload);
|
||||
}
|
||||
@@ -575,7 +605,6 @@ function App() {
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ label: 'Jobs', path: '/jobs' },
|
||||
{ label: 'Ripper', path: '/ripper' },
|
||||
{ label: 'Converter', path: '/converter' },
|
||||
{ label: 'Audiobooks', path: '/audiobooks' },
|
||||
@@ -705,13 +734,12 @@ function App() {
|
||||
</RipperPage>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="jobs" replace />} />
|
||||
<Route index element={<Navigate to="ripper" replace />} />
|
||||
<Route path="ripper" element={null} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="database" element={<DatabasePage />} />
|
||||
<Route path="jobs" element={<JobsInboxPage />} />
|
||||
<Route path="converter" element={<ConverterPage />} />
|
||||
<Route
|
||||
path="audiobooks"
|
||||
|
||||
@@ -1012,12 +1012,26 @@ export const api = {
|
||||
forceRefresh: options.forceRefresh
|
||||
});
|
||||
},
|
||||
getUserPresetDefaults(options = {}) {
|
||||
return requestCachedGet('/settings/user-preset-defaults', {
|
||||
ttlMs: 2 * 60 * 1000,
|
||||
forceRefresh: options.forceRefresh
|
||||
});
|
||||
},
|
||||
async updateUserPresetDefaults(defaults = {}) {
|
||||
const result = await request('/settings/user-preset-defaults', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ defaults })
|
||||
});
|
||||
afterMutationInvalidate(['/settings/user-preset-defaults']);
|
||||
return result;
|
||||
},
|
||||
async createUserPreset(payload = {}) {
|
||||
const result = await request('/settings/user-presets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
afterMutationInvalidate(['/settings/user-presets']);
|
||||
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
|
||||
return result;
|
||||
},
|
||||
async updateUserPreset(id, payload = {}) {
|
||||
@@ -1025,14 +1039,14 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
afterMutationInvalidate(['/settings/user-presets']);
|
||||
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
|
||||
return result;
|
||||
},
|
||||
async deleteUserPreset(id) {
|
||||
const result = await request(`/settings/user-presets/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
afterMutationInvalidate(['/settings/user-presets']);
|
||||
afterMutationInvalidate(['/settings/user-presets', '/settings/user-preset-defaults']);
|
||||
return result;
|
||||
},
|
||||
|
||||
|
||||
@@ -955,32 +955,7 @@ function parseAudioSelectorFromArgs(extraArgsString, baseSelector) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// Tokenize: split on whitespace but respect quoted strings
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const ch = args[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ' ' || ch === '\t') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
const tokens = tokenizeCliArgs(args);
|
||||
|
||||
const result = { ...base };
|
||||
|
||||
@@ -1026,6 +1001,113 @@ function parseAudioSelectorFromArgs(extraArgsString, baseSelector) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSubtitleSelectorFromArgs(extraArgsString, baseSelector) {
|
||||
const base = baseSelector && typeof baseSelector === 'object' ? baseSelector : {};
|
||||
const args = String(extraArgsString || '').trim();
|
||||
if (!args) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const tokens = tokenizeCliArgs(args);
|
||||
const result = { ...base };
|
||||
const getNext = (i) => (i + 1 < tokens.length ? tokens[i + 1] : null);
|
||||
const parseTrackId = (raw) => {
|
||||
const first = String(raw || '').split(',').map((item) => String(item || '').trim()).filter(Boolean)[0] || '';
|
||||
const parsed = Number(first);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : null;
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
const eqIdx = token.indexOf('=');
|
||||
const flag = eqIdx !== -1 ? token.slice(0, eqIdx) : token;
|
||||
const inlineVal = eqIdx !== -1 ? token.slice(eqIdx + 1) : null;
|
||||
|
||||
const getValue = () => {
|
||||
if (inlineVal !== null) return inlineVal;
|
||||
const next = getNext(i);
|
||||
if (next && !next.startsWith('-')) {
|
||||
i++;
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (flag === '--all-subtitles') {
|
||||
result.mode = 'all';
|
||||
} else if (flag === '--first-subtitle') {
|
||||
result.mode = 'first';
|
||||
result.firstOnly = true;
|
||||
} else if (flag === '--subtitle-lang-list') {
|
||||
const val = getValue();
|
||||
if (val) {
|
||||
result.mode = 'language';
|
||||
result.languages = val.split(',').map((item) => String(item || '').trim()).filter(Boolean);
|
||||
}
|
||||
} else if (flag === '--subtitle' || flag === '-s') {
|
||||
const val = getValue();
|
||||
if (val) {
|
||||
const normalized = String(val || '').trim().toLowerCase();
|
||||
result.mode = normalized === 'none' ? 'none' : 'explicit';
|
||||
}
|
||||
} else if (flag === '--subtitle-burned') {
|
||||
result.burnBehavior = 'first';
|
||||
const val = getValue();
|
||||
const parsedTrack = parseTrackId(val);
|
||||
if (parsedTrack !== null) {
|
||||
result.burnedTrackId = parsedTrack;
|
||||
}
|
||||
} else if (flag === '--subtitle-default') {
|
||||
const val = getValue();
|
||||
const parsedTrack = parseTrackId(val);
|
||||
if (parsedTrack !== null) {
|
||||
result.defaultTrackId = parsedTrack;
|
||||
}
|
||||
} else if (flag === '--subtitle-forced') {
|
||||
const val = getValue();
|
||||
const parsedTrack = parseTrackId(val);
|
||||
if (parsedTrack !== null) {
|
||||
result.forcedTrackId = parsedTrack;
|
||||
result.forcedOnly = false;
|
||||
} else {
|
||||
result.forcedOnly = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function tokenizeCliArgs(argsString) {
|
||||
const source = String(argsString || '');
|
||||
const tokens = [];
|
||||
let current = '';
|
||||
let inQuote = null;
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === ' ' || ch === '\t') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function buildAudioActionPreviewSummary(track, selectedIndex, audioSelector) {
|
||||
const selector = audioSelector && typeof audioSelector === 'object' ? audioSelector : {};
|
||||
const availableEncoders = Array.isArray(selector.encoders) ? selector.encoders : [];
|
||||
@@ -1412,7 +1494,10 @@ export default function MediaInfoReviewPanel({
|
||||
selectedUserPresetId = null,
|
||||
onUserPresetChange = null,
|
||||
selectedHandBrakePreset = '',
|
||||
comparisonReviewItems = []
|
||||
onHandBrakePresetChange = null,
|
||||
handBrakePresetOptions = [],
|
||||
comparisonReviewItems = [],
|
||||
topContent = null
|
||||
}) {
|
||||
if (!review) {
|
||||
return <p>Keine Mediainfo-Daten vorhanden.</p>;
|
||||
@@ -1647,6 +1732,11 @@ export default function MediaInfoReviewPanel({
|
||||
const processedFiles = Number(review.processedFiles || displayTitles.length || 0);
|
||||
const totalFiles = Number(review.totalFiles || displayTitles.length || 0);
|
||||
const playlistRecommendation = review.playlistRecommendation || null;
|
||||
const selectedPlaylistId = String(
|
||||
review?.selectedPlaylistId
|
||||
?? review?.selectedPlaylist
|
||||
?? ''
|
||||
).trim();
|
||||
const rawPreset = String(review.selectors?.preset || '').trim();
|
||||
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
|
||||
const multipartSettingsLock = review?.multipartSettingsLock && typeof review.multipartSettingsLock === 'object'
|
||||
@@ -1661,14 +1751,47 @@ export default function MediaInfoReviewPanel({
|
||||
&& Number(multipartSettingsLock.sourceDiscNumber) > 0
|
||||
? Math.trunc(Number(multipartSettingsLock.sourceDiscNumber))
|
||||
: null;
|
||||
const isTitleSelectionMode = Boolean(
|
||||
review?.playlistDecisionRequired
|
||||
|| review?.titleSelectionRequired
|
||||
|| review?.handBrakeTitleDecisionRequired
|
||||
);
|
||||
const playlistAlreadyAccepted = !isTitleSelectionMode && Boolean(selectedPlaylistId);
|
||||
const hiddenTechnicalNotePatternsWhenPlaylistAccepted = [
|
||||
/^Preset:\s/i,
|
||||
/^Extra Args:\s/i,
|
||||
/^Preset-Quelle:\s/i,
|
||||
/^Preset-Defaults werden als Basis genutzt\./i,
|
||||
/^Preset-Hinweis:\s/i,
|
||||
/^Manuelle Playlist-Auswahl aktiv:\s/i,
|
||||
/^MakeMKV Full-Analyse wurde einmal für Playlist-\/Titel-Mapping verwendet\./i,
|
||||
/^HandBrake Track-Analyse aktiv:\s/i
|
||||
];
|
||||
const visibleReviewNotes = Array.isArray(review.notes)
|
||||
? review.notes.filter((note) => {
|
||||
const normalized = String(note || '').trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (!playlistAlreadyAccepted) {
|
||||
return true;
|
||||
}
|
||||
return !hiddenTechnicalNotePatternsWhenPlaylistAccepted
|
||||
.some((pattern) => pattern.test(normalized));
|
||||
})
|
||||
: [];
|
||||
|
||||
// User preset resolution
|
||||
const normalizedUserPresets = Array.isArray(userPresets) ? userPresets : [];
|
||||
const normalizedHandBrakePresetOptions = Array.isArray(handBrakePresetOptions) ? handBrakePresetOptions : [];
|
||||
const selectedUserPreset = selectedUserPresetId
|
||||
? normalizedUserPresets.find((p) => Number(p.id) === Number(selectedUserPresetId)) || null
|
||||
: null;
|
||||
const hasUserPresets = normalizedUserPresets.length > 0;
|
||||
const allowUserPresetSelection = hasUserPresets && typeof onUserPresetChange === 'function' && allowEncodeItemSelection;
|
||||
const hasHandBrakePresetOptions = normalizedHandBrakePresetOptions.some((option) => !option?.disabled);
|
||||
const allowPresetSelection = allowEncodeItemSelection
|
||||
&& (typeof onUserPresetChange === 'function' || typeof onHandBrakePresetChange === 'function')
|
||||
&& (hasUserPresets || hasHandBrakePresetOptions);
|
||||
const isUserPresetActive = Boolean(selectedUserPreset);
|
||||
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
|
||||
const isHandBrakePresetActive = !isUserPresetActive && Boolean(trimmedHandBrakePreset);
|
||||
@@ -1678,9 +1801,97 @@ export default function MediaInfoReviewPanel({
|
||||
const effectiveAudioSelector = effectivePresetOverride?.extraArgs
|
||||
? parseAudioSelectorFromArgs(effectivePresetOverride.extraArgs, review?.selectors?.audio)
|
||||
: (review?.selectors?.audio || null);
|
||||
const presetSourceLabel = isUserPresetActive
|
||||
? `User-Preset: ${selectedUserPreset?.name || 'Preset'}`
|
||||
: (isHandBrakePresetActive ? 'HandBrake-Preset' : '');
|
||||
const activePresetDisplayLabel = isUserPresetActive
|
||||
? 'User-Preset'
|
||||
: ((isHandBrakePresetActive || String(presetLabel || '').trim()) ? 'HandBrake-Preset' : '(kein Preset)');
|
||||
const minLengthMinutes = Number(review?.minLengthMinutes);
|
||||
const minLengthLabel = Number.isFinite(minLengthMinutes)
|
||||
? (minLengthMinutes <= 0
|
||||
? 'Kein Mindestwert (deaktiviert)'
|
||||
: `${Math.trunc(minLengthMinutes)} Minute(n)`)
|
||||
: '-';
|
||||
const subtitleSelector = review?.selectors?.subtitle && typeof review.selectors.subtitle === 'object'
|
||||
? review.selectors.subtitle
|
||||
: {};
|
||||
const effectiveSubtitleSelector = effectivePresetOverride?.extraArgs
|
||||
? parseSubtitleSelectorFromArgs(effectivePresetOverride.extraArgs, subtitleSelector)
|
||||
: subtitleSelector;
|
||||
const subtitleModeLabelMap = {
|
||||
all: 'Alle Spuren',
|
||||
first: 'Erste Spur',
|
||||
language: 'Nach Sprache',
|
||||
explicit: 'Manuell (Track-IDs)',
|
||||
none: 'Keine Spuren'
|
||||
};
|
||||
const subtitleModeLabel = subtitleModeLabelMap[String(effectiveSubtitleSelector?.mode || '').trim().toLowerCase()] || '-';
|
||||
const subtitleFlagLabels = [];
|
||||
if (effectiveSubtitleSelector?.burnBehavior === 'first') {
|
||||
subtitleFlagLabels.push('Burn-In (erste Subtitle-Spur)');
|
||||
}
|
||||
if (effectiveSubtitleSelector?.defaultTrackId != null) {
|
||||
subtitleFlagLabels.push(`Default-Track #${effectiveSubtitleSelector.defaultTrackId}`);
|
||||
}
|
||||
if (effectiveSubtitleSelector?.forcedTrackId != null) {
|
||||
subtitleFlagLabels.push(`Forced-Track #${effectiveSubtitleSelector.forcedTrackId}`);
|
||||
} else if (effectiveSubtitleSelector?.forcedOnly) {
|
||||
subtitleFlagLabels.push('Forced-Only');
|
||||
}
|
||||
const subtitleFlagsLabel = subtitleFlagLabels.join(', ') || 'Keine';
|
||||
const audioEncoderSourceLabel = String(effectiveAudioSelector?.encoderSource || '').trim() || 'default';
|
||||
const audioCopyMaskSourceLabel = String(effectiveAudioSelector?.copyMaskSource || '').trim() || 'default';
|
||||
const audioFallbackSourceLabel = String(effectiveAudioSelector?.fallbackSource || '').trim() || 'default';
|
||||
const presetDropdownOptions = (() => {
|
||||
const options = [];
|
||||
if (hasUserPresets) {
|
||||
options.push({ label: 'User-Presets/', value: '__group__userpresets', disabled: true });
|
||||
for (const preset of normalizedUserPresets) {
|
||||
const presetId = Number(preset?.id);
|
||||
if (!Number.isFinite(presetId) || presetId <= 0) {
|
||||
continue;
|
||||
}
|
||||
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
|
||||
const mediaTypeLabel = mediaType && mediaType !== 'all'
|
||||
? ` [${mediaType === 'bluray' ? 'Blu-ray' : mediaType === 'dvd' ? 'DVD' : mediaType}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: `\u00A0\u00A0\u00A0${String(preset?.name || `Preset #${presetId}`).trim() || `Preset #${presetId}`}${mediaTypeLabel}`,
|
||||
value: `user:${Math.trunc(presetId)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const option of normalizedHandBrakePresetOptions) {
|
||||
if (!option) {
|
||||
continue;
|
||||
}
|
||||
if (option.disabled) {
|
||||
options.push({
|
||||
label: String(option?.label || '').trim() || 'HandBrake/',
|
||||
value: String(option?.value || option?.label || '').trim() || `__group__hb_${options.length}`,
|
||||
disabled: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const value = String(option?.value || '').trim();
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
options.push({
|
||||
label: String(option?.label || value).trim() || value,
|
||||
value: `hb:${value}`
|
||||
});
|
||||
}
|
||||
const selectedPresetValue = String(selectedHandBrakePreset || '').trim();
|
||||
if (selectedPresetValue && !options.some((entry) => String(entry?.value || '').trim() === `hb:${selectedPresetValue}`)) {
|
||||
options.push({
|
||||
label: selectedPresetValue,
|
||||
value: `hb:${selectedPresetValue}`
|
||||
});
|
||||
}
|
||||
return options;
|
||||
})();
|
||||
const selectedPresetToken = isUserPresetActive
|
||||
? `user:${selectedUserPreset.id}`
|
||||
: (trimmedHandBrakePreset ? `hb:${trimmedHandBrakePreset}` : null);
|
||||
|
||||
const scriptCatalog = (Array.isArray(availableScripts) ? availableScripts : [])
|
||||
.map((item) => ({
|
||||
@@ -1872,22 +2083,42 @@ export default function MediaInfoReviewPanel({
|
||||
|
||||
return (
|
||||
<div className="media-review-wrap">
|
||||
{!compactTitleSelection && allowUserPresetSelection && (
|
||||
{topContent}
|
||||
{!compactTitleSelection && allowPresetSelection && (
|
||||
<div className="user-preset-selector" style={{ marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
|
||||
Encode-Preset auswählen
|
||||
</label>
|
||||
<Dropdown
|
||||
value={selectedUserPresetId ? Number(selectedUserPresetId) : null}
|
||||
options={[
|
||||
{ label: '(Einstellungen-Fallback)', value: null },
|
||||
...normalizedUserPresets.map((p) => ({
|
||||
label: `${p.name}${p.mediaType !== 'all' ? ` [${p.mediaType === 'bluray' ? 'Blu-ray' : p.mediaType === 'dvd' ? 'DVD' : 'Sonstiges'}]` : ''}`,
|
||||
value: Number(p.id)
|
||||
}))
|
||||
]}
|
||||
onChange={(e) => onUserPresetChange(e.value)}
|
||||
placeholder="(Einstellungen-Fallback)"
|
||||
value={selectedPresetToken}
|
||||
options={presetDropdownOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
onChange={(e) => {
|
||||
const value = String(e?.value || '').trim();
|
||||
if (value.startsWith('user:')) {
|
||||
const presetId = Number(value.slice(5));
|
||||
if (typeof onUserPresetChange === 'function') {
|
||||
onUserPresetChange(Number.isFinite(presetId) && presetId > 0 ? Math.trunc(presetId) : null);
|
||||
}
|
||||
if (typeof onHandBrakePresetChange === 'function') {
|
||||
onHandBrakePresetChange('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.startsWith('hb:')) {
|
||||
const presetName = value.slice(3);
|
||||
if (typeof onUserPresetChange === 'function') {
|
||||
onUserPresetChange(null);
|
||||
}
|
||||
if (typeof onHandBrakePresetChange === 'function') {
|
||||
onHandBrakePresetChange(presetName);
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Preset auswählen …"
|
||||
filter
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{selectedUserPreset && (
|
||||
@@ -1905,37 +2136,13 @@ export default function MediaInfoReviewPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!compactTitleSelection ? (
|
||||
{!compactTitleSelection && !isTitleSelectionMode ? (
|
||||
<div className="media-review-meta">
|
||||
<div>
|
||||
<strong>Preset:</strong>{' '}
|
||||
{effectivePresetOverride
|
||||
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
|
||||
: presetLabel}
|
||||
{effectivePresetOverride && presetSourceLabel && (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>
|
||||
({presetSourceLabel})
|
||||
</span>
|
||||
)}
|
||||
{activePresetDisplayLabel}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Extra Args:</strong>{' '}
|
||||
{effectivePresetOverride
|
||||
? (effectivePresetOverride.extraArgs || '(keine)')
|
||||
: (review.selectors?.extraArgs || '(keine)')}
|
||||
{effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', opacity: 0.7 }}>(aus User-Preset)</span>
|
||||
)}
|
||||
</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>Encode Input:</strong> {encodeInputTitle?.fileName || '-'}</div>
|
||||
<div><strong>Audio Auswahl:</strong> {effectiveAudioSelector?.mode || '-'}</div>
|
||||
<div><strong>Audio Encoder:</strong> {(effectiveAudioSelector?.encoders || []).join(', ') || 'Preset-Default'}</div>
|
||||
<div><strong>Audio Copy-Mask:</strong> {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}</div>
|
||||
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
|
||||
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
|
||||
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
|
||||
<div><strong>Titel-Mindestlänge:</strong> {minLengthLabel}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1943,7 +2150,7 @@ export default function MediaInfoReviewPanel({
|
||||
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
|
||||
) : null}
|
||||
|
||||
{!compactTitleSelection && playlistRecommendation ? (
|
||||
{!compactTitleSelection && !isTitleSelectionMode && !playlistAlreadyAccepted && playlistRecommendation ? (
|
||||
<div className="playlist-recommendation-box">
|
||||
<small>
|
||||
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
|
||||
@@ -1953,9 +2160,9 @@ export default function MediaInfoReviewPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? (
|
||||
{!compactTitleSelection && !isTitleSelectionMode && visibleReviewNotes.length > 0 ? (
|
||||
<div className="media-review-notes">
|
||||
{review.notes.map((note, idx) => (
|
||||
{visibleReviewNotes.map((note, idx) => (
|
||||
<small key={`${idx}-${note}`}>{note}</small>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -829,6 +829,16 @@ function formatPercentNoDecimals(value) {
|
||||
return `${Math.trunc(Math.max(0, Math.min(100, parsed)))}%`;
|
||||
}
|
||||
|
||||
function isPlaylistTrackPreparationStatus(value) {
|
||||
const normalized = String(value || '').trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized.includes('PLAYLIST')
|
||||
&& normalized.includes('TRACKDATEN')
|
||||
&& normalized.includes('VORBEREIT');
|
||||
}
|
||||
|
||||
function getSeriesBatchTagSeverity(status) {
|
||||
const normalized = normalizeSeriesBatchStatus(status);
|
||||
if (normalized === 'FINISHED') {
|
||||
@@ -1401,7 +1411,7 @@ function resolveProfiledSetting(settings, key, mediaProfile) {
|
||||
? ['dvd']
|
||||
: mediaProfile === 'dvd'
|
||||
? ['bluray']
|
||||
: [];
|
||||
: ['bluray', 'dvd', 'cd', 'audiobook'];
|
||||
for (const fb of fallbackProfiles) {
|
||||
const fbKey = `${key}_${fb}`;
|
||||
if (settings?.[fbKey] != null && settings[fbKey] !== '') {
|
||||
@@ -1850,12 +1860,17 @@ export default function PipelineStatusCard({
|
||||
const nextOutputFormat = String(mediaInfoReview?.outputFormat || 'mkv').trim().toLowerCase() || 'mkv';
|
||||
setSelectedOutputFormat(nextOutputFormat);
|
||||
const userPresetId = Number(mediaInfoReview?.userPreset?.id);
|
||||
const settingsPresetName = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||
if (Number.isFinite(userPresetId) && userPresetId > 0) {
|
||||
setSelectedUserPresetId(Math.trunc(userPresetId));
|
||||
setSelectedHandBrakePreset('');
|
||||
} else {
|
||||
setSelectedUserPresetId(null);
|
||||
const hbPresetName = String(mediaInfoReview?.userPreset?.handbrakePreset || '').trim();
|
||||
const hbPresetName = String(
|
||||
mediaInfoReview?.userPreset?.handbrakePreset
|
||||
|| settingsPresetName
|
||||
|| ''
|
||||
).trim();
|
||||
setSelectedHandBrakePreset(hbPresetName);
|
||||
}
|
||||
}, [
|
||||
@@ -1866,6 +1881,7 @@ export default function PipelineStatusCard({
|
||||
mediaInfoReview?.outputFormat,
|
||||
mediaInfoReview?.userPreset?.id,
|
||||
mediaInfoReview?.userPreset?.handbrakePreset,
|
||||
mediaInfoReview?.selectors?.preset,
|
||||
selectedMetadata?.seasonNumber,
|
||||
selectedMetadata?.episodes,
|
||||
selectedMetadata?.metadataProvider,
|
||||
@@ -2294,10 +2310,12 @@ export default function PipelineStatusCard({
|
||||
const hasSelectedEncodeTitle = normalizeTitleIdList(selectedEncodeTitleIds).length > 0;
|
||||
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
|
||||
const trimmedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
|
||||
const settingsPresetFallback = String(mediaInfoReview?.selectors?.preset || '').trim();
|
||||
const hasSelectedPreset = Boolean(selectedUserPresetId) || Boolean(trimmedHandBrakePreset);
|
||||
const hasEffectiveSelectedPreset = hasSelectedPreset || Boolean(settingsPresetFallback);
|
||||
const converterMediaType = String(mediaInfoReview?.converterMediaType || 'video').trim().toLowerCase() || 'video';
|
||||
const showConverterConfig = jobMediaProfile === 'converter' && converterMediaType !== 'audio';
|
||||
const requiresPresetSelection = showConverterConfig;
|
||||
const requiresPresetSelection = showConverterConfig || jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray';
|
||||
const multipartSettingsLock = mediaInfoReview?.multipartSettingsLock
|
||||
&& typeof mediaInfoReview.multipartSettingsLock === 'object'
|
||||
? mediaInfoReview.multipartSettingsLock
|
||||
@@ -2335,7 +2353,7 @@ export default function PipelineStatusCard({
|
||||
const canStartReadyJob = (isPreRipReview
|
||||
? Boolean(retryJobId)
|
||||
: Boolean(retryJobId && encodeInputPath))
|
||||
&& (!requiresPresetSelection || hasSelectedPreset);
|
||||
&& (!requiresPresetSelection || hasEffectiveSelectedPreset);
|
||||
const canRestartEncodeFromLastSettings = Boolean(
|
||||
(state === 'ERROR' || state === 'CANCELLED')
|
||||
&& retryJobId
|
||||
@@ -3076,6 +3094,8 @@ export default function PipelineStatusCard({
|
||||
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
|
||||
)
|
||||
: (pipeline?.statusText || 'Bereit');
|
||||
const showIndeterminatePrimaryProgress = !hasSeriesBatchProgress
|
||||
&& isPlaylistTrackPreparationStatus(pipeline?.statusText);
|
||||
const ripIssueEntries = useMemo(
|
||||
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
|
||||
[jobRow?.makemkvInfo]
|
||||
@@ -3594,13 +3614,19 @@ export default function PipelineStatusCard({
|
||||
|
||||
{running && (
|
||||
<div className="progress-wrap">
|
||||
{showIndeterminatePrimaryProgress ? (
|
||||
<ProgressBar mode="indeterminate" showValue={false} />
|
||||
) : (
|
||||
<ProgressBar
|
||||
value={primaryProgressValue}
|
||||
showValue
|
||||
displayValueTemplate={(value) => formatPercentNoDecimals(value)}
|
||||
/>
|
||||
)}
|
||||
<small>
|
||||
{primaryEta
|
||||
{showIndeterminatePrimaryProgress
|
||||
? 'Vorbereitung läuft …'
|
||||
: primaryEta
|
||||
? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`)
|
||||
: 'ETA unbekannt'}
|
||||
</small>
|
||||
@@ -3791,10 +3817,14 @@ export default function PipelineStatusCard({
|
||||
selectedPostEncodeScriptIds: selectedPostScriptIds,
|
||||
selectedPreEncodeScriptIds: selectedPreScriptIds,
|
||||
selectedPostEncodeChainIds: selectedPostChainIds,
|
||||
selectedPreEncodeChainIds: selectedPreChainIds,
|
||||
selectedHandBrakePreset
|
||||
selectedPreEncodeChainIds: selectedPreChainIds
|
||||
};
|
||||
startPayload.selectedUserPresetId = selectedUserPresetId || null;
|
||||
if (selectedUserPresetId !== null && selectedUserPresetId !== undefined) {
|
||||
startPayload.selectedUserPresetId = selectedUserPresetId;
|
||||
}
|
||||
if (String(selectedHandBrakePreset || '').trim()) {
|
||||
startPayload.selectedHandBrakePreset = String(selectedHandBrakePreset || '').trim();
|
||||
}
|
||||
await onStart(retryJobId, startPayload);
|
||||
}}
|
||||
loading={busy}
|
||||
@@ -4139,7 +4169,7 @@ export default function PipelineStatusCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
|
||||
{(mediaInfoReview || state === 'READY_TO_ENCODE') && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
|
||||
<div className="mediainfo-review-block">
|
||||
{!isDiscTitleSelectionRequired ? <h3>Titel-/Spurprüfung</h3> : null}
|
||||
{state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? (
|
||||
@@ -4150,16 +4180,6 @@ export default function PipelineStatusCard({
|
||||
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
|
||||
</small>
|
||||
) : null}
|
||||
{multipartSettingsLocked ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Multipart-Lock aktiv</h3>
|
||||
<small>
|
||||
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
|
||||
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
|
||||
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
|
||||
</small>
|
||||
</div>
|
||||
) : 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))' }}>
|
||||
@@ -4227,7 +4247,7 @@ export default function PipelineStatusCard({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{allowConverterConfigEdit && !hasSelectedPreset ? (
|
||||
{allowConverterConfigEdit && !hasEffectiveSelectedPreset ? (
|
||||
<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>
|
||||
@@ -4245,6 +4265,23 @@ export default function PipelineStatusCard({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<MediaInfoReviewPanel
|
||||
review={mediaInfoReview}
|
||||
presetDisplayValue={presetDisplayValue}
|
||||
commandOutputPath={commandOutputPath}
|
||||
commandOutputPathByTitle={commandOutputPathByTitle}
|
||||
topContent={(
|
||||
<>
|
||||
{multipartSettingsLocked ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Multipart-Lock aktiv</h3>
|
||||
<small>
|
||||
Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt.
|
||||
{multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''}
|
||||
{multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''}
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
{isSeriesDvdReview && hasMultiEpisodeInJob ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Hinweis Episoden-Zuordnung</h3>
|
||||
@@ -4256,11 +4293,21 @@ export default function PipelineStatusCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<MediaInfoReviewPanel
|
||||
review={mediaInfoReview}
|
||||
presetDisplayValue={presetDisplayValue}
|
||||
commandOutputPath={commandOutputPath}
|
||||
commandOutputPathByTitle={commandOutputPathByTitle}
|
||||
{!showConverterConfig
|
||||
&& requiresPresetSelection
|
||||
&& allowReviewSelectionEdit
|
||||
&& !hasEffectiveSelectedPreset
|
||||
&& state === 'READY_TO_ENCODE'
|
||||
&& mediaInfoReview ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>Preset-Auswahl erforderlich</h3>
|
||||
<small>
|
||||
Es ist weder ein User-Preset noch ein Settings-Preset gesetzt. Bitte ein Preset auswählen, bevor das Encoding starten kann.
|
||||
</small>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
|
||||
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
|
||||
allowTitleSelection={allowTitleSelectionInReview}
|
||||
@@ -4451,8 +4498,24 @@ export default function PipelineStatusCard({
|
||||
postEncodeItems={postEncodeItems}
|
||||
userPresets={effectiveUserPresets}
|
||||
selectedUserPresetId={selectedUserPresetId}
|
||||
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => setSelectedUserPresetId(presetId)}
|
||||
onUserPresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetId) => {
|
||||
const nextPresetId = Number.isFinite(Number(presetId)) && Number(presetId) > 0
|
||||
? Math.trunc(Number(presetId))
|
||||
: null;
|
||||
setSelectedUserPresetId(nextPresetId);
|
||||
if (nextPresetId) {
|
||||
setSelectedHandBrakePreset('');
|
||||
}
|
||||
}}
|
||||
selectedHandBrakePreset={selectedHandBrakePreset}
|
||||
onHandBrakePresetChange={showConverterConfig || multipartSettingsLocked ? null : (presetName) => {
|
||||
const nextPresetName = String(presetName || '').trim();
|
||||
setSelectedHandBrakePreset(nextPresetName);
|
||||
if (nextPresetName) {
|
||||
setSelectedUserPresetId(null);
|
||||
}
|
||||
}}
|
||||
handBrakePresetOptions={handBrakePresetOptions}
|
||||
allowEncodeItemSelection={allowEncodeItemSelectionInReview}
|
||||
comparisonReviewItems={multipartComparisonReviews}
|
||||
onAddPreEncodeItem={(itemType) => {
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from 'primereact/card';
|
||||
import { Button } from 'primereact/button';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Paginator } from 'primereact/paginator';
|
||||
import { api } from '../api/client';
|
||||
import { classifyJob } from '../utils/jobTaxonomy';
|
||||
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
|
||||
|
||||
const ACTIVE_STATUSES = [
|
||||
'ANALYZING',
|
||||
'METADATA_SELECTION',
|
||||
'WAITING_FOR_USER_DECISION',
|
||||
'READY_TO_START',
|
||||
'MEDIAINFO_CHECK',
|
||||
'READY_TO_ENCODE',
|
||||
'RIPPING',
|
||||
'ENCODING',
|
||||
'CD_METADATA_SELECTION',
|
||||
'CD_READY_TO_RIP',
|
||||
'CD_ANALYZING',
|
||||
'CD_RIPPING',
|
||||
'CD_ENCODING'
|
||||
];
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'active', label: 'Aktiv' },
|
||||
{ key: 'errors', label: 'Fehler/Abbruch' }
|
||||
];
|
||||
|
||||
const VIEW_FILTERS = [
|
||||
{ key: 'all', label: 'Alle Jobs' },
|
||||
{ key: 'ripper', label: 'Ripper-View' },
|
||||
{ key: 'converter', label: 'Converter-View' },
|
||||
{ key: 'audiobook', label: 'Audiobooks' }
|
||||
];
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return parsed.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
function getKindLabel(meta) {
|
||||
if (meta.family === 'converter') {
|
||||
if (meta.converterMediaType === 'audio') {
|
||||
return 'Converter Audio';
|
||||
}
|
||||
if (meta.converterMediaType === 'iso') {
|
||||
return 'Converter ISO';
|
||||
}
|
||||
return 'Converter Video';
|
||||
}
|
||||
if (meta.family === 'audiobook') {
|
||||
return 'Audiobook';
|
||||
}
|
||||
if (meta.mediaType === 'bluray') {
|
||||
return 'Blu-ray';
|
||||
}
|
||||
if (meta.mediaType === 'dvd') {
|
||||
return 'DVD';
|
||||
}
|
||||
if (meta.mediaType === 'cd') {
|
||||
return 'CD';
|
||||
}
|
||||
return 'Sonstiges';
|
||||
}
|
||||
|
||||
function getKindSeverity(meta) {
|
||||
if (meta.family === 'converter') {
|
||||
return 'info';
|
||||
}
|
||||
if (meta.family === 'audiobook') {
|
||||
return 'warning';
|
||||
}
|
||||
if (meta.mediaType === 'bluray' || meta.mediaType === 'dvd') {
|
||||
return 'success';
|
||||
}
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
export default function JobsInboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewFilter, setViewFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [queuedJobIds, setQueuedJobIds] = useState(new Set());
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState(null);
|
||||
const [first, setFirst] = useState(0);
|
||||
const [rows, setRows] = useState(25);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = { limit: 500, lite: true };
|
||||
if (statusFilter === 'active') {
|
||||
query.statuses = ACTIVE_STATUSES;
|
||||
} else if (statusFilter === 'errors') {
|
||||
query.statuses = ['ERROR', 'CANCELLED'];
|
||||
}
|
||||
|
||||
const [jobsResponse, queueResponse] = await Promise.allSettled([
|
||||
api.getJobs(query),
|
||||
api.getPipelineQueue()
|
||||
]);
|
||||
|
||||
const nextJobs = jobsResponse.status === 'fulfilled' && Array.isArray(jobsResponse.value?.jobs)
|
||||
? jobsResponse.value.jobs
|
||||
: [];
|
||||
setJobs(nextJobs);
|
||||
|
||||
if (queueResponse.status === 'fulfilled') {
|
||||
const rows = Array.isArray(queueResponse.value?.queue?.queuedJobs)
|
||||
? queueResponse.value.queue.queuedJobs
|
||||
: [];
|
||||
setQueuedJobIds(new Set(rows.map((item) => normalizeJobId(item?.jobId)).filter(Boolean)));
|
||||
} else {
|
||||
setQueuedJobIds(new Set());
|
||||
}
|
||||
setLastUpdatedAt(new Date().toISOString());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
loadJobs();
|
||||
const interval = setInterval(() => {
|
||||
loadJobs().catch(() => null);
|
||||
}, 7000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadJobs]);
|
||||
|
||||
const jobsWithMeta = useMemo(() => (
|
||||
jobs.map((job) => ({
|
||||
job,
|
||||
meta: classifyJob(job)
|
||||
}))
|
||||
), [jobs]);
|
||||
|
||||
const viewCounts = useMemo(() => {
|
||||
let all = 0;
|
||||
let ripper = 0;
|
||||
let converter = 0;
|
||||
let audiobook = 0;
|
||||
for (const item of jobsWithMeta) {
|
||||
all += 1;
|
||||
if (item.meta.family === 'converter') {
|
||||
converter += 1;
|
||||
} else {
|
||||
ripper += 1;
|
||||
}
|
||||
if (item.meta.family === 'audiobook') {
|
||||
audiobook += 1;
|
||||
}
|
||||
}
|
||||
return { all, ripper, converter, audiobook };
|
||||
}, [jobsWithMeta]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedSearch = String(search || '').trim().toLowerCase();
|
||||
return jobsWithMeta.filter(({ job, meta }) => {
|
||||
if (viewFilter === 'ripper' && meta.family === 'converter') {
|
||||
return false;
|
||||
}
|
||||
if (viewFilter === 'converter' && meta.family !== 'converter') {
|
||||
return false;
|
||||
}
|
||||
if (viewFilter === 'audiobook' && meta.family !== 'audiobook') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
job?.title,
|
||||
job?.detected_title,
|
||||
job?.imdb_id,
|
||||
job?.status,
|
||||
job?.job_kind,
|
||||
job?.media_type
|
||||
]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return haystack.includes(normalizedSearch);
|
||||
});
|
||||
}, [jobsWithMeta, search, viewFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setFirst(0);
|
||||
}, [viewFilter, statusFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredRows.length === 0) {
|
||||
if (first !== 0) {
|
||||
setFirst(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (first >= filteredRows.length) {
|
||||
const pageStart = Math.max(0, Math.floor((filteredRows.length - 1) / rows) * rows);
|
||||
setFirst(pageStart);
|
||||
}
|
||||
}, [filteredRows.length, first, rows]);
|
||||
|
||||
const pagedRows = useMemo(
|
||||
() => filteredRows.slice(first, first + rows),
|
||||
[filteredRows, first, rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Job Inbox"
|
||||
subTitle="Zentrale Jobliste mit denselben Datensätzen für Ripper-, Converter- und Audiobook-Sicht."
|
||||
>
|
||||
<div className="job-inbox-toolbar">
|
||||
<div className="job-inbox-filter-row">
|
||||
{VIEW_FILTERS.map((filter) => {
|
||||
const isActive = filter.key === viewFilter;
|
||||
const count = viewCounts[filter.key] ?? 0;
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
label={`${filter.label} (${count})`}
|
||||
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
|
||||
outlined={!isActive}
|
||||
onClick={() => setViewFilter(filter.key)}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="job-inbox-filter-row">
|
||||
{STATUS_FILTERS.map((filter) => {
|
||||
const isActive = filter.key === statusFilter;
|
||||
return (
|
||||
<Button
|
||||
key={filter.key}
|
||||
label={filter.label}
|
||||
className={isActive ? 'job-inbox-filter-active' : 'job-inbox-filter'}
|
||||
outlined={!isActive}
|
||||
onClick={() => setStatusFilter(filter.key)}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
label="Aktualisieren"
|
||||
icon="pi pi-refresh"
|
||||
outlined
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
loadJobs().catch(() => null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="job-inbox-search-row">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel, Status oder IMDB-ID"
|
||||
className="job-inbox-search-input"
|
||||
/>
|
||||
<small>
|
||||
Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="job-inbox-list">
|
||||
{loading && filteredRows.length === 0 ? (
|
||||
<p>Inbox wird geladen ...</p>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<p>Keine Jobs für den aktuellen Filter gefunden.</p>
|
||||
) : (
|
||||
pagedRows.map(({ job, meta }) => {
|
||||
const jobId = normalizeJobId(job?.id);
|
||||
if (!jobId) {
|
||||
return null;
|
||||
}
|
||||
const isQueued = queuedJobIds.has(jobId);
|
||||
const normalizedStatus = normalizeStatus(job?.status);
|
||||
const targetPath = meta.family === 'converter' ? '/converter' : '/ripper';
|
||||
const jobTitle = String(job?.title || job?.detected_title || '').trim() || `Job #${jobId}`;
|
||||
return (
|
||||
<article key={jobId} className="job-inbox-row">
|
||||
<div className="job-inbox-row-main">
|
||||
<strong>#{jobId} | {jobTitle}</strong>
|
||||
<small>
|
||||
Status: {getStatusLabel(normalizedStatus, { queued: isQueued })}
|
||||
{' | '}
|
||||
Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="job-inbox-row-tags">
|
||||
<Tag value={getKindLabel(meta)} severity={getKindSeverity(meta)} />
|
||||
<Tag
|
||||
value={getStatusLabel(normalizedStatus, { queued: isQueued })}
|
||||
severity={getStatusSeverity(normalizedStatus, { queued: isQueued })}
|
||||
/>
|
||||
</div>
|
||||
<div className="job-inbox-row-actions">
|
||||
<Button
|
||||
label={meta.family === 'converter' ? 'Im Converter öffnen' : 'Im Ripper öffnen'}
|
||||
icon={meta.family === 'converter' ? 'pi pi-external-link' : 'pi pi-arrow-right'}
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => navigate(targetPath)}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredRows.length > 0 ? (
|
||||
<div className="job-inbox-pagination">
|
||||
<Paginator
|
||||
first={first}
|
||||
rows={rows}
|
||||
totalRecords={filteredRows.length}
|
||||
rowsPerPageOptions={[25, 50, 100]}
|
||||
onPageChange={(event) => {
|
||||
setFirst(event.first);
|
||||
setRows(event.rows);
|
||||
}}
|
||||
template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport"
|
||||
currentPageReportTemplate="{first} - {last} von {totalRecords}"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,16 @@ function formatPercent(value) {
|
||||
return `${Math.trunc(parsed)}%`;
|
||||
}
|
||||
|
||||
function isPlaylistTrackPreparationStatus(value) {
|
||||
const normalized = String(value || '').trim().toUpperCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return normalized.includes('PLAYLIST')
|
||||
&& normalized.includes('TRACKDATEN')
|
||||
&& normalized.includes('VORBEREIT');
|
||||
}
|
||||
|
||||
function formatTemperature(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
@@ -3135,6 +3145,7 @@ export default function RipperPage({
|
||||
let showProgress = false;
|
||||
let progressValue = 0;
|
||||
let statusText = null;
|
||||
let showIndeterminateDriveProgress = false;
|
||||
|
||||
if (cdDrive) {
|
||||
const s = String(cdDrive.state || '').toUpperCase();
|
||||
@@ -3148,6 +3159,7 @@ export default function RipperPage({
|
||||
showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s);
|
||||
progressValue = Number(cdDrive.progress ?? 0);
|
||||
statusText = cdDrive.statusText || null;
|
||||
showIndeterminateDriveProgress = isPlaylistTrackPreparationStatus(statusText);
|
||||
} else if (pipelineDevice) {
|
||||
const s = String(drv.pipelineState || '').toUpperCase();
|
||||
stateLabel = s || 'DISC_DETECTED';
|
||||
@@ -3287,7 +3299,9 @@ export default function RipperPage({
|
||||
</div>
|
||||
) : null}
|
||||
{showProgress && (
|
||||
<ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
|
||||
showIndeterminateDriveProgress
|
||||
? <ProgressBar mode="indeterminate" style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
|
||||
: <ProgressBar value={progressValue} style={{ height: '5px', margin: '0.2rem 0 0' }} showValue={false} />
|
||||
)}
|
||||
{showProgress && statusText && (
|
||||
<div className="drive-list-status-text">{statusText}</div>
|
||||
@@ -3463,7 +3477,8 @@ export default function RipperPage({
|
||||
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
|
||||
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
|
||||
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
|
||||
const pipelineStatusText = String(pipelineForJob?.statusText || '').trim().toUpperCase();
|
||||
const pipelineStatusTextRaw = String(pipelineForJob?.statusText || '').trim();
|
||||
const pipelineStatusText = pipelineStatusTextRaw.toUpperCase();
|
||||
const isCdJob = jobState.startsWith('CD_')
|
||||
|| pipelineStage.startsWith('CD_')
|
||||
|| mediaProfile === 'cd'
|
||||
@@ -3509,6 +3524,8 @@ export default function RipperPage({
|
||||
? Math.max(0, Math.min(100, rawProgress))
|
||||
: 0;
|
||||
const progressLabel = `${Math.trunc(clampedProgress)}%`;
|
||||
const showIndeterminateProgress = !hasSeriesBatchProgress
|
||||
&& isPlaylistTrackPreparationStatus(pipelineStatusTextRaw);
|
||||
const etaLabel = isCancelledState ? '' : String(
|
||||
hasSeriesBatchProgress
|
||||
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
|
||||
@@ -3982,9 +3999,16 @@ export default function RipperPage({
|
||||
: null}
|
||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
||||
</div>
|
||||
<div className="ripper-job-row-progress" aria-label={`Job Fortschritt ${progressLabel}`}>
|
||||
<div
|
||||
className="ripper-job-row-progress"
|
||||
aria-label={showIndeterminateProgress ? 'Job Fortschritt Vorbereitung läuft' : `Job Fortschritt ${progressLabel}`}
|
||||
>
|
||||
{showIndeterminateProgress ? (
|
||||
<ProgressBar mode="indeterminate" showValue={false} />
|
||||
) : (
|
||||
<ProgressBar value={clampedProgress} showValue={false} />
|
||||
<small>{etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel}</small>
|
||||
)}
|
||||
<small>{showIndeterminateProgress ? 'Vorbereitung läuft …' : (etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel)}</small>
|
||||
</div>
|
||||
</div>
|
||||
<i className="pi pi-angle-down" aria-hidden="true" />
|
||||
@@ -4051,6 +4075,8 @@ export default function RipperPage({
|
||||
: (jobProgressEntry?.progress ?? 0)
|
||||
);
|
||||
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
|
||||
const queueStatusText = String(jobProgressEntry?.statusText || '').trim();
|
||||
const showIndeterminateQueueProgress = isPlaylistTrackPreparationStatus(queueStatusText);
|
||||
return (
|
||||
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
|
||||
<div className="pipeline-queue-item running queue-job-item">
|
||||
@@ -4062,8 +4088,12 @@ export default function RipperPage({
|
||||
</strong>
|
||||
{item.subtitle ? <small>{item.subtitle}</small> : null}
|
||||
<small>#{item.jobId} | {getMergeAwareStatusLabel(item, item.status)}</small>
|
||||
{clampedQueueProgress > 0 && (
|
||||
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
||||
{showIndeterminateQueueProgress ? (
|
||||
<ProgressBar mode="indeterminate" style={{ height: '5px' }} showValue={false} />
|
||||
) : (
|
||||
clampedQueueProgress > 0
|
||||
? <ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
|
||||
: null
|
||||
)}
|
||||
</div>
|
||||
{hasScriptSummary ? (
|
||||
|
||||
@@ -14,6 +14,16 @@ import CronJobsTab from '../components/CronJobsTab';
|
||||
import { confirmModal } from '../utils/confirmModal';
|
||||
|
||||
const EXPERT_MODE_SETTING_KEY = 'ui_expert_mode';
|
||||
const USER_PRESET_DEFAULT_SLOTS = Object.freeze([
|
||||
{ key: 'bluray_movie', label: 'Standard-Userpreset für BluRay Film', mediaType: 'bluray' },
|
||||
{ key: 'bluray_series', label: 'Standard-Userpreset für BluRay Serie', mediaType: 'bluray' },
|
||||
{ key: 'dvd_movie', label: 'Standard-Userpreset für DVD Film', mediaType: 'dvd' },
|
||||
{ key: 'dvd_series', label: 'Standard-Userpreset für DVD Serie', mediaType: 'dvd' }
|
||||
]);
|
||||
|
||||
const EMPTY_USER_PRESET_DEFAULTS = Object.freeze(
|
||||
USER_PRESET_DEFAULT_SLOTS.reduce((acc, slot) => ({ ...acc, [slot.key]: null }), {})
|
||||
);
|
||||
|
||||
// Validates template/string fields: only letters, digits, space, _, -, and {placeholders} allowed
|
||||
function validateSettingValue(key, value) {
|
||||
@@ -45,6 +55,68 @@ function buildValuesMap(categories) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizePositiveId(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(numeric);
|
||||
}
|
||||
|
||||
function normalizeUserPresetDefaultsMap(raw = {}) {
|
||||
const source = raw && typeof raw === 'object' ? raw : {};
|
||||
const normalized = { ...EMPTY_USER_PRESET_DEFAULTS };
|
||||
for (const slot of USER_PRESET_DEFAULT_SLOTS) {
|
||||
normalized[slot.key] = normalizePositiveId(source[slot.key]);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPresetCompatibleWithSlot(preset, slotMediaType) {
|
||||
const mediaType = String(preset?.mediaType || '').trim().toLowerCase();
|
||||
if (mediaType === 'all') {
|
||||
return true;
|
||||
}
|
||||
return mediaType === slotMediaType;
|
||||
}
|
||||
|
||||
function buildUserPresetDefaultOptions(userPresets, slotMediaType, selectedPresetId = null) {
|
||||
const presets = Array.isArray(userPresets) ? userPresets : [];
|
||||
const options = [{ label: '(keine Standardzuordnung)', value: null }];
|
||||
const seen = new Set([null]);
|
||||
|
||||
for (const preset of presets) {
|
||||
const presetId = normalizePositiveId(preset?.id);
|
||||
if (!presetId || !isPresetCompatibleWithSlot(preset, slotMediaType)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(presetId);
|
||||
options.push({
|
||||
label: `#${presetId} - ${preset?.name || 'Preset'}`,
|
||||
value: presetId
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedSelectedId = normalizePositiveId(selectedPresetId);
|
||||
if (normalizedSelectedId && !seen.has(normalizedSelectedId)) {
|
||||
const existingPreset = presets.find((preset) => normalizePositiveId(preset?.id) === normalizedSelectedId);
|
||||
options.push({
|
||||
label: existingPreset
|
||||
? `#${normalizedSelectedId} - ${existingPreset?.name || 'Preset'} (nicht kompatibel)`
|
||||
: `#${normalizedSelectedId} - (nicht gefunden)`,
|
||||
value: normalizedSelectedId
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function isSameValue(a, b) {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
return Number(a) === Number(b);
|
||||
@@ -213,6 +285,10 @@ export default function SettingsPage() {
|
||||
const [userPresets, setUserPresets] = useState([]);
|
||||
const [userPresetsLoading, setUserPresetsLoading] = useState(false);
|
||||
const [userPresetSaving, setUserPresetSaving] = useState(false);
|
||||
const [userPresetDefaults, setUserPresetDefaults] = useState(EMPTY_USER_PRESET_DEFAULTS);
|
||||
const [userPresetDefaultsDraft, setUserPresetDefaultsDraft] = useState(EMPTY_USER_PRESET_DEFAULTS);
|
||||
const [userPresetDefaultsLoading, setUserPresetDefaultsLoading] = useState(false);
|
||||
const [userPresetDefaultsSaving, setUserPresetDefaultsSaving] = useState(false);
|
||||
const [userPresetEditor, setUserPresetEditor] = useState({
|
||||
open: false,
|
||||
id: null,
|
||||
@@ -235,6 +311,23 @@ export default function SettingsPage() {
|
||||
),
|
||||
[handBrakePresetSourceOptions, userPresetEditor.handbrakePreset]
|
||||
);
|
||||
const userPresetDefaultsDirty = useMemo(
|
||||
() => USER_PRESET_DEFAULT_SLOTS.some((slot) => (
|
||||
normalizePositiveId(userPresetDefaultsDraft?.[slot.key]) !== normalizePositiveId(userPresetDefaults?.[slot.key])
|
||||
)),
|
||||
[userPresetDefaultsDraft, userPresetDefaults]
|
||||
);
|
||||
const userPresetDefaultOptionsBySlot = useMemo(() => {
|
||||
const next = {};
|
||||
for (const slot of USER_PRESET_DEFAULT_SLOTS) {
|
||||
next[slot.key] = buildUserPresetDefaultOptions(
|
||||
userPresets,
|
||||
slot.mediaType,
|
||||
userPresetDefaultsDraft?.[slot.key]
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}, [userPresets, userPresetDefaultsDraft]);
|
||||
|
||||
const loadScripts = async ({ silent = false } = {}) => {
|
||||
if (!silent) {
|
||||
@@ -291,6 +384,26 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadUserPresetDefaults = async ({ silent = false } = {}) => {
|
||||
if (!silent) {
|
||||
setUserPresetDefaultsLoading(true);
|
||||
}
|
||||
try {
|
||||
const response = await api.getUserPresetDefaults();
|
||||
const normalized = normalizeUserPresetDefaultsMap(response?.defaults || {});
|
||||
setUserPresetDefaults(normalized);
|
||||
setUserPresetDefaultsDraft(normalized);
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Preset-Standards', detail: error.message });
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setUserPresetDefaultsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openNewUserPreset = () => {
|
||||
setUserPresetEditor({ open: true, id: null, name: '', mediaType: 'all', handbrakePreset: '', extraArgs: '', description: '' });
|
||||
setUserPresetErrors({});
|
||||
@@ -352,12 +465,59 @@ export default function SettingsPage() {
|
||||
try {
|
||||
await api.deleteUserPreset(presetId);
|
||||
toastRef.current?.show({ severity: 'success', summary: 'Preset', detail: 'Preset gelöscht.' });
|
||||
await loadUserPresets({ silent: true });
|
||||
await Promise.all([
|
||||
loadUserPresets({ silent: true }),
|
||||
loadUserPresetDefaults({ silent: true })
|
||||
]);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({ severity: 'error', summary: 'Preset löschen', detail: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUserPresetDefault = (slotKey, rawValue) => {
|
||||
const normalized = normalizePositiveId(rawValue);
|
||||
setUserPresetDefaultsDraft((prev) => ({
|
||||
...prev,
|
||||
[slotKey]: normalized
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetUserPresetDefaults = () => {
|
||||
setUserPresetDefaultsDraft({ ...userPresetDefaults });
|
||||
};
|
||||
|
||||
const handleSaveUserPresetDefaults = async () => {
|
||||
if (!userPresetDefaultsDirty) {
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Preset-Standards',
|
||||
detail: 'Keine Änderungen zum Speichern.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUserPresetDefaultsSaving(true);
|
||||
try {
|
||||
const payload = normalizeUserPresetDefaultsMap(userPresetDefaultsDraft);
|
||||
const response = await api.updateUserPresetDefaults(payload);
|
||||
const normalized = normalizeUserPresetDefaultsMap(response?.defaults || payload);
|
||||
setUserPresetDefaults(normalized);
|
||||
setUserPresetDefaultsDraft(normalized);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Preset-Standards',
|
||||
detail: 'Standard-Zuordnungen gespeichert.'
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Preset-Standards',
|
||||
detail: error.message
|
||||
});
|
||||
} finally {
|
||||
setUserPresetDefaultsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEffectivePaths = async ({ silent = false } = {}) => {
|
||||
try {
|
||||
const paths = await api.getEffectivePaths({ forceRefresh: true });
|
||||
@@ -430,6 +590,7 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadUserPresets();
|
||||
loadUserPresetDefaults();
|
||||
}, []);
|
||||
|
||||
const dirtyKeys = useMemo(() => {
|
||||
@@ -1670,12 +1831,57 @@ export default function SettingsPage() {
|
||||
label="Presets neu laden"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
onClick={() => loadUserPresets()}
|
||||
loading={userPresetsLoading}
|
||||
disabled={userPresetSaving}
|
||||
onClick={() => Promise.all([loadUserPresets(), loadUserPresetDefaults()])}
|
||||
loading={userPresetsLoading || userPresetDefaultsLoading}
|
||||
disabled={userPresetSaving || userPresetDefaultsSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="script-list-box" style={{ marginTop: '1rem' }}>
|
||||
<h4>Standard-Zuordnungen</h4>
|
||||
<small>
|
||||
Weise für jeden Medientyp/Fall ein Standard-Userpreset zu. Diese Zuordnungen werden persistent gespeichert.
|
||||
</small>
|
||||
<div className="user-preset-default-grid" style={{ marginTop: '0.8rem' }}>
|
||||
{USER_PRESET_DEFAULT_SLOTS.map((slot) => (
|
||||
<div key={slot.key} className="user-preset-default-grid-item">
|
||||
<label htmlFor={`user-preset-default-${slot.key}`} style={{ display: 'block', marginBottom: '0.3rem' }}>
|
||||
{slot.label}
|
||||
</label>
|
||||
<Dropdown
|
||||
id={`user-preset-default-${slot.key}`}
|
||||
value={normalizePositiveId(userPresetDefaultsDraft?.[slot.key])}
|
||||
options={userPresetDefaultOptionsBySlot?.[slot.key] || []}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => handleChangeUserPresetDefault(slot.key, event.value)}
|
||||
placeholder="Preset auswählen"
|
||||
showClear
|
||||
disabled={userPresetDefaultsLoading || userPresetDefaultsSaving}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="actions-row" style={{ marginTop: '0.8rem' }}>
|
||||
<Button
|
||||
label="Zuordnungen speichern"
|
||||
icon="pi pi-save"
|
||||
onClick={handleSaveUserPresetDefaults}
|
||||
loading={userPresetDefaultsSaving}
|
||||
disabled={userPresetDefaultsLoading || !userPresetDefaultsDirty}
|
||||
/>
|
||||
<Button
|
||||
label="Änderungen verwerfen"
|
||||
icon="pi pi-undo"
|
||||
severity="secondary"
|
||||
outlined
|
||||
onClick={handleResetUserPresetDefaults}
|
||||
disabled={userPresetDefaultsLoading || userPresetDefaultsSaving || !userPresetDefaultsDirty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small>
|
||||
Encode-Presets fassen ein HandBrake-Preset und zusätzliche CLI-Argumente zusammen.
|
||||
Sie sind medienbezogen (Blu-ray, DVD oder Universell) und können vor dem Encode
|
||||
|
||||
+21
-102
@@ -345,106 +345,6 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.job-inbox-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.job-inbox-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.job-inbox-filter.p-button,
|
||||
.job-inbox-filter-active.p-button {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.job-inbox-search-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-inbox-search-input {
|
||||
width: min(40rem, 100%);
|
||||
}
|
||||
|
||||
.job-inbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.job-inbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.job-inbox-row-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.22rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.job-inbox-row-main strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.job-inbox-row-main small {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.job-inbox-row-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.job-inbox-row-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.job-inbox-pagination {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.job-inbox-pagination .p-paginator {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.job-inbox-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.job-inbox-row-tags,
|
||||
.job-inbox-row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.job-inbox-pagination .p-paginator {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.ripper-3col-grid {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
|
||||
@@ -2809,6 +2709,22 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-preset-default-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.user-preset-default-grid-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.user-preset-default-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.script-editor-fields {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
@@ -3730,16 +3646,19 @@ body {
|
||||
|
||||
.playlist-decision-block {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--rip-border);
|
||||
border: 1px solid #d4a29e;
|
||||
border-left: 4px solid #8b2c2c;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
background: var(--rip-panel-soft);
|
||||
background: #f8e1df;
|
||||
color: #8b2c2c;
|
||||
}
|
||||
|
||||
.playlist-decision-block h3 {
|
||||
margin: 0;
|
||||
color: #7a2525;
|
||||
}
|
||||
|
||||
.rip-error-notice {
|
||||
|
||||
@@ -297,29 +297,3 @@ export function isSeriesDvdJob(job) {
|
||||
export function isConverterJob(job) {
|
||||
return resolveMediaType(job) === 'converter';
|
||||
}
|
||||
|
||||
export function classifyJob(job) {
|
||||
const jobKind = resolveJobKind(job);
|
||||
const mediaType = resolveMediaType(job);
|
||||
const converterMediaType = normalizeConverterMediaType(
|
||||
(jobKind && jobKind.startsWith('converter_'))
|
||||
? jobKind.replace('converter_', '')
|
||||
: (getEncodePlan(job)?.converterMediaType || job?.converterMediaType)
|
||||
);
|
||||
|
||||
let family = 'other';
|
||||
if (mediaType === 'converter') {
|
||||
family = 'converter';
|
||||
} else if (mediaType === 'audiobook') {
|
||||
family = 'audiobook';
|
||||
} else if (mediaType === 'cd' || mediaType === 'dvd' || mediaType === 'bluray') {
|
||||
family = 'rip';
|
||||
}
|
||||
|
||||
return {
|
||||
jobKind,
|
||||
mediaType,
|
||||
converterMediaType,
|
||||
family
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ nav:
|
||||
- Erster Lauf: getting-started/quickstart.md
|
||||
- GUI-Seiten:
|
||||
- gui/index.md
|
||||
- Jobs: gui/jobs.md
|
||||
- Ripper: gui/ripper.md
|
||||
- Audiobooks: gui/audiobooks.md
|
||||
- Settings: gui/settings.md
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.16.1-4",
|
||||
"version": "0.16.1-5",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user