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({