diff --git a/Bildschirmfoto 2026-04-11 um 21.20.19.png b/Bildschirmfoto 2026-04-11 um 21.20.19.png deleted file mode 100644 index 538aeec..0000000 Binary files a/Bildschirmfoto 2026-04-11 um 21.20.19.png and /dev/null differ diff --git a/Bildschirmfoto 2026-04-11 um 21.20.25.png b/Bildschirmfoto 2026-04-11 um 21.20.25.png deleted file mode 100644 index 6f82326..0000000 Binary files a/Bildschirmfoto 2026-04-11 um 21.20.25.png and /dev/null differ diff --git a/Bildschirmfoto 2026-04-12 um 20.43.33.png b/Bildschirmfoto 2026-04-12 um 20.43.33.png new file mode 100644 index 0000000..a770fac Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 20.43.33.png differ diff --git a/Bildschirmfoto 2026-04-12 um 20.45.16.png b/Bildschirmfoto 2026-04-12 um 20.45.16.png new file mode 100644 index 0000000..5d20187 Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 20.45.16.png differ diff --git a/Bildschirmfoto 2026-04-12 um 21.03.46.png b/Bildschirmfoto 2026-04-12 um 21.03.46.png new file mode 100644 index 0000000..098f1b9 Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 21.03.46.png differ diff --git a/Bildschirmfoto 2026-04-12 um 21.11.19.png b/Bildschirmfoto 2026-04-12 um 21.11.19.png new file mode 100644 index 0000000..a8143aa Binary files /dev/null and b/Bildschirmfoto 2026-04-12 um 21.11.19.png differ diff --git a/backend/package-lock.json b/backend/package-lock.json index 4251329..31e0e78 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.13.1", + "version": "0.13.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.13.1", + "version": "0.13.1-1", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 5c0b80f..6785597 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.13.1", + "version": "0.13.1-1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index 253dce3..3ae364f 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -1159,6 +1159,70 @@ function normalizePositiveIntegerOrNull(value) { return Math.trunc(parsed); } +function resolveSeriesAssignmentEpisodeSpan(assignment = null) { + const source = assignment && typeof assignment === 'object' ? assignment : {}; + const start = normalizePositiveIntegerOrNull( + source?.episodeNumberStart + ?? source?.episodeNoStart + ?? source?.episodeNumber + ?? source?.number + ?? null + ); + const explicitEnd = normalizePositiveIntegerOrNull( + source?.episodeNumberEnd + ?? source?.episodeNoEnd + ?? null + ); + const explicitSpan = normalizePositiveIntegerOrNull( + source?.episodeSpan + ?? source?.episodeCount + ?? null + ); + if (start && explicitEnd && explicitEnd >= start) { + return Math.max(1, Math.trunc(explicitEnd - start + 1)); + } + if (explicitSpan && explicitSpan > 0) { + return Math.max(1, Math.trunc(explicitSpan)); + } + if (start) { + return 1; + } + return 0; +} + +function countSelectedEpisodeSlotsFromPlan(plan = null) { + const sourcePlan = plan && typeof plan === 'object' ? plan : {}; + const titles = Array.isArray(sourcePlan?.titles) ? sourcePlan.titles : []; + const selectedFromFlags = titles + .filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput)) + .map((title) => normalizePositiveIntegerOrNull(title?.id)) + .filter(Boolean); + const selectedFromPlan = Array.isArray(sourcePlan?.selectedTitleIds) + ? sourcePlan.selectedTitleIds + .map((value) => normalizePositiveIntegerOrNull(value)) + .filter(Boolean) + : []; + const selectedFromInput = normalizePositiveIntegerOrNull(sourcePlan?.encodeInputTitleId); + const selectedTitleIds = Array.from(new Set([ + ...selectedFromFlags, + ...selectedFromPlan, + ...(selectedFromInput ? [selectedFromInput] : []) + ])); + if (selectedTitleIds.length === 0) { + return 0; + } + const assignments = sourcePlan?.episodeAssignments && typeof sourcePlan.episodeAssignments === 'object' + ? sourcePlan.episodeAssignments + : {}; + let total = 0; + for (const titleId of selectedTitleIds) { + const assignment = assignments[String(titleId)] || assignments[titleId] || null; + const span = resolveSeriesAssignmentEpisodeSpan(assignment); + total += span > 0 ? span : 1; + } + return Math.max(0, total); +} + function isSeriesBatchEpisodeSubJobPlan(encodePlan = null) { const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; if (Boolean(plan?.seriesBatchChild) || Boolean(plan?.seriesBatchVirtualEpisode)) { @@ -3816,8 +3880,7 @@ class HistoryService { let selectedCount = 0; const childPlan = parseJsonSafe(childRow?.encode_plan_json, null); if (childPlan && typeof childPlan === 'object') { - const titles = Array.isArray(childPlan?.titles) ? childPlan.titles : []; - selectedCount = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; + selectedCount = countSelectedEpisodeSlotsFromPlan(childPlan); } const handbrakeInfo = parseJsonSafe(childRow?.handbrake_info_json, null); const hbStatus = String(handbrakeInfo?.status || '').trim().toUpperCase(); @@ -3958,8 +4021,7 @@ class HistoryService { const episodesLength = Array.isArray(selected?.episodes) ? selected.episodes.length : 0; const expectedFromMetadata = episodeCount > 0 ? episodeCount : (episodesLength > 0 ? episodesLength : 0); const plan = parseJsonSafe(job?.encode_plan_json, null); - const titles = Array.isArray(plan?.titles) ? plan.titles : []; - const expectedFromPlan = titles.filter((title) => title?.selectedForEncode || title?.encodeInput).length; + const expectedFromPlan = countSelectedEpisodeSlotsFromPlan(plan); const expectedFinal = expectedFromPlan > 0 ? expectedFromPlan : expectedFromMetadata; const outputSet = outputsByJobId.get(jobId) || new Set(); const directOutput = String(job?.output_path || '').trim(); diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js index a696247..ed22886 100644 --- a/backend/src/services/pipelineService.js +++ b/backend/src/services/pipelineService.js @@ -874,6 +874,7 @@ function resolveOutputTemplateValues(job, fallbackJobId = null) { const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; const DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; +const DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'; function parseJsonObjectSafe(raw) { if (!raw) { @@ -915,6 +916,188 @@ function formatTemplateTwoDigit(rawValue) { return String(rawValue || '').trim() || String(numeric); } +function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1) { + const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; + const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {}; + const episodeStart = normalizeTemplateNumberValue( + assignmentSource?.episodeNumberStart + ?? assignmentSource?.episodeNoStart + ?? assignmentSource?.episodeNumber + ?? assignmentSource?.number + ?? selectedTitleSource?.episodeNumber + ?? null, + fallbackEpisodeNumber + ); + const explicitEpisodeEnd = normalizeEpisodeNumberValue( + assignmentSource?.episodeNumberEnd + ?? assignmentSource?.episodeNoEnd + ?? null + ); + const explicitSpan = normalizePositiveInteger( + assignmentSource?.episodeSpan + ?? assignmentSource?.episodeCount + ?? selectedTitleSource?.episodeSpan + ?? null + ); + + let episodeEnd = explicitEpisodeEnd; + if (episodeEnd === null && explicitSpan && explicitSpan > 1) { + episodeEnd = episodeStart + explicitSpan - 1; + } + if (episodeEnd !== null && episodeEnd < episodeStart) { + episodeEnd = episodeStart; + } + const normalizedEpisodeEnd = episodeEnd === null ? episodeStart : episodeEnd; + return { + start: episodeStart, + end: normalizedEpisodeEnd, + isMulti: normalizedEpisodeEnd > episodeStart + }; +} + +function buildSeriesEpisodeRangeToken(start, end) { + const startToken = formatTemplateTwoDigit(start); + const endToken = formatTemplateTwoDigit(end); + if (Number(end) > Number(start)) { + return `${startToken}-${endToken}`; + } + return startToken; +} + +function buildSeriesEpisodePartsToken(episodeRangeInfo = null) { + const info = episodeRangeInfo && typeof episodeRangeInfo === 'object' + ? episodeRangeInfo + : { start: 1, end: 1 }; + const start = normalizePositiveInteger(info.start) || 1; + const end = normalizePositiveInteger(info.end) || start; + const span = Math.max(1, end - start + 1); + if (span <= 1) { + return '1'; + } + if (span === 2) { + return '1+2'; + } + return `1-${span}`; +} + +function normalizeSeriesEpisodeTitleTokenForCompare(value) { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .replace(/[^a-z0-9]+/g, '') + .trim(); +} + +function findCommonSeriesEpisodeTitlePrefix(values = []) { + const rows = (Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean); + if (rows.length === 0) { + return ''; + } + if (rows.length === 1) { + return rows[0]; + } + const tokenRows = rows.map((value) => value.split(/\s+/).filter(Boolean)); + const firstTokens = tokenRows[0]; + if (firstTokens.length === 0) { + return ''; + } + let prefixLength = 0; + for (let index = 0; index < firstTokens.length; index += 1) { + const tokenKey = normalizeSeriesEpisodeTitleTokenForCompare(firstTokens[index]); + if (!tokenKey) { + break; + } + const matchesAll = tokenRows.slice(1).every((tokens) => { + const current = tokens[index]; + return normalizeSeriesEpisodeTitleTokenForCompare(current) === tokenKey; + }); + if (!matchesAll) { + break; + } + prefixLength += 1; + } + if (prefixLength <= 0) { + return ''; + } + return firstTokens + .slice(0, prefixLength) + .join(' ') + .replace(/[-–—:.,\s]+$/g, '') + .trim(); +} + +function stripSeriesEpisodePartSuffix(value) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i; + let normalized = source; + let changed = false; + for (let i = 0; i < 2; i += 1) { + const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim(); + if (!next || next === normalized) { + break; + } + normalized = next; + changed = true; + } + if (!changed) { + return source; + } + return normalized || source; +} + +function normalizeSeriesEpisodeTitleForOutput(value, episodeRangeInfo = null) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + if (!Boolean(episodeRangeInfo?.isMulti)) { + return source; + } + const stripped = stripSeriesEpisodePartSuffix(source) || source; + const rawSegments = source + .split(/\s+(?:\+|\/|\||&)\s+/) + .map((part) => String(part || '').trim()) + .filter(Boolean); + if (rawSegments.length < 2) { + return stripped; + } + const cleanedSegments = rawSegments + .map((part) => stripSeriesEpisodePartSuffix(part) || part) + .map((part) => String(part || '').replace(/[-–—:.,\s]+$/g, '').trim()) + .filter(Boolean); + if (cleanedSegments.length === 0) { + return stripped; + } + const uniqueSegments = []; + const seen = new Set(); + for (const segment of cleanedSegments) { + const key = normalizeSeriesEpisodeTitleTokenForCompare(segment); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + uniqueSegments.push(segment); + } + if (uniqueSegments.length === 1) { + return uniqueSegments[0]; + } + const allSegmentsLookLikePartTitles = rawSegments.every((segment) => /\b(?:teil|part|pt)\b/i.test(segment)); + if (allSegmentsLookLikePartTitles) { + const commonPrefix = findCommonSeriesEpisodeTitlePrefix(cleanedSegments); + if (commonPrefix && commonPrefix.length >= 6) { + return commonPrefix; + } + return uniqueSegments[0] || cleanedSegments[0] || stripped; + } + return stripped; +} + function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { const encodePlan = parseJsonObjectSafe(job?.encode_plan_json); const makemkvInfo = parseJsonObjectSafe(job?.makemkv_info_json); @@ -968,12 +1151,17 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null ) || 1; - const episodeTemplateValue = normalizeTemplateNumberValue( - assignment?.episodeNumber - ?? selectedTitle?.episodeNumber - ?? null, + const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment( + assignment, + selectedTitle, primaryTitleId || 1 ); + const episodeTemplateValue = episodeRangeInfo.start; + const episodeRangeToken = buildSeriesEpisodeRangeToken( + episodeRangeInfo.start, + episodeRangeInfo.end + ); + const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo); const discNumber = normalizePositiveInteger( selectedMetadata?.discNumber ?? assignment?.discNumber @@ -987,12 +1175,16 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { || job?.detected_title || (fallbackJobId ? `job-${fallbackJobId}` : 'series') ) || (fallbackJobId ? `job-${fallbackJobId}` : 'series'); - const episodeTitle = normalizeCdTrackText( + const rawEpisodeTitle = normalizeCdTrackText( assignment?.episodeTitle || selectedTitle?.episodeTitle || selectedTitle?.fileName - || `Episode ${String(episodeTemplateValue)}` - ) || `Episode ${String(episodeTemplateValue)}`; + || `Episode ${episodeRangeToken}` + ) || `Episode ${episodeRangeToken}`; + const episodeTitle = normalizeSeriesEpisodeTitleForOutput( + rawEpisodeTitle, + episodeRangeInfo + ); const language = String( settings?.dvd_series_language || selectedMetadata?.language @@ -1000,16 +1192,28 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) { ).trim() || 'unknown'; const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear(); - const template = String( + const singleTemplate = String( settings?.output_template_dvd_series_episode || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE ).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE; + const multiTemplate = String( + settings?.output_template_dvd_series_multi_episode + || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE + ).trim() || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE; + const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate; const rendered = renderTemplate(template, { seriesTitle, seasonNr: seasonNumber, seasonNo: String(seasonNumber), episodeNr: episodeTemplateValue, episodeNo: String(episodeTemplateValue), + episodeNoStart: episodeRangeInfo.start, + episodeNoEnd: episodeRangeInfo.end, + episodeNumberStart: episodeRangeInfo.start, + episodeNumberEnd: episodeRangeInfo.end, + episodeSpan: Math.max(1, Number(episodeRangeInfo.end || 0) - Number(episodeRangeInfo.start || 0) + 1), + episodeRange: episodeRangeToken, + parts: episodePartsToken, episodeTitle, discNr: discNumber || '', year, @@ -3059,6 +3263,100 @@ function normalizeSeriesScanTitleKind(rawKind) { return String(rawKind || '').trim().toLowerCase(); } +function medianPositiveNumber(values = []) { + const normalized = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (normalized.length === 0) { + return 0; + } + const middle = Math.floor(normalized.length / 2); + if (normalized.length % 2 === 1) { + return normalized[middle]; + } + return (normalized[middle - 1] + normalized[middle]) / 2; +} + +function deriveSeriesMultiEpisodeCandidateIds(seriesTitles = []) { + const normalizedTitles = (Array.isArray(seriesTitles) ? seriesTitles : []) + .map((title) => ({ + index: normalizePositiveInteger(title?.index), + kind: normalizeSeriesScanTitleKind(title?.kind), + durationSeconds: Number(title?.durationSeconds || 0), + chapterCount: normalizePositiveInteger(title?.chapterCount) || 0 + })) + .filter((title) => + Number.isFinite(title?.index) + && title.index > 0 + && Number.isFinite(title?.durationSeconds) + && title.durationSeconds > 0 + ); + if (normalizedTitles.length === 0) { + return new Set(); + } + + const episodeCandidates = normalizedTitles.filter((title) => title.kind === 'episode_candidate'); + if (episodeCandidates.length < 2) { + return new Set(); + } + const baselineEpisodeDuration = medianPositiveNumber( + episodeCandidates.map((title) => title.durationSeconds) + ); + if (!baselineEpisodeDuration) { + return new Set(); + } + const baselineEpisodeDurations = episodeCandidates + .map((title) => title.durationSeconds) + .filter((value) => value <= baselineEpisodeDuration * 1.35); + const baselineDuration = medianPositiveNumber( + baselineEpisodeDurations.length > 0 + ? baselineEpisodeDurations + : episodeCandidates.map((title) => title.durationSeconds) + ) || baselineEpisodeDuration; + if (!baselineDuration) { + return new Set(); + } + + const baselineChapterCount = medianPositiveNumber( + episodeCandidates + .map((title) => title.chapterCount) + .filter((value) => Number.isFinite(value) && value > 0) + ); + + const candidates = normalizedTitles.filter((title) => !( + title.kind === 'episode_candidate' + || title.kind === 'play_all' + || title.kind === 'duplicate' + || title.kind === 'short' + )); + const output = new Set(); + for (const title of candidates) { + const durationRatio = title.durationSeconds / baselineDuration; + const roundedSpan = Math.round(durationRatio); + if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) { + continue; + } + const minDurationRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72; + const maxDurationRatio = roundedSpan * 1.32; + if (durationRatio < minDurationRatio || durationRatio > maxDurationRatio) { + continue; + } + if (baselineChapterCount > 0 && title.chapterCount > 0) { + const chapterRatio = title.chapterCount / baselineChapterCount; + const minChapterRatio = roundedSpan === 2 + ? 1.2 + : Math.max(1.2, roundedSpan * 0.55); + if (chapterRatio < minChapterRatio) { + continue; + } + } + output.add(Number(title.index)); + } + + return output; +} + function enrichSeriesAnalyzeContextFromScan(analyzeContext = null, scanLines = null, options = {}) { const baseAnalyzeContext = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext @@ -3137,6 +3435,7 @@ function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = nu .filter((value) => Number.isFinite(value) && value > 0) .map((value) => Math.trunc(value)) ); + const multiEpisodeCandidateIds = deriveSeriesMultiEpisodeCandidateIds(seriesTitles); const blockedIds = new Set( seriesTitles .filter((title) => ['play_all', 'duplicate', 'short'].includes(normalizeSeriesScanTitleKind(title?.kind))) @@ -3149,10 +3448,16 @@ function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = nu let reason = null; if (episodeCandidateIds.size > 0) { - const byEpisodeIds = sourceCandidates.filter((item) => episodeCandidateIds.has(Number(item?.handBrakeTitleId))); + const allowedEpisodeIds = new Set([ + ...Array.from(episodeCandidateIds.values()), + ...Array.from(multiEpisodeCandidateIds.values()) + ]); + const byEpisodeIds = sourceCandidates.filter((item) => allowedEpisodeIds.has(Number(item?.handBrakeTitleId))); if (byEpisodeIds.length > 0) { filtered = byEpisodeIds; - reason = 'episode_candidates_only'; + reason = multiEpisodeCandidateIds.size > 0 + ? 'episode_candidates_plus_multi_episode' + : 'episode_candidates_only'; } } @@ -3837,15 +4142,61 @@ function findExistingRawDirectory(rawBaseDir, metadataBase) { return candidates.length > 0 ? candidates[0].path : null; } -function buildRawMetadataBase(jobLike = {}, fallbackJobId = null) { +function buildRawMetadataBase(jobLike = {}, fallbackJobId = null, options = {}) { + const opts = options && typeof options === 'object' ? options : {}; const normalizedJobId = Number(fallbackJobId || jobLike?.id || 0); const fallbackTitle = Number.isFinite(normalizedJobId) && normalizedJobId > 0 ? `job-${Math.trunc(normalizedJobId)}` : 'job-unknown'; - const rawYear = Number(jobLike?.year ?? jobLike?.fallbackYear ?? null); + + const analyzeContext = opts.analyzeContext && typeof opts.analyzeContext === 'object' + ? opts.analyzeContext + : null; + const selectedMetadata = opts.selectedMetadata && typeof opts.selectedMetadata === 'object' + ? opts.selectedMetadata + : resolveSelectedMetadataForJob(jobLike, analyzeContext, null); + const mediaProfile = normalizeMediaProfile( + opts.mediaProfile + || jobLike?.media_type + || analyzeContext?.mediaProfile + || null + ); + const isSeriesDvd = isSeriesDvdMetadataSelection(mediaProfile, selectedMetadata, analyzeContext); + const seasonNumber = normalizePositiveInteger( + opts.seriesSeasonNumber + ?? selectedMetadata?.seasonNumber + ?? analyzeContext?.seriesLookupHint?.seasonNumber + ?? null + ); + const discNumber = normalizePositiveInteger( + opts.seriesDiscNumber + ?? selectedMetadata?.discNumber + ?? analyzeContext?.seriesLookupHint?.discNumber + ?? null + ); + const rawYear = Number( + opts.year + ?? selectedMetadata?.year + ?? jobLike?.year + ?? jobLike?.fallbackYear + ?? null + ); const yearValue = Number.isFinite(rawYear) && rawYear > 0 ? Math.trunc(rawYear) : new Date().getFullYear(); + + if (isSeriesDvd && seasonNumber && discNumber) { + const seriesTitle = normalizeCdTrackText( + opts.seriesTitle + || selectedMetadata?.title + || selectedMetadata?.seriesTitle + || jobLike?.title + || jobLike?.detected_title + || fallbackTitle + ) || fallbackTitle; + return sanitizeFileName(`${seriesTitle} (${yearValue}) - S${seasonNumber}D${discNumber}`); + } + return sanitizeFileName( renderTemplate('${title} (${year})', { title: jobLike?.title || jobLike?.detected_title || jobLike?.detectedTitle || fallbackTitle, @@ -3882,6 +4233,23 @@ function stripRawStatePrefix(folderName) { .trim(); } +function extractSeriesSeasonDiscFromRawFolderName(folderName) { + const baseName = stripRawStatePrefix(path.basename(String(folderName || '').trim())); + if (!baseName) { + return { seasonNumber: null, discNumber: null }; + } + + const tokenMatch = baseName.match(/\bS(\d{1,3})D(\d{1,3})\b/i); + if (!tokenMatch) { + return { seasonNumber: null, discNumber: null }; + } + + return { + seasonNumber: normalizePositiveInteger(tokenMatch[1]), + discNumber: normalizePositiveInteger(tokenMatch[2]) + }; +} + function applyRawFolderStateToName(folderName, state) { const baseName = stripRawStatePrefix(folderName); if (!baseName) { @@ -4486,23 +4854,111 @@ function normalizeEpisodeAssignmentsPayload(rawAssignments, selectedTitleIds = [ const episodeId = Number.isFinite(episodeIdRaw) && episodeIdRaw > 0 ? Math.trunc(episodeIdRaw) : null; - const episodeNumber = normalizeEpisodeNumberValue(assignment?.episodeNumber ?? assignment?.number ?? null); + const episodeIdStartRaw = Number( + assignment?.episodeIdStart + ?? assignment?.episodeStartId + ?? assignment?.episode_start_id + ?? 0 + ); + const episodeIdStart = Number.isFinite(episodeIdStartRaw) && episodeIdStartRaw > 0 + ? Math.trunc(episodeIdStartRaw) + : null; + const episodeIdEndRaw = Number( + assignment?.episodeIdEnd + ?? assignment?.episodeEndId + ?? assignment?.episode_end_id + ?? 0 + ); + const episodeIdEnd = Number.isFinite(episodeIdEndRaw) && episodeIdEndRaw > 0 + ? Math.trunc(episodeIdEndRaw) + : null; + const baseEpisodeNumber = normalizeEpisodeNumberValue(assignment?.episodeNumber ?? assignment?.number ?? null); + const episodeNumberStart = normalizeEpisodeNumberValue( + assignment?.episodeNumberStart + ?? assignment?.episodeNoStart + ?? assignment?.episode_start + ?? baseEpisodeNumber + ?? null + ); + let episodeNumberEnd = normalizeEpisodeNumberValue( + assignment?.episodeNumberEnd + ?? assignment?.episodeNoEnd + ?? assignment?.episode_end + ?? null + ); + const explicitSpan = normalizePositiveInteger( + assignment?.episodeSpan + ?? assignment?.episodeCount + ?? null + ); + if (episodeNumberEnd === null && episodeNumberStart !== null && explicitSpan && explicitSpan > 1) { + episodeNumberEnd = episodeNumberStart + explicitSpan - 1; + } + if (episodeNumberEnd !== null && episodeNumberStart !== null && episodeNumberEnd < episodeNumberStart) { + episodeNumberEnd = episodeNumberStart; + } + const episodeNumber = episodeNumberStart ?? baseEpisodeNumber; + const normalizedEpisodeSpan = ( + episodeNumberStart !== null + && episodeNumberEnd !== null + && episodeNumberEnd >= episodeNumberStart + ) + ? Math.max(1, Math.trunc(episodeNumberEnd - episodeNumberStart + 1)) + : ( + explicitSpan && explicitSpan > 0 + ? explicitSpan + : null + ); + const episodeRange = String(assignment?.episodeRange || '').trim() || null; const seasonNumber = normalizePositiveInteger(assignment?.seasonNumber ?? assignment?.season ?? null); - const episodeTitle = String( + const rawEpisodeTitle = String( assignment?.episodeTitle ?? assignment?.title ?? assignment?.name ?? '' ).trim() || null; - if (!episodeId && episodeNumber === null && seasonNumber === null && !episodeTitle) { + const episodeTitleStart = String(assignment?.episodeTitleStart || '').trim() || null; + const episodeTitleEnd = String(assignment?.episodeTitleEnd || '').trim() || null; + if ( + !episodeId + && !episodeIdStart + && !episodeIdEnd + && episodeNumber === null + && episodeNumberStart === null + && episodeNumberEnd === null + && seasonNumber === null + && !rawEpisodeTitle + && !episodeTitleStart + && !episodeTitleEnd + ) { continue; } + const rangeStart = episodeNumberStart ?? episodeNumber ?? 1; + const rangeEnd = episodeNumberEnd ?? episodeNumberStart ?? episodeNumber ?? rangeStart; + const episodeTitle = rawEpisodeTitle + ? normalizeSeriesEpisodeTitleForOutput( + rawEpisodeTitle, + { + start: rangeStart, + end: rangeEnd, + isMulti: (normalizedEpisodeSpan || 1) > 1 + } + ) + : null; output[String(titleId)] = { titleId, - episodeId, + episodeId: episodeIdStart || episodeId || null, episodeNumber, + episodeNumberStart: episodeNumberStart ?? episodeNumber ?? null, + episodeNumberEnd: episodeNumberEnd ?? episodeNumberStart ?? episodeNumber ?? null, + episodeSpan: normalizedEpisodeSpan, + episodeRange, seasonNumber, - episodeTitle + episodeTitle, + episodeIdStart: episodeIdStart || episodeId || null, + episodeIdEnd, + episodeTitleStart, + episodeTitleEnd }; } return output; @@ -4699,26 +5155,24 @@ function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, chi ?? selectedTitle?.seasonNumber ?? null ); - const episodeNumber = normalizeEpisodeNumberValue( - assignment?.episodeNumber - ?? selectedTitle?.episodeNumber - ?? null + const episodeRange = resolveSeriesEpisodeRangeFromAssignment( + assignment, + selectedTitle, + childIndex + 1 ); - const episodeLabel = String( + const rawEpisodeLabel = String( assignment?.episodeTitle || selectedTitle?.episodeTitle || selectedTitle?.fileName || '' ).trim(); + const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange); const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null; - const episodeToken = episodeNumber !== null - ? ( - Number.isInteger(episodeNumber) - ? String(Math.trunc(episodeNumber)).padStart(2, '0') - : String(episodeNumber) - ) - : null; + const episodeToken = buildSeriesEpisodeRangeToken( + episodeRange.start, + episodeRange.end + ); const code = seasonToken && episodeToken ? `S${seasonToken}E${episodeToken}` : `Episode ${childIndex + 1}`; return `${baseTitle} - ${code}${episodeLabel ? ` - ${episodeLabel}` : ''}`; @@ -6468,7 +6922,27 @@ class PipelineService extends EventEmitter { isEncodeSuccessful(job = null) { const handBrakeInfo = this.safeParseJson(job?.handbrake_info_json); - return String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + const directSuccess = String(handBrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'; + if (directSuccess) { + return true; + } + + const seriesBatchSummary = handBrakeInfo?.seriesBatch && typeof handBrakeInfo.seriesBatch === 'object' + ? handBrakeInfo.seriesBatch + : null; + const seriesBatchMode = String(handBrakeInfo?.mode || '').trim().toLowerCase() === 'series_batch'; + if (!seriesBatchMode || !seriesBatchSummary) { + return false; + } + + const totalCount = Number(seriesBatchSummary?.totalCount || 0); + const finishedCount = Number(seriesBatchSummary?.finishedCount || 0); + const errorCount = Number(seriesBatchSummary?.errorCount || 0); + const cancelledCount = Number(seriesBatchSummary?.cancelledCount || 0); + return totalCount > 0 + && finishedCount >= totalCount + && errorCount <= 0 + && cancelledCount <= 0; } resolveDesiredRawFolderState(job = null) { @@ -6714,7 +7188,8 @@ class PipelineService extends EventEmitter { } const rows = await db.all(` - SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, makemkv_info_json, handbrake_info_json + SELECT id, title, year, detected_title, raw_path, status, last_state, rip_successful, + makemkv_info_json, handbrake_info_json, encode_plan_json, media_type, job_kind FROM jobs WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> '' AND (media_type IS NULL OR media_type <> 'converter') @@ -6774,6 +7249,17 @@ class PipelineService extends EventEmitter { if (!Number.isFinite(jobId) || jobId <= 0) { continue; } + const encodePlan = this.safeParseJson(row?.encode_plan_json); + const jobKind = String(row?.job_kind || '').trim().toLowerCase(); + const isSeriesEpisodeSubJob = ( + isSeriesBatchChildPlan(encodePlan) + || Boolean(encodePlan?.seriesBatchVirtualEpisode) + || jobKind === 'dvd_series_episode' + || jobKind === 'dvd_series_virtual_episode' + ); + if (isSeriesEpisodeSubJob) { + continue; + } const ripSuccessful = this.isRipSuccessful(row); if (ripSuccessful && Number(row?.rip_successful || 0) !== 1) { @@ -6796,11 +7282,25 @@ class PipelineService extends EventEmitter { const fallbackYear = folderYearMatch ? Number(String(folderYearMatch[0]).replace(/[()]/g, '')) : null; + const folderSeriesSeasonDisc = extractSeriesSeasonDiscFromRawFolderName(currentFolderName); + const mkInfo = this.safeParseJson(row?.makemkv_info_json); + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : null; + const selectedMetadata = resolveSelectedMetadataForJob(row, analyzeContext, null); const metadataBase = buildRawMetadataBase({ title: row.title || row.detected_title || null, year: row.year || null, - fallbackYear - }, jobId); + fallbackYear, + detected_title: row.detected_title || null, + media_type: row.media_type || null + }, jobId, { + mediaProfile: row.media_type || null, + analyzeContext, + selectedMetadata, + seriesSeasonNumber: folderSeriesSeasonDisc.seasonNumber, + seriesDiscNumber: folderSeriesSeasonDisc.discNumber + }); const desiredRawFolderState = this.resolveDesiredRawFolderState(row); const desiredRawPath = path.join( currentBaseDir, @@ -13152,7 +13652,7 @@ class PipelineService extends EventEmitter { } } - const selectedMetadata = { + let selectedMetadata = { title: effectiveTitle, year: effectiveYear, imdbId: effectiveImdbId, @@ -13172,7 +13672,19 @@ class PipelineService extends EventEmitter { let parentContainerJobId = normalizePositiveInteger(job.parent_job_id || null); const isDvdSeriesSelection = isDvdTmdbMetadataSelection; if (isDvdSeriesSelection) { - const resolveDiscNumberFromJobRow = (row) => { + const normalizeEpisodeRows = (rows) => { + const list = Array.isArray(rows) ? rows : []; + return list.filter((entry) => entry && typeof entry === 'object'); + }; + const hasEpisodeRows = (rows) => Array.isArray(rows) && rows.length > 0; + const toPositiveIntOrNull = (value) => { + const parsed = Number(value || 0); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); + }; + const extractSelectedMetadataFromJobRow = (row) => { const rowMkInfo = this.safeParseJson(row?.makemkv_info_json) || {}; const rowAnalyzeContext = rowMkInfo?.analyzeContext && typeof rowMkInfo.analyzeContext === 'object' ? rowMkInfo.analyzeContext @@ -13182,8 +13694,15 @@ class PipelineService extends EventEmitter { : (rowMkInfo?.selectedMetadata && typeof rowMkInfo.selectedMetadata === 'object' ? rowMkInfo.selectedMetadata : {}); + return rowSelectedMetadata && typeof rowSelectedMetadata === 'object' + ? rowSelectedMetadata + : {}; + }; + const resolveDiscNumberFromJobRow = (row) => { + const rowSelectedMetadata = extractSelectedMetadataFromJobRow(row); return normalizePositiveInteger(rowSelectedMetadata?.discNumber); }; + let containerJobRow = null; if (!parentContainerJobId) { const existingContainer = await historyService.findSeriesContainerJob(effectiveTmdbId, effectiveSeasonNumber); @@ -13226,6 +13745,10 @@ class PipelineService extends EventEmitter { } } + if (parentContainerJobId) { + containerJobRow = await historyService.getJobById(parentContainerJobId).catch(() => null); + } + if (parentContainerJobId && effectiveDiscNumber) { const containerChildren = await historyService.listChildJobs(parentContainerJobId); const conflictingDiscChild = (Array.isArray(containerChildren) ? containerChildren : []).find((childRow) => { @@ -13257,11 +13780,68 @@ class PipelineService extends EventEmitter { } } + const currentEpisodes = normalizeEpisodeRows(effectiveEpisodes); + const currentEpisodeCount = toPositiveIntOrNull(effectiveEpisodeCount) || 0; + const needsEpisodeFallback = !hasEpisodeRows(currentEpisodes) || currentEpisodeCount <= 0; + if (needsEpisodeFallback && effectiveTmdbId && effectiveSeasonNumber) { + const fallbackRows = []; + if (containerJobRow) { + fallbackRows.push(containerJobRow); + } + const siblingRowsForEpisodes = await historyService.listSeriesSiblingJobs(effectiveTmdbId, effectiveSeasonNumber); + fallbackRows.push(...(Array.isArray(siblingRowsForEpisodes) ? siblingRowsForEpisodes : [])); + + for (const fallbackRow of fallbackRows) { + const fallbackSelectedMetadata = extractSelectedMetadataFromJobRow(fallbackRow); + const fallbackEpisodes = normalizeEpisodeRows(fallbackSelectedMetadata?.episodes); + if (!hasEpisodeRows(fallbackEpisodes)) { + continue; + } + effectiveEpisodes = fallbackEpisodes; + const fallbackEpisodeCount = toPositiveIntOrNull(fallbackSelectedMetadata?.episodeCount); + effectiveEpisodeCount = fallbackEpisodeCount || fallbackEpisodes.length; + if (!effectiveSeasonName) { + effectiveSeasonName = String(fallbackSelectedMetadata?.seasonName || '').trim() || null; + } + break; + } + } + + selectedMetadata = { + ...selectedMetadata, + seasonName: effectiveSeasonName, + episodeCount: effectiveEpisodeCount, + episodes: effectiveEpisodes + }; + if (parentContainerJobId) { + const containerSelectedMetadata = extractSelectedMetadataFromJobRow(containerJobRow); + const nextEpisodes = normalizeEpisodeRows(selectedMetadata?.episodes); + const fallbackEpisodes = normalizeEpisodeRows(containerSelectedMetadata?.episodes); + const mergedEpisodes = hasEpisodeRows(nextEpisodes) + ? nextEpisodes + : fallbackEpisodes; + const selectedEpisodeCount = toPositiveIntOrNull(selectedMetadata?.episodeCount); + const fallbackEpisodeCount = toPositiveIntOrNull(containerSelectedMetadata?.episodeCount); + const mergedEpisodeCount = selectedEpisodeCount + || fallbackEpisodeCount + || (hasEpisodeRows(mergedEpisodes) ? mergedEpisodes.length : 0); + const mergedSeasonName = String( + selectedMetadata?.seasonName + || containerSelectedMetadata?.seasonName + || '' + ).trim() || null; + const containerSelectedMetadataPatch = { + ...selectedMetadata, + seasonName: mergedSeasonName, + episodeCount: mergedEpisodeCount, + episodes: mergedEpisodes + }; + selectedMetadata = containerSelectedMetadataPatch; const containerMkInfo = this.withAnalyzeContextMediaProfile({ analyzeContext: { metadataProvider: effectiveMetadataProvider, - selectedMetadata + selectedMetadata: containerSelectedMetadataPatch } }, mediaProfile); await historyService.updateJob(parentContainerJobId, { @@ -13303,13 +13883,16 @@ class PipelineService extends EventEmitter { ? 'backup' : 'mkv'; const isBackupMode = ripMode === 'backup'; - const rawTitleBase = isDvdSeriesSelection && effectiveDiscNumber - ? `${selectedMetadata.title || job.detected_title || 'Serie'} - Disc ${effectiveDiscNumber}` - : (selectedMetadata.title || job.detected_title || null); const metadataBase = buildRawMetadataBase({ - title: rawTitleBase, - year: selectedMetadata.year || null - }, jobId); + title: selectedMetadata.title || job.detected_title || null, + year: selectedMetadata.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile + }, jobId, { + mediaProfile, + analyzeContext: mkInfo?.analyzeContext || null, + selectedMetadata + }); const rawStorage = resolveSeriesAwareRawStorage( settings, mediaProfile, @@ -14131,11 +14714,36 @@ class PipelineService extends EventEmitter { return title; } const episodeTitle = String(assignment?.episodeTitle || '').trim() || null; + const episodeNumberStart = assignment?.episodeNumberStart + ?? assignment?.episodeNoStart + ?? assignment?.episodeNumber + ?? null; + const episodeNumberEnd = assignment?.episodeNumberEnd + ?? assignment?.episodeNoEnd + ?? null; + const normalizedEpisodeSpan = normalizePositiveInteger( + assignment?.episodeSpan + ?? ( + normalizeEpisodeNumberValue(episodeNumberStart) !== null + && normalizeEpisodeNumberValue(episodeNumberEnd) !== null + && normalizeEpisodeNumberValue(episodeNumberEnd) >= normalizeEpisodeNumberValue(episodeNumberStart) + ? (normalizeEpisodeNumberValue(episodeNumberEnd) - normalizeEpisodeNumberValue(episodeNumberStart) + 1) + : null + ) + ); return { ...title, - episodeId: assignment?.episodeId || null, - episodeNumber: assignment?.episodeNumber ?? null, + episodeId: assignment?.episodeId || assignment?.episodeIdStart || null, + episodeIdStart: assignment?.episodeIdStart || assignment?.episodeId || null, + episodeIdEnd: assignment?.episodeIdEnd || null, + episodeNumber: assignment?.episodeNumber ?? episodeNumberStart ?? null, + episodeNumberStart: episodeNumberStart ?? null, + episodeNumberEnd: episodeNumberEnd ?? episodeNumberStart ?? null, + episodeSpan: normalizedEpisodeSpan, + episodeRange: assignment?.episodeRange || null, seasonNumber: assignment?.seasonNumber ?? title?.seasonNumber ?? null, + episodeTitleStart: assignment?.episodeTitleStart || null, + episodeTitleEnd: assignment?.episodeTitleEnd || null, episodeTitle, fileName: episodeTitle || title?.fileName || `Title #${titleId}` }; @@ -17192,8 +17800,14 @@ class PipelineService extends EventEmitter { const metadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, - year: job.year || null - }, jobId); + year: job.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile + }, jobId, { + mediaProfile, + analyzeContext, + selectedMetadata + }); const rawDirName = buildRawDirName(metadataBase, jobId, { state: RAW_FOLDER_STATES.INCOMPLETE }); const rawJobDir = path.join(effectiveRawBaseDir, rawDirName); ensureDir(rawJobDir); @@ -21181,6 +21795,7 @@ class PipelineService extends EventEmitter { const settings = await settingsService.getEffectiveSettingsMap(mediaProfile); const mkInfo = this.safeParseJson(job.makemkv_info_json) || {}; const encodePlan = this.safeParseJson(job.encode_plan_json) || {}; + const selectedMetadata = resolveSelectedMetadataForJob(job, mkInfo?.analyzeContext || null, null); // Rename raw folder const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null; @@ -21206,8 +21821,14 @@ class PipelineService extends EventEmitter { const rawBaseDir = path.dirname(currentRawPath); const newMetadataBase = buildRawMetadataBase({ title: job.title || job.detected_title || null, - year: job.year || null - }, jobId); + year: job.year || null, + detected_title: job.detected_title || null, + media_type: mediaProfile + }, jobId, { + mediaProfile, + analyzeContext: mkInfo?.analyzeContext || null, + selectedMetadata + }); const currentState = resolveRawFolderStateFromPath(currentRawPath); const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState }); newRawPath = path.join(rawBaseDir, newRawDirName); diff --git a/backend/src/utils/files.js b/backend/src/utils/files.js index 8b9402f..18832ef 100644 --- a/backend/src/utils/files.js +++ b/backend/src/utils/files.js @@ -28,7 +28,7 @@ function transliterateForFilename(input) { function sanitizeFileName(input) { return transliterateForFilename(String(input || 'untitled')) - .replace(/[^a-zA-Z0-9 ()[\]_-]/g, '') + .replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, 180); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f51ff71..d9b8b4f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.13.1", + "version": "0.13.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.13.1", + "version": "0.13.1-1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index 530b7d2..1050186 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.13.1", + "version": "0.13.1-1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/MediaInfoReviewPanel.jsx b/frontend/src/components/MediaInfoReviewPanel.jsx index 4a8f9a9..0722f37 100644 --- a/frontend/src/components/MediaInfoReviewPanel.jsx +++ b/frontend/src/components/MediaInfoReviewPanel.jsx @@ -32,6 +32,137 @@ function normalizeTrackId(value) { return Math.trunc(parsed); } +function normalizePositiveInt(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return Math.trunc(parsed); +} + +function formatTwoDigit(value) { + const normalized = normalizePositiveInt(value); + if (normalized === null) { + return '??'; + } + return String(normalized).padStart(2, '0'); +} + +function buildEpisodeRangeToken(start, end) { + const startNormalized = normalizePositiveInt(start); + const endNormalized = normalizePositiveInt(end); + if (!startNormalized) { + return '??'; + } + if (!endNormalized || endNormalized <= startNormalized) { + return formatTwoDigit(startNormalized); + } + return `${formatTwoDigit(startNormalized)}-${formatTwoDigit(endNormalized)}`; +} + +function buildEpisodePartsToken(start, end) { + const startNormalized = normalizePositiveInt(start) || 1; + const endNormalized = normalizePositiveInt(end) || startNormalized; + const span = Math.max(1, endNormalized - startNormalized + 1); + if (span <= 1) { + return '1'; + } + if (span === 2) { + return '1+2'; + } + return `1-${span}`; +} + +function stripEpisodePartSuffix(value) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i; + let normalized = source; + let changed = false; + for (let i = 0; i < 2; i += 1) { + const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim(); + if (!next || next === normalized) { + break; + } + normalized = next; + changed = true; + } + return changed ? (normalized || source) : source; +} + +function normalizeEpisodeFillTitle(value, isMulti = false) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + if (!isMulti) { + return source; + } + return stripEpisodePartSuffix(source) || source; +} + +function resolveTitleDurationSecondsForFill(title = null) { + const durationSeconds = Number(title?.durationSeconds || 0); + if (Number.isFinite(durationSeconds) && durationSeconds > 0) { + return durationSeconds; + } + const durationMinutes = Number(title?.durationMinutes || 0); + if (Number.isFinite(durationMinutes) && durationMinutes > 0) { + return durationMinutes * 60; + } + return 0; +} + +function medianPositiveNumber(values = []) { + const numbers = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((a, b) => a - b); + if (numbers.length === 0) { + return 0; + } + const middle = Math.floor(numbers.length / 2); + if (numbers.length % 2 === 1) { + return numbers[middle]; + } + return (numbers[middle - 1] + numbers[middle]) / 2; +} + +function estimateEpisodeSpanForFill(title = null, allTitles = []) { + const titleDuration = resolveTitleDurationSecondsForFill(title); + if (!Number.isFinite(titleDuration) || titleDuration <= 0) { + return 1; + } + const durations = (Array.isArray(allTitles) ? allTitles : []) + .map((item) => resolveTitleDurationSecondsForFill(item)) + .filter((value) => Number.isFinite(value) && value > 0); + if (durations.length === 0) { + return 1; + } + const medianDuration = medianPositiveNumber(durations); + if (!medianDuration) { + return 1; + } + const baselinePool = durations.filter((value) => value <= medianDuration * 1.35); + const baselineDuration = medianPositiveNumber(baselinePool.length > 0 ? baselinePool : durations) || medianDuration; + if (!baselineDuration) { + return 1; + } + const ratio = titleDuration / baselineDuration; + const roundedSpan = Math.round(ratio); + if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) { + return 1; + } + const minRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72; + const maxRatio = roundedSpan * 1.32; + if (ratio < minRatio || ratio > maxRatio) { + return 1; + } + return roundedSpan; +} + function normalizeTrackIdList(values) { const list = Array.isArray(values) ? values : []; const seen = new Set(); @@ -429,7 +560,7 @@ function transliterateForFilename(input) { function sanitizePathSegment(value) { return transliterateForFilename(String(value || '')) - .replace(/[^a-zA-Z0-9 ().[\]_-]/g, '') + .replace(/[^a-zA-Z0-9 ().[\]_+-]/g, '') .replace(/\s+/g, ' ') .trim(); } @@ -1319,27 +1450,175 @@ export default function MediaInfoReviewPanel({ const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : []) .map((episode) => { const episodeId = Number(episode?.episodeId ?? episode?.id ?? 0); - const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? 0); + const episodeIdStart = Number(episode?.episodeIdStart ?? episode?.idStart ?? episode?.episodeId ?? episode?.id ?? 0); + const episodeIdEnd = Number(episode?.episodeIdEnd ?? episode?.idEnd ?? 0); + const episodeNumberStart = Number( + episode?.episodeNumberStart + ?? episode?.numberStart + ?? episode?.episodeNoStart + ?? episode?.episodeNumber + ?? episode?.number + ?? 0 + ); + const episodeNumberEnd = Number( + episode?.episodeNumberEnd + ?? episode?.numberEnd + ?? episode?.episodeNoEnd + ?? 0 + ); + const episodeSpan = Number(episode?.episodeSpan ?? episode?.episodeCount ?? 0); + const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? episodeNumberStart ?? 0); const seasonNumber = Number(episode?.seasonNumber ?? episode?.season ?? 0); const episodeTitle = String(episode?.episodeTitle || episode?.name || episode?.title || '').trim(); return { episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null, + episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null, + episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null, episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null, + episodeNumberStart: Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 ? Math.trunc(episodeNumberStart) : null, + episodeNumberEnd: Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 ? Math.trunc(episodeNumberEnd) : null, + episodeSpan: Number.isFinite(episodeSpan) && episodeSpan > 0 ? Math.trunc(episodeSpan) : null, seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null, episodeTitle: episodeTitle || null }; }) .filter((episode) => episode.episodeId || episode.episodeNumber !== null); - const episodeFillOptions = normalizedEpisodeRows.map((episode) => { - const seasonLabel = episode.seasonNumber !== null ? `S${String(episode.seasonNumber).padStart(2, '0')}` : 'S??'; - const episodeLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : 'E??'; - const titleLabel = episode.episodeTitle || '(ohne Titel)'; - const value = String(episode.episodeId || episode.episodeNumber || ''); - return { - label: `${seasonLabel}${episodeLabel} - ${titleLabel}`, - value - }; - }).filter((option) => option.value); + const selectedTitlesForFill = selectedTitleIds.length > 0 + ? displayTitles.filter((title) => selectedTitleIdSet.has(String(normalizeTitleId(title?.id)))) + : displayTitles.filter((title) => Boolean(title?.selectedForEncode)); + const titleAnchorsByEpisodeIndex = (() => { + const anchors = new Map(); + const rows = Array.isArray(normalizedEpisodeRows) ? normalizedEpisodeRows : []; + if (rows.length === 0) { + return anchors; + } + let cursor = 0; + for (const title of selectedTitlesForFill) { + if (cursor >= rows.length) { + break; + } + const explicitSpan = normalizePositiveInt( + title?.episodeSpan + ?? title?.episodeCount + ?? null + ); + const estimatedSpan = estimateEpisodeSpanForFill(title, selectedTitlesForFill); + const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1); + const safeSpan = Math.max(1, Math.min(span, rows.length - cursor)); + anchors.set(cursor, { span: safeSpan }); + cursor += safeSpan; + } + return anchors; + })(); + + const fallbackEpisodeFillOptions = (() => { + const rows = Array.isArray(normalizedEpisodeRows) ? normalizedEpisodeRows : []; + const options = []; + let index = 0; + while (index < rows.length) { + const startRow = rows[index]; + const seasonNumber = normalizePositiveInt(startRow?.seasonNumber ?? null); + const seasonLabel = seasonNumber !== null ? `S${String(seasonNumber).padStart(2, '0')}` : 'S??'; + const rangeStart = normalizePositiveInt( + startRow?.episodeNumberStart + ?? startRow?.episodeNoStart + ?? startRow?.episodeNumber + ?? null + ); + const explicitRangeEnd = normalizePositiveInt( + startRow?.episodeNumberEnd + ?? startRow?.episodeNoEnd + ?? null + ); + const explicitRangeSpan = normalizePositiveInt( + startRow?.episodeSpan + ?? startRow?.episodeCount + ?? null + ); + let consumedRows = 1; + let rangeEnd = explicitRangeEnd || rangeStart; + + if ( + rangeStart + && ( + (explicitRangeEnd && explicitRangeEnd > rangeStart) + || (explicitRangeSpan && explicitRangeSpan > 1) + ) + ) { + const effectiveSpan = explicitRangeEnd && explicitRangeEnd > rangeStart + ? Math.max(1, explicitRangeEnd - rangeStart + 1) + : Math.max(1, explicitRangeSpan || 1); + rangeEnd = rangeStart + effectiveSpan - 1; + consumedRows = 1; + } else { + const anchor = titleAnchorsByEpisodeIndex.get(index) || null; + const anchorSpan = normalizePositiveInt(anchor?.span) || 1; + if (anchorSpan > 1) { + const endIndex = Math.min(rows.length - 1, index + anchorSpan - 1); + const endRow = rows[endIndex]; + const endRowStart = normalizePositiveInt( + endRow?.episodeNumberStart + ?? endRow?.episodeNoStart + ?? endRow?.episodeNumber + ?? null + ); + rangeEnd = endRowStart || (rangeStart ? (rangeStart + anchorSpan - 1) : null); + consumedRows = anchorSpan; + } + } + + if (rangeStart && rangeEnd && rangeEnd < rangeStart) { + rangeEnd = rangeStart; + } + const isMulti = Boolean(rangeStart && rangeEnd && rangeEnd > rangeStart); + const episodeRangeToken = buildEpisodeRangeToken(rangeStart, rangeEnd); + const partsToken = buildEpisodePartsToken(rangeStart, rangeEnd); + const baseTitle = String(startRow?.episodeTitle || '(ohne Titel)').trim() || '(ohne Titel)'; + const normalizedTitle = normalizeEpisodeFillTitle(baseTitle, isMulti); + const titleLabel = isMulti + ? `${normalizedTitle} (Teil ${partsToken})` + : normalizedTitle; + const episodeLabel = rangeStart ? `E${episodeRangeToken}` : 'E??'; + const value = String( + startRow?.episodeIdStart + || startRow?.episodeId + || rangeStart + || '' + ); + if (value) { + options.push({ + label: `${seasonLabel}${episodeLabel} - ${titleLabel}`, + value, + startRef: value + }); + } + index += Math.max(1, consumedRows); + } + return options; + })(); + const episodeFillOptions = fallbackEpisodeFillOptions; + const selectedEpisodeFillValue = (() => { + const raw = String(selectedEpisodeFillStart || '').trim(); + if (!raw) { + return null; + } + const direct = episodeFillOptions.find((option) => String(option?.value || '').trim() === raw); + if (direct) { + return direct.value; + } + const byStartRef = episodeFillOptions.find((option) => String(option?.startRef || '').trim() === raw); + if (byStartRef) { + return byStartRef.value; + } + const tokenMatch = raw.match(/^fill:([^:]+):title:\d+$/i); + if (tokenMatch && tokenMatch[1]) { + const byTokenRef = episodeFillOptions.find((option) => String(option?.startRef || '').trim() === String(tokenMatch[1]).trim()); + if (byTokenRef) { + return byTokenRef.value; + } + } + return null; + })(); const processedFiles = Number(review.processedFiles || displayTitles.length || 0); const totalFiles = Number(review.totalFiles || displayTitles.length || 0); const playlistRecommendation = review.playlistRecommendation || null; @@ -1893,7 +2172,7 @@ export default function MediaInfoReviewPanel({
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach. + Multi-Episoden werden als Bereich (z.B. E16-17 | Teil 1+2) angezeigt.
diff --git a/frontend/src/components/PipelineStatusCard.jsx b/frontend/src/components/PipelineStatusCard.jsx index 9724489..9aecf4a 100644 --- a/frontend/src/components/PipelineStatusCard.jsx +++ b/frontend/src/components/PipelineStatusCard.jsx @@ -7,6 +7,7 @@ import { Dropdown } from 'primereact/dropdown'; import MediaInfoReviewPanel from './MediaInfoReviewPanel'; import { api } from '../api/client'; import { getStatusLabel, getStatusSeverity } from '../utils/statusPresentation'; +import { confirmModal } from '../utils/confirmModal'; const CONVERTER_VIDEO_OUTPUT_FORMATS = [ { label: 'MKV', value: 'mkv' }, @@ -430,7 +431,7 @@ function transliterateForFilename(input) { function sanitizeFileName(input) { return transliterateForFilename(String(input || 'untitled')) - .replace(/[^a-zA-Z0-9 ()[\]_-]/g, '') + .replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, 180); @@ -537,6 +538,28 @@ function collectEpisodeReferencesFromPlan(plan = null) { if (normalized) { refs.push(normalized); } + const seasonNumber = normalizePositiveInt(assignment?.seasonNumber ?? assignment?.season ?? null); + const episodeStart = normalizePositiveInt( + assignment?.episodeNumberStart + ?? assignment?.episodeNoStart + ?? assignment?.episodeNumber + ?? assignment?.number + ?? null + ); + const episodeEnd = normalizePositiveInt( + assignment?.episodeNumberEnd + ?? assignment?.episodeNoEnd + ?? null + ); + if (episodeStart && episodeEnd && episodeEnd > episodeStart && (episodeEnd - episodeStart) <= 64) { + for (let number = episodeStart; number <= episodeEnd; number += 1) { + refs.push({ + episodeId: null, + episodeNumber: number, + seasonNumber + }); + } + } } const titles = Array.isArray(source?.titles) ? source.titles : []; @@ -602,7 +625,300 @@ function getSeriesBatchTagSeverity(status) { return 'secondary'; } -function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assignment = null, fallbackJobId = null) { +function medianPositiveInt(values = []) { + const nums = (Array.isArray(values) ? values : []) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .sort((left, right) => left - right); + if (nums.length === 0) { + return 0; + } + const middle = Math.floor(nums.length / 2); + if (nums.length % 2 === 1) { + return nums[middle]; + } + return (nums[middle - 1] + nums[middle]) / 2; +} + +function formatTemplateTwoDigit(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return String(value || '').trim() || '00'; + } + if (Number.isInteger(numeric)) { + return String(Math.trunc(numeric)).padStart(2, '0'); + } + return String(value || '').trim() || String(numeric); +} + +function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1) { + const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; + const titleSource = title && typeof title === 'object' ? title : {}; + const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1; + const episodeStart = normalizePositiveInt( + assignmentSource?.episodeNumberStart + ?? assignmentSource?.episodeNoStart + ?? assignmentSource?.episodeNumber + ?? titleSource?.episodeNumber + ?? fallbackNumber + ) || fallbackNumber; + const explicitEpisodeEnd = normalizePositiveInt( + assignmentSource?.episodeNumberEnd + ?? assignmentSource?.episodeNoEnd + ?? null + ); + const explicitSpan = normalizePositiveInt( + assignmentSource?.episodeSpan + ?? assignmentSource?.episodeCount + ?? null + ); + + let episodeEnd = explicitEpisodeEnd; + if (!episodeEnd && explicitSpan && explicitSpan > 1) { + episodeEnd = episodeStart + explicitSpan - 1; + } + if (episodeEnd && episodeEnd < episodeStart) { + episodeEnd = episodeStart; + } + const normalizedEnd = episodeEnd || episodeStart; + const episodeSpan = Math.max(1, normalizedEnd - episodeStart + 1); + return { + start: episodeStart, + end: normalizedEnd, + span: episodeSpan, + isMulti: normalizedEnd > episodeStart + }; +} + +function hasExplicitEpisodeAssignmentRange(assignment = null) { + const source = assignment && typeof assignment === 'object' ? assignment : null; + if (!source) { + return false; + } + const hasStart = normalizePositiveInt( + source?.episodeNumberStart + ?? source?.episodeNoStart + ?? null + ) !== null; + const hasEnd = normalizePositiveInt( + source?.episodeNumberEnd + ?? source?.episodeNoEnd + ?? null + ) !== null; + const hasSpan = normalizePositiveInt( + source?.episodeSpan + ?? source?.episodeCount + ?? null + ) !== null; + const hasEpisodeNumber = normalizePositiveInt(source?.episodeNumber ?? null) !== null; + const hasEpisodeIdStart = normalizePositiveInt(source?.episodeIdStart ?? null) !== null; + const hasRangeToken = String(source?.episodeRange || '').trim().length > 0; + return Boolean(hasStart || hasEnd || hasSpan || hasEpisodeNumber || hasEpisodeIdStart || hasRangeToken); +} + +function buildEpisodeRangeToken(start, end) { + const startToken = formatTemplateTwoDigit(start); + const endToken = formatTemplateTwoDigit(end); + if (Number(end) > Number(start)) { + return `${startToken}-${endToken}`; + } + return startToken; +} + +function buildEpisodePartsToken(episodeRange = null) { + const start = normalizePositiveInt(episodeRange?.start) || 1; + const end = normalizePositiveInt(episodeRange?.end) || start; + const span = Math.max(1, end - start + 1); + if (span <= 1) { + return '1'; + } + if (span === 2) { + return '1+2'; + } + return `1-${span}`; +} + +function normalizeEpisodeTitleTokenForCompare(value) { + return String(value || '') + .toLowerCase() + .normalize('NFD') + .replace(/\p{M}+/gu, '') + .replace(/[^a-z0-9]+/g, '') + .trim(); +} + +function findCommonEpisodeTitlePrefix(values = []) { + const rows = (Array.isArray(values) ? values : []) + .map((value) => String(value || '').trim()) + .filter(Boolean); + if (rows.length === 0) { + return ''; + } + if (rows.length === 1) { + return rows[0]; + } + const tokenRows = rows.map((value) => value.split(/\s+/).filter(Boolean)); + const firstTokens = tokenRows[0]; + if (firstTokens.length === 0) { + return ''; + } + let prefixLength = 0; + for (let index = 0; index < firstTokens.length; index += 1) { + const tokenKey = normalizeEpisodeTitleTokenForCompare(firstTokens[index]); + if (!tokenKey) { + break; + } + const matchesAll = tokenRows.slice(1).every((tokens) => { + const current = tokens[index]; + return normalizeEpisodeTitleTokenForCompare(current) === tokenKey; + }); + if (!matchesAll) { + break; + } + prefixLength += 1; + } + if (prefixLength <= 0) { + return ''; + } + return firstTokens + .slice(0, prefixLength) + .join(' ') + .replace(/[-–—:.,\s]+$/g, '') + .trim(); +} + +function stripEpisodePartSuffix(value) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + const suffixPattern = /(?:\s*[-–—:.,]?\s*)(?:\(?\s*(?:teil|part|pt)\s*(?:\d{1,2}|[ivxlcdm]{1,6})(?:\s*(?:\/|von|of)\s*(?:\d{1,2}|[ivxlcdm]{1,6}))?\s*\)?)\s*$/i; + let normalized = source; + let changed = false; + for (let i = 0; i < 2; i += 1) { + const next = normalized.replace(suffixPattern, '').replace(/[-–—:.,\s]+$/g, '').trim(); + if (!next || next === normalized) { + break; + } + normalized = next; + changed = true; + } + return changed ? (normalized || source) : source; +} + +function normalizeEpisodeTitleForOutput(value, episodeRange = null) { + const source = String(value || '').replace(/\s+/g, ' ').trim(); + if (!source) { + return ''; + } + if (!Boolean(episodeRange?.isMulti)) { + return source; + } + const strippedSource = stripEpisodePartSuffix(source) || source; + const rawSegments = source + .split(/\s+(?:\+|\/|\||&)\s+/) + .map((part) => String(part || '').trim()) + .filter(Boolean); + if (rawSegments.length < 2) { + return strippedSource; + } + const cleanedSegments = rawSegments + .map((part) => stripEpisodePartSuffix(part) || part) + .map((part) => String(part || '').replace(/[-–—:.,\s]+$/g, '').trim()) + .filter(Boolean); + if (cleanedSegments.length === 0) { + return strippedSource; + } + const uniqueSegments = []; + const seen = new Set(); + for (const segment of cleanedSegments) { + const key = normalizeEpisodeTitleTokenForCompare(segment); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + uniqueSegments.push(segment); + } + if (uniqueSegments.length === 1) { + return uniqueSegments[0]; + } + const allSegmentsLookLikePartTitles = rawSegments.every((segment) => /\b(?:teil|part|pt)\b/i.test(segment)); + if (allSegmentsLookLikePartTitles) { + const commonPrefix = findCommonEpisodeTitlePrefix(cleanedSegments); + if (commonPrefix && commonPrefix.length >= 6) { + return commonPrefix; + } + return uniqueSegments[0] || cleanedSegments[0] || strippedSource; + } + return strippedSource; +} + +function resolveTitleDurationSeconds(title = null) { + const fromSeconds = Number(title?.durationSeconds || 0); + if (Number.isFinite(fromSeconds) && fromSeconds > 0) { + return fromSeconds; + } + const fromMinutes = Number(title?.durationMinutes || 0); + if (Number.isFinite(fromMinutes) && fromMinutes > 0) { + return fromMinutes * 60; + } + return 0; +} + +function estimateSeriesEpisodeSpanForTitle(title = null, allTitles = []) { + const titleDuration = resolveTitleDurationSeconds(title); + if (!Number.isFinite(titleDuration) || titleDuration <= 0) { + return 1; + } + const durations = (Array.isArray(allTitles) ? allTitles : []) + .map((item) => resolveTitleDurationSeconds(item)) + .filter((value) => Number.isFinite(value) && value > 0); + if (durations.length === 0) { + return 1; + } + const medianDuration = medianPositiveInt(durations); + if (!medianDuration) { + return 1; + } + const baselinePool = durations.filter((value) => value <= medianDuration * 1.35); + const baselineDuration = medianPositiveInt(baselinePool.length > 0 ? baselinePool : durations) || medianDuration; + if (!baselineDuration) { + return 1; + } + const ratio = titleDuration / baselineDuration; + const roundedSpan = Math.round(ratio); + if (!Number.isFinite(roundedSpan) || roundedSpan < 2 || roundedSpan > 4) { + return 1; + } + const minRatio = roundedSpan === 2 ? 1.55 : roundedSpan * 0.72; + const maxRatio = roundedSpan * 1.32; + if (ratio < minRatio || ratio > maxRatio) { + return 1; + } + return roundedSpan; +} + +function resolveEffectiveEpisodeRangeForTitle(assignment = null, title = null, allTitles = [], fallbackEpisodeNumber = 1) { + const baseRange = resolveEpisodeAssignmentRange(assignment, title, fallbackEpisodeNumber); + if (baseRange.isMulti) { + return baseRange; + } + if (hasExplicitEpisodeAssignmentRange(assignment)) { + return baseRange; + } + const spanHint = estimateSeriesEpisodeSpanForTitle(title, allTitles); + if (spanHint <= 1) { + return baseRange; + } + return { + start: baseRange.start, + end: baseRange.start + spanHint - 1, + span: spanHint, + isMulti: true + }; +} + +function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assignment = null, fallbackJobId = null, allTitles = []) { const seriesRootDir = String( settings?.series_dir_dvd || settings?.movie_dir_dvd @@ -616,9 +932,15 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig const seasonNumber = normalizePositiveInt( assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 1 ) || 1; - const episodeNumber = normalizePositiveInt( - assignment?.episodeNumber ?? title?.episodeNumber ?? title?.id - ) || 1; + const episodeRange = resolveEffectiveEpisodeRangeForTitle( + assignment, + title, + allTitles, + title?.id || 1 + ); + const episodeNumber = episodeRange.start; + const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end); + const partsToken = buildEpisodePartsToken(episodeRange); const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null); const seriesTitleRaw = String( metadata?.title @@ -632,21 +954,37 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig || title?.fileName || `Episode ${episodeNumber}` ).trim(); - const episodeTitle = episodeTitleRaw || `Episode ${episodeNumber}`; + const episodeTitle = normalizeEpisodeTitleForOutput( + episodeTitleRaw || `Episode ${episodeRangeToken}`, + episodeRange + ); const year = String(metadata?.year || new Date().getFullYear()).trim(); const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null); - const templateRaw = String( + const singleTemplateRaw = String( settings?.output_template_dvd_series_episode || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}' ).trim(); - const template = templateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; + const multiTemplateRaw = String( + settings?.output_template_dvd_series_multi_episode + || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' + ).trim(); + const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; + const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'; + const template = episodeRange.isMulti ? multiTemplate : singleTemplate; const rendered = renderTemplate(template, { seriesTitle, seasonNr: seasonNumber, seasonNo: String(seasonNumber), episodeNr: episodeNumber, episodeNo: String(episodeNumber), + episodeNoStart: episodeRange.start, + episodeNoEnd: episodeRange.end, + episodeNumberStart: episodeRange.start, + episodeNumberEnd: episodeRange.end, + episodeRange: episodeRangeToken, + episodeSpan: episodeRange.span, + parts: partsToken, episodeTitle, discNr: discNumber || '', year, @@ -802,6 +1140,7 @@ export default function PipelineStatusCard({ const [episodeAssignmentsByTitle, setEpisodeAssignmentsByTitle] = useState({}); const [selectedEpisodeFillStart, setSelectedEpisodeFillStart] = useState(null); const [containerUsedEpisodeKeys, setContainerUsedEpisodeKeys] = useState([]); + const [containerSeasonEpisodes, setContainerSeasonEpisodes] = useState([]); const [settingsMap, setSettingsMap] = useState({}); const [presetDisplayMap, setPresetDisplayMap] = useState({}); const [scriptCatalog, setScriptCatalog] = useState([]); @@ -819,16 +1158,41 @@ export default function PipelineStatusCard({ const converterConfigLatestRef = useRef(null); const converterConfigSaveTimeoutRef = useRef(null); const selectedMetadataEpisodes = useMemo(() => { - const rows = Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : []; + const metadataEpisodes = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0 + ? selectedMetadata.episodes + : (Array.isArray(containerSeasonEpisodes) ? containerSeasonEpisodes : []); + const rows = Array.isArray(metadataEpisodes) ? metadataEpisodes : []; const normalizedRows = rows .map((item) => { - const episodeId = Number(item?.id || 0); - const episodeNumber = Number(item?.number ?? item?.episodeNumber ?? 0); + const episodeId = Number(item?.id ?? item?.episodeId ?? 0); + const episodeIdStart = Number(item?.episodeIdStart ?? item?.idStart ?? item?.id ?? item?.episodeId ?? 0); + const episodeIdEnd = Number(item?.episodeIdEnd ?? item?.idEnd ?? 0); + const episodeNumberStart = Number( + item?.episodeNumberStart + ?? item?.numberStart + ?? item?.episodeNoStart + ?? item?.number + ?? item?.episodeNumber + ?? 0 + ); + const episodeNumberEnd = Number( + item?.episodeNumberEnd + ?? item?.numberEnd + ?? item?.episodeNoEnd + ?? 0 + ); + const episodeSpan = Number(item?.episodeSpan ?? item?.episodeCount ?? 0); + const episodeNumber = Number(item?.number ?? item?.episodeNumber ?? episodeNumberStart ?? 0); const seasonNumber = Number(item?.seasonNumber ?? item?.season ?? 0); const title = String(item?.name || item?.title || '').trim(); return { episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null, + episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null, + episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null, episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null, + episodeNumberStart: Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 ? Math.trunc(episodeNumberStart) : null, + episodeNumberEnd: Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 ? Math.trunc(episodeNumberEnd) : null, + episodeSpan: Number.isFinite(episodeSpan) && episodeSpan > 0 ? Math.trunc(episodeSpan) : null, seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null, episodeTitle: title || null }; @@ -857,7 +1221,7 @@ export default function PipelineStatusCard({ const key = buildEpisodeReferenceKey(episode); return !key || !usedKeySet.has(key); }); - }, [selectedMetadata?.episodes, containerUsedEpisodeKeys]); + }, [selectedMetadata?.episodes, containerSeasonEpisodes, containerUsedEpisodeKeys]); useEffect(() => { let cancelled = false; @@ -934,6 +1298,7 @@ export default function PipelineStatusCard({ setSelectedEncodeTitleId(effectiveSelected[0] || fromReview || null); setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview)); const initialEpisodeAssignments = {}; + const allReviewTitles = Array.isArray(reviewTitles) ? reviewTitles : []; for (const title of reviewTitles) { const titleId = normalizeTitleId(title?.id); if (!titleId) { @@ -942,7 +1307,16 @@ export default function PipelineStatusCard({ const episodeId = Number(title?.episodeId || 0); const episodeNumber = Number(title?.episodeNumber || 0); const seasonNumber = Number(title?.seasonNumber || 0); - const episodeTitle = String(title?.episodeTitle || '').trim(); + const rawEpisodeTitle = String(title?.episodeTitle || '').trim(); + const titleEpisodeRange = resolveEffectiveEpisodeRangeForTitle( + title, + title, + allReviewTitles, + titleId + ); + const episodeTitle = rawEpisodeTitle + ? normalizeEpisodeTitleForOutput(rawEpisodeTitle, titleEpisodeRange) + : ''; if (!episodeTitle && !episodeId && !episodeNumber && !seasonNumber) { continue; } @@ -1042,7 +1416,12 @@ export default function PipelineStatusCard({ if (episodeRows.length === 0) { return; } - const selectedTitleIds = normalizeTitleIdList(selectedEncodeTitleIds); + const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; + const selectedTitleIds = normalizeTitleIdList([ + ...normalizeTitleIdList(selectedEncodeTitleIds), + ...(Array.isArray(mediaInfoReview?.selectedTitleIds) ? mediaInfoReview.selectedTitleIds : []), + ...reviewTitles.filter((title) => Boolean(title?.selectedForEncode)).map((title) => title?.id) + ]); if (selectedTitleIds.length === 0) { return; } @@ -1052,39 +1431,215 @@ export default function PipelineStatusCard({ title })) .filter((row) => row.id !== null); - const orderedSelectedTitleIds = orderedTitles - .map((row) => row.id) - .filter((titleId) => selectedTitleIds.includes(titleId)); - const fallbackSelectedTitleIds = orderedSelectedTitleIds.length > 0 - ? orderedSelectedTitleIds - : selectedTitleIds; - if (fallbackSelectedTitleIds.length === 0) { + const orderedSelectedTitleRows = orderedTitles.filter((row) => selectedTitleIds.includes(row.id)); + const fallbackSelectedTitleRows = orderedSelectedTitleRows.length > 0 + ? orderedSelectedTitleRows + : orderedTitles.filter((row) => selectedTitleIds.includes(row.id)); + if (fallbackSelectedTitleRows.length === 0) { return; } - const normalizedRef = String(startEpisodeRef || '').trim(); - let startIndex = 0; - if (normalizedRef) { - const foundIndex = episodeRows.findIndex((episode) => - String(episode?.episodeId || '') === normalizedRef - || String(episode?.episodeNumber || '') === normalizedRef + const rawFillRef = String(startEpisodeRef || '').trim(); + const normalizedRefMatch = rawFillRef.match(/^fill:([^:]+):title:\d+$/i); + const normalizedRef = normalizedRefMatch && normalizedRefMatch[1] + ? String(normalizedRefMatch[1]).trim() + : rawFillRef; + + const allReviewTitles = fallbackSelectedTitleRows.map((item) => item?.title).filter(Boolean); + const normalizeEpisodeStart = (episode) => normalizePositiveInt( + episode?.episodeNumberStart + ?? episode?.episodeNoStart + ?? episode?.episodeNumber + ?? null + ); + const normalizeEpisodeEnd = (episode, fallbackStart = null) => { + const start = normalizePositiveInt(fallbackStart); + const explicitEnd = normalizePositiveInt( + episode?.episodeNumberEnd + ?? episode?.episodeNoEnd + ?? null ); - if (foundIndex >= 0) { - startIndex = foundIndex; + if (explicitEnd && (!start || explicitEnd >= start)) { + return explicitEnd; + } + const explicitSpan = normalizePositiveInt( + episode?.episodeSpan + ?? episode?.episodeCount + ?? null + ); + if (start && explicitSpan && explicitSpan > 1) { + return start + explicitSpan - 1; + } + return start; + }; + const titleAnchorsByEpisodeIndex = new Map(); + if (episodeRows.length > 0) { + let episodeAnchorCursor = 0; + for (const selectedTitleRow of fallbackSelectedTitleRows) { + if (episodeAnchorCursor >= episodeRows.length) { + break; + } + const explicitSpan = normalizePositiveInt( + selectedTitleRow?.title?.episodeSpan + ?? selectedTitleRow?.title?.episodeCount + ?? null + ); + const estimatedSpan = estimateSeriesEpisodeSpanForTitle(selectedTitleRow?.title, allReviewTitles); + const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1); + const safeSpan = Math.max(1, Math.min(span, episodeRows.length - episodeAnchorCursor)); + titleAnchorsByEpisodeIndex.set(episodeAnchorCursor, { span: safeSpan }); + episodeAnchorCursor += safeSpan; + } + } + + const groupedEpisodeUnits = []; + let sourceEpisodeIndex = 0; + while (sourceEpisodeIndex < episodeRows.length) { + const startRow = episodeRows[sourceEpisodeIndex]; + const rangeStart = normalizeEpisodeStart(startRow) + ?? normalizePositiveInt(startRow?.episodeNumber) + ?? null; + const explicitRangeEnd = normalizeEpisodeEnd(startRow, rangeStart); + const explicitRangeSpan = ( + rangeStart + && explicitRangeEnd + && explicitRangeEnd >= rangeStart + ) + ? Math.max(1, Math.trunc(explicitRangeEnd - rangeStart + 1)) + : 1; + + if (explicitRangeSpan > 1) { + groupedEpisodeUnits.push({ + startRow, + endRow: startRow, + rangeStart, + rangeEnd: explicitRangeEnd || rangeStart, + consumedRows: 1 + }); + sourceEpisodeIndex += 1; + continue; + } + + const anchor = titleAnchorsByEpisodeIndex.get(sourceEpisodeIndex) || null; + const anchorSpan = normalizePositiveInt(anchor?.span) || 1; + if (anchorSpan > 1) { + const endIndex = Math.min(episodeRows.length - 1, sourceEpisodeIndex + anchorSpan - 1); + const endRow = episodeRows[endIndex] || startRow; + const endRowStart = normalizeEpisodeStart(endRow) + ?? normalizePositiveInt(endRow?.episodeNumber) + ?? null; + const rangeEnd = ( + rangeStart + && endRowStart + && endRowStart >= rangeStart + ) + ? endRowStart + : (rangeStart ? (rangeStart + anchorSpan - 1) : endRowStart); + groupedEpisodeUnits.push({ + startRow, + endRow, + rangeStart, + rangeEnd, + consumedRows: anchorSpan + }); + sourceEpisodeIndex += anchorSpan; + continue; + } + + groupedEpisodeUnits.push({ + startRow, + endRow: startRow, + rangeStart, + rangeEnd: rangeStart, + consumedRows: 1 + }); + sourceEpisodeIndex += 1; + } + + let startUnitIndex = 0; + if (normalizedRef) { + const foundUnitIndex = groupedEpisodeUnits.findIndex((unit) => + String(unit?.startRow?.episodeIdStart || '') === normalizedRef + || String(unit?.startRow?.episodeId || '') === normalizedRef + || String(unit?.startRow?.episodeNumberStart || '') === normalizedRef + || String(unit?.startRow?.episodeNumber || '') === normalizedRef + ); + if (foundUnitIndex >= 0) { + startUnitIndex = foundUnitIndex; } } const nextAssignments = {}; - fallbackSelectedTitleIds.forEach((titleId, idx) => { - const episode = episodeRows[startIndex + idx]; - if (!episode) { + let unitCursor = startUnitIndex; + + fallbackSelectedTitleRows.forEach((row) => { + const titleId = row.id; + const unit = groupedEpisodeUnits[unitCursor]; + if (!unit || !titleId) { return; } - nextAssignments[titleId] = { - episodeId: episode?.episodeId || null, - episodeNumber: episode?.episodeNumber ?? null, - seasonNumber: episode?.seasonNumber ?? null, - episodeTitle: episode?.episodeTitle || null + const startRow = unit.startRow || null; + const endRow = unit.endRow || startRow; + const rangeStart = normalizePositiveInt(unit?.rangeStart) + ?? normalizeEpisodeStart(startRow) + ?? normalizePositiveInt(startRow?.episodeNumber) + ?? null; + let rangeEnd = normalizePositiveInt(unit?.rangeEnd) + ?? normalizeEpisodeStart(endRow) + ?? normalizePositiveInt(endRow?.episodeNumber) + ?? rangeStart; + if (rangeStart && rangeEnd && rangeEnd < rangeStart) { + rangeEnd = rangeStart; + } + const normalizedSpan = ( + rangeStart + && rangeEnd + && rangeEnd >= rangeStart + ) + ? Math.max(1, Math.trunc(rangeEnd - rangeStart + 1)) + : Math.max(1, consumedRows); + const rangeToken = buildEpisodeRangeToken( + rangeStart, + rangeEnd + ); + const assignmentRange = { + start: rangeStart, + end: rangeEnd, + span: normalizedSpan, + isMulti: normalizedSpan > 1 }; + const episodeTitleRaw = String(startRow?.episodeTitle || '').trim(); + const endEpisodeTitleRaw = String(endRow?.episodeTitle || '').trim(); + const titleSource = episodeTitleRaw || endEpisodeTitleRaw; + const mergedTitle = titleSource + ? normalizeEpisodeTitleForOutput(titleSource, assignmentRange) + : null; + const episodeIdStart = normalizePositiveInt( + startRow?.episodeIdStart + ?? startRow?.episodeId + ?? null + ); + const endRowEpisodeId = normalizePositiveInt( + endRow?.episodeIdStart + ?? endRow?.episodeId + ?? null + ); + const explicitEpisodeIdEnd = normalizePositiveInt(startRow?.episodeIdEnd ?? null); + const episodeIdEnd = endRowEpisodeId || explicitEpisodeIdEnd || episodeIdStart || null; + nextAssignments[titleId] = { + episodeId: episodeIdStart, + episodeNumber: rangeStart, + seasonNumber: startRow?.seasonNumber ?? null, + episodeTitle: mergedTitle, + episodeIdStart: episodeIdStart, + episodeIdEnd, + episodeNumberStart: rangeStart, + episodeNumberEnd: rangeEnd, + episodeSpan: normalizedSpan, + episodeRange: rangeToken, + episodeTitleStart: episodeTitleRaw || null, + episodeTitleEnd: endEpisodeTitleRaw || episodeTitleRaw || null + }; + unitCursor += 1; }); setEpisodeAssignmentsByTitle((prev) => ({ ...(prev && typeof prev === 'object' ? prev : {}), @@ -1106,7 +1661,11 @@ export default function PipelineStatusCard({ if (hasAnyEpisodeAssignment) { return; } - const defaultStartRef = selectedMetadataEpisodes[0]?.episodeId || selectedMetadataEpisodes[0]?.episodeNumber || null; + const defaultStartRef = selectedMetadataEpisodes[0]?.episodeIdStart + || selectedMetadataEpisodes[0]?.episodeId + || selectedMetadataEpisodes[0]?.episodeNumberStart + || selectedMetadataEpisodes[0]?.episodeNumber + || null; if (!defaultStartRef) { return; } @@ -1420,6 +1979,7 @@ export default function PipelineStatusCard({ if (!isSeriesDvdReview || !retryJobId) { if (!cancelled) { setContainerUsedEpisodeKeys([]); + setContainerSeasonEpisodes([]); } return; } @@ -1441,6 +2001,7 @@ export default function PipelineStatusCard({ if (!containerJobId) { if (!cancelled) { setContainerUsedEpisodeKeys([]); + setContainerSeasonEpisodes([]); } return; } @@ -1454,6 +2015,25 @@ export default function PipelineStatusCard({ : null; const children = Array.isArray(containerJob?.children) ? containerJob.children : []; const usedKeys = new Set(); + const extractEpisodesFromJob = (jobRow) => { + const mkInfo = jobRow?.makemkvInfo && typeof jobRow.makemkvInfo === 'object' + ? jobRow.makemkvInfo + : {}; + const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object' + ? mkInfo.analyzeContext + : {}; + const selectedFromAnalyze = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object' + ? analyzeContext.selectedMetadata + : {}; + const selectedFromRoot = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object' + ? mkInfo.selectedMetadata + : {}; + const episodes = Array.isArray(selectedFromAnalyze?.episodes) + ? selectedFromAnalyze.episodes + : (Array.isArray(selectedFromRoot?.episodes) ? selectedFromRoot.episodes : []); + return Array.isArray(episodes) ? episodes : []; + }; + let fallbackEpisodes = extractEpisodesFromJob(containerJob); for (const child of children) { const childId = normalizeJobId(child?.id); @@ -1485,14 +2065,23 @@ export default function PipelineStatusCard({ usedKeys.add(key); } } + + if ((!Array.isArray(fallbackEpisodes) || fallbackEpisodes.length === 0)) { + const childEpisodes = extractEpisodesFromJob(child); + if (Array.isArray(childEpisodes) && childEpisodes.length > 0) { + fallbackEpisodes = childEpisodes; + } + } } if (!cancelled) { setContainerUsedEpisodeKeys(Array.from(usedKeys)); + setContainerSeasonEpisodes(Array.isArray(fallbackEpisodes) ? fallbackEpisodes : []); } } catch (_error) { if (!cancelled) { setContainerUsedEpisodeKeys([]); + setContainerSeasonEpisodes([]); } } }; @@ -1616,7 +2205,8 @@ export default function PipelineStatusCard({ selectedMetadata || {}, title, assignment, - retryJobId + retryJobId, + Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [] ); if (path) { map[titleId] = path; @@ -1624,6 +2214,191 @@ export default function PipelineStatusCard({ } return map; }, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, settingsMap, selectedMetadata, retryJobId]); + const multiEpisodeReviewWarnings = useMemo(() => { + if (!isSeriesDvdReview) { + return []; + } + const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; + if (reviewTitles.length === 0) { + return []; + } + return reviewTitles + .map((title) => { + const titleId = normalizeTitleId(title?.id); + if (!titleId) { + return null; + } + const assignment = episodeAssignmentsByTitle?.[titleId] + || episodeAssignmentsByTitle?.[String(titleId)] + || null; + const episodeRange = resolveEffectiveEpisodeRangeForTitle( + assignment, + title, + reviewTitles, + titleId + ); + if (!episodeRange.isMulti) { + return null; + } + const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end); + const partsToken = buildEpisodePartsToken(episodeRange); + const rawTitle = String( + assignment?.episodeTitle + || title?.episodeTitle + || title?.fileName + || `Episode ${episodeRangeToken}` + ).trim() || `Episode ${episodeRangeToken}`; + const normalizedTitle = normalizeEpisodeTitleForOutput(rawTitle, episodeRange); + const normalizedByHeuristic = normalizedTitle !== rawTitle; + const listTitleRaw = String(title?.episodeTitle || '').trim(); + const normalizedListTitle = listTitleRaw + ? normalizeEpisodeTitleForOutput(listTitleRaw, episodeRange) + : ''; + const mismatchWithTitleList = Boolean( + normalizedListTitle + && normalizeEpisodeTitleTokenForCompare(normalizedTitle) !== normalizeEpisodeTitleTokenForCompare(normalizedListTitle) + ); + return { + titleId, + episodeRangeToken, + partsToken, + rawTitle, + normalizedTitle, + normalizedByHeuristic, + normalizedListTitle: normalizedListTitle || null, + mismatchWithTitleList + }; + }) + .filter(Boolean); + }, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle]); + const episodeRangeConflictWarnings = useMemo(() => { + if (!isSeriesDvdReview) { + return []; + } + const reviewTitles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []; + if (reviewTitles.length === 0) { + return []; + } + const metadataRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : []) + .map((episode) => { + const episodeNumber = normalizePositiveInt( + episode?.episodeNumberStart + ?? episode?.episodeNoStart + ?? episode?.episodeNumber + ?? episode?.number + ?? null + ); + const episodeTitle = String( + episode?.episodeTitle + || episode?.name + || episode?.title + || '' + ).trim(); + return { + episodeNumber, + episodeTitle + }; + }) + .filter((row) => row.episodeNumber); + const titleAnchorSuggestionById = new Map(); + if (metadataRows.length > 0) { + let cursor = 0; + for (const title of reviewTitles) { + const titleId = normalizeTitleId(title?.id); + if (!titleId) { + continue; + } + if (cursor >= metadataRows.length) { + break; + } + const explicitSpan = normalizePositiveInt( + title?.episodeSpan + ?? title?.episodeCount + ?? null + ); + const estimatedSpan = estimateSeriesEpisodeSpanForTitle(title, reviewTitles); + const span = Math.max(1, explicitSpan || 1, estimatedSpan || 1); + const safeSpan = Math.max(1, Math.min(span, metadataRows.length - cursor)); + const spanRows = metadataRows.slice(cursor, cursor + safeSpan); + const startNumber = normalizePositiveInt(spanRows[0]?.episodeNumber); + const endNumber = normalizePositiveInt(spanRows[spanRows.length - 1]?.episodeNumber) || startNumber; + const isMulti = Boolean(startNumber && endNumber && endNumber > startNumber); + const sourceTitle = spanRows + .map((row) => String(row?.episodeTitle || '').trim()) + .filter(Boolean) + .join(' + '); + const normalizedTitle = normalizeEpisodeTitleForOutput( + sourceTitle || String(title?.episodeTitle || '').trim() || '(ohne Titel)', + { + start: startNumber || 1, + end: endNumber || startNumber || 1, + span: safeSpan, + isMulti + } + ); + titleAnchorSuggestionById.set(titleId, { + suggestedTitle: normalizedTitle || '(ohne Titel)', + isMulti + }); + cursor += safeSpan; + } + } + return reviewTitles + .map((title) => { + const titleId = normalizeTitleId(title?.id); + if (!titleId) { + return null; + } + const assignment = episodeAssignmentsByTitle?.[titleId] + || episodeAssignmentsByTitle?.[String(titleId)] + || null; + if (!hasExplicitEpisodeAssignmentRange(assignment)) { + return null; + } + const explicitRange = resolveEpisodeAssignmentRange(assignment, title, titleId); + if (!explicitRange || explicitRange.isMulti) { + return null; + } + const spanHint = estimateSeriesEpisodeSpanForTitle(title, reviewTitles); + if (!Number.isFinite(spanHint) || spanHint <= 1) { + return null; + } + const effectiveSpan = Math.max(1, Math.trunc(spanHint)); + const suggestedRange = { + start: explicitRange.start, + end: explicitRange.start + effectiveSpan - 1, + span: effectiveSpan, + isMulti: effectiveSpan > 1 + }; + return { + titleId, + episodeTitle: String( + assignment?.episodeTitle + || title?.episodeTitle + || title?.fileName + || '' + ).trim() || '(ohne Titel)', + suggestedTitle: ( + String(titleAnchorSuggestionById.get(titleId)?.suggestedTitle || '').trim() + || normalizeEpisodeTitleForOutput( + String(title?.episodeTitle || '').trim() || '(ohne Titel)', + suggestedRange + ) + ) + }; + }) + .filter(Boolean); + }, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, selectedMetadataEpisodes]); + const episodeAssignmentWarningLines = useMemo(() => ( + episodeRangeConflictWarnings + .map((item) => { + const currentTitle = String(item?.episodeTitle || '').trim() || '(ohne Titel)'; + const suggestionTitle = String(item?.suggestedTitle || '').trim() || '(ohne Titel)'; + return `Titel ${item.titleId}: ${currentTitle} --> Vorschlag: ${suggestionTitle} (Heuristik vermutet Doppelfolge)`; + }) + .filter(Boolean) + ), [episodeRangeConflictWarnings]); + const hasMultiEpisodeInJob = multiEpisodeReviewWarnings.length > 0 || episodeRangeConflictWarnings.length > 0; const presetDisplayValue = useMemo(() => { const preset = String(mediaInfoReview?.selectors?.preset || '').trim(); if (!preset) { @@ -1751,18 +2526,83 @@ export default function PipelineStatusCard({ || episodeAssignmentsByTitle?.[String(titleId)] || null; const title = titlesById.get(titleId) || null; - const episodeId = Number(assignment?.episodeId || title?.episodeId || 0); - const episodeNumber = Number(assignment?.episodeNumber || title?.episodeNumber || 0); + const episodeId = Number( + assignment?.episodeId + || assignment?.episodeIdStart + || title?.episodeId + || 0 + ); + const episodeNumber = Number( + assignment?.episodeNumber + || assignment?.episodeNumberStart + || title?.episodeNumber + || 0 + ); const seasonNumber = Number(assignment?.seasonNumber || title?.seasonNumber || 0); - const episodeTitle = String(assignment?.episodeTitle || title?.episodeTitle || '').trim(); - if (!episodeTitle && !(episodeId > 0) && !(episodeNumber > 0) && !(seasonNumber > 0)) { + const episodeIdStart = Number( + assignment?.episodeIdStart + || assignment?.episodeId + || 0 + ); + const episodeIdEnd = Number( + assignment?.episodeIdEnd + || 0 + ); + const episodeNumberStart = Number( + assignment?.episodeNumberStart + || assignment?.episodeNoStart + || assignment?.episodeNumber + || title?.episodeNumber + || 0 + ); + const episodeNumberEnd = Number( + assignment?.episodeNumberEnd + || assignment?.episodeNoEnd + || 0 + ); + const episodeSpanRaw = Number(assignment?.episodeSpan || assignment?.episodeCount || 0); + const rawEpisodeTitle = String(assignment?.episodeTitle || title?.episodeTitle || '').trim(); + const normalizedEpisodeStart = Number.isFinite(episodeNumberStart) && episodeNumberStart > 0 + ? Math.trunc(episodeNumberStart) + : (Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null); + const normalizedEpisodeEnd = Number.isFinite(episodeNumberEnd) && episodeNumberEnd > 0 + ? Math.trunc(episodeNumberEnd) + : normalizedEpisodeStart; + const normalizedEpisodeSpan = Number.isFinite(episodeSpanRaw) && episodeSpanRaw > 0 + ? Math.trunc(episodeSpanRaw) + : ( + normalizedEpisodeStart && normalizedEpisodeEnd && normalizedEpisodeEnd >= normalizedEpisodeStart + ? (normalizedEpisodeEnd - normalizedEpisodeStart + 1) + : 1 + ); + const normalizedEpisodeRange = { + start: normalizedEpisodeStart || 1, + end: normalizedEpisodeEnd || normalizedEpisodeStart || 1, + span: normalizedEpisodeSpan, + isMulti: normalizedEpisodeSpan > 1 + }; + const normalizedEpisodeTitle = rawEpisodeTitle + ? normalizeEpisodeTitleForOutput(rawEpisodeTitle, normalizedEpisodeRange) + : ''; + if (!normalizedEpisodeTitle && !(episodeId > 0) && !(episodeNumber > 0) && !(seasonNumber > 0)) { continue; } + const normalizedRangeToken = normalizedEpisodeStart + ? buildEpisodeRangeToken(normalizedEpisodeStart, normalizedEpisodeEnd || normalizedEpisodeStart) + : null; episodeAssignments[titleId] = { episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null, episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null, seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null, - episodeTitle: episodeTitle || null + episodeTitle: normalizedEpisodeTitle || null, + episodeIdStart: Number.isFinite(episodeIdStart) && episodeIdStart > 0 ? Math.trunc(episodeIdStart) : null, + episodeIdEnd: Number.isFinite(episodeIdEnd) && episodeIdEnd > 0 ? Math.trunc(episodeIdEnd) : null, + episodeNumberStart: normalizedEpisodeStart, + episodeNumberEnd: normalizedEpisodeEnd || normalizedEpisodeStart, + episodeSpan: normalizedEpisodeSpan, + episodeRange: normalizedRangeToken, + episodeTitleStart: String(assignment?.episodeTitleStart || '').trim() || null, + episodeTitleEnd: String(assignment?.episodeTitleEnd || '').trim() || null }; } const selectedPostScriptIds = postEncodeItems.filter((i) => i.type === 'script').map((i) => i.id); @@ -1943,6 +2783,28 @@ export default function PipelineStatusCard({ icon="pi pi-play" severity="success" onClick={async () => { + if (hasMultiEpisodeInJob) { + const introText = 'Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich prüfen, bevor das Encoding startet.'; + const rows = episodeAssignmentWarningLines; + const modalMessage = rows.length > 0 + ? `${introText}\n\n${rows.join('\n')}` + : introText; + const confirmed = await confirmModal({ + header: 'Episoden-Zuordnung prüfen', + message: modalMessage, + acceptLabel: 'Encoding starten', + rejectLabel: 'Abbrechen', + icon: 'pi pi-exclamation-triangle', + style: { width: '34rem', maxWidth: '92vw' }, + breakpoints: { + '960px': '92vw', + '640px': '95vw' + } + }); + if (!confirmed) { + return; + } + } const { encodeTitleId, selectedEncodeTitleIds: selectedEncodeTitleIdsForPayload, @@ -2296,6 +3158,17 @@ export default function PipelineStatusCard({ ) : null} ) : null} + {isSeriesDvdReview && hasMultiEpisodeInJob ? ( +
+

Hinweis Episoden-Zuordnung

+ + Im Job wurden Hinweise zur Episoden-Zuordnung erkannt. Bitte Episodentitel und Episodenbereich pruefen, bevor das Encoding startet. + + {episodeAssignmentWarningLines.map((line, index) => ( + {line} + ))} +
+ ) : null} 0 && Number.isFinite(end) && end > 0) { + next.episodeSpan = Math.max(1, Math.trunc(end - start + 1)); + next.episodeRange = buildEpisodeRangeToken(start, end); + } + } return { ...currentMap, [normalizedTitleId]: next diff --git a/frontend/src/pages/HistoryPage.jsx b/frontend/src/pages/HistoryPage.jsx index e8a04f0..ad4ba50 100644 --- a/frontend/src/pages/HistoryPage.jsx +++ b/frontend/src/pages/HistoryPage.jsx @@ -345,6 +345,11 @@ function getQueueActionResult(response) { return response?.result && typeof response.result === 'object' ? response.result : {}; } +function isPipelineMetadataFlowStatus(value) { + const normalized = String(value || '').trim().toUpperCase(); + return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized); +} + function normalizeSortText(value) { return String(value || '').trim().toLocaleLowerCase('de-DE'); } @@ -1113,6 +1118,14 @@ export default function HistoryPage({ refreshToken = 0 }) { const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null; const context = { jobId, + status: row?.status || null, + lastState: row?.last_state || null, + submitMode: ( + isPipelineMetadataFlowStatus(row?.status) + || isPipelineMetadataFlowStatus(row?.last_state) + ) + ? 'pipeline' + : 'assign', detectedTitle: row?.detected_title || row?.title || '', selectedMetadata: { ...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}), @@ -1166,8 +1179,53 @@ export default function HistoryPage({ refreshToken = 0 }) { const handleOmdbSubmit = async (payload) => { const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; + const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign'; + const isPipelineFlow = submitMode === 'pipeline'; setOmdbAssignBusy(true); try { + if (isPipelineFlow) { + await api.selectMetadata(payload); + setMetadataDialogVisible(false); + setMetadataDialogContext(null); + + try { + const startResponse = await api.startJob(payload.jobId); + const startResult = getQueueActionResult(startResponse); + if (startResult.queued) { + toastRef.current?.show({ + severity: 'info', + summary: 'Job in Queue', + detail: startResult.queuePosition > 0 + ? `Metadaten übernommen. Start wurde in Queue-Position ${startResult.queuePosition} eingeplant.` + : 'Metadaten übernommen. Start wurde in der Queue eingeplant.', + life: 3500 + }); + } else { + toastRef.current?.show({ + severity: 'success', + summary: 'Job gestartet', + detail: 'Metadaten übernommen. Die Verarbeitung wurde gestartet.', + life: 3200 + }); + } + } catch (startError) { + const startMessage = String(startError?.message || '').trim(); + const waitingForPlaylist = startMessage.includes('waiting_for_manual_playlist_selection'); + toastRef.current?.show({ + severity: waitingForPlaylist ? 'warn' : 'error', + summary: waitingForPlaylist ? 'Metadaten übernommen' : 'Start fehlgeschlagen', + detail: waitingForPlaylist + ? 'Metadaten wurden übernommen, aber es fehlt noch eine Titel-/Playlist-Auswahl. Bitte im Ripper fortsetzen.' + : (startMessage || 'Job konnte nach der Metadaten-Übernahme nicht gestartet werden.'), + life: waitingForPlaylist ? 4200 : 4500 + }); + } + + await load(); + await refreshDetailIfOpen(payload.jobId); + return; + } + await api.assignJobOmdb(payload.jobId, payload); toastRef.current?.show({ severity: 'success', @@ -1180,6 +1238,9 @@ export default function HistoryPage({ refreshToken = 0 }) { await load(); await refreshDetailIfOpen(payload.jobId); } catch (error) { + if (isPipelineFlow) { + throw error; + } toastRef.current?.show({ severity: 'error', summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen', diff --git a/frontend/src/utils/confirmModal.js b/frontend/src/utils/confirmModal.js index 3a9185e..a5c25fe 100644 --- a/frontend/src/utils/confirmModal.js +++ b/frontend/src/utils/confirmModal.js @@ -11,6 +11,12 @@ export function confirmModal(options = {}) { const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen'; const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle'; const danger = Boolean(options?.danger); + const style = options?.style && typeof options.style === 'object' + ? options.style + : null; + const breakpoints = options?.breakpoints && typeof options.breakpoints === 'object' + ? options.breakpoints + : null; return new Promise((resolve) => { let finished = false; @@ -32,6 +38,8 @@ export function confirmModal(options = {}) { dismissableMask: true, acceptLabel, rejectLabel, + ...(style ? { style } : {}), + ...(breakpoints ? { breakpoints } : {}), ...(danger ? { acceptClassName: 'p-button-danger' } : {}), accept: () => settle(true), reject: () => settle(false), diff --git a/package-lock.json b/package-lock.json index de009c0..7f9c6f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster", - "version": "0.13.1", + "version": "0.13.1-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster", - "version": "0.13.1", + "version": "0.13.1-1", "devDependencies": { "concurrently": "^9.1.2" } diff --git a/package.json b/package.json index 350428f..1877977 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ripster", "private": true, - "version": "0.13.1", + "version": "0.13.1-1", "scripts": { "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev:backend": "npm run dev --prefix backend",