diff --git a/backend/package-lock.json b/backend/package-lock.json index 0fbdbd1..bc56949 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index a45498b..2cac1b7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.16.1-4", + "version": "0.16.1-5", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index 5355651..d45c5cd 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -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, diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 4804637..baa12a9 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -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) => { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 6bb573a..25ed62a 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -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); - if (!resolvedUserPreset) { - const error = new Error(`User-Preset ${userPresetId} nicht gefunden.`); - error.statusCode = 404; - throw error; - } + resolvedUserPreset = await userPresetService.getPresetById(explicitUserPresetId); + if (!resolvedUserPreset) { + 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; diff --git a/backend/src/services/userPresetDefaultsService.js b/backend/src/services/userPresetDefaultsService.js new file mode 100644 index 0000000..1d0dd6d --- /dev/null +++ b/backend/src/services/userPresetDefaultsService.js @@ -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 +}; diff --git a/db/schema.sql b/db/schema.sql index 56dbb12..fa678d5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 3c76c7f..c420e0e 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -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: diff --git a/docs/gui/index.md b/docs/gui/index.md index 276386b..2d6ae92 100644 --- a/docs/gui/index.md +++ b/docs/gui/index.md @@ -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: diff --git a/docs/gui/jobs.md b/docs/gui/jobs.md deleted file mode 100644 index b790faf..0000000 --- a/docs/gui/jobs.md +++ /dev/null @@ -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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7fce5b5..30c257c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b53928d..e2dd562 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.16.1-4", + "version": "0.16.1-5", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 38eb3b3..db3651f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } > - } /> + } /> } /> } /> } /> } /> - } /> } /> (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

Keine Mediainfo-Daten vorhanden.

; @@ -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 (
- {!compactTitleSelection && allowUserPresetSelection && ( + {topContent} + {!compactTitleSelection && allowPresetSelection && (
({ - 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,45 +2136,21 @@ export default function MediaInfoReviewPanel({ )}
)} - {!compactTitleSelection ? ( + {!compactTitleSelection && !isTitleSelectionMode ? (
Preset:{' '} - {effectivePresetOverride - ? (effectivePresetOverride.handbrakePreset || '(kein Preset)') - : presetLabel} - {effectivePresetOverride && presetSourceLabel && ( - - ({presetSourceLabel}) - - )} -
-
- Extra Args:{' '} - {effectivePresetOverride - ? (effectivePresetOverride.extraArgs || '(keine)') - : (review.selectors?.extraArgs || '(keine)')} - {effectivePresetOverride && isUserPresetActive && !selectedUserPreset?.extraArgs && ( - (aus User-Preset) - )} -
-
Preset-Profil: {effectivePresetOverride ? (isUserPresetActive ? 'user-preset' : 'handbrake-preset') : (review.selectors?.presetProfileSource || '-')}
-
MIN_LENGTH_MINUTES: {review.minLengthMinutes}
-
Encode Input: {encodeInputTitle?.fileName || '-'}
-
Audio Auswahl: {effectiveAudioSelector?.mode || '-'}
-
Audio Encoder: {(effectiveAudioSelector?.encoders || []).join(', ') || 'Preset-Default'}
-
Audio Copy-Mask: {(effectiveAudioSelector?.copyMask || []).join(', ') || '-'}
-
Audio Fallback: {effectiveAudioSelector?.fallbackEncoder || '-'}
-
Subtitle Auswahl: {review.selectors?.subtitle?.mode || '-'}
-
Subtitle Flags: {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}
+ {activePresetDisplayLabel}
+
Titel-Mindestlänge: {minLengthLabel}
+
) : null} {!compactTitleSelection && review.partial ? ( Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert. ) : null} - {!compactTitleSelection && playlistRecommendation ? ( + {!compactTitleSelection && !isTitleSelectionMode && !playlistAlreadyAccepted && playlistRecommendation ? (
Empfehlung: {playlistRecommendation.playlistFile || '-'} @@ -1953,9 +2160,9 @@ export default function MediaInfoReviewPanel({
) : null} - {!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? ( + {!compactTitleSelection && !isTitleSelectionMode && visibleReviewNotes.length > 0 ? (
- {review.notes.map((note, idx) => ( + {visibleReviewNotes.map((note, idx) => ( {note} ))}
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index fc7d5e5..181e98a 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -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 && (
- formatPercentNoDecimals(value)} - /> + {showIndeterminatePrimaryProgress ? ( + + ) : ( + formatPercentNoDecimals(value)} + /> + )} - {primaryEta + {showIndeterminatePrimaryProgress + ? 'Vorbereitung läuft …' + : primaryEta ? (hasSeriesBatchProgress ? `ETA aktuelle Episode ${primaryEta}` : `ETA ${primaryEta}`) : 'ETA unbekannt'} @@ -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({
) : null} - {(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? ( + {(mediaInfoReview || state === 'READY_TO_ENCODE') && !(handBrakeTitleDecisionRequired && isSeriesPlayAllDecisionMode) ? (
{!isDiscTitleSelectionRequired ?

Titel-/Spurprüfung

: null} {state === 'READY_TO_ENCODE' && !queueLocked && !isDiscTitleSelectionRequired ? ( @@ -4150,16 +4180,6 @@ export default function PipelineStatusCard({ {reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
) : null} - {multipartSettingsLocked ? ( -
-

Multipart-Lock aktiv

- - Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt. - {multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''} - {multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''} - -
- ) : null} {showConverterConfig ? (
@@ -4227,7 +4247,7 @@ export default function PipelineStatusCard({ ) : null}
- {allowConverterConfigEdit && !hasSelectedPreset ? ( + {allowConverterConfigEdit && !hasEffectiveSelectedPreset ? ( Bitte wähle ein User- oder HandBrake-Preset aus, bevor das Encoding starten kann. @@ -4245,22 +4265,49 @@ export default function PipelineStatusCard({ ) : null}
) : null} - {isSeriesDvdReview && hasMultiEpisodeInJob ? ( -
-

Hinweis Episoden-Zuordnung

- - Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet. - - {episodeAssignmentWarningLines.map((line, index) => ( - {line} - ))} -
- ) : null} + {multipartSettingsLocked ? ( +
+

Multipart-Lock aktiv

+ + Die Encode-Einstellungen wurden automatisch von der bereits vorhandenen Disc übernommen und sind gesperrt. + {multipartLockSourceJobId ? ` Quelle: Job #${multipartLockSourceJobId}.` : ''} + {multipartLockSourceDiscNumber ? ` Referenz-Disc: ${multipartLockSourceDiscNumber}.` : ''} + +
+ ) : null} + {isSeriesDvdReview && hasMultiEpisodeInJob ? ( +
+

Hinweis Episoden-Zuordnung

+ + Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet. + + {episodeAssignmentWarningLines.map((line, index) => ( + {line} + ))} +
+ ) : null} + {!showConverterConfig + && requiresPresetSelection + && allowReviewSelectionEdit + && !hasEffectiveSelectedPreset + && state === 'READY_TO_ENCODE' + && mediaInfoReview ? ( +
+

Preset-Auswahl erforderlich

+ + Es ist weder ein User-Preset noch ein Settings-Preset gesetzt. Bitte ein Preset auswählen, bevor das Encoding starten kann. + +
+ ) : 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) => { diff --git a/frontend/src/pages/JobsInboxPage.jsx b/frontend/src/pages/JobsInboxPage.jsx deleted file mode 100644 index 1eb33c2..0000000 --- a/frontend/src/pages/JobsInboxPage.jsx +++ /dev/null @@ -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 ( - -
-
- {VIEW_FILTERS.map((filter) => { - const isActive = filter.key === viewFilter; - const count = viewCounts[filter.key] ?? 0; - return ( -
-
- {STATUS_FILTERS.map((filter) => { - const isActive = filter.key === statusFilter; - return ( -
-
- -
- setSearch(event.target.value)} - placeholder="Suche nach Titel, Status oder IMDB-ID" - className="job-inbox-search-input" - /> - - Letztes Update: {formatUpdatedAt(lastUpdatedAt)} | Treffer: {filteredRows.length} - -
- -
- {loading && filteredRows.length === 0 ? ( -

Inbox wird geladen ...

- ) : filteredRows.length === 0 ? ( -

Keine Jobs für den aktuellen Filter gefunden.

- ) : ( - 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 ( -
-
- #{jobId} | {jobTitle} - - Status: {getStatusLabel(normalizedStatus, { queued: isQueued })} - {' | '} - Aktualisiert: {formatUpdatedAt(job?.updated_at || job?.created_at || null)} - -
-
- - -
-
-
-
- ); - }) - )} -
- - {filteredRows.length > 0 ? ( -
- { - setFirst(event.first); - setRows(event.rows); - }} - template="PrevPageLink PageLinks NextPageLink RowsPerPageDropdown CurrentPageReport" - currentPageReportTemplate="{first} - {last} von {totalRecords}" - /> -
- ) : null} -
- ); -} diff --git a/frontend/src/pages/RipperPage.jsx b/frontend/src/pages/RipperPage.jsx index 13292ae..1ea4a64 100644 --- a/frontend/src/pages/RipperPage.jsx +++ b/frontend/src/pages/RipperPage.jsx @@ -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)) { @@ -3132,9 +3142,10 @@ export default function RipperPage({ let stateLabel = null; let stateSeverity = 'secondary'; let discInfo = null; - let showProgress = false; - let progressValue = 0; - let statusText = null; + let showProgress = false; + let progressValue = 0; + let statusText = null; + let showIndeterminateDriveProgress = false; if (cdDrive) { const s = String(cdDrive.state || '').toUpperCase(); @@ -3145,9 +3156,10 @@ export default function RipperPage({ : 'info'; const cd = cdDrive.device || {}; discInfo = cd.discLabel || cd.model || null; - showProgress = ['CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING'].includes(s); - progressValue = Number(cdDrive.progress ?? 0); - statusText = cdDrive.statusText || null; + 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'; @@ -3286,12 +3298,14 @@ export default function RipperPage({ {discInfo ? {discInfo} : null} ) : null} - {showProgress && ( - - )} - {showProgress && statusText && ( -
{statusText}
- )} + {showProgress && ( + showIndeterminateDriveProgress + ? + : + )} + {showProgress && statusText && ( +
{statusText}
+ )} ); })} @@ -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} -
- - {etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel} +
+ {showIndeterminateProgress ? ( + + ) : ( + + )} + {showIndeterminateProgress ? 'Vorbereitung läuft …' : (etaLabel ? `${progressLabel} | ETA ${etaLabel}` : progressLabel)}