diff --git a/backend/package-lock.json b/backend/package-lock.json index cb984ef..e713edc 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.10.2-9", + "version": "0.10.2-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.10.2-9", + "version": "0.10.2-10", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 0ed7dc5..ad12aa3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.10.2-9", + "version": "0.10.2-10", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index b6b5e78..e267d49 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -197,7 +197,7 @@ router.post( router.post( '/select-metadata', asyncHandler(async (req, res) => { - const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist } = req.body; + const { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId } = req.body; if (!jobId) { const error = new Error('jobId fehlt.'); @@ -213,7 +213,8 @@ router.post( imdbId, poster, fromOmdb, - selectedPlaylist + selectedPlaylist, + selectedHandBrakeTitleId }); const job = await pipelineService.selectMetadata({ @@ -223,7 +224,8 @@ router.post( imdbId, poster, fromOmdb, - selectedPlaylist + selectedPlaylist, + selectedHandBrakeTitleId }); res.json({ job }); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index 6db6513..c9d27c3 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -6959,9 +6959,9 @@ class PipelineService extends EventEmitter { return this.runMediainfoReviewForJob(jobId, rawPath, options); } - async selectMetadata({ jobId, title, year, imdbId, poster, fromOmdb = null, selectedPlaylist = null }) { + async selectMetadata({ jobId, title, year, imdbId, poster, fromOmdb = null, selectedPlaylist = null, selectedHandBrakeTitleId = null }) { this.ensureNotBusy('selectMetadata', jobId); - logger.info('metadata:selected', { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist }); + logger.info('metadata:selected', { jobId, title, year, imdbId, poster, fromOmdb, selectedPlaylist, selectedHandBrakeTitleId }); const job = await historyService.getJobById(jobId); if (!job) { @@ -6973,6 +6973,7 @@ class PipelineService extends EventEmitter { const mediaProfile = this.resolveMediaProfileForJob(job, { makemkvInfo: mkInfo }); const normalizedSelectedPlaylist = normalizePlaylistId(selectedPlaylist); + const normalizedSelectedHandBrakeTitleId = normalizeReviewTitleId(selectedHandBrakeTitleId); const waitingForPlaylistSelection = ( job.status === 'WAITING_FOR_USER_DECISION' || job.last_state === 'WAITING_FOR_USER_DECISION' @@ -6984,6 +6985,52 @@ class PipelineService extends EventEmitter { || poster !== undefined || (fromOmdb !== null && fromOmdb !== undefined) ); + + if (normalizedSelectedHandBrakeTitleId && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload && !normalizedSelectedPlaylist) { + const currentMkInfo = mkInfo; + const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; + const updatedMkInfo = this.withAnalyzeContextMediaProfile({ + ...currentMkInfo, + analyzeContext: { + ...currentAnalyzeContext, + selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId + } + }, mediaProfile); + + await historyService.updateJob(jobId, { + status: 'MEDIAINFO_CHECK', + last_state: 'MEDIAINFO_CHECK', + error_message: null, + makemkv_info_json: JSON.stringify(updatedMkInfo), + mediainfo_info_json: null, + encode_plan_json: null, + encode_input_path: null, + encode_review_confirmed: 0 + }); + await historyService.appendLog( + jobId, + 'USER_ACTION', + `DVD Titel-Auswahl gesetzt: -t ${normalizedSelectedHandBrakeTitleId}.` + ); + + try { + await this.runMediainfoReviewForJob(jobId, job.raw_path, { + mode: 'rip', + mediaProfile, + selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId + }); + } catch (error) { + logger.error('metadata:handbrake-title-selection:review-failed', { + jobId, + selectedHandBrakeTitleId: normalizedSelectedHandBrakeTitleId, + error: errorToMeta(error) + }); + await this.failJob(jobId, 'MEDIAINFO_CHECK', error); + throw error; + } + return historyService.getJobById(jobId); + } + if (normalizedSelectedPlaylist && waitingForPlaylistSelection && job.raw_path && !hasExplicitMetadataPayload) { const currentMkInfo = mkInfo; const currentAnalyzeContext = currentMkInfo?.analyzeContext || {}; @@ -8222,9 +8269,156 @@ class PipelineService extends EventEmitter { : null ); enrichedReview = reviewPrefillResult.plan; + + // HandBrake title scan for DVD structures (VIDEO_TS directories / ISO images) + const needsHandBrakeTitleScan = rawMedia.source === 'dvd' + || rawMedia.source === 'dvd_image' + || rawMedia.source === 'single_extensionless'; + if (needsHandBrakeTitleScan) { + const dvdScanInputPath = rawMedia.source === 'dvd' ? rawPath : mediaFiles[0].path; + const preSelectedHandBrakeTitleId = normalizeReviewTitleId(options?.selectedHandBrakeTitleId); + if (preSelectedHandBrakeTitleId) { + enrichedReview = { + ...enrichedReview, + handBrakeTitleId: preSelectedHandBrakeTitleId, + encodeInputPath: dvdScanInputPath + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}` + ); + } else { + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Titel-Scan für DVD-Struktur wird gestartet...' + ); + await this.updateProgress('MEDIAINFO_CHECK', 100, null, 'HandBrake DVD Titel-Scan läuft', jobId); + const dvdTitleScanLines = []; + const dvdTitleScanConfig = await settingsService.buildHandBrakeScanConfigForInput(dvdScanInputPath, { + mediaProfile, + settingsMap: settings + }); + logger.info('mediainfo:review:dvd-title-scan:command', { + jobId, + cmd: dvdTitleScanConfig.cmd, + args: dvdTitleScanConfig.args, + dvdScanInputPath + }); + await this.runCommand({ + jobId, + stage: 'MEDIAINFO_CHECK', + source: 'HANDBRAKE_SCAN_DVD_TITLES', + cmd: dvdTitleScanConfig.cmd, + args: dvdTitleScanConfig.args, + collectLines: dvdTitleScanLines, + collectStderrLines: false + }); + const dvdTitleScanJson = parseMediainfoJsonOutput(dvdTitleScanLines.join('\n')); + if (dvdTitleScanJson) { + const allTitles = parseHandBrakeTitleList(dvdTitleScanJson); + const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0); + const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60)); + const titleCandidates = allTitles.filter((t) => t.durationSeconds >= minLengthSeconds); + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake DVD Titel-Scan: ${allTitles.length} gesamt, ${titleCandidates.length} ≥ ${minLengthMinutes} min.` + ); + if (titleCandidates.length === 1) { + enrichedReview = { + ...enrichedReview, + handBrakeTitleId: titleCandidates[0].handBrakeTitleId, + encodeInputPath: dvdScanInputPath + }; + await historyService.appendLog( + jobId, + 'SYSTEM', + `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 + }; + await historyService.updateJob(jobId, { + status: 'WAITING_FOR_USER_DECISION', + last_state: 'WAITING_FOR_USER_DECISION', + error_message: null, + mediainfo_info_json: JSON.stringify({ + generatedAt: nowIso(), + files: mediaInfoRuns + }), + 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.` + ); + if (this.isPrimaryJob(jobId)) { + await this.setState('WAITING_FOR_USER_DECISION', { + activeJobId: jobId, + progress: 0, + eta: null, + statusText: `DVD Titelauswahl: ${titleCandidates.length} Kandidaten gefunden`, + context: { + jobId, + rawPath, + handBrakeTitleDecisionRequired: true, + handBrakeTitleCandidates: candidatesData, + handBrakeDvdInputPath: dvdScanInputPath, + reviewConfirmed: false, + mode: options.mode || 'rip', + mediaProfile, + sourceJobId: options.sourceJobId || null, + mediaInfoReview: enrichedReview, + selectedMetadata + } + }); + } + 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, + 'SYSTEM', + `HandBrake DVD Titel-Scan: Kein Titel ≥ ${minLengthMinutes} min. Ohne Titel-ID fortgefahren.` + ); + enrichedReview = { ...enrichedReview, encodeInputPath: dvdScanInputPath }; + } + } else { + logger.warn('mediainfo:review:dvd-title-scan:parse-failed', { jobId }); + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake DVD Titel-Scan: Ausgabe nicht lesbar. Ohne Titel-ID fortgefahren.' + ); + enrichedReview = { ...enrichedReview, encodeInputPath: dvdScanInputPath }; + } + } + } + const hasEncodableTitle = Boolean(enrichedReview.encodeInputPath); const titleSelectionRequired = Boolean(enrichedReview.titleSelectionRequired); - if (!hasEncodableTitle && !titleSelectionRequired) { + if (!hasEncodableTitle && !titleSelectionRequired && !enrichedReview.handBrakeTitleDecisionRequired) { enrichedReview.notes = [ ...(Array.isArray(enrichedReview.notes) ? enrichedReview.notes : []), 'Kein Titel erfüllt aktuell MIN_LENGTH_MINUTES. Bitte Konfiguration prüfen.' @@ -8991,7 +9185,61 @@ class PipelineService extends EventEmitter { `HandBrake Titel-Mapping: ${selectedPlaylistId}.mpls -> -t ${handBrakeTitleId}` ); } else if (!handBrakeTitleId) { - handBrakeTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId ?? encodePlan?.encodeInputTitleId); + const dvdResolveScanLines = []; + const dvdResolveScanConfig = await settingsService.buildHandBrakeScanConfigForInput(inputPath, { + mediaProfile, + settingsMap: settings + }); + await historyService.appendLog( + jobId, + 'SYSTEM', + 'HandBrake Titel-Scan für Verzeichnis ohne Playlist wird gestartet...' + ); + logger.info('encoding:dvd-title-resolve-scan:command', { + jobId, + cmd: dvdResolveScanConfig.cmd, + args: dvdResolveScanConfig.args + }); + const dvdResolveRunInfo = await this.runCommand({ + jobId, + stage: 'ENCODING', + source: 'HANDBRAKE_SCAN_TITLE_RESOLVE', + cmd: dvdResolveScanConfig.cmd, + args: dvdResolveScanConfig.args, + collectLines: dvdResolveScanLines, + collectStderrLines: false + }); + const dvdResolveParsed = parseMediainfoJsonOutput(dvdResolveScanLines.join('\n')); + if (dvdResolveParsed) { + const dvdTitleInfo = parseHandBrakeSelectedTitleInfo(dvdResolveParsed, { + handBrakeTitleId: null, + playlistId: null + }); + if (dvdTitleInfo?.handBrakeTitleId) { + handBrakeTitleId = dvdTitleInfo.handBrakeTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-Scan: längster Titel gefunden -> -t ${handBrakeTitleId}` + ); + } + } else { + logger.warn('encoding:dvd-title-resolve-scan:parse-failed', { + jobId, + runInfo: dvdResolveRunInfo + }); + } + } + } + if (!handBrakeTitleId) { + const fallbackTitleId = normalizeReviewTitleId(encodePlan?.handBrakeTitleId); + if (fallbackTitleId) { + handBrakeTitleId = fallbackTitleId; + await historyService.appendLog( + jobId, + 'SYSTEM', + `HandBrake Titel-ID aus Encode-Plan übernommen: -t ${handBrakeTitleId}` + ); } } const handBrakeConfig = await settingsService.buildHandBrakeConfig(inputPath, incompleteOutputPath, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3dacee..b40a63c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.10.2-9", + "version": "0.10.2-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.10.2-9", + "version": "0.10.2-10", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 9cb0b37..c93603c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.10.2-9", + "version": "0.10.2-10", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b50bced..e3fda71 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -706,7 +706,13 @@ export const api = { return request('/downloads/summary'); }, downloadPreparedArchive(downloadId) { - return download(`/downloads/${encodeURIComponent(downloadId)}/file`); + const link = document.createElement('a'); + link.href = `${API_BASE}/downloads/${encodeURIComponent(downloadId)}/file`; + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); + return Promise.resolve(); }, deleteDownload(downloadId) { return request(`/downloads/${encodeURIComponent(downloadId)}`, { diff --git a/frontend/src/components/JobDetailDialog.jsx b/frontend/src/components/JobDetailDialog.jsx index 619c1b0..25178b1 100644 --- a/frontend/src/components/JobDetailDialog.jsx +++ b/frontend/src/components/JobDetailDialog.jsx @@ -547,6 +547,23 @@ export default function JobDetailDialog({ : null; const encodePlanUserPresetId = Number(encodePlanUserPreset?.id); const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : []; + const encodePlanManualTrackSelection = job?.encodePlan?.manualTrackSelection + && typeof job.encodePlan.manualTrackSelection === 'object' + ? job.encodePlan.manualTrackSelection + : null; + const reviewTrackSelectionByTitle = encodePlanManualTrackSelection?.titleId != null + ? { + [encodePlanManualTrackSelection.titleId]: { + audioTrackIds: Array.isArray(encodePlanManualTrackSelection.audioTrackIds) + ? encodePlanManualTrackSelection.audioTrackIds + : [], + subtitleTrackIds: Array.isArray(encodePlanManualTrackSelection.subtitleTrackIds) + ? encodePlanManualTrackSelection.subtitleTrackIds + : [] + } + } + : {}; + const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? null; const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo); const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function'); const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function'); @@ -846,6 +863,8 @@ export default function JobDetailDialog({ { + const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates) + ? pipeline.context.handBrakeTitleCandidates + : []; + return raw + .map((item) => { + const titleId = Number(item?.handBrakeTitleId); + if (!Number.isFinite(titleId) || titleId <= 0) return null; + const durationSeconds = Number(item?.durationSeconds || 0); + const minutes = Math.floor(durationSeconds / 60); + const seconds = durationSeconds % 60; + const durationLabel = `${minutes}:${String(seconds).padStart(2, '0')} min`; + return { + handBrakeTitleId: Math.trunc(titleId), + durationSeconds, + durationLabel, + durationMinutes: Number(item?.durationMinutes || 0), + audioTrackCount: Number(item?.audioTrackCount || 0), + subtitleTrackCount: Number(item?.subtitleTrackCount || 0), + sizeBytes: Number(item?.sizeBytes || 0) + }; + }) + .filter(Boolean) + .sort((a, b) => b.durationSeconds - a.durationSeconds); + }, [pipeline?.context?.handBrakeTitleCandidates]); + + useEffect(() => { + if (!handBrakeTitleDecisionRequired) { + setSelectedHandBrakeTitleIdState(null); + return; + } + const best = waitingHandBrakeTitleRows[0]?.handBrakeTitleId || null; + setSelectedHandBrakeTitleIdState(best); + }, [handBrakeTitleDecisionRequired, retryJobId, waitingHandBrakeTitleRows]); + useEffect(() => { if (state !== 'WAITING_FOR_USER_DECISION') { setSelectedPlaylistId(null); @@ -551,7 +591,7 @@ export default function PipelineStatusCard({ pipeline?.context?.selectedPlaylist ]); - const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION'; + const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired; const commandOutputPath = useMemo( () => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId), [settingsMap, jobMediaProfile, selectedMetadata, retryJobId] @@ -696,6 +736,18 @@ export default function PipelineStatusCard({ /> )} + {handBrakeTitleDecisionRequired && retryJobId && ( +