0.13.1-1 Episode Detection
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.1-1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user