diff --git a/backend/src/routes/pipelineRoutes.js b/backend/src/routes/pipelineRoutes.js index a722334..75981c0 100644 --- a/backend/src/routes/pipelineRoutes.js +++ b/backend/src/routes/pipelineRoutes.js @@ -99,6 +99,9 @@ router.post( const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null; const selectedTrackSelection = req.body?.selectedTrackSelection ?? null; const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds; + const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds; + const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds; + const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds; const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate); const selectedUserPresetId = req.body?.selectedUserPresetId ?? null; logger.info('post:confirm-encode', { @@ -110,12 +113,24 @@ router.post( selectedUserPresetId, selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds) ? selectedPostEncodeScriptIds.length + : 0, + selectedPreEncodeScriptIdsCount: Array.isArray(selectedPreEncodeScriptIds) + ? selectedPreEncodeScriptIds.length + : 0, + selectedPostEncodeChainIdsCount: Array.isArray(selectedPostEncodeChainIds) + ? selectedPostEncodeChainIds.length + : 0, + selectedPreEncodeChainIdsCount: Array.isArray(selectedPreEncodeChainIds) + ? selectedPreEncodeChainIds.length : 0 }); const job = await pipelineService.confirmEncodeReview(jobId, { selectedEncodeTitleId, selectedTrackSelection, selectedPostEncodeScriptIds, + selectedPreEncodeScriptIds, + selectedPostEncodeChainIds, + selectedPreEncodeChainIds, skipPipelineStateUpdate, selectedUserPresetId }); diff --git a/backend/src/services/diskDetectionService.js b/backend/src/services/diskDetectionService.js index ad83820..b8b4bf3 100644 --- a/backend/src/services/diskDetectionService.js +++ b/backend/src/services/diskDetectionService.js @@ -98,8 +98,12 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { } if (fstype.includes('udf')) { + // UDF is used by both DVDs (UDF 1.x) and Blu-rays (UDF 2.x). + // Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive + // with a DVD inside would incorrectly be detected as Blu-ray. + // Return null so UDF version detection via blkid can decide. if (hasBlurayModelMarker) { - return 'bluray'; + return null; } if (hasDvdModelMarker) { return 'dvd'; @@ -108,9 +112,8 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { } if (fstype.includes('iso9660') || fstype.includes('cdfs')) { - if (hasBlurayModelMarker) { - return 'bluray'; - } + // iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.x). + // Ignore hasBlurayModelMarker – it only reflects drive capability. if (hasCdOnlyModelMarker) { return 'other'; } @@ -120,6 +123,30 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { return null; } +function inferMediaProfileFromUdevProperties(properties = {}) { + const flags = Object.entries(properties).reduce((acc, [key, rawValue]) => { + const normalizedKey = String(key || '').trim().toUpperCase(); + if (!normalizedKey) { + return acc; + } + + acc[normalizedKey] = String(rawValue || '').trim(); + return acc; + }, {}); + + const hasFlag = (prefix) => Object.entries(flags).some(([key, value]) => key.startsWith(prefix) && value === '1'); + if (hasFlag('ID_CDROM_MEDIA_BD')) { + return 'bluray'; + } + if (hasFlag('ID_CDROM_MEDIA_DVD')) { + return 'dvd'; + } + if (hasFlag('ID_CDROM_MEDIA_CD')) { + return 'other'; + } + return null; +} + class DiskDetectionService extends EventEmitter { constructor() { super(); @@ -496,31 +523,60 @@ class DiskDetectionService extends EventEmitter { } } + async inferMediaProfileFromUdev(devicePath) { + const normalizedPath = String(devicePath || '').trim(); + if (!normalizedPath) { + return null; + } + + try { + const { stdout } = await execFileAsync('udevadm', ['info', '--query=property', '--name', normalizedPath]); + const properties = {}; + for (const line of String(stdout || '').split(/\r?\n/)) { + const idx = line.indexOf('='); + if (idx <= 0) { + continue; + } + const key = String(line.slice(0, idx)).trim(); + const value = String(line.slice(idx + 1)).trim(); + if (!key) { + continue; + } + properties[key] = value; + } + + const inferred = inferMediaProfileFromUdevProperties(properties); + if (inferred) { + logger.debug('udev:media-profile', { devicePath: normalizedPath, inferred }); + } + return inferred; + } catch (error) { + logger.debug('udev:media-profile:failed', { + devicePath: normalizedPath, + error: errorToMeta(error) + }); + return null; + } + } + async inferMediaProfile(devicePath, hints = {}) { const explicit = normalizeMediaProfile(hints?.mediaProfile); if (isSpecificMediaProfile(explicit)) { return explicit; } + // Only pass disc-specific fields – NOT hints?.model (drive model). + // Drive model (e.g. "BD-ROM") reflects drive capability, not disc type. + // A BD-ROM drive with a DVD would otherwise be detected as Blu-ray here. const hinted = inferMediaProfileFromTextParts([ hints?.discLabel, hints?.label, hints?.fstype, - hints?.model ]); if (hinted) { return hinted; } - const hintFstype = String(hints?.fstype || '').trim().toLowerCase(); - const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model); - // UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model - // marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the - // early return and fall through to the blkid check which uses the UDF version number. - if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { - return byFsTypeHint; - } - const mountpoint = String(hints?.mountpoint || '').trim(); if (mountpoint) { try { @@ -539,8 +595,25 @@ class DiskDetectionService extends EventEmitter { } } + const byUdev = await this.inferMediaProfileFromUdev(devicePath); + if (byUdev) { + return byUdev; + } + + const hintFstype = String(hints?.fstype || '').trim().toLowerCase(); + const byFsTypeHint = inferMediaProfileFromFsTypeAndModel(hints?.fstype, hints?.model); + const udfHintFallback = hintFstype.includes('udf') + ? inferMediaProfileFromFsTypeAndModel(hints?.fstype, null) + : null; + // UDF is used for both Blu-ray (UDF 2.x) and DVD (UDF 1.x). Without a clear model + // marker identifying it as Blu-ray, a 'dvd' result from UDF is ambiguous. Skip the + // early return and fall through to the blkid check which uses the UDF version number. + if (byFsTypeHint && !(hintFstype.includes('udf') && byFsTypeHint !== 'bluray')) { + return byFsTypeHint; + } + try { - const { stdout } = await execFileAsync('blkid', ['-o', 'export', devicePath]); + const { stdout } = await execFileAsync('blkid', ['-p', '-o', 'export', devicePath]); const payload = {}; for (const line of String(stdout || '').split(/\r?\n/)) { const idx = line.indexOf('='); @@ -555,28 +628,40 @@ class DiskDetectionService extends EventEmitter { payload[key] = value; } + // APPLICATION_ID contains disc-specific strings (e.g. "BDAV"/"BDMV" for Blu-ray, + // "DVD_VIDEO" for DVD). Drive model is excluded – see reasoning above. const byBlkidMarker = inferMediaProfileFromTextParts([ payload.LABEL, payload.TYPE, payload.VERSION, payload.APPLICATION_ID, - hints?.model ]); if (byBlkidMarker) { return byBlkidMarker; } const type = String(payload.TYPE || '').trim().toLowerCase(); + // For UDF, VERSION is the most reliable discriminator: 1.x → DVD, 2.x → Blu-ray. + // This check must run independently of inferMediaProfileFromFsTypeAndModel so it + // is not skipped when the drive model returns null (BD-ROM drive with DVD inside). + if (type.includes('udf')) { + const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.')); + if (Number.isFinite(version)) { + return version >= 2 ? 'bluray' : 'dvd'; + } + } + const byBlkidFsType = inferMediaProfileFromFsTypeAndModel(type, hints?.model); if (byBlkidFsType) { - if (type.includes('udf')) { - const version = Number.parseFloat(String(payload.VERSION || '').replace(',', '.')); - if (Number.isFinite(version)) { - return version >= 2 ? 'bluray' : 'dvd'; - } - } return byBlkidFsType; } + + // Last resort for drives that only expose TYPE=udf without VERSION/APPLICATION_ID: + // prefer DVD over "other" so DVDs in BD-capable drives do not fall back to Misc. + const byBlkidFsTypeWithoutModel = inferMediaProfileFromFsTypeAndModel(type, null); + if (byBlkidFsTypeWithoutModel) { + return byBlkidFsTypeWithoutModel; + } } catch (error) { logger.debug('infer-media-profile:blkid-failed', { devicePath, @@ -584,7 +669,11 @@ class DiskDetectionService extends EventEmitter { }); } - return explicit === 'other' ? 'other' : null; + if (udfHintFallback) { + return udfHintFallback; + } + + return 'other'; } guessDiscIndex(name) { diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index d5f148d..5b6adf9 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -128,8 +128,12 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { } if (fstype.includes('udf')) { + // UDF is used by both DVDs (UDF 1.02) and Blu-rays (UDF 2.5/2.6). + // Drive model alone (hasBlurayModelMarker) is not reliable: a BD-ROM drive + // with a DVD inside would incorrectly be detected as Blu-ray. + // Return null so the mountpoint BDMV/VIDEO_TS check can decide. if (hasBlurayModelMarker) { - return 'bluray'; + return null; } if (hasDvdModelMarker) { return 'dvd'; @@ -138,9 +142,8 @@ function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) { } if (fstype.includes('iso9660') || fstype.includes('cdfs')) { - if (hasBlurayModelMarker) { - return 'bluray'; - } + // iso9660/cdfs is never used by Blu-ray discs (they use UDF 2.5/2.6). + // Ignore hasBlurayModelMarker here – it only reflects drive capability. if (hasCdOnlyModelMarker) { return 'other'; } @@ -266,20 +269,22 @@ function inferMediaProfileFromDeviceInfo(deviceInfo = null) { return explicit; } - const markerText = [ + // Only use disc-specific fields for keyword detection, NOT device.model. + // The drive model describes drive capability (e.g. "BD-ROM"), not disc type. + // A BD-ROM drive with a DVD inserted would otherwise be misdetected as Blu-ray. + const discMarkerText = [ device.discLabel, device.label, device.fstype, - device.model ] .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) .join(' '); - if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(markerText)) { + if (/(^|[\s_-])bdmv($|[\s_-])|blu[\s-]?ray|bd-rom|bd-r|bd-re/.test(discMarkerText)) { return 'bluray'; } - if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(markerText)) { + if (/(^|[\s_-])video_ts($|[\s_-])|dvd/.test(discMarkerText)) { return 'dvd'; } @@ -1166,10 +1171,14 @@ function parseMakeMkvDurationSeconds(rawValue) { } function buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo) { + const durationSeconds = Math.max(0, Math.trunc(Number(titleInfo?.durationSeconds || 0))); const tracks = []; tracks.push({ '@type': 'General', - Duration: String(Number(titleInfo?.durationSeconds || 0)) + // MediaInfo reports numeric Duration as milliseconds. Keep this format so + // parseDurationSeconds() does not misinterpret long titles. + Duration: String(durationSeconds * 1000), + Duration_String3: formatDurationClock(durationSeconds) || null }); const audioTracks = Array.isArray(titleInfo?.audioTracks) ? titleInfo.audioTracks : []; @@ -3903,10 +3912,17 @@ class PipelineService extends EventEmitter { eta: patch.eta ?? null, statusText: patch.statusText ?? null }); - } else if (patch.activeJobId === null && previousActiveJobId != null) { + } else if (patch.activeJobId === null) { // Job slot cleared – remove the finished job's live entry so it falls // back to DB data in the frontend. - this.jobProgress.delete(Number(previousActiveJobId)); + // Use patch.finishingJobId when provided (parallel-safe); fall back to + // previousActiveJobId only when no parallel job has overwritten the slot. + const finishingJobId = patch.finishingJobId != null + ? Number(patch.finishingJobId) + : (previousActiveJobId != null ? Number(previousActiveJobId) : null); + if (finishingJobId != null) { + this.jobProgress.delete(finishingJobId); + } } logger.info('state:changed', { from: previous, @@ -4917,7 +4933,8 @@ class PipelineService extends EventEmitter { 'MEDIAINFO_CHECK', 25, null, - 'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet' + 'HandBrake Trackdaten für Playlist-Auswahl werden vorbereitet', + jobId ); try { const resolveScanLines = []; @@ -5169,16 +5186,15 @@ class PipelineService extends EventEmitter { ); } - if (this.isPrimaryJob(jobId)) { - await this.updateProgress( - 'MEDIAINFO_CHECK', - 30, - null, - hasCachedHandBrakeEntry - ? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` - : `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` - ); - } + await this.updateProgress( + 'MEDIAINFO_CHECK', + 30, + null, + hasCachedHandBrakeEntry + ? `HandBrake Trackdaten aus Cache (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})` + : `HandBrake Titel-/Spurscan läuft (${toPlaylistFile(resolvedPlaylistId) || resolvedPlaylistId})`, + jobId + ); let handBrakeResolveRunInfo = null; let handBrakeTitleRunInfo = null; @@ -5371,6 +5387,8 @@ class PipelineService extends EventEmitter { review = remapReviewTrackIdsToSourceIds(review); const resolvedPlaylistInfo = resolvePlaylistInfoFromAnalysis(playlistAnalysis, resolvedPlaylistId); + const minLengthMinutesForReview = Number(review?.minLengthMinutes ?? settings?.makemkv_min_length_minutes ?? 0); + const minLengthSecondsForReview = Math.max(0, Math.round(minLengthMinutesForReview * 60)); const subtitleTrackMetaBySourceId = new Map( (Array.isArray(reviewTitleInfo?.subtitleTracks) ? reviewTitleInfo.subtitleTracks : []) .map((track) => { @@ -5382,6 +5400,8 @@ class PipelineService extends EventEmitter { const normalizedTitles = (Array.isArray(review.titles) ? review.titles : []) .slice(0, 1) .map((title) => { + const durationSeconds = Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0); + const eligibleForEncode = durationSeconds >= minLengthSecondsForReview; const subtitleTracks = (Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : []).map((track) => { const sourceTrackId = normalizeTrackIdList([track?.sourceTrackId ?? track?.id])[0] || null; const sourceMeta = sourceTrackId ? (subtitleTrackMetaBySourceId.get(sourceTrackId) || null) : null; @@ -5403,8 +5423,10 @@ class PipelineService extends EventEmitter { ...title, filePath: rawPath, fileName: reviewTitleInfo?.fileName || title?.fileName || `Title #${selectedTitleForReview}`, - durationSeconds: Number(reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0), - durationMinutes: Number((((reviewTitleInfo?.durationSeconds || title?.durationSeconds || 0) / 60)).toFixed(2)), + durationSeconds, + durationMinutes: Number(((durationSeconds / 60)).toFixed(2)), + selectedByMinLength: eligibleForEncode, + eligibleForEncode, sizeBytes: Number(reviewTitleInfo?.sizeBytes || title?.sizeBytes || 0), playlistId: resolvedPlaylistInfo.playlistId || title?.playlistId || null, playlistFile: resolvedPlaylistInfo.playlistFile || title?.playlistFile || null, @@ -6559,7 +6581,7 @@ class PipelineService extends EventEmitter { for (let i = 0; i < mediaFiles.length; i += 1) { const file = mediaFiles[i]; const percent = Number((((i + 1) / mediaFiles.length) * 100).toFixed(2)); - await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`); + await this.updateProgress('MEDIAINFO_CHECK', percent, null, `Mediainfo ${i + 1}/${mediaFiles.length}: ${path.basename(file.path)}`, jobId); const result = await this.runMediainfoForFile(jobId, file.path, { mediaProfile, @@ -7479,6 +7501,7 @@ class PipelineService extends EventEmitter { setTimeout(async () => { if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) { await this.setState('IDLE', { + finishingJobId: jobId, activeJobId: null, progress: 0, eta: null, diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 33bd516..3b4d143 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -371,6 +371,18 @@ export default function DashboardPage({ refreshPipeline }) { const [busy, setBusy] = useState(false); + const [busyJobIds, setBusyJobIds] = useState(() => new Set()); + const setJobBusy = (jobId, isBusy) => { + setBusyJobIds((prev) => { + const next = new Set(prev); + if (isBusy) { + next.add(jobId); + } else { + next.delete(jobId); + } + return next; + }); + }; const [metadataDialogVisible, setMetadataDialogVisible] = useState(false); const [metadataDialogContext, setMetadataDialogContext] = useState(null); const [cancelCleanupDialog, setCancelCleanupDialog] = useState({ @@ -713,7 +725,7 @@ export default function DashboardPage({ || 'IDLE' ).trim().toUpperCase(); - setBusy(true); + if (cancelledJobId) setJobBusy(cancelledJobId, true); try { await api.cancelPipeline(cancelledJobId); await refreshPipeline(); @@ -736,7 +748,7 @@ export default function DashboardPage({ } catch (error) { showError(error); } finally { - setBusy(false); + if (cancelledJobId) setJobBusy(cancelledJobId, false); } }; @@ -782,7 +794,7 @@ export default function DashboardPage({ } const startOptions = options && typeof options === 'object' ? options : {}; - setBusy(true); + setJobBusy(normalizedJobId, true); try { if (startOptions.ensureConfirmed) { await api.confirmEncodeReview(normalizedJobId, { @@ -808,7 +820,7 @@ export default function DashboardPage({ } catch (error) { showError(error); } finally { - setBusy(false); + setJobBusy(normalizedJobId, false); } }; @@ -818,7 +830,8 @@ export default function DashboardPage({ selectedTrackSelection = null, selectedPostEncodeScriptIds = undefined ) => { - setBusy(true); + const normalizedJobId = normalizeJobId(jobId); + if (normalizedJobId) setJobBusy(normalizedJobId, true); try { await api.confirmEncodeReview(jobId, { selectedEncodeTitleId, @@ -827,16 +840,17 @@ export default function DashboardPage({ }); await refreshPipeline(); await loadDashboardJobs(); - setExpandedJobId(normalizeJobId(jobId)); + setExpandedJobId(normalizedJobId); } catch (error) { showError(error); } finally { - setBusy(false); + if (normalizedJobId) setJobBusy(normalizedJobId, false); } }; const handleSelectPlaylist = async (jobId, selectedPlaylist = null) => { - setBusy(true); + const normalizedJobId = normalizeJobId(jobId); + if (normalizedJobId) setJobBusy(normalizedJobId, true); try { await api.selectMetadata({ jobId, @@ -844,16 +858,17 @@ export default function DashboardPage({ }); await refreshPipeline(); await loadDashboardJobs(); - setExpandedJobId(normalizeJobId(jobId)); + setExpandedJobId(normalizedJobId); } catch (error) { showError(error); } finally { - setBusy(false); + if (normalizedJobId) setJobBusy(normalizedJobId, false); } }; const handleRetry = async (jobId) => { - setBusy(true); + const normalizedJobId = normalizeJobId(jobId); + if (normalizedJobId) setJobBusy(normalizedJobId, true); try { const response = await api.retryJob(jobId); const result = getQueueActionResult(response); @@ -862,12 +877,12 @@ export default function DashboardPage({ if (result.queued) { showQueuedToast(toastRef, 'Retry', result); } else { - setExpandedJobId(normalizeJobId(jobId)); + setExpandedJobId(normalizedJobId); } } catch (error) { showError(error); } finally { - setBusy(false); + if (normalizedJobId) setJobBusy(normalizedJobId, false); } }; @@ -884,7 +899,8 @@ export default function DashboardPage({ } } - setBusy(true); + const normalizedJobId = normalizeJobId(jobId); + if (normalizedJobId) setJobBusy(normalizedJobId, true); try { const response = await api.restartEncodeWithLastSettings(jobId); const result = getQueueActionResult(response); @@ -893,12 +909,12 @@ export default function DashboardPage({ if (result.queued) { showQueuedToast(toastRef, 'Encode-Neustart', result); } else { - setExpandedJobId(normalizeJobId(jobId)); + setExpandedJobId(normalizedJobId); } } catch (error) { showError(error); } finally { - setBusy(false); + if (normalizedJobId) setJobBusy(normalizedJobId, false); } }; @@ -908,7 +924,7 @@ export default function DashboardPage({ return; } - setBusy(true); + setJobBusy(normalizedJobId, true); try { await api.restartReviewFromRaw(normalizedJobId); await refreshPipeline(); @@ -917,7 +933,7 @@ export default function DashboardPage({ } catch (error) { showError(error); } finally { - setBusy(false); + setJobBusy(normalizedJobId, false); } }; @@ -975,6 +991,7 @@ export default function DashboardPage({ } setQueueReorderBusy(true); + setJobBusy(normalizedJobId, true); try { await api.cancelPipeline(normalizedJobId); const latest = await api.getPipelineQueue(); @@ -983,6 +1000,7 @@ export default function DashboardPage({ showError(error); } finally { setQueueReorderBusy(false); + setJobBusy(normalizedJobId, false); } }; @@ -1429,7 +1447,7 @@ export default function DashboardPage({ severity="secondary" outlined onClick={() => setExpandedJobId(null)} - disabled={busy} + disabled={busyJobIds.has(jobId)} />