0.13.1-1 Episode Detection

This commit is contained in:
2026-04-12 19:43:56 +00:00
parent bc193db50a
commit 850f05453c
19 changed files with 2046 additions and 119 deletions
+66 -4
View File
@@ -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();
+669 -48
View File
@@ -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);
+1 -1
View File
@@ -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);