From 1f8e6d3ce8673f855ca7df65f6881372a098bd89 Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sat, 11 Apr 2026 19:11:31 +0000 Subject: [PATCH] 0.13.0-1 Series Flow --- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/services/historyService.js | 156 ++- backend/src/services/pipelineService.js | 1040 +++++++++++++++-- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/components/JobDetailDialog.jsx | 19 +- .../components/MetadataSelectionDialog.jsx | 68 +- package-lock.json | 4 +- package.json | 2 +- 10 files changed, 1138 insertions(+), 163 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index db7b526..6dff27a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.0", + "version": "0.13.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.0", + "version": "0.13.0-1", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 753a787..f981696 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.0", + "version": "0.13.0-1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index efda175..e4efa0e 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -905,11 +905,27 @@ function enrichJobRow(job, settings = null, options = {}) { const backupSuccess = ripSuccessful; const finishedWithOutput = normalizedJobStatus === 'FINISHED' && (includeFsChecks ? Boolean(outputStatus?.exists) : true); + const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object' + ? handbrakeInfo.seriesBatch + : null; + const seriesBatchMode = String(handbrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'; + const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0); + const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0); + const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0); + const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0); + const seriesBatchEncodeSuccess = seriesBatchMode + && seriesBatchTotal > 0 + && seriesBatchFinished >= seriesBatchTotal + && seriesBatchErrors <= 0 + && seriesBatchCancelled <= 0; const encodeSuccess = directoryLikeOutput ? finishedWithOutput - : (mediaType === 'converter' - ? finishedWithOutput - : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'); + : ( + seriesBatchEncodeSuccess + || (mediaType === 'converter' + ? finishedWithOutput + : String(handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS') + ); return { ...job, @@ -3629,6 +3645,7 @@ class HistoryService { }); const seriesCandidateIds = seriesCandidates.map((job) => normalizeJobIdValue(job?.id)).filter(Boolean); let childRows = []; + let nestedChildRows = []; let outputFolders = []; if (containerIds.length > 0) { const placeholders = containerIds.map(() => '?').join(', '); @@ -3639,15 +3656,46 @@ class HistoryService { const childIds = childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean); if (childIds.length > 0) { const childPlaceholders = childIds.map(() => '?').join(', '); + nestedChildRows = await db.all( + `SELECT id, parent_job_id, output_path, raw_path, handbrake_info_json, status, encode_plan_json, makemkv_info_json, rip_successful FROM jobs WHERE parent_job_id IN (${childPlaceholders})`, + childIds + ); + } + const summaryJobIds = Array.from(new Set([ + ...childRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean), + ...nestedChildRows.map((row) => normalizeJobIdValue(row?.id)).filter(Boolean) + ])); + if (summaryJobIds.length > 0) { + const childPlaceholders = summaryJobIds.map(() => '?').join(', '); outputFolders = await db.all( `SELECT job_id, output_path FROM job_output_folders WHERE job_id IN (${childPlaceholders})`, - childIds + summaryJobIds ); } } + const allContainerChildRows = [...childRows, ...nestedChildRows]; + const directChildRowById = new Map(); + for (const row of childRows) { + const childId = normalizeJobIdValue(row?.id); + if (childId) { + directChildRowById.set(childId, row); + } + } + const nestedRowsByParentId = new Map(); + for (const row of nestedChildRows) { + const parentId = normalizeJobIdValue(row?.parent_job_id); + if (!parentId) { + continue; + } + if (!nestedRowsByParentId.has(parentId)) { + nestedRowsByParentId.set(parentId, []); + } + nestedRowsByParentId.get(parentId).push(row); + } + const childOutputMap = new Map(); - for (const child of childRows) { + for (const child of allContainerChildRows) { const childId = normalizeJobIdValue(child?.id); if (!childId) { continue; @@ -3701,9 +3749,12 @@ class HistoryService { let expectedFromPlans = 0; const seenOutputs = new Set(); let existingCount = 0; + let existingCountFromSeriesBatch = 0; const totalChildren = childIds.length; for (const childId of childIds) { - const childRow = childRows.find((row) => normalizeJobIdValue(row?.id) === childId) || null; + const childRow = directChildRowById.get(childId) || null; + const descendantRows = nestedRowsByParentId.get(childId) || []; + const relatedRows = [childRow, ...descendantRows].filter(Boolean); if (childRow) { const rawPath = String(childRow?.raw_path || '').trim(); if (rawPath) { @@ -3722,32 +3773,87 @@ class HistoryService { if (ripSuccessful) { backupSuccessCount += 1; } + let selectedCount = 0; const childPlan = parseJsonSafe(childRow?.encode_plan_json, null); if (childPlan && typeof childPlan === 'object') { const titles = Array.isArray(childPlan?.titles) ? childPlan.titles : []; - const selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; - if (selectedCount > 0) { - expectedFromPlans += selectedCount; - } + selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; } const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null); const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase(); - if (hbStatus === 'SUCCESS') { - encodeSuccessAny = true; - encodeSuccessCount += 1; + const seriesBatchSummary = handbrakeInfo?.seriesBatch && typeof handbrakeInfo.seriesBatch === 'object' + ? handbrakeInfo.seriesBatch + : null; + const seriesBatchTotal = Number(seriesBatchSummary?.totalCount || 0); + const seriesBatchFinished = Number(seriesBatchSummary?.finishedCount || 0); + const seriesBatchErrors = Number(seriesBatchSummary?.errorCount || 0); + const seriesBatchCancelled = Number(seriesBatchSummary?.cancelledCount || 0); + const expectedFromNested = descendantRows.length > 0 ? descendantRows.length : 0; + const expectedFromChild = seriesBatchTotal > 0 + ? seriesBatchTotal + : (selectedCount > 0 ? selectedCount : expectedFromNested); + if (expectedFromChild > 0) { + expectedFromPlans += expectedFromChild; } + if (seriesBatchFinished > 0) { + existingCountFromSeriesBatch += seriesBatchFinished; + } + + let childEncodeSuccess = hbStatus === 'SUCCESS'; + if ( + !childEncodeSuccess + && seriesBatchTotal > 0 + && seriesBatchFinished >= seriesBatchTotal + && seriesBatchErrors <= 0 + && seriesBatchCancelled <= 0 + ) { + childEncodeSuccess = true; + } + if (!childEncodeSuccess && descendantRows.length > 0) { + let finishedDescendants = 0; + let errorDescendants = 0; + let cancelledDescendants = 0; + for (const row of descendantRows) { + const rowState = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (rowState === 'FINISHED') { + finishedDescendants += 1; + } else if (rowState === 'ERROR') { + errorDescendants += 1; + } else if (rowState === 'CANCELLED') { + cancelledDescendants += 1; + } + } + childEncodeSuccess = finishedDescendants >= descendantRows.length + && errorDescendants === 0 + && cancelledDescendants === 0; + } + if (childEncodeSuccess) { + encodeSuccessAny = true; + encodeSuccessCount += 1; + } + } else if (descendantRows.length > 0) { + expectedFromPlans += descendantRows.length; } - const outputs = Array.from(childOutputMap.get(childId) || []); - for (const outputPath of outputs) { - if (!outputPath || seenOutputs.has(outputPath)) { + for (const relatedRow of relatedRows) { + const relatedRowId = normalizeJobIdValue(relatedRow?.id); + if (!relatedRowId) { continue; } - seenOutputs.add(outputPath); - if (!includeFsChecks || fs.existsSync(outputPath)) { - existingCount += 1; + const outputs = Array.from(childOutputMap.get(relatedRowId) || []); + for (const outputPath of outputs) { + if (!outputPath || seenOutputs.has(outputPath)) { + continue; + } + seenOutputs.add(outputPath); + if (!includeFsChecks || fs.existsSync(outputPath)) { + existingCount += 1; + } } } } + if (existingCountFromSeriesBatch > existingCount) { + existingCount = existingCountFromSeriesBatch; + } if (!encodeSuccessAny && existingCount > 0) { encodeSuccessAny = true; if (encodeSuccessCount === 0 && totalChildren > 0) { @@ -5102,6 +5208,18 @@ class HistoryService { ?? analyzeContext?.seriesLookupHint?.discNumber ?? null ); + const resolvedJobMediaType = inferMediaType( + job, + makemkvInfo, + job?.mediainfo_info_json, + job?.encode_plan_json, + job?.handbrake_info_json + ); + if (resolvedJobMediaType === 'dvd' && discNumber === null) { + const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); + error.statusCode = 400; + throw error; + } let metadataKind = manualMetadataKind || String( existingSelectedMetadata?.metadataKind diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index dc88671..f8b4f40 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -3216,6 +3216,28 @@ function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = nu } } + if (filtered.length >= 3) { + const durations = filtered + .map((item) => Number(item?.durationSeconds || 0)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (durations.length >= 3) { + const median = durations[Math.floor(durations.length / 2)]; + // Only apply this guard when the disc mostly contains regular-length episodes. + // This prevents accidentally removing genuinely short-format episode discs. + if (median >= (15 * 60)) { + const shortThresholdSeconds = Math.max(5 * 60, Math.round(median * 0.5)); + const withoutShort = filtered.filter((item) => Number(item?.durationSeconds || 0) >= shortThresholdSeconds); + if (withoutShort.length >= 2 && withoutShort.length < filtered.length) { + filtered = withoutShort; + reason = reason + ? `${reason}+heuristic_excluded_short_titles` + : 'heuristic_excluded_short_titles'; + } + } + } + } + return { candidates: filtered, applied: filtered !== sourceCandidates, @@ -3405,7 +3427,8 @@ function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) { } function buildSelectedHandBrakeReviewTitles(scanJson, selectedHandBrakeTitleIds = [], options = {}) { - const normalizedTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds); + const normalizedTitleIds = normalizeReviewTitleIdList(selectedHandBrakeTitleIds) + .sort((a, b) => Number(a) - Number(b)); if (normalizedTitleIds.length === 0) { return []; } @@ -4575,6 +4598,7 @@ function buildSeriesBatchEpisodesFromPlan(parentJob, parentPlan, titleIds = []) nextEpisodes.push({ episodeIndex: index + 1, titleId: Number(titleId), + jobId: normalizePositiveInteger(existing?.jobId) || null, label, status, progress, @@ -4626,7 +4650,7 @@ function buildSeriesBatchProgressFromEpisodes(parentJobId, episodes = []) { } aggregateProgress += (status === 'FINISHED' ? 100 : progress); children.push({ - jobId: Number(parentJobId), + jobId: normalizePositiveInteger(entry?.jobId) || Number(parentJobId), title: String(entry?.label || `Episode #${entry?.episodeIndex || children.length + 1}`).trim(), status, progress: Number(progress.toFixed(2)), @@ -6858,6 +6882,14 @@ class PipelineService extends EventEmitter { }); } + try { + await this.syncAllSeriesContainerStatusesOnStartup(db); + } catch (seriesContainerRecoveryError) { + logger.warn('init:series-container-recovery-failed', { + error: errorToMeta(seriesContainerRecoveryError) + }); + } + try { await this._restoreDriveLocksFromJobs(); } catch (driveLockRecoveryError) { @@ -6884,7 +6916,8 @@ class PipelineService extends EventEmitter { const staleRows = await db.all(` SELECT id, status, last_state FROM jobs - WHERE status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', + WHERE COALESCE(job_kind, '') != 'dvd_series_container' + AND status IN ('ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING') ORDER BY updated_at ASC, id ASC `); @@ -7453,10 +7486,11 @@ class PipelineService extends EventEmitter { const db = await getDb(); const rows = await db.all( ` - SELECT id, disc_device, status, last_state, rip_successful + SELECT id, disc_device, status, last_state, rip_successful, job_kind FROM jobs WHERE disc_device IS NOT NULL AND TRIM(disc_device) <> '' + AND COALESCE(job_kind, '') != 'dvd_series_container' AND status IN ('RIPPING', 'CD_RIPPING', 'ERROR', 'CANCELLED') ` ); @@ -8072,7 +8106,7 @@ class PipelineService extends EventEmitter { let filmRunning = 0; let audioRunning = 0; for (const job of rows) { - if (this.isSeriesBatchChildJob(job)) { + if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { continue; } const poolType = this.resolveQueuePoolTypeForJob(job); @@ -8094,6 +8128,231 @@ class PipelineService extends EventEmitter { return isSeriesBatchParentPlan(plan); } + isSeriesContainerHistoryJob(jobLike = null) { + const jobKind = String(jobLike?.job_kind || jobLike?.jobKind || '').trim().toLowerCase(); + return jobKind === 'dvd_series_container'; + } + + _deriveSeriesContainerStateFromChildren(childJobs = []) { + const rows = Array.isArray(childJobs) ? childJobs : []; + if (rows.length === 0) { + return null; + } + + const normalizedRows = []; + const stateCounts = new Map(); + for (const row of rows) { + const state = String(row?.status || row?.last_state || '').trim().toUpperCase(); + if (!state) { + continue; + } + normalizedRows.push({ row, state }); + stateCounts.set(state, (stateCounts.get(state) || 0) + 1); + } + if (normalizedRows.length === 0) { + return null; + } + + const hasState = (state) => stateCounts.has(state); + const runningPriority = ['RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'ANALYZING', 'CD_RIPPING', 'CD_ENCODING', 'CD_ANALYZING']; + let nextState = null; + for (const candidate of runningPriority) { + if (hasState(candidate)) { + nextState = candidate; + break; + } + } + + if (!nextState) { + if (hasState('ERROR')) { + nextState = 'ERROR'; + } else if (hasState('WAITING_FOR_USER_DECISION')) { + nextState = 'WAITING_FOR_USER_DECISION'; + } else if (hasState('READY_TO_START')) { + nextState = 'READY_TO_START'; + } else if (hasState('READY_TO_ENCODE')) { + nextState = 'READY_TO_ENCODE'; + } else if (hasState('METADATA_SELECTION')) { + nextState = 'METADATA_SELECTION'; + } else if (hasState('CANCELLED')) { + nextState = 'CANCELLED'; + } else if (hasState('FINISHED')) { + nextState = 'FINISHED'; + } else { + nextState = normalizedRows[0]?.state || null; + } + } + + if (!nextState) { + return null; + } + + const terminalStates = new Set(['FINISHED', 'ERROR', 'CANCELLED']); + const isTerminal = terminalStates.has(nextState); + let endTime = null; + if (isTerminal) { + let latestEndTime = null; + let latestEndTimeMs = null; + for (const item of normalizedRows) { + if (!terminalStates.has(item.state)) { + continue; + } + const rawEndTime = String(item?.row?.end_time || '').trim(); + if (!rawEndTime) { + continue; + } + const parsedMs = Date.parse(rawEndTime); + if (!Number.isFinite(parsedMs)) { + continue; + } + if (latestEndTimeMs === null || parsedMs > latestEndTimeMs) { + latestEndTimeMs = parsedMs; + latestEndTime = rawEndTime; + } + } + endTime = latestEndTime || nowIso(); + } + + let errorMessage = null; + if (nextState === 'ERROR' || nextState === 'CANCELLED') { + const matchingRows = normalizedRows + .filter((item) => item.state === nextState) + .sort((left, right) => { + const rightMs = Date.parse(String(right?.row?.end_time || '')) || 0; + const leftMs = Date.parse(String(left?.row?.end_time || '')) || 0; + return rightMs - leftMs; + }); + errorMessage = matchingRows + .map((item) => String(item?.row?.error_message || '').trim()) + .find(Boolean) || null; + } + + return { + state: nextState, + isTerminal, + endTime, + errorMessage + }; + } + + async syncSeriesContainerStatusFromChildren(parentJobId, options = {}) { + const normalizedParentJobId = this.normalizeQueueJobId(parentJobId); + if (!normalizedParentJobId) { + return null; + } + + const db = await getDb(); + const parentJob = await db.get( + ` + SELECT id, status, last_state, end_time, error_message, job_kind + FROM jobs + WHERE id = ? + `, + [normalizedParentJobId] + ); + if (!parentJob || !this.isSeriesContainerHistoryJob(parentJob)) { + return null; + } + + const sourceChildRows = Array.isArray(options?.childJobs) + ? options.childJobs + : await historyService.listChildJobs(normalizedParentJobId); + const childRows = (Array.isArray(sourceChildRows) ? sourceChildRows : []) + .filter((row) => Number(row?.id) > 0 && !this.isSeriesContainerHistoryJob(row)); + if (childRows.length === 0) { + return { + parentJobId: normalizedParentJobId, + updated: false, + childCount: 0, + state: String(parentJob.status || parentJob.last_state || '').trim().toUpperCase() || null + }; + } + + const aggregate = this._deriveSeriesContainerStateFromChildren(childRows); + if (!aggregate || !aggregate.state) { + return null; + } + + const nextStatus = aggregate.state; + const nextLastState = aggregate.state; + const nextEndTime = aggregate.isTerminal ? aggregate.endTime : null; + const nextErrorMessage = aggregate.errorMessage || null; + + const currentStatus = String(parentJob?.status || '').trim().toUpperCase(); + const currentLastState = String(parentJob?.last_state || '').trim().toUpperCase(); + const currentEndTime = String(parentJob?.end_time || '').trim() || null; + const currentErrorMessage = String(parentJob?.error_message || '').trim() || null; + + const changed = currentStatus !== nextStatus + || currentLastState !== nextLastState + || currentEndTime !== nextEndTime + || currentErrorMessage !== nextErrorMessage; + if (!changed) { + return { + parentJobId: normalizedParentJobId, + updated: false, + childCount: childRows.length, + state: nextStatus + }; + } + + await historyService.updateJob(normalizedParentJobId, { + status: nextStatus, + last_state: nextLastState, + end_time: nextEndTime, + error_message: nextErrorMessage + }); + + return { + parentJobId: normalizedParentJobId, + updated: true, + childCount: childRows.length, + state: nextStatus + }; + } + + async syncAllSeriesContainerStatusesOnStartup(db = null) { + const database = db || await getDb(); + const rows = await database.all(` + SELECT id + FROM jobs + WHERE COALESCE(job_kind, '') = 'dvd_series_container' + ORDER BY id ASC + `); + const containerRows = Array.isArray(rows) ? rows : []; + let updated = 0; + + for (const row of containerRows) { + const parentJobId = this.normalizeQueueJobId(row?.id); + if (!parentJobId) { + continue; + } + try { + const result = await this.syncSeriesContainerStatusFromChildren(parentJobId); + if (result?.updated) { + updated += 1; + } + } catch (error) { + logger.warn('series-container:startup-sync:failed', { + parentJobId, + error: errorToMeta(error) + }); + } + } + + if (updated > 0) { + logger.info('series-container:startup-sync:updated', { + scanned: containerRows.length, + updated + }); + } + + return { + scanned: containerRows.length, + updated + }; + } + isSeriesBatchChildJob(jobLike = null) { const plan = this.extractQueueJobPlan(jobLike); return isSeriesBatchChildPlan(plan); @@ -8159,7 +8418,112 @@ class PipelineService extends EventEmitter { return null; } - const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, episodes); + const childJobs = await this.listSeriesBatchChildJobs(normalizedParentJobId); + const childByJobId = new Map(); + const childByTitleId = new Map(); + const childByEpisodeIndex = new Map(); + const sortedChildJobs = [...childJobs].sort((left, right) => Number(right?.id || 0) - Number(left?.id || 0)); + for (const childJob of sortedChildJobs) { + const childId = normalizePositiveInteger(childJob?.id); + if (!childId) { + continue; + } + childByJobId.set(Number(childId), childJob); + const childPlan = this.safeParseJson(childJob?.encode_plan_json); + const childTitleId = normalizeReviewTitleId( + childPlan?.seriesBatchTitleId + ?? childPlan?.encodeInputTitleId + ?? null + ); + const childEpisodeIndex = normalizePositiveInteger( + childPlan?.seriesBatchChildIndex + ?? null + ); + if (childTitleId && !childByTitleId.has(Number(childTitleId))) { + childByTitleId.set(Number(childTitleId), childJob); + } + if (childEpisodeIndex && !childByEpisodeIndex.has(Number(childEpisodeIndex))) { + childByEpisodeIndex.set(Number(childEpisodeIndex), childJob); + } + } + + const normalizeEpisodeStatusFromChild = (childJob, fallbackStatus = 'QUEUED') => { + const rawStatus = String(childJob?.status || childJob?.last_state || '').trim().toUpperCase(); + if (rawStatus === 'FINISHED') { + return 'FINISHED'; + } + if (rawStatus === 'ERROR') { + return 'ERROR'; + } + if (rawStatus === 'CANCELLED') { + return 'CANCELLED'; + } + if (['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'].includes(rawStatus)) { + return 'RUNNING'; + } + return normalizeSeriesEpisodeStatus(fallbackStatus, 'QUEUED'); + }; + + const syncedEpisodes = episodes.map((episode) => { + const episodeJobId = normalizePositiveInteger(episode?.jobId); + const episodeTitleId = normalizeReviewTitleId(episode?.titleId); + const episodeIndex = normalizePositiveInteger(episode?.episodeIndex); + const childJob = ( + (episodeJobId ? childByJobId.get(Number(episodeJobId)) : null) + || (episodeTitleId ? childByTitleId.get(Number(episodeTitleId)) : null) + || (episodeIndex ? childByEpisodeIndex.get(Number(episodeIndex)) : null) + || null + ); + + if (!childJob) { + return episode; + } + + const childId = normalizePositiveInteger(childJob?.id) || episodeJobId || null; + const nextStatus = normalizeEpisodeStatusFromChild(childJob, episode?.status); + const liveChildProgress = Number(this.jobProgress.get(Number(childId))?.progress); + const liveChildEta = this.jobProgress.get(Number(childId))?.eta ?? null; + const fallbackProgress = Number(episode?.progress || 0); + let nextProgress = Number.isFinite(fallbackProgress) ? fallbackProgress : 0; + if (nextStatus === 'FINISHED') { + nextProgress = 100; + } else if (nextStatus === 'RUNNING' && Number.isFinite(liveChildProgress)) { + nextProgress = Math.max(0, Math.min(100, Number(liveChildProgress))); + } else if (nextStatus === 'ERROR' || nextStatus === 'CANCELLED') { + nextProgress = Math.max(0, Math.min(100, nextProgress)); + } else { + nextProgress = Math.max(0, Math.min(100, nextProgress)); + } + + const childHandbrakeInfo = this.safeParseJson(childJob?.handbrake_info_json); + return { + ...episode, + jobId: childId, + status: nextStatus, + progress: Number(nextProgress.toFixed(2)), + eta: nextStatus === 'RUNNING' ? liveChildEta : null, + startedAt: String(childJob?.start_time || '').trim() || episode?.startedAt || null, + finishedAt: ( + nextStatus === 'FINISHED' + || nextStatus === 'ERROR' + || nextStatus === 'CANCELLED' + ) + ? (String(childJob?.end_time || '').trim() || episode?.finishedAt || null) + : null, + outputPath: String(childJob?.output_path || '').trim() || episode?.outputPath || null, + error: ( + nextStatus === 'ERROR' + || nextStatus === 'CANCELLED' + ) + ? (String(childJob?.error_message || '').trim() || episode?.error || null) + : null, + handbrakeInfo: childHandbrakeInfo && typeof childHandbrakeInfo === 'object' + ? childHandbrakeInfo + : (episode?.handbrakeInfo && typeof episode.handbrakeInfo === 'object' ? episode.handbrakeInfo : null) + }; + }); + + const summary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, syncedEpisodes); const totalCount = summary.totalCount; const finishedCount = summary.finishedCount; const cancelledCount = summary.cancelledCount; @@ -8179,15 +8543,54 @@ class PipelineService extends EventEmitter { children: summary.children } }; - - for (const [childJobId, mappedParentJobId] of this.seriesBatchParentByChild.entries()) { - if (Number(mappedParentJobId) === Number(normalizedParentJobId)) { - this.seriesBatchParentByChild.delete(Number(childJobId)); + const nextSeriesBatchChildJobIds = normalizeReviewTitleIdList( + syncedEpisodes + .map((episode) => normalizePositiveInteger(episode?.jobId)) + .filter(Boolean) + ); + const nextCompletedTitleIds = normalizeReviewTitleIdList([ + ...(Array.isArray(parentPlan?.seriesBatchCompletedTitleIds) ? parentPlan.seriesBatchCompletedTitleIds : []), + ...syncedEpisodes + .filter((episode) => normalizeSeriesEpisodeStatus(episode?.status, 'QUEUED') === 'FINISHED') + .map((episode) => episode?.titleId) + ]); + const nextPlan = { + ...parentPlan, + seriesBatchParent: true, + seriesBatchChild: false, + seriesBatchEpisodes: syncedEpisodes, + seriesBatchChildJobIds: nextSeriesBatchChildJobIds, + seriesBatchCompletedTitleIds: nextCompletedTitleIds + }; + const previousHandbrakeInfo = this.safeParseJson(parentJob?.handbrake_info_json) || {}; + const nextHandbrakeInfo = { + ...(previousHandbrakeInfo && typeof previousHandbrakeInfo === 'object' ? previousHandbrakeInfo : {}), + mode: 'series_batch', + seriesBatch: { + parentJobId: normalizedParentJobId, + totalCount: summary.totalCount, + finishedCount: summary.finishedCount, + cancelledCount: summary.cancelledCount, + errorCount: summary.errorCount, + runningCount: summary.runningCount, + episodes: syncedEpisodes } - } + }; + + const containerParentJobId = this.normalizeQueueJobId(parentJob?.parent_job_id); if (allTerminal) { this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + for (const [childJobId, mappedParentJobId] of this.seriesBatchParentByChild.entries()) { + if (Number(mappedParentJobId) === Number(normalizedParentJobId)) { + this.seriesBatchParentByChild.delete(Number(childJobId)); + } + } + const seriesChildJobIdSet = new Set( + nextSeriesBatchChildJobIds + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + ); const finalState = errorCount > 0 ? 'ERROR' : (cancelledCount > 0 ? 'CANCELLED' : 'FINISHED'); @@ -8199,11 +8602,28 @@ class PipelineService extends EventEmitter { : 'Serien-Batch abgebrochen.' ); await historyService.updateJob(normalizedParentJobId, { + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), status: finalState, last_state: finalState, end_time: nowIso(), error_message: finalErrorMessage }); + // Remove stale queue entries that still target this parent batch. + this.queueEntries = this.queueEntries.filter((entry) => { + const action = String(entry?.action || '').trim().toUpperCase(); + const entryJobId = Number(entry?.jobId); + const isLegacyParentEntry = action === QUEUE_ACTIONS.START_SERIES_EPISODE + && entryJobId === Number(normalizedParentJobId); + const isSeriesChildEntry = (!entry?.type || entry.type === 'job') + && Number.isFinite(entryJobId) + && entryJobId > 0 + && seriesChildJobIdSet.has(entryJobId); + if (isLegacyParentEntry || isSeriesChildEntry) { + return false; + } + return true; + }); if (Number(this.snapshot.activeJobId) === Number(normalizedParentJobId)) { await this.setState(finalState, { activeJobId: normalizedParentJobId, @@ -8221,6 +8641,15 @@ class PipelineService extends EventEmitter { contextPatch }); } + if (containerParentJobId) { + await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { + logger.warn('series-container:status-sync-from-batch-parent-final-failed', { + containerJobId: containerParentJobId, + batchParentJobId: normalizedParentJobId, + error: errorToMeta(syncError) + }); + }); + } return { parentJobId: normalizedParentJobId, final: true, @@ -8231,19 +8660,47 @@ class PipelineService extends EventEmitter { }; } + const terminalCount = finishedCount + cancelledCount + errorCount; + const hasPendingChildren = terminalCount < totalCount; + const hasErrors = errorCount > 0; + const hasCancellations = cancelledCount > 0; + const nextState = hasErrors + ? 'ERROR' + : (hasCancellations ? 'CANCELLED' : (hasPendingChildren ? 'ENCODING' : 'READY_TO_ENCODE')); + const nextLastState = hasPendingChildren && nextState === 'ENCODING' + ? 'ENCODING' + : nextState; + const nextErrorMessage = hasErrors + ? `Serien-Batch enthält ${errorCount} Fehler-Episode(n).` + : ( + hasCancellations + ? `Serien-Batch enthält ${cancelledCount} abgebrochene Episode(n).` + : null + ); await historyService.updateJob(normalizedParentJobId, { - status: 'ENCODING', - last_state: 'ENCODING', + encode_plan_json: JSON.stringify(nextPlan), + handbrake_info_json: JSON.stringify(nextHandbrakeInfo), + status: nextState, + last_state: nextLastState, end_time: null, - error_message: null + error_message: nextErrorMessage }); - await this.updateProgress('ENCODING', totalProgress, null, summaryText, normalizedParentJobId, { + await this.updateProgress(nextState, totalProgress, null, summaryText, normalizedParentJobId, { contextPatch }); + if (containerParentJobId) { + await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { + logger.warn('series-container:status-sync-from-batch-parent-progress-failed', { + containerJobId: containerParentJobId, + batchParentJobId: normalizedParentJobId, + error: errorToMeta(syncError) + }); + }); + } return { parentJobId: normalizedParentJobId, final: false, - state: 'ENCODING', + state: nextState, progress: totalProgress, summaryText, childCount: totalCount @@ -8300,24 +8757,99 @@ class PipelineService extends EventEmitter { if (nextEpisodes.length <= 1) { return { handled: false }; } + const preparedEpisodes = []; + for (let index = 0; index < nextEpisodes.length; index += 1) { + const episode = nextEpisodes[index]; + const childPlan = buildSeriesBatchChildPlan( + parentPlan, + episode.titleId, + normalizedParentJobId, + index, + nextEpisodes.length + ); + const episodeTitle = String(episode?.label || '').trim() + || resolveSeriesBatchChildDisplayTitle(sourceJob, parentPlan, episode.titleId, index); + + const childJob = await historyService.createJob({ + discDevice: sourceJob.disc_device || null, + status: 'READY_TO_ENCODE', + detectedTitle: episodeTitle, + mediaType: sourceJob.media_type || mediaProfile, + jobKind: 'dvd_series_child' + }); + const childJobId = this.normalizeQueueJobId(childJob?.id); + if (!childJobId) { + const error = new Error('Serien-Episode konnte nicht als Child-Job angelegt werden.'); + error.statusCode = 500; + throw error; + } + + await historyService.updateJob(childJobId, { + parent_job_id: Number(normalizedParentJobId), + media_type: sourceJob.media_type || mediaProfile, + job_kind: 'dvd_series_child', + title: episodeTitle, + detected_title: episodeTitle, + year: sourceJob.year ?? null, + imdb_id: sourceJob.imdb_id || null, + poster_url: sourceJob.poster_url || null, + omdb_json: sourceJob.omdb_json || null, + selected_from_omdb: Number(sourceJob.selected_from_omdb || 0), + status: 'READY_TO_ENCODE', + last_state: 'READY_TO_ENCODE', + error_message: null, + end_time: null, + output_path: null, + disc_device: sourceJob.disc_device || null, + raw_path: sourceJob.raw_path || null, + rip_successful: Number(sourceJob.rip_successful || 0), + makemkv_info_json: sourceJob.makemkv_info_json || null, + handbrake_info_json: null, + mediainfo_info_json: sourceJob.mediainfo_info_json || null, + encode_plan_json: JSON.stringify({ + ...childPlan, + reviewConfirmed: true, + reviewConfirmedAt: String(childPlan?.reviewConfirmedAt || '').trim() || nowIso() + }), + encode_input_path: childPlan?.encodeInputPath || sourceJob.encode_input_path || sourceJob.raw_path || null, + encode_review_confirmed: 1 + }); + await historyService.appendLog( + childJobId, + 'SYSTEM', + `Serien-Episode als Queue-Subjob vorbereitet (Parent #${normalizedParentJobId}, Episode ${episode.episodeIndex}/${nextEpisodes.length}).` + ); + preparedEpisodes.push({ + ...episode, + jobId: Number(childJobId), + status: 'QUEUED', + progress: 0, + eta: null, + startedAt: null, + finishedAt: null, + outputPath: null, + error: null + }); + } const nextParentPlan = { ...parentPlan, seriesBatchParent: true, seriesBatchChild: false, seriesBatchParentJobId: null, - seriesBatchChildJobIds: [], + seriesBatchChildJobIds: normalizeReviewTitleIdList(preparedEpisodes.map((episode) => episode?.jobId)), seriesBatchCompletedTitleIds: [], seriesBatchDispatchedAt: nowIso(), - seriesBatchTotalChildren: nextEpisodes.length, - seriesBatchEpisodes: nextEpisodes, + seriesBatchTotalChildren: preparedEpisodes.length, + seriesBatchEpisodes: preparedEpisodes, reviewConfirmed: true }; - const progressSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, nextEpisodes); + const progressSummary = buildSeriesBatchProgressFromEpisodes(normalizedParentJobId, preparedEpisodes); + const parentBatchState = preparedEpisodes.length > 0 ? 'ENCODING' : 'READY_TO_ENCODE'; await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextParentPlan), - status: 'READY_TO_ENCODE', - last_state: 'READY_TO_ENCODE', + status: parentBatchState, + last_state: parentBatchState, end_time: null, error_message: null, encode_review_confirmed: 1 @@ -8325,28 +8857,26 @@ class PipelineService extends EventEmitter { await historyService.appendLog( normalizedParentJobId, 'SYSTEM', - `Serien-Batch gestartet: ${nextEpisodes.length} Episode(n) als interne Queue-Einträge angelegt (Single-Job-Modus).` + `Serien-Batch gestartet: ${preparedEpisodes.length} Episode(n) als reguläre Queue-Subjobs angelegt.` ); let queuedChildren = 0; let startedChildren = 0; - for (let index = 0; index < nextEpisodes.length; index += 1) { - const episode = nextEpisodes[index]; + for (let index = 0; index < preparedEpisodes.length; index += 1) { + const episode = preparedEpisodes[index]; + const episodeJobId = this.normalizeQueueJobId(episode?.jobId); + if (!episodeJobId) { + continue; + } const queueResult = await this.enqueueOrStartAction( - QUEUE_ACTIONS.START_SERIES_EPISODE, - normalizedParentJobId, - () => this.startSeriesBatchEpisode(normalizedParentJobId, { - immediate: true, - titleId: episode.titleId, - episodeIndex: episode.episodeIndex - }), + QUEUE_ACTIONS.START_PREPARED, + episodeJobId, + () => this.startPreparedJob(episodeJobId, { immediate: true }), { poolType: 'film', allowDuplicateJobEntries: true, - // Always queue series-episodes first so all remaining episodes are - // visible in Queue immediately. PumpQueue starts the first one. forceQueue: true, - uniqueEntryKey: `series_episode_${episode.episodeIndex}_${episode.titleId}`, + uniqueEntryKey: `series_episode_child_${episodeJobId}`, entryData: { seriesEpisodeIndex: episode.episodeIndex, seriesTitleId: episode.titleId, @@ -8362,7 +8892,7 @@ class PipelineService extends EventEmitter { } await this.updateProgress( - startedChildren > 0 ? 'ENCODING' : 'READY_TO_ENCODE', + parentBatchState, progressSummary.overallProgress, null, progressSummary.summaryText, @@ -8381,11 +8911,21 @@ class PipelineService extends EventEmitter { } } ); + const containerParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id); + if (containerParentJobId) { + await this.syncSeriesContainerStatusFromChildren(containerParentJobId).catch((syncError) => { + logger.warn('series-container:status-sync-on-batch-start-failed', { + containerJobId: containerParentJobId, + batchParentJobId: normalizedParentJobId, + error: errorToMeta(syncError) + }); + }); + } return { handled: true, result: { started: startedChildren > 0, - stage: startedChildren > 0 ? 'ENCODING' : 'READY_TO_ENCODE', + stage: parentBatchState, seriesBatch: true, childCount: nextEpisodes.length, queuedChildren, @@ -8985,9 +9525,10 @@ class PipelineService extends EventEmitter { historyService.getRunningJobs(), historyService.getQueueIdleJobs() ]); - const visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => - !this.isSeriesBatchChildJob(job) - ); + const visibleRunningJobs = (Array.isArray(runningJobs) ? runningJobs : []).filter((job) => ( + !this.isSeriesContainerHistoryJob(job) + && !this.isSeriesBatchParentQueueAnchor(job) + )); const runningPoolUsage = this.buildRunningPoolUsage(runningJobs); const runningEncodeCount = runningPoolUsage.filmRunning; const runningCdCount = runningPoolUsage.audioRunning; @@ -9002,7 +9543,7 @@ class PipelineService extends EventEmitter { .filter((id) => Number.isFinite(id) && id > 0) ); const idleJobs = (Array.isArray(idleJobsRaw) ? idleJobsRaw : []).filter((job) => { - if (this.isSeriesBatchChildJob(job)) { + if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) { return false; } const jobId = Number(job?.id); @@ -9040,6 +9581,51 @@ class PipelineService extends EventEmitter { const label = String(candidate?.label || '').trim(); return label || null; }; + const resolveRunningSeriesSeasonDiscSubtitle = (job) => { + const status = String(job?.status || job?.last_state || '').trim().toUpperCase(); + if (status !== 'RIPPING') { + return null; + } + + const encodePlan = this.safeParseJson(job?.encode_plan_json); + const makemkvInfo = this.safeParseJson(job?.makemkv_info_json); + const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object' + ? makemkvInfo.analyzeContext + : {}; + const selectedMetadata = resolveSelectedMetadataForJob(job, analyzeContext, null); + const mediaProfile = this.resolveMediaProfileForJob(job, { + encodePlan, + makemkvInfo + }); + const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + if (!isSeriesDvd) { + return null; + } + + const seasonNumber = normalizePositiveInteger( + selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ); + const discNumber = normalizePositiveInteger( + selectedMetadata?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const subtitleParts = []; + if (seasonNumber) { + subtitleParts.push(`Staffel ${seasonNumber}`); + } + if (discNumber) { + subtitleParts.push(`Disk ${discNumber}`); + } + return subtitleParts.length > 0 ? subtitleParts.join(' | ') : null; + }; + const resolveRunningQueueSubtitle = (job) => ( + resolveRunningSeriesEpisodeSubtitle(job) + || resolveRunningSeriesSeasonDiscSubtitle(job) + || null + ); const queue = { maxParallelJobs, @@ -9052,7 +9638,7 @@ class PipelineService extends EventEmitter { runningJobs: visibleRunningJobs.map((job) => ({ jobId: Number(job.id), title: job.title || job.detected_title || `Job #${job.id}`, - subtitle: resolveRunningSeriesEpisodeSubtitle(job), + subtitle: resolveRunningQueueSubtitle(job), status: job.status, lastState: job.last_state || null, poolType: this.resolveQueuePoolTypeForJob(job), @@ -9095,12 +9681,7 @@ class PipelineService extends EventEmitter { const seriesEpisodeLabel = String(entry?.seriesEpisodeLabel || '').trim(); const displayTitleBase = row?.title || row?.detected_title || `Job #${entry.jobId}`; const displayTitle = displayTitleBase; - const subtitle = ( - String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE - && seriesEpisodeLabel - ) - ? seriesEpisodeLabel - : null; + const subtitle = seriesEpisodeLabel || null; return { ...base, jobId: Number(entry.jobId), @@ -10696,11 +11277,34 @@ class PipelineService extends EventEmitter { seriesAnalysis = seriesPluginResult?.seriesAnalysis || null; seriesLookupHint = seriesPluginResult?.seriesLookupHint || null; - const seriesLike = Boolean(seriesAnalysis?.summary?.seriesLike); + const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object' + ? seriesAnalysis.summary + : null; + const seriesLike = Boolean(rawSeriesSummary?.seriesLike); + seriesAnalysis = rawSeriesSummary + ? { + source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text', + generatedAt: seriesAnalysis?.generatedAt || nowIso(), + summary: { + seriesLike, + confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low', + reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [], + titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0 + } + } + : null; if (seriesLike) { metadataProvider = 'tmdb'; metadataCandidates = []; + const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null; + if (fallbackQuery) { + seriesLookupHint = { + ...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}), + query: fallbackQuery, + seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery + }; + } if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) { metadataCandidates = await tmdbService.searchSeriesWithSeasons( @@ -12447,6 +13051,13 @@ class PipelineService extends EventEmitter { : []); let tmdbDetails = null; + const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveMetadataProvider === 'tmdb'; + if (isDvdTmdbMetadataSelection && !effectiveDiscNumber) { + const error = new Error('Serien-DVD erkannt: Disk-Nummer ist Pflicht (Start bei 1).'); + error.statusCode = 400; + throw error; + } + // Fetch full OMDb details when selecting from OMDb with a valid IMDb ID. let omdbJsonValue = effectiveMetadataProvider === 'omdb' ? (job.omdb_json || null) @@ -12559,12 +13170,7 @@ class PipelineService extends EventEmitter { }; let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null); - const isDvdSeriesSelection = ( - mediaProfile === 'dvd' - && effectiveMetadataProvider === 'tmdb' - && Number(effectiveTmdbId) > 0 - && Number(effectiveSeasonNumber) > 0 - ); + const isDvdSeriesSelection = isDvdTmdbMetadataSelection; if (isDvdSeriesSelection) { if (!parentContainerJobId) { const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); @@ -14838,6 +15444,49 @@ class PipelineService extends EventEmitter { `HandBrake DVD Titel automatisch gewählt (aus Scan): -t ${dvdSelectedTitleInfo.handBrakeTitleId}` ); } else if (dvdScannedCandidates && dvdScannedCandidates.length > 0) { + if (isSeriesDvdReview) { + const seriesTitleIds = normalizeReviewTitleIdList( + dvdScannedCandidates.map((item) => item?.handBrakeTitleId) + ); + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + dvdHandBrakeScanJson, + seriesTitleIds, + { encodeInputPath: dvdScanInputPath } + ); + const resolvedSelectedTitleIds = normalizeReviewTitleIdList( + selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) + ); + const resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; + if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + const normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + enrichedReview = { + ...enrichedReview, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + encodeInputPath: dvdScanInputPath, + titleSelectionRequired: false, + handBrakeTitleDecisionRequired: false, + handBrakeTitleCandidates: [] + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-DVD: ${resolvedSelectedTitleIds.length} Episoden-Titel aus initialem HandBrake-Scan übernommen (ohne erneuten Titelscan).` + ); + } + } else { // Multiple titles passed the MIN_LENGTH filter during the initial DVD scan — // present them to the user without running a second HandBrake scan. const candidatesData = dvdScannedCandidates.map((t) => ({ @@ -14908,6 +15557,7 @@ class PipelineService extends EventEmitter { message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` }); return enrichedReview; + } } else { // No pre-scanned result available — run a fresh HandBrake scan for title selection. let dvdTitleScanJson = null; @@ -14979,74 +15629,118 @@ class PipelineService extends EventEmitter { `HandBrake DVD Titel automatisch gewählt: -t ${titleCandidates[0].handBrakeTitleId}` ); } else if (titleCandidates.length > 1) { - const candidatesData = titleCandidates.map((t) => ({ - handBrakeTitleId: t.handBrakeTitleId, - durationSeconds: t.durationSeconds, - durationMinutes: Number((t.durationSeconds / 60).toFixed(1)), - audioTrackCount: t.audioTrackCount, - subtitleTrackCount: t.subtitleTrackCount, - sizeBytes: t.sizeBytes || 0 - })); - enrichedReview = { - ...enrichedReview, - handBrakeTitleDecisionRequired: true, - handBrakeTitleCandidates: candidatesData, - handBrakeDvdInputPath: dvdScanInputPath, - encodeInputPath: null - }; - const waitingMediainfoInfo = this.withPluginExecutionMeta({ - generatedAt: nowIso(), - files: mediaInfoRuns - }, reviewPluginExecution); - await historyService.updateJob(jobId, { - status: 'WAITING_FOR_USER_DECISION', - last_state: 'WAITING_FOR_USER_DECISION', - error_message: null, - mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), - encode_plan_json: JSON.stringify(enrichedReview), - encode_input_path: null, - encode_review_confirmed: 0 - }); - await historyService.appendLog( - jobId, - 'SYSTEM', - `HandBrake DVD Titelauswahl erforderlich: ${titleCandidates.length} Kandidaten. Nutzer muss Titel wählen.` - ); - const dvdWaitingStatusText = `DVD Titelauswahl: ${titleCandidates.length} Kandidaten gefunden`; - const dvdWaitingContextPatch = { - jobId, - rawPath, - handBrakeTitleDecisionRequired: true, - handBrakeTitleCandidates: candidatesData, - handBrakeDvdInputPath: dvdScanInputPath, - reviewConfirmed: false, - mode: options.mode || 'rip', - mediaProfile, - sourceJobId: options.sourceJobId || null, - mediaInfoReview: enrichedReview, - selectedMetadata - }; - if (this.isPrimaryJob(jobId)) { - await this.setState('WAITING_FOR_USER_DECISION', { - activeJobId: jobId, - progress: 0, - eta: null, - statusText: dvdWaitingStatusText, - context: { - ...dvdWaitingContextPatch, - ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) - } - }); + if (isSeriesDvdReview) { + const seriesTitleIds = normalizeReviewTitleIdList( + titleCandidates.map((item) => item?.handBrakeTitleId) + ); + const selectedReviewTitles = buildSelectedHandBrakeReviewTitles( + dvdTitleScanJson, + seriesTitleIds, + { encodeInputPath: dvdScanInputPath } + ); + const resolvedSelectedTitleIds = normalizeReviewTitleIdList( + selectedReviewTitles.map((title) => normalizeReviewTitleId(title?.id)) + ); + const resolvedPrimaryTitleId = resolvedSelectedTitleIds[0] || null; + if (selectedReviewTitles.length > 0 && resolvedPrimaryTitleId) { + const selectedSet = new Set(resolvedSelectedTitleIds.map((id) => Number(id))); + const normalizedTitles = selectedReviewTitles.map((title) => { + const titleId = normalizeReviewTitleId(title?.id); + const selectedForEncode = selectedSet.has(Number(titleId)); + return { + ...title, + selectedForEncode, + encodeInput: titleId === resolvedPrimaryTitleId + }; + }); + enrichedReview = { + ...enrichedReview, + titles: normalizedTitles, + selectedTitleIds: resolvedSelectedTitleIds, + encodeInputTitleId: resolvedPrimaryTitleId, + handBrakeTitleId: resolvedPrimaryTitleId, + handBrakeTitleIds: resolvedSelectedTitleIds, + encodeInputPath: dvdScanInputPath, + titleSelectionRequired: false, + handBrakeTitleDecisionRequired: false, + handBrakeTitleCandidates: [] + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `Serien-DVD: ${resolvedSelectedTitleIds.length} Episoden-Titel aus HandBrake-Scan übernommen (kein separater Titelauswahl-Schritt).` + ); + } } else { - await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { - contextPatch: dvdWaitingContextPatch + const candidatesData = titleCandidates.map((t) => ({ + handBrakeTitleId: t.handBrakeTitleId, + durationSeconds: t.durationSeconds, + durationMinutes: Number((t.durationSeconds / 60).toFixed(1)), + audioTrackCount: t.audioTrackCount, + subtitleTrackCount: t.subtitleTrackCount, + sizeBytes: t.sizeBytes || 0 + })); + enrichedReview = { + ...enrichedReview, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + encodeInputPath: null + }; + const waitingMediainfoInfo = this.withPluginExecutionMeta({ + generatedAt: nowIso(), + files: mediaInfoRuns + }, reviewPluginExecution); + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + mediainfo_info_json: JSON.stringify(waitingMediainfoInfo), + encode_plan_json: JSON.stringify(enrichedReview), + encode_input_path: null, + encode_review_confirmed: 0 }); + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titelauswahl erforderlich: ${titleCandidates.length} Kandidaten. Nutzer muss Titel wählen.` + ); + const dvdWaitingStatusText = `DVD Titelauswahl: ${titleCandidates.length} Kandidaten gefunden`; + const dvdWaitingContextPatch = { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + }; + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: dvdWaitingStatusText, + context: { + ...dvdWaitingContextPatch, + ...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {}) + } + }); + } else { + await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, { + contextPatch: dvdWaitingContextPatch + }); + } + void this.notifyPushover('metadata_ready', { + title: 'Ripster - DVD Titelauswahl', + message: `Job #${jobId}: ${titleCandidates.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` + }); + return enrichedReview; } - void this.notifyPushover('metadata_ready', { - title: 'Ripster - DVD Titelauswahl', - message: `Job #${jobId}: ${titleCandidates.length} DVD-Titel gefunden, manuelle Auswahl erforderlich` - }); - return enrichedReview; } else { await historyService.appendLog( jobId, @@ -16278,6 +16972,16 @@ class PipelineService extends EventEmitter { error_message: null }); + if (job?.parent_job_id) { + await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => { + logger.warn('series-container:status-sync-on-child-finish-failed', { + parentJobId: job.parent_job_id, + childJobId: jobId, + error: errorToMeta(parentSyncError) + }); + }); + } + // Thumbnail aus Cache in persistenten Ordner verschieben const promotedUrl = thumbnailService.promoteJobThumbnail(jobId); if (promotedUrl) { @@ -17956,11 +18660,27 @@ class PipelineService extends EventEmitter { } const reason = 'Serien-Batch vom Benutzer abgebrochen.'; + const seriesChildJobs = await this.listSeriesBatchChildJobs(normalizedParentJobId); + const seriesChildJobIds = normalizeReviewTitleIdList( + seriesChildJobs.map((childJob) => normalizePositiveInteger(childJob?.id)).filter(Boolean) + ); + const seriesChildJobIdSet = new Set(seriesChildJobIds.map((value) => Number(value))); const queueEntriesBefore = this.queueEntries.length; - this.queueEntries = this.queueEntries.filter((entry) => !( - Number(entry?.jobId) === Number(normalizedParentJobId) - && String(entry?.action || '').trim().toUpperCase() === QUEUE_ACTIONS.START_SERIES_EPISODE - )); + this.queueEntries = this.queueEntries.filter((entry) => { + const action = String(entry?.action || '').trim().toUpperCase(); + const entryJobId = Number(entry?.jobId); + const isLegacyParentEntry = ( + entryJobId === Number(normalizedParentJobId) + && action === QUEUE_ACTIONS.START_SERIES_EPISODE + ); + const isSeriesChildJobEntry = ( + (!entry?.type || entry.type === 'job') + && Number.isFinite(entryJobId) + && entryJobId > 0 + && seriesChildJobIdSet.has(entryJobId) + ); + return !(isLegacyParentEntry || isSeriesChildJobEntry); + }); const removedQueueEntries = Math.max(0, queueEntriesBefore - this.queueEntries.length); const sourcePlan = this.safeParseJson(sourceParentJob.encode_plan_json); @@ -18012,6 +18732,64 @@ class PipelineService extends EventEmitter { } }; + let cancelledChildRunning = 0; + let cancelledChildQueued = 0; + const softCancelableChildStates = new Set([ + 'READY_TO_START', + 'READY_TO_ENCODE', + 'METADATA_SELECTION', + 'WAITING_FOR_USER_DECISION' + ]); + const currentChildJobsById = new Map( + seriesChildJobs.map((childJob) => [Number(childJob?.id), childJob]) + ); + for (const childJobIdRaw of seriesChildJobIds) { + const childJobId = this.normalizeQueueJobId(childJobIdRaw); + if (!childJobId) { + continue; + } + const childJob = currentChildJobsById.get(Number(childJobId)) || null; + const childStatus = String( + childJob?.status + || childJob?.last_state + || '' + ).trim().toUpperCase(); + if (isTerminalStatus(childStatus)) { + continue; + } + const hasActiveHandle = this.activeProcesses.has(Number(childJobId)); + if (hasActiveHandle || RUNNING_STATES.has(childStatus)) { + try { + await this.cancel(childJobId); + cancelledChildRunning += 1; + } catch (cancelChildError) { + logger.warn('series-batch:cancel:child-cancel-failed', { + parentJobId: normalizedParentJobId, + childJobId, + error: errorToMeta(cancelChildError) + }); + } + continue; + } + if (softCancelableChildStates.has(childStatus)) { + await historyService.updateJob(childJobId, { + status: 'CANCELLED', + last_state: childStatus || 'CANCELLED', + end_time: nowIso(), + error_message: reason + }); + await historyService.appendLog( + childJobId, + 'USER_ACTION', + reason + ); + this.jobProgress.delete(Number(childJobId)); + this.cancelRequestedByJob.delete(Number(childJobId)); + this.seriesBatchParentByChild.delete(Number(childJobId)); + cancelledChildQueued += 1; + } + } + const processHandle = this.activeProcesses.get(normalizedParentJobId) || null; if (processHandle) { this.cancelRequestedByJob.add(Number(normalizedParentJobId)); @@ -18023,6 +18801,9 @@ class PipelineService extends EventEmitter { } this.seriesBatchParentProgressSyncAt.delete(Number(normalizedParentJobId)); + for (const childJobId of seriesChildJobIdSet) { + this.seriesBatchParentByChild.delete(Number(childJobId)); + } await historyService.updateJob(normalizedParentJobId, { encode_plan_json: JSON.stringify(nextPlan), handbrake_info_json: JSON.stringify(nextHandbrakeInfo), @@ -18034,7 +18815,7 @@ class PipelineService extends EventEmitter { await historyService.appendLog( normalizedParentJobId, 'USER_ACTION', - `${reason} Episoden: ${summary.totalCount}, Queue-Einträge entfernt: ${removedQueueEntries}, aktiver Episode-Run: ${processHandle ? 'ja' : 'nein'}.` + `${reason} Episoden: ${summary.totalCount}, Queue-Einträge entfernt: ${removedQueueEntries}, aktive Child-Abbrüche: ${cancelledChildRunning}, queued Child-Abbrüche: ${cancelledChildQueued}, aktiver Episode-Run am Parent: ${processHandle ? 'ja' : 'nein'}.` ); await this.updateProgress('CANCELLED', summary.overallProgress, null, summary.summaryText, normalizedParentJobId, { contextPatch: { @@ -18639,6 +19420,15 @@ class PipelineService extends EventEmitter { end_time: nowIso(), error_message: message }); + if (job?.parent_job_id) { + await this.syncSeriesContainerStatusFromChildren(job.parent_job_id).catch((parentSyncError) => { + logger.warn('series-container:status-sync-on-child-fail-failed', { + parentJobId: job.parent_job_id, + childJobId: jobId, + error: errorToMeta(parentSyncError) + }); + }); + } await historyService.appendLog( jobId, 'SYSTEM', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 644f25c..ec372d5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.0", + "version": "0.13.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.0", + "version": "0.13.0-1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 4684190..ca849ba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.0", + "version": "0.13.0-1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 4205827..32d6187 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -739,6 +739,21 @@ function resolveSeriesDiscNumber(job) { ); } +function compareSeriesChildJobsByDisc(leftJob, rightJob) { + const leftDisc = resolveSeriesDiscNumber(leftJob); + const rightDisc = resolveSeriesDiscNumber(rightJob); + if (leftDisc !== null && rightDisc !== null && leftDisc !== rightDisc) { + return leftDisc - rightDisc; + } + if (leftDisc !== null && rightDisc === null) { + return -1; + } + if (leftDisc === null && rightDisc !== null) { + return 1; + } + return Number(leftJob?.id || 0) - Number(rightJob?.id || 0); +} + function BoolState({ value }) { const isTrue = Boolean(value); return isTrue ? ( @@ -1113,7 +1128,9 @@ export default function JobDetailDialog({ const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo); const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null; const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt'; - const childJobs = Array.isArray(job?.children) ? job.children : []; + const childJobs = Array.isArray(job?.children) + ? [...job.children].sort(compareSeriesChildJobsByDisc) + : []; const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container'; const seriesChildSummary = job?.seriesChildSummary || null; const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null; diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx index 2d1f9ee..425df94 100644 --- a/frontend/src/components/MetadataSelectionDialog.jsx +++ b/frontend/src/components/MetadataSelectionDialog.jsx @@ -13,6 +13,17 @@ export default function MetadataSelectionDialog({ onSearch, busy }) { + const parseDiscNumber = (rawValue) => { + const text = String(rawValue ?? '').trim(); + if (!/^\d+$/.test(text)) { + return null; + } + const parsed = Number(text); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; const [selected, setSelected] = useState(null); const [query, setQuery] = useState(''); const [manualTitle, setManualTitle] = useState(''); @@ -23,9 +34,18 @@ export default function MetadataSelectionDialog({ const [searchBusy, setSearchBusy] = useState(false); const [searchError, setSearchError] = useState(''); const [seasonFilter, setSeasonFilter] = useState(''); + const [discValidationError, setDiscValidationError] = useState(''); const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; + const mediaProfile = String( + context?.mediaProfile + || context?.selectedMetadata?.mediaProfile + || '' + ).trim().toLowerCase(); const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'; const supportsExternalIdInput = metadataProvider === 'omdb'; + const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile); + const effectiveDiscNumber = parseDiscNumber(manualDiscNumber); + const hasValidDiscNumber = effectiveDiscNumber !== null; useEffect(() => { if (!visible) { @@ -51,8 +71,18 @@ export default function MetadataSelectionDialog({ setSearchBusy(false); setSearchError(''); setSeasonFilter(''); + setDiscValidationError(''); }, [visible, context]); + useEffect(() => { + if (!discValidationError) { + return; + } + if (!requiresDiscNumber || hasValidDiscNumber) { + setDiscValidationError(''); + } + }, [discValidationError, requiresDiscNumber, hasValidDiscNumber]); + const rows = useMemo(() => { const base = Array.isArray(context?.metadataCandidates) ? context.metadataCandidates @@ -136,10 +166,10 @@ export default function MetadataSelectionDialog({ }; const handleSubmit = async () => { - const parsedDiscNumber = Number(manualDiscNumber); - const effectiveDiscNumber = Number.isFinite(parsedDiscNumber) && parsedDiscNumber > 0 - ? Math.trunc(parsedDiscNumber) - : null; + if (requiresDiscNumber && !hasValidDiscNumber) { + setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.'); + return; + } const payload = selected ? { @@ -174,11 +204,19 @@ export default function MetadataSelectionDialog({ await onSubmit(payload); }; + const handleHideRequest = () => { + if (requiresDiscNumber && !hasValidDiscNumber) { + setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.'); + return; + } + onHide?.(); + }; + return ( setManualDiscNumber(event.target.value)} - placeholder="Disc-Nr (optional)" + placeholder={requiresDiscNumber ? 'Disc-Nr (Pflicht)' : 'Disc-Nr (optional)'} inputMode="numeric" disabled={searchBusy || busy} /> - Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert. + {requiresDiscNumber + ? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht verlassen werden.' + : 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'} {searchError ? {searchError} : null} + {discValidationError ? {discValidationError} : null}
-
diff --git a/package-lock.json b/package-lock.json index ece081d..f3c9016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.13.0", + "version": "0.13.0-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.13.0", + "version": "0.13.0-1", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 313ed72..23a082e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.13.0", + "version": "0.13.0-1", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",