0.13.1-1 Episode Detection
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
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);
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.1-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
};
|
||||
}).filter((option) => option.value);
|
||||
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({
|
||||
<div className="episode-fill-field">
|
||||
<label>Episodentitel befüllen ab Episode</label>
|
||||
<Dropdown
|
||||
value={String(selectedEpisodeFillStart || '').trim() || null}
|
||||
value={selectedEpisodeFillValue}
|
||||
options={episodeFillOptions}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
@@ -1906,6 +2185,7 @@ export default function MediaInfoReviewPanel({
|
||||
/>
|
||||
<small>
|
||||
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach.
|
||||
Multi-Episoden werden als Bereich (z.B. E16-17 | Teil 1+2) angezeigt.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -345,6 +345,11 @@ function getQueueActionResult(response) {
|
||||
return response?.result && typeof response.result === 'object' ? response.result : {};
|
||||
}
|
||||
|
||||
function isPipelineMetadataFlowStatus(value) {
|
||||
const normalized = String(value || '').trim().toUpperCase();
|
||||
return ['METADATA_SELECTION', 'READY_TO_START', 'WAITING_FOR_USER_DECISION'].includes(normalized);
|
||||
}
|
||||
|
||||
function normalizeSortText(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
}
|
||||
@@ -1113,6 +1118,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
|
||||
const context = {
|
||||
jobId,
|
||||
status: row?.status || null,
|
||||
lastState: row?.last_state || null,
|
||||
submitMode: (
|
||||
isPipelineMetadataFlowStatus(row?.status)
|
||||
|| isPipelineMetadataFlowStatus(row?.last_state)
|
||||
)
|
||||
? 'pipeline'
|
||||
: 'assign',
|
||||
detectedTitle: row?.detected_title || row?.title || '',
|
||||
selectedMetadata: {
|
||||
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
|
||||
@@ -1166,8 +1179,53 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
|
||||
const handleOmdbSubmit = async (payload) => {
|
||||
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
|
||||
const submitMode = String(metadataDialogContext?.submitMode || 'assign').trim().toLowerCase() || 'assign';
|
||||
const isPipelineFlow = submitMode === 'pipeline';
|
||||
setOmdbAssignBusy(true);
|
||||
try {
|
||||
if (isPipelineFlow) {
|
||||
await api.selectMetadata(payload);
|
||||
setMetadataDialogVisible(false);
|
||||
setMetadataDialogContext(null);
|
||||
|
||||
try {
|
||||
const startResponse = await api.startJob(payload.jobId);
|
||||
const startResult = getQueueActionResult(startResponse);
|
||||
if (startResult.queued) {
|
||||
toastRef.current?.show({
|
||||
severity: 'info',
|
||||
summary: 'Job in Queue',
|
||||
detail: startResult.queuePosition > 0
|
||||
? `Metadaten übernommen. Start wurde in Queue-Position ${startResult.queuePosition} eingeplant.`
|
||||
: 'Metadaten übernommen. Start wurde in der Queue eingeplant.',
|
||||
life: 3500
|
||||
});
|
||||
} else {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Job gestartet',
|
||||
detail: 'Metadaten übernommen. Die Verarbeitung wurde gestartet.',
|
||||
life: 3200
|
||||
});
|
||||
}
|
||||
} catch (startError) {
|
||||
const startMessage = String(startError?.message || '').trim();
|
||||
const waitingForPlaylist = startMessage.includes('waiting_for_manual_playlist_selection');
|
||||
toastRef.current?.show({
|
||||
severity: waitingForPlaylist ? 'warn' : 'error',
|
||||
summary: waitingForPlaylist ? 'Metadaten übernommen' : 'Start fehlgeschlagen',
|
||||
detail: waitingForPlaylist
|
||||
? 'Metadaten wurden übernommen, aber es fehlt noch eine Titel-/Playlist-Auswahl. Bitte im Ripper fortsetzen.'
|
||||
: (startMessage || 'Job konnte nach der Metadaten-Übernahme nicht gestartet werden.'),
|
||||
life: waitingForPlaylist ? 4200 : 4500
|
||||
});
|
||||
}
|
||||
|
||||
await load();
|
||||
await refreshDetailIfOpen(payload.jobId);
|
||||
return;
|
||||
}
|
||||
|
||||
await api.assignJobOmdb(payload.jobId, payload);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
@@ -1180,6 +1238,9 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
await load();
|
||||
await refreshDetailIfOpen(payload.jobId);
|
||||
} catch (error) {
|
||||
if (isPipelineFlow) {
|
||||
throw error;
|
||||
}
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
|
||||
|
||||
@@ -11,6 +11,12 @@ export function confirmModal(options = {}) {
|
||||
const rejectLabel = String(options?.rejectLabel || 'Abbrechen').trim() || 'Abbrechen';
|
||||
const icon = String(options?.icon || 'pi pi-exclamation-triangle').trim() || 'pi pi-exclamation-triangle';
|
||||
const danger = Boolean(options?.danger);
|
||||
const style = options?.style && typeof options.style === 'object'
|
||||
? options.style
|
||||
: null;
|
||||
const breakpoints = options?.breakpoints && typeof options.breakpoints === 'object'
|
||||
? options.breakpoints
|
||||
: null;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let finished = false;
|
||||
@@ -32,6 +38,8 @@ export function confirmModal(options = {}) {
|
||||
dismissableMask: true,
|
||||
acceptLabel,
|
||||
rejectLabel,
|
||||
...(style ? { style } : {}),
|
||||
...(breakpoints ? { breakpoints } : {}),
|
||||
...(danger ? { acceptClassName: 'p-button-danger' } : {}),
|
||||
accept: () => settle(true),
|
||||
reject: () => settle(false),
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.1-1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.1-1",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.13.1",
|
||||
"version": "0.13.1-1",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user