|
|
|
@@ -874,7 +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}';
|
|
|
|
|
const DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
|
|
|
|
|
|
|
|
|
|
function parseJsonObjectSafe(raw) {
|
|
|
|
|
if (!raw) {
|
|
|
|
@@ -1034,7 +1034,10 @@ function stripSeriesEpisodePartSuffix(value) {
|
|
|
|
|
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;
|
|
|
|
|
// Output cleanup for multi-episode labels:
|
|
|
|
|
// remove classic "Teil/Part" markers and pure numeric/roman suffixes in
|
|
|
|
|
// parentheses like "(1)" / "(2)" without affecting assignment logic.
|
|
|
|
|
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*(?:\d{1,2}|[ivxlcdm]{1,6})\s*\))\s*$/i;
|
|
|
|
|
let normalized = source;
|
|
|
|
|
let changed = false;
|
|
|
|
|
for (let i = 0; i < 2; i += 1) {
|
|
|
|
@@ -3552,6 +3555,96 @@ function filterSeriesDvdHandBrakeCandidates(candidates = [], analyzeContext = nu
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveSeriesPlayAllDoubleEpisodeDecision(seriesCandidates = [], allTitles = [], options = {}) {
|
|
|
|
|
const normalizedSeriesCandidates = (Array.isArray(seriesCandidates) ? seriesCandidates : [])
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id),
|
|
|
|
|
durationSeconds: Number(item?.durationSeconds || 0)
|
|
|
|
|
}))
|
|
|
|
|
.filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0);
|
|
|
|
|
if (normalizedSeriesCandidates.length < 2) {
|
|
|
|
|
return {
|
|
|
|
|
required: false,
|
|
|
|
|
reason: 'insufficient_series_candidates'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const allRows = (Array.isArray(allTitles) ? allTitles : [])
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
handBrakeTitleId: normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id),
|
|
|
|
|
durationSeconds: Number(item?.durationSeconds || 0)
|
|
|
|
|
}))
|
|
|
|
|
.filter((item) => item.handBrakeTitleId && Number.isFinite(item.durationSeconds) && item.durationSeconds > 0);
|
|
|
|
|
if (allRows.length === 0) {
|
|
|
|
|
return {
|
|
|
|
|
required: false,
|
|
|
|
|
reason: 'all_titles_missing'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectedIdSet = new Set(normalizedSeriesCandidates.map((item) => Number(item.handBrakeTitleId)));
|
|
|
|
|
const nonSelectedRows = allRows
|
|
|
|
|
.filter((item) => !selectedIdSet.has(Number(item.handBrakeTitleId)))
|
|
|
|
|
.sort((a, b) => b.durationSeconds - a.durationSeconds || a.handBrakeTitleId - b.handBrakeTitleId);
|
|
|
|
|
if (nonSelectedRows.length === 0) {
|
|
|
|
|
return {
|
|
|
|
|
required: false,
|
|
|
|
|
reason: 'no_non_selected_title'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const longestCandidate = nonSelectedRows[0];
|
|
|
|
|
const selectedDurationSumSeconds = normalizedSeriesCandidates
|
|
|
|
|
.reduce((sum, item) => sum + Number(item.durationSeconds || 0), 0);
|
|
|
|
|
const longestSelectedDurationSeconds = normalizedSeriesCandidates
|
|
|
|
|
.reduce((maxValue, item) => Math.max(maxValue, Number(item.durationSeconds || 0)), 0);
|
|
|
|
|
if (!selectedDurationSumSeconds || !longestSelectedDurationSeconds) {
|
|
|
|
|
return {
|
|
|
|
|
required: false,
|
|
|
|
|
reason: 'duration_data_missing'
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const minToleranceSeconds = Number(options?.minToleranceSeconds);
|
|
|
|
|
const ratioTolerance = Number(options?.ratioTolerance);
|
|
|
|
|
const minDominanceRatio = Number(options?.minDominanceRatio);
|
|
|
|
|
const maxAutoEpisodeCandidates = Number(options?.maxAutoEpisodeCandidates);
|
|
|
|
|
const toleranceSeconds = Math.max(
|
|
|
|
|
Number.isFinite(minToleranceSeconds) && minToleranceSeconds > 0 ? Math.trunc(minToleranceSeconds) : 120,
|
|
|
|
|
Math.round(
|
|
|
|
|
selectedDurationSumSeconds * (
|
|
|
|
|
Number.isFinite(ratioTolerance) && ratioTolerance > 0
|
|
|
|
|
? ratioTolerance
|
|
|
|
|
: 0.08
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const durationDeltaSeconds = Math.abs(longestCandidate.durationSeconds - selectedDurationSumSeconds);
|
|
|
|
|
const withinTolerance = durationDeltaSeconds <= toleranceSeconds;
|
|
|
|
|
const longestVsEpisodeRatio = longestCandidate.durationSeconds / Math.max(1, longestSelectedDurationSeconds);
|
|
|
|
|
const dominanceThreshold = Number.isFinite(minDominanceRatio) && minDominanceRatio > 0
|
|
|
|
|
? minDominanceRatio
|
|
|
|
|
: 1.4;
|
|
|
|
|
const hasDominantLongestCandidate = longestVsEpisodeRatio >= dominanceThreshold;
|
|
|
|
|
const maxAutoCandidates = Number.isFinite(maxAutoEpisodeCandidates) && maxAutoEpisodeCandidates > 0
|
|
|
|
|
? Math.trunc(maxAutoEpisodeCandidates)
|
|
|
|
|
: 2;
|
|
|
|
|
const isBoundaryEpisodeCount = normalizedSeriesCandidates.length <= maxAutoCandidates;
|
|
|
|
|
const required = withinTolerance && hasDominantLongestCandidate && isBoundaryEpisodeCount;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
required,
|
|
|
|
|
reason: required ? 'playall_or_double_episode_boundary' : 'not_boundary_case',
|
|
|
|
|
defaultSelectedTitleIds: normalizeReviewTitleIdList(normalizedSeriesCandidates.map((item) => item.handBrakeTitleId)),
|
|
|
|
|
longestCandidateTitleId: longestCandidate.handBrakeTitleId,
|
|
|
|
|
selectedDurationSumSeconds,
|
|
|
|
|
longestCandidateDurationSeconds: longestCandidate.durationSeconds,
|
|
|
|
|
durationDeltaSeconds,
|
|
|
|
|
toleranceSeconds,
|
|
|
|
|
longestVsEpisodeRatio: Number(longestVsEpisodeRatio.toFixed(3))
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) {
|
|
|
|
|
const titleList = pickScanTitleList(scanJson);
|
|
|
|
|
if (!Array.isArray(titleList) || titleList.length === 0) {
|
|
|
|
@@ -6246,6 +6339,31 @@ function collectRawMediaCandidates(rawPath, { playlistAnalysis = null, selectedP
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveDvdScanInputPathFromRaw(rawPath, rawMedia = null) {
|
|
|
|
|
const sourcePath = String(rawPath || '').trim();
|
|
|
|
|
const media = rawMedia && typeof rawMedia === 'object'
|
|
|
|
|
? rawMedia
|
|
|
|
|
: collectRawMediaCandidates(sourcePath);
|
|
|
|
|
const mediaFiles = Array.isArray(media?.mediaFiles) ? media.mediaFiles : [];
|
|
|
|
|
const mediaSource = String(media?.source || '').trim().toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (mediaFiles.length > 0 && mediaSource === 'dvd') {
|
|
|
|
|
// mediaFiles[0] points to ".../VIDEO_TS/VTS_XX_*.VOB" -> scan parent folder that contains VIDEO_TS.
|
|
|
|
|
const firstPath = String(mediaFiles[0]?.path || '').trim();
|
|
|
|
|
if (firstPath) {
|
|
|
|
|
return path.dirname(path.dirname(firstPath));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
mediaFiles.length > 0
|
|
|
|
|
&& (mediaSource === 'dvd_image' || mediaSource === 'single_extensionless' || mediaSource === 'single_file')
|
|
|
|
|
) {
|
|
|
|
|
return String(mediaFiles[0]?.path || '').trim() || sourcePath || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sourcePath || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasBluRayBackupStructure(rawPath) {
|
|
|
|
|
if (!rawPath) {
|
|
|
|
|
return false;
|
|
|
|
@@ -11578,6 +11696,387 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async analyzeRawImportJob(jobId, options = {}) {
|
|
|
|
|
const normalizedJobId = this.normalizeQueueJobId(jobId);
|
|
|
|
|
if (!normalizedJobId) {
|
|
|
|
|
const error = new Error('Ungültige Job-ID für RAW-Analyse.');
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ensureNotBusy('analyzeRawImportJob', normalizedJobId);
|
|
|
|
|
logger.info('analyze:raw-import:start', {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
rawPath: options?.rawPath || null
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const sourceJob = await historyService.getJobById(normalizedJobId);
|
|
|
|
|
if (!sourceJob) {
|
|
|
|
|
const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
const sourceMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
|
|
|
|
|
|
|
|
|
const requestedRawPath = String(options?.rawPath || sourceJob.raw_path || '').trim();
|
|
|
|
|
if (!requestedRawPath) {
|
|
|
|
|
const error = new Error('RAW-Analyse nicht möglich: raw_path fehlt.');
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const globalSettings = await settingsService.getSettingsMap();
|
|
|
|
|
const resolvedRawPath = this.resolveCurrentRawPathForSettings(
|
|
|
|
|
globalSettings,
|
|
|
|
|
null,
|
|
|
|
|
requestedRawPath
|
|
|
|
|
) || requestedRawPath;
|
|
|
|
|
if (!resolvedRawPath || !fs.existsSync(resolvedRawPath)) {
|
|
|
|
|
const error = new Error(`RAW-Analyse nicht möglich: RAW-Pfad nicht gefunden (${requestedRawPath}).`);
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const inferredRawProfile = normalizeMediaProfile(
|
|
|
|
|
options?.mediaProfile || inferMediaProfileFromRawPath(resolvedRawPath)
|
|
|
|
|
);
|
|
|
|
|
const fallbackProfile = normalizeMediaProfile(
|
|
|
|
|
sourceJob?.media_type
|
|
|
|
|
|| inferMediaProfileFromJobKind(sourceJob?.job_kind)
|
|
|
|
|
|| null
|
|
|
|
|
);
|
|
|
|
|
const mediaProfile = isSpecificMediaProfile(inferredRawProfile)
|
|
|
|
|
? inferredRawProfile
|
|
|
|
|
: (isSpecificMediaProfile(fallbackProfile) ? fallbackProfile : null);
|
|
|
|
|
|
|
|
|
|
if (!mediaProfile) {
|
|
|
|
|
const error = new Error(`RAW-Analyse nicht möglich: Medientyp konnte nicht bestimmt werden (${resolvedRawPath}).`);
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mediaProfile === 'cd') {
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
normalizedJobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
'RAW-Import erkannt als CD. Starte CD-Vorprüfung wie nach "Disk analysieren".'
|
|
|
|
|
);
|
|
|
|
|
const cdResult = await this.restartCdReviewFromRaw(normalizedJobId, {});
|
|
|
|
|
return {
|
|
|
|
|
started: true,
|
|
|
|
|
stage: 'CD_METADATA_SELECTION',
|
|
|
|
|
mediaProfile: 'cd',
|
|
|
|
|
...(cdResult && typeof cdResult === 'object' ? cdResult : {})
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mediaProfile === 'audiobook' || mediaProfile === 'converter') {
|
|
|
|
|
const error = new Error(`RAW-Analyse für Medientyp "${mediaProfile}" wird über diesen Einstieg nicht unterstützt.`);
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const detectedTitle = String(
|
|
|
|
|
options?.detectedTitle
|
|
|
|
|
|| sourceJob.detected_title
|
|
|
|
|
|| sourceJob.title
|
|
|
|
|
|| path.basename(resolvedRawPath)
|
|
|
|
|
|| 'Unknown Disc'
|
|
|
|
|
).trim() || 'Unknown Disc';
|
|
|
|
|
const deviceWithProfile = {
|
|
|
|
|
path: resolvedRawPath,
|
|
|
|
|
discLabel: detectedTitle,
|
|
|
|
|
label: detectedTitle,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
source: 'raw_import'
|
|
|
|
|
};
|
|
|
|
|
const analyzePlugin = await this.resolveAnalyzePlugin(deviceWithProfile, 'analyze');
|
|
|
|
|
|
|
|
|
|
await historyService.resetProcessLog(normalizedJobId);
|
|
|
|
|
await historyService.updateJob(normalizedJobId, {
|
|
|
|
|
status: 'ANALYZING',
|
|
|
|
|
last_state: 'ANALYZING',
|
|
|
|
|
error_message: null,
|
|
|
|
|
detected_title: detectedTitle,
|
|
|
|
|
title: null,
|
|
|
|
|
year: null,
|
|
|
|
|
imdb_id: null,
|
|
|
|
|
poster_url: null,
|
|
|
|
|
omdb_json: null,
|
|
|
|
|
selected_from_omdb: 0,
|
|
|
|
|
rip_successful: 0,
|
|
|
|
|
end_time: null,
|
|
|
|
|
media_type: mediaProfile,
|
|
|
|
|
job_kind: resolveJobKindForMediaProfile(mediaProfile),
|
|
|
|
|
parent_job_id: null,
|
|
|
|
|
disc_device: null,
|
|
|
|
|
output_path: null,
|
|
|
|
|
handbrake_info_json: null,
|
|
|
|
|
mediainfo_info_json: null,
|
|
|
|
|
encode_plan_json: null,
|
|
|
|
|
encode_input_path: null,
|
|
|
|
|
encode_review_confirmed: 0,
|
|
|
|
|
...(resolvedRawPath !== sourceJob.raw_path ? { raw_path: resolvedRawPath } : {})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
normalizedJobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
`RAW-Import wird wie "Disk analysieren" verarbeitet: ${path.basename(resolvedRawPath)} (Profil: ${mediaProfile}).`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await this.setState('ANALYZING', {
|
|
|
|
|
activeJobId: normalizedJobId,
|
|
|
|
|
progress: 0,
|
|
|
|
|
eta: null,
|
|
|
|
|
statusText: 'Disk wird analysiert ...',
|
|
|
|
|
context: {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
detectedTitle,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
rawPath: resolvedRawPath,
|
|
|
|
|
rawImport: true
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
let effectiveDetectedTitle = detectedTitle;
|
|
|
|
|
let omdbCandidates = null;
|
|
|
|
|
let metadataCandidates = null;
|
|
|
|
|
let metadataProvider = 'omdb';
|
|
|
|
|
let seriesAnalysis = null;
|
|
|
|
|
let seriesLookupHint = null;
|
|
|
|
|
let pluginExecution = null;
|
|
|
|
|
|
|
|
|
|
if (analyzePlugin) {
|
|
|
|
|
const pluginContext = await this.buildPluginContext(analyzePlugin.id, {
|
|
|
|
|
discInfo: deviceWithProfile,
|
|
|
|
|
jobId: normalizedJobId
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const pluginResult = await analyzePlugin.analyze(resolvedRawPath, sourceJob, pluginContext);
|
|
|
|
|
pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
|
|
|
|
|
const pluginDetectedTitle = String(pluginResult?.detectedTitle || '').trim();
|
|
|
|
|
if (pluginDetectedTitle) {
|
|
|
|
|
effectiveDetectedTitle = pluginDetectedTitle;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(pluginResult?.omdbCandidates)) {
|
|
|
|
|
omdbCandidates = pluginResult.omdbCandidates;
|
|
|
|
|
}
|
|
|
|
|
logger.info('plugin:analyze:used', {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
pluginId: analyzePlugin.id,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
source: 'raw_import'
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
pluginExecution = this.sanitizePluginExecutionState(pluginContext.getPluginExecution());
|
|
|
|
|
logger.warn('plugin:analyze:raw-import:fallback-legacy', {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
pluginId: analyzePlugin.id,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
error: errorToMeta(error)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mediaProfile === 'dvd' && analyzePlugin?.id === 'dvd') {
|
|
|
|
|
try {
|
|
|
|
|
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
|
|
|
|
|
const rawMedia = collectRawMediaCandidates(resolvedRawPath);
|
|
|
|
|
const dvdScanInputPath = resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath;
|
|
|
|
|
const reviewResult = await this.runPluginReviewScan(analyzePlugin, sourceJob, {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
rawPath: dvdScanInputPath,
|
|
|
|
|
reviewMode: 'rip',
|
|
|
|
|
source: 'HANDBRAKE_SCAN',
|
|
|
|
|
silent: true
|
|
|
|
|
});
|
|
|
|
|
pluginExecution = this.mergePluginExecutionState(pluginExecution, reviewResult?.pluginExecution || null);
|
|
|
|
|
|
|
|
|
|
const scanText = Array.isArray(reviewResult?.scanLines)
|
|
|
|
|
? reviewResult.scanLines.join('\n')
|
|
|
|
|
: '';
|
|
|
|
|
if (scanText) {
|
|
|
|
|
const { DVDSeriesPlugin } = require('../plugins/DVDSeriesPlugin');
|
|
|
|
|
const dvdSeriesPlugin = new DVDSeriesPlugin();
|
|
|
|
|
const dvdSeriesContext = await this.buildPluginContext(dvdSeriesPlugin.id, {
|
|
|
|
|
discInfo: {
|
|
|
|
|
...deviceWithProfile,
|
|
|
|
|
path: dvdScanInputPath
|
|
|
|
|
},
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
scanText,
|
|
|
|
|
detectedTitle: effectiveDetectedTitle
|
|
|
|
|
});
|
|
|
|
|
const seriesPluginResult = await dvdSeriesPlugin.analyze(dvdScanInputPath, sourceJob, dvdSeriesContext);
|
|
|
|
|
pluginExecution = this.mergePluginExecutionState(
|
|
|
|
|
pluginExecution,
|
|
|
|
|
this.sanitizePluginExecutionState(dvdSeriesContext.getPluginExecution())
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
seriesAnalysis = seriesPluginResult?.seriesAnalysis || null;
|
|
|
|
|
seriesLookupHint = seriesPluginResult?.seriesLookupHint || null;
|
|
|
|
|
const rawSeriesSummary = seriesAnalysis?.summary && typeof seriesAnalysis.summary === 'object'
|
|
|
|
|
? seriesAnalysis.summary
|
|
|
|
|
: null;
|
|
|
|
|
const seriesLike = Boolean(rawSeriesSummary?.seriesLike);
|
|
|
|
|
seriesAnalysis = rawSeriesSummary
|
|
|
|
|
? {
|
|
|
|
|
source: String(seriesAnalysis?.source || 'handbrake_scan_text').trim() || 'handbrake_scan_text',
|
|
|
|
|
generatedAt: seriesAnalysis?.generatedAt || nowIso(),
|
|
|
|
|
summary: {
|
|
|
|
|
seriesLike,
|
|
|
|
|
confidence: String(rawSeriesSummary?.confidence || 'low').trim().toLowerCase() || 'low',
|
|
|
|
|
reasons: Array.isArray(rawSeriesSummary?.reasons) ? rawSeriesSummary.reasons : [],
|
|
|
|
|
titleCount: Number(rawSeriesSummary?.titleCount || 0) || 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (seriesLike) {
|
|
|
|
|
metadataProvider = 'tmdb';
|
|
|
|
|
metadataCandidates = [];
|
|
|
|
|
const fallbackQuery = String(seriesLookupHint?.query || effectiveDetectedTitle || '').trim() || null;
|
|
|
|
|
if (fallbackQuery) {
|
|
|
|
|
seriesLookupHint = {
|
|
|
|
|
...(seriesLookupHint && typeof seriesLookupHint === 'object' ? seriesLookupHint : {}),
|
|
|
|
|
query: fallbackQuery,
|
|
|
|
|
seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) {
|
|
|
|
|
metadataCandidates = await tmdbService.searchSeriesWithSeasons(
|
|
|
|
|
seriesLookupHint.query,
|
|
|
|
|
{ language: dvdSettings?.dvd_series_language || null }
|
|
|
|
|
).catch((error) => {
|
|
|
|
|
logger.warn('dvd-series:tmdb-search-failed', {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
query: seriesLookupHint?.query || null,
|
|
|
|
|
seasonNumber: seriesLookupHint?.seasonNumber || null,
|
|
|
|
|
error: errorToMeta(error)
|
|
|
|
|
});
|
|
|
|
|
return [];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info('dvd-series:analyze:done', {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
seriesLike: Boolean(seriesAnalysis?.summary?.seriesLike),
|
|
|
|
|
confidence: seriesAnalysis?.summary?.confidence || null,
|
|
|
|
|
metadataProvider,
|
|
|
|
|
metadataCandidateCount: Array.isArray(metadataCandidates) ? metadataCandidates.length : 0,
|
|
|
|
|
seriesLookupHint,
|
|
|
|
|
source: 'raw_import'
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.warn('dvd-series:analyze:raw-import:fallback-legacy', {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
error: errorToMeta(error)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
|
|
|
|
|
omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
|
|
|
|
|
}
|
|
|
|
|
if (metadataProvider !== 'tmdb') {
|
|
|
|
|
metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prepareInfo = this.withPluginExecutionMeta(
|
|
|
|
|
this.withAnalyzeContextMediaProfile({
|
|
|
|
|
source: 'orphan_raw_import',
|
|
|
|
|
importContext: sourceMakemkvInfo?.importContext && typeof sourceMakemkvInfo.importContext === 'object'
|
|
|
|
|
? sourceMakemkvInfo.importContext
|
|
|
|
|
: null,
|
|
|
|
|
rawPath: resolvedRawPath,
|
|
|
|
|
phase: 'PREPARE',
|
|
|
|
|
preparedAt: nowIso(),
|
|
|
|
|
analyzeContext: {
|
|
|
|
|
playlistAnalysis: null,
|
|
|
|
|
playlistDecisionRequired: false,
|
|
|
|
|
selectedPlaylist: null,
|
|
|
|
|
selectedTitleId: null,
|
|
|
|
|
metadataProvider,
|
|
|
|
|
metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [],
|
|
|
|
|
seriesAnalysis,
|
|
|
|
|
seriesLookupHint
|
|
|
|
|
}
|
|
|
|
|
}, mediaProfile),
|
|
|
|
|
pluginExecution
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await historyService.updateJob(normalizedJobId, {
|
|
|
|
|
status: 'METADATA_SELECTION',
|
|
|
|
|
last_state: 'METADATA_SELECTION',
|
|
|
|
|
detected_title: effectiveDetectedTitle,
|
|
|
|
|
media_type: mediaProfile,
|
|
|
|
|
job_kind: resolveJobKindForMediaProfile(mediaProfile),
|
|
|
|
|
parent_job_id: null,
|
|
|
|
|
makemkv_info_json: JSON.stringify(prepareInfo)
|
|
|
|
|
});
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
normalizedJobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
metadataProvider === 'tmdb'
|
|
|
|
|
? `Serien-DVD erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.`
|
|
|
|
|
: `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await this.setState('METADATA_SELECTION', {
|
|
|
|
|
activeJobId: normalizedJobId,
|
|
|
|
|
progress: 0,
|
|
|
|
|
eta: null,
|
|
|
|
|
statusText: 'Metadaten auswählen',
|
|
|
|
|
context: {
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
detectedTitle: effectiveDetectedTitle,
|
|
|
|
|
detectedTitleSource: effectiveDetectedTitle !== detectedTitle ? 'plugin' : 'raw_import',
|
|
|
|
|
metadataProvider,
|
|
|
|
|
metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [],
|
|
|
|
|
omdbCandidates,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
seriesAnalysis,
|
|
|
|
|
seriesLookupHint,
|
|
|
|
|
playlistAnalysis: null,
|
|
|
|
|
playlistDecisionRequired: false,
|
|
|
|
|
playlistCandidates: [],
|
|
|
|
|
selectedPlaylist: null,
|
|
|
|
|
selectedTitleId: null,
|
|
|
|
|
rawPath: resolvedRawPath,
|
|
|
|
|
rawImport: true,
|
|
|
|
|
...(pluginExecution ? { pluginExecution } : {})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void this.notifyPushover('metadata_ready', {
|
|
|
|
|
title: 'Ripster - Metadaten bereit',
|
|
|
|
|
message: `Job #${normalizedJobId}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)`
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
started: true,
|
|
|
|
|
stage: 'METADATA_SELECTION',
|
|
|
|
|
jobId: normalizedJobId,
|
|
|
|
|
rawPath: resolvedRawPath,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
detectedTitle: effectiveDetectedTitle,
|
|
|
|
|
metadataProvider,
|
|
|
|
|
metadataCandidates: Array.isArray(metadataCandidates) ? metadataCandidates : [],
|
|
|
|
|
seriesAnalysis,
|
|
|
|
|
seriesLookupHint
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('metadata:prepare:raw-import:failed', { jobId: normalizedJobId, error: errorToMeta(error) });
|
|
|
|
|
await this.failJob(normalizedJobId, 'METADATA_SELECTION', error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async analyzeDisc(devicePath = null) {
|
|
|
|
|
this.ensureNotBusy('analyze');
|
|
|
|
|
logger.info('analyze:start', { devicePath });
|
|
|
|
@@ -13908,7 +14407,18 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
|| ''
|
|
|
|
|
).trim();
|
|
|
|
|
const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase);
|
|
|
|
|
let updatedRawPath = existingRawPath || null;
|
|
|
|
|
const resolvedCurrentJobRawPath = this.resolveCurrentRawPathForSettings(
|
|
|
|
|
settings,
|
|
|
|
|
mediaProfile,
|
|
|
|
|
job.raw_path
|
|
|
|
|
) || String(job.raw_path || '').trim() || null;
|
|
|
|
|
const currentJobRawPathPresent = Boolean(resolvedCurrentJobRawPath);
|
|
|
|
|
const currentJobRawPathExists = Boolean(
|
|
|
|
|
resolvedCurrentJobRawPath
|
|
|
|
|
&& fs.existsSync(resolvedCurrentJobRawPath)
|
|
|
|
|
);
|
|
|
|
|
let updatedRawPath = existingRawPath || (currentJobRawPathPresent ? resolvedCurrentJobRawPath : null);
|
|
|
|
|
const shouldAutoReviewFromRaw = Boolean(existingRawPath || currentJobRawPathExists);
|
|
|
|
|
if (existingRawPath) {
|
|
|
|
|
const existingRawState = resolveRawFolderStateFromPath(existingRawPath);
|
|
|
|
|
const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE
|
|
|
|
@@ -13943,6 +14453,7 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null
|
|
|
|
|
);
|
|
|
|
|
const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START';
|
|
|
|
|
const isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import';
|
|
|
|
|
|
|
|
|
|
const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({
|
|
|
|
|
...mkInfo,
|
|
|
|
@@ -13987,6 +14498,18 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
`Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}`
|
|
|
|
|
);
|
|
|
|
|
} else if (currentJobRawPathExists) {
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
`Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Verwende vorhandenes Job-RAW: ${resolvedCurrentJobRawPath}`
|
|
|
|
|
);
|
|
|
|
|
} else if (currentJobRawPathPresent) {
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
`Metadaten-Match im RAW-Root nicht gefunden (${metadataBase}). Job-RAW ist aktuell nicht verfügbar: ${resolvedCurrentJobRawPath}`
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
@@ -14009,7 +14532,7 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
eta: null,
|
|
|
|
|
statusText: requiresManualPlaylistSelection
|
|
|
|
|
? 'waiting_for_manual_playlist_selection'
|
|
|
|
|
: (existingRawPath
|
|
|
|
|
: (shouldAutoReviewFromRaw
|
|
|
|
|
? 'Metadaten übernommen - vorhandenes RAW erkannt'
|
|
|
|
|
: 'Metadaten übernommen - bereit zum Start'),
|
|
|
|
|
context: {
|
|
|
|
@@ -14068,11 +14591,11 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existingRawPath) {
|
|
|
|
|
if (shouldAutoReviewFromRaw) {
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
'Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW.'
|
|
|
|
|
`Metadaten übernommen. Starte automatische Spur-Ermittlung (Mediainfo) mit vorhandenem RAW: ${updatedRawPath}`
|
|
|
|
|
);
|
|
|
|
|
const startResult = await this.startPreparedJob(jobId);
|
|
|
|
|
logger.info('metadata:auto-track-review-started', {
|
|
|
|
@@ -14085,6 +14608,12 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
return historyService.getJobById(jobId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRawImportMetadataJob) {
|
|
|
|
|
const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.');
|
|
|
|
|
error.statusCode = 409;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
@@ -14119,6 +14648,7 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
|
|
|
|
|
const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json);
|
|
|
|
|
const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
|
|
|
|
|
const isRawImportPreparedJob = String(preloadedMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import';
|
|
|
|
|
const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, {
|
|
|
|
|
makemkvInfo: preloadedMakemkvInfo,
|
|
|
|
|
encodePlan: preloadedEncodePlan
|
|
|
|
@@ -14174,6 +14704,11 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasUsableRawInput) {
|
|
|
|
|
if (isRawImportPreparedJob) {
|
|
|
|
|
const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip wird nicht gestartet.');
|
|
|
|
|
error.statusCode = 409;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
// No raw input yet → will rip from disc. Bypass the encode queue entirely.
|
|
|
|
|
return this.startPreparedJob(jobId, { ...options, immediate: true });
|
|
|
|
|
}
|
|
|
|
@@ -14185,6 +14720,7 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
|
|
|
|
|
const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json);
|
|
|
|
|
const jobEncodePlan = this.safeParseJson(job.encode_plan_json);
|
|
|
|
|
const isRawImportPreparedJob = String(jobMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import';
|
|
|
|
|
const jobMediaProfile = this.resolveMediaProfileForJob(job, {
|
|
|
|
|
makemkvInfo: jobMakemkvInfo,
|
|
|
|
|
encodePlan: jobEncodePlan
|
|
|
|
@@ -14276,6 +14812,12 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
return { started: true, stage: 'ENCODING', reusedRaw: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRawImportPreparedJob) {
|
|
|
|
|
const error = new Error('RAW-Import-Job: kein nutzbares RAW gefunden. Laufwerks-Rip ist deaktiviert.');
|
|
|
|
|
error.statusCode = 409;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await historyService.updateJob(jobId, {
|
|
|
|
|
status: 'RIPPING',
|
|
|
|
|
last_state: 'RIPPING',
|
|
|
|
@@ -14415,6 +14957,12 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRawImportPreparedJob) {
|
|
|
|
|
const error = new Error('RAW-Import-Job: kein verwertbares RAW gefunden. Laufwerks-Rip ist deaktiviert.');
|
|
|
|
|
error.statusCode = 409;
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await historyService.updateJob(jobId, {
|
|
|
|
|
status: 'RIPPING',
|
|
|
|
|
last_state: 'RIPPING',
|
|
|
|
@@ -15726,6 +16274,7 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
let dvdHandBrakeScanInputPath = null;
|
|
|
|
|
let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection
|
|
|
|
|
let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection
|
|
|
|
|
let dvdAllTitles = null; // full DVD title list from initial scan
|
|
|
|
|
|
|
|
|
|
if (isDvdSource && mediaFiles.length > 0) {
|
|
|
|
|
// For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir).
|
|
|
|
@@ -15760,6 +16309,7 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
|
|
|
|
|
if (dvdHandBrakeScanJson) {
|
|
|
|
|
const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson);
|
|
|
|
|
dvdAllTitles = allDvdTitles;
|
|
|
|
|
const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines);
|
|
|
|
|
effectiveAnalyzeContext = seriesScanContextResult.analyzeContext;
|
|
|
|
|
const wasSeriesDvdReview = effectiveIsSeriesDvdReview;
|
|
|
|
@@ -16118,6 +16668,130 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
const seriesTitleIds = normalizeReviewTitleIdList(
|
|
|
|
|
dvdScannedCandidates.map((item) => item?.handBrakeTitleId)
|
|
|
|
|
);
|
|
|
|
|
const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision(
|
|
|
|
|
dvdScannedCandidates,
|
|
|
|
|
Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0 ? dvdAllTitles : dvdScannedCandidates
|
|
|
|
|
);
|
|
|
|
|
if (seriesPlayAllDecision.required) {
|
|
|
|
|
const manualTitleSourceRows = Array.isArray(dvdAllTitles) && dvdAllTitles.length > 0
|
|
|
|
|
? dvdAllTitles
|
|
|
|
|
: dvdScannedCandidates;
|
|
|
|
|
const manualCandidateIds = normalizeReviewTitleIdList([
|
|
|
|
|
...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds)
|
|
|
|
|
? seriesPlayAllDecision.defaultSelectedTitleIds
|
|
|
|
|
: []),
|
|
|
|
|
seriesPlayAllDecision.longestCandidateTitleId
|
|
|
|
|
]);
|
|
|
|
|
const candidatesData = manualCandidateIds
|
|
|
|
|
.map((titleId) => {
|
|
|
|
|
const matched = manualTitleSourceRows.find((item) =>
|
|
|
|
|
normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId
|
|
|
|
|
) || null;
|
|
|
|
|
const durationSeconds = Number(matched?.durationSeconds || 0);
|
|
|
|
|
return {
|
|
|
|
|
handBrakeTitleId: titleId,
|
|
|
|
|
durationSeconds,
|
|
|
|
|
durationMinutes: Number((durationSeconds / 60).toFixed(1)),
|
|
|
|
|
audioTrackCount: Number(matched?.audioTrackCount || 0),
|
|
|
|
|
subtitleTrackCount: Number(matched?.subtitleTrackCount || 0),
|
|
|
|
|
sizeBytes: Number(matched?.sizeBytes || 0)
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0);
|
|
|
|
|
const preselectedTitleIds = normalizeReviewTitleIdList(
|
|
|
|
|
(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds)
|
|
|
|
|
? seriesPlayAllDecision.defaultSelectedTitleIds
|
|
|
|
|
: []
|
|
|
|
|
).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId)))
|
|
|
|
|
);
|
|
|
|
|
const effectivePreselectedTitleIds = preselectedTitleIds.length > 0
|
|
|
|
|
? preselectedTitleIds
|
|
|
|
|
: (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []);
|
|
|
|
|
enrichedReview = {
|
|
|
|
|
...enrichedReview,
|
|
|
|
|
handBrakeTitleDecisionRequired: true,
|
|
|
|
|
handBrakeTitleCandidates: candidatesData,
|
|
|
|
|
handBrakeDvdInputPath: dvdScanInputPath,
|
|
|
|
|
handBrakeDecisionMode: 'series_playall_or_double',
|
|
|
|
|
handBrakeDecisionSummary: {
|
|
|
|
|
reason: seriesPlayAllDecision.reason,
|
|
|
|
|
longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId,
|
|
|
|
|
selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds,
|
|
|
|
|
longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds,
|
|
|
|
|
durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds,
|
|
|
|
|
toleranceSeconds: seriesPlayAllDecision.toleranceSeconds,
|
|
|
|
|
longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio
|
|
|
|
|
},
|
|
|
|
|
selectedHandBrakeTitleIds: effectivePreselectedTitleIds,
|
|
|
|
|
selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null,
|
|
|
|
|
encodeInputPath: null
|
|
|
|
|
};
|
|
|
|
|
const waitingMediainfoInfo = this.withPluginExecutionMeta({
|
|
|
|
|
generatedAt: nowIso(),
|
|
|
|
|
files: mediaInfoRuns
|
|
|
|
|
}, reviewPluginExecution);
|
|
|
|
|
await historyService.updateJob(jobId, {
|
|
|
|
|
status: 'WAITING_FOR_USER_DECISION',
|
|
|
|
|
last_state: 'WAITING_FOR_USER_DECISION',
|
|
|
|
|
error_message: null,
|
|
|
|
|
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
|
|
|
|
...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}),
|
|
|
|
|
analyzeContext: effectiveAnalyzeContext
|
|
|
|
|
}, mediaProfile)),
|
|
|
|
|
mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
|
|
|
|
|
encode_plan_json: JSON.stringify(enrichedReview),
|
|
|
|
|
encode_input_path: null,
|
|
|
|
|
encode_review_confirmed: 0
|
|
|
|
|
});
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
`Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} `
|
|
|
|
|
+ `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der `
|
|
|
|
|
+ `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, `
|
|
|
|
|
+ `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). `
|
|
|
|
|
+ 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.'
|
|
|
|
|
);
|
|
|
|
|
const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?';
|
|
|
|
|
const dvdWaitingContextPatch = {
|
|
|
|
|
jobId,
|
|
|
|
|
rawPath,
|
|
|
|
|
handBrakeTitleDecisionRequired: true,
|
|
|
|
|
handBrakeTitleCandidates: candidatesData,
|
|
|
|
|
handBrakeDvdInputPath: dvdScanInputPath,
|
|
|
|
|
handBrakeDecisionMode: 'series_playall_or_double',
|
|
|
|
|
handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary,
|
|
|
|
|
selectedHandBrakeTitleIds: effectivePreselectedTitleIds,
|
|
|
|
|
selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null,
|
|
|
|
|
reviewConfirmed: false,
|
|
|
|
|
mode: options.mode || 'rip',
|
|
|
|
|
mediaProfile,
|
|
|
|
|
sourceJobId: options.sourceJobId || null,
|
|
|
|
|
mediaInfoReview: enrichedReview,
|
|
|
|
|
selectedMetadata
|
|
|
|
|
};
|
|
|
|
|
if (this.isPrimaryJob(jobId)) {
|
|
|
|
|
await this.setState('WAITING_FOR_USER_DECISION', {
|
|
|
|
|
activeJobId: jobId,
|
|
|
|
|
progress: 0,
|
|
|
|
|
eta: null,
|
|
|
|
|
statusText: dvdWaitingStatusText,
|
|
|
|
|
context: {
|
|
|
|
|
...dvdWaitingContextPatch,
|
|
|
|
|
...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, {
|
|
|
|
|
contextPatch: dvdWaitingContextPatch
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
void this.notifyPushover('metadata_ready', {
|
|
|
|
|
title: 'Ripster - Serien-Grenzfall',
|
|
|
|
|
message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist`
|
|
|
|
|
});
|
|
|
|
|
return enrichedReview;
|
|
|
|
|
}
|
|
|
|
|
const selectedReviewTitles = buildSelectedHandBrakeReviewTitles(
|
|
|
|
|
dvdHandBrakeScanJson,
|
|
|
|
|
seriesTitleIds,
|
|
|
|
@@ -16322,6 +16996,127 @@ class PipelineService extends EventEmitter {
|
|
|
|
|
const seriesTitleIds = normalizeReviewTitleIdList(
|
|
|
|
|
titleCandidates.map((item) => item?.handBrakeTitleId)
|
|
|
|
|
);
|
|
|
|
|
const seriesPlayAllDecision = resolveSeriesPlayAllDoubleEpisodeDecision(
|
|
|
|
|
titleCandidates,
|
|
|
|
|
allTitles
|
|
|
|
|
);
|
|
|
|
|
if (seriesPlayAllDecision.required) {
|
|
|
|
|
const manualCandidateIds = normalizeReviewTitleIdList([
|
|
|
|
|
...(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds)
|
|
|
|
|
? seriesPlayAllDecision.defaultSelectedTitleIds
|
|
|
|
|
: []),
|
|
|
|
|
seriesPlayAllDecision.longestCandidateTitleId
|
|
|
|
|
]);
|
|
|
|
|
const candidatesData = manualCandidateIds
|
|
|
|
|
.map((titleId) => {
|
|
|
|
|
const matched = allTitles.find((item) =>
|
|
|
|
|
normalizeReviewTitleId(item?.handBrakeTitleId ?? item?.index ?? item?.id) === titleId
|
|
|
|
|
) || null;
|
|
|
|
|
const durationSeconds = Number(matched?.durationSeconds || 0);
|
|
|
|
|
return {
|
|
|
|
|
handBrakeTitleId: titleId,
|
|
|
|
|
durationSeconds,
|
|
|
|
|
durationMinutes: Number((durationSeconds / 60).toFixed(1)),
|
|
|
|
|
audioTrackCount: Number(matched?.audioTrackCount || 0),
|
|
|
|
|
subtitleTrackCount: Number(matched?.subtitleTrackCount || 0),
|
|
|
|
|
sizeBytes: Number(matched?.sizeBytes || 0)
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((row) => Number.isFinite(Number(row?.handBrakeTitleId)) && Number(row.handBrakeTitleId) > 0);
|
|
|
|
|
const preselectedTitleIds = normalizeReviewTitleIdList(
|
|
|
|
|
(Array.isArray(seriesPlayAllDecision.defaultSelectedTitleIds)
|
|
|
|
|
? seriesPlayAllDecision.defaultSelectedTitleIds
|
|
|
|
|
: []
|
|
|
|
|
).filter((titleId) => candidatesData.some((row) => Number(row.handBrakeTitleId) === Number(titleId)))
|
|
|
|
|
);
|
|
|
|
|
const effectivePreselectedTitleIds = preselectedTitleIds.length > 0
|
|
|
|
|
? preselectedTitleIds
|
|
|
|
|
: (candidatesData[0]?.handBrakeTitleId ? [candidatesData[0].handBrakeTitleId] : []);
|
|
|
|
|
enrichedReview = {
|
|
|
|
|
...enrichedReview,
|
|
|
|
|
handBrakeTitleDecisionRequired: true,
|
|
|
|
|
handBrakeTitleCandidates: candidatesData,
|
|
|
|
|
handBrakeDvdInputPath: dvdScanInputPath,
|
|
|
|
|
handBrakeDecisionMode: 'series_playall_or_double',
|
|
|
|
|
handBrakeDecisionSummary: {
|
|
|
|
|
reason: seriesPlayAllDecision.reason,
|
|
|
|
|
longestCandidateTitleId: seriesPlayAllDecision.longestCandidateTitleId,
|
|
|
|
|
selectedDurationSumSeconds: seriesPlayAllDecision.selectedDurationSumSeconds,
|
|
|
|
|
longestCandidateDurationSeconds: seriesPlayAllDecision.longestCandidateDurationSeconds,
|
|
|
|
|
durationDeltaSeconds: seriesPlayAllDecision.durationDeltaSeconds,
|
|
|
|
|
toleranceSeconds: seriesPlayAllDecision.toleranceSeconds,
|
|
|
|
|
longestVsEpisodeRatio: seriesPlayAllDecision.longestVsEpisodeRatio
|
|
|
|
|
},
|
|
|
|
|
selectedHandBrakeTitleIds: effectivePreselectedTitleIds,
|
|
|
|
|
selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null,
|
|
|
|
|
encodeInputPath: null
|
|
|
|
|
};
|
|
|
|
|
const waitingMediainfoInfo = this.withPluginExecutionMeta({
|
|
|
|
|
generatedAt: nowIso(),
|
|
|
|
|
files: mediaInfoRuns
|
|
|
|
|
}, reviewPluginExecution);
|
|
|
|
|
await historyService.updateJob(jobId, {
|
|
|
|
|
status: 'WAITING_FOR_USER_DECISION',
|
|
|
|
|
last_state: 'WAITING_FOR_USER_DECISION',
|
|
|
|
|
error_message: null,
|
|
|
|
|
makemkv_info_json: JSON.stringify(this.withAnalyzeContextMediaProfile({
|
|
|
|
|
...(mkInfo && typeof mkInfo === 'object' ? mkInfo : {}),
|
|
|
|
|
analyzeContext: effectiveAnalyzeContext
|
|
|
|
|
}, mediaProfile)),
|
|
|
|
|
mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
|
|
|
|
|
encode_plan_json: JSON.stringify(enrichedReview),
|
|
|
|
|
encode_input_path: null,
|
|
|
|
|
encode_review_confirmed: 0
|
|
|
|
|
});
|
|
|
|
|
await historyService.appendLog(
|
|
|
|
|
jobId,
|
|
|
|
|
'SYSTEM',
|
|
|
|
|
`Serien-Grenzfall erkannt: Längster Titel -t ${seriesPlayAllDecision.longestCandidateTitleId || '-'} `
|
|
|
|
|
+ `(${formatDurationClock(seriesPlayAllDecision.longestCandidateDurationSeconds) || '-'}) liegt innerhalb der `
|
|
|
|
|
+ `PlayAll-Toleranz (Summe Episoden=${formatDurationClock(seriesPlayAllDecision.selectedDurationSumSeconds) || '-'}, `
|
|
|
|
|
+ `Delta=${seriesPlayAllDecision.durationDeltaSeconds || 0}s, Toleranz=${seriesPlayAllDecision.toleranceSeconds || 0}s). `
|
|
|
|
|
+ 'Bitte manuell entscheiden: PlayAll oder Doppelfolge.'
|
|
|
|
|
);
|
|
|
|
|
const dvdWaitingStatusText = 'Serien-Grenzfall: PlayAll oder Doppelfolge?';
|
|
|
|
|
const dvdWaitingContextPatch = {
|
|
|
|
|
jobId,
|
|
|
|
|
rawPath,
|
|
|
|
|
handBrakeTitleDecisionRequired: true,
|
|
|
|
|
handBrakeTitleCandidates: candidatesData,
|
|
|
|
|
handBrakeDvdInputPath: dvdScanInputPath,
|
|
|
|
|
handBrakeDecisionMode: 'series_playall_or_double',
|
|
|
|
|
handBrakeDecisionSummary: enrichedReview.handBrakeDecisionSummary,
|
|
|
|
|
selectedHandBrakeTitleIds: effectivePreselectedTitleIds,
|
|
|
|
|
selectedHandBrakeTitleId: effectivePreselectedTitleIds[0] || null,
|
|
|
|
|
reviewConfirmed: false,
|
|
|
|
|
mode: options.mode || 'rip',
|
|
|
|
|
mediaProfile,
|
|
|
|
|
sourceJobId: options.sourceJobId || null,
|
|
|
|
|
mediaInfoReview: enrichedReview,
|
|
|
|
|
selectedMetadata
|
|
|
|
|
};
|
|
|
|
|
if (this.isPrimaryJob(jobId)) {
|
|
|
|
|
await this.setState('WAITING_FOR_USER_DECISION', {
|
|
|
|
|
activeJobId: jobId,
|
|
|
|
|
progress: 0,
|
|
|
|
|
eta: null,
|
|
|
|
|
statusText: dvdWaitingStatusText,
|
|
|
|
|
context: {
|
|
|
|
|
...dvdWaitingContextPatch,
|
|
|
|
|
...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, {
|
|
|
|
|
contextPatch: dvdWaitingContextPatch
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
void this.notifyPushover('metadata_ready', {
|
|
|
|
|
title: 'Ripster - Serien-Grenzfall',
|
|
|
|
|
message: `Job #${jobId}: Bitte entscheiden, ob Titel ${seriesPlayAllDecision.longestCandidateTitleId || '-'} PlayAll oder Doppelfolge ist`
|
|
|
|
|
});
|
|
|
|
|
return enrichedReview;
|
|
|
|
|
}
|
|
|
|
|
const selectedReviewTitles = buildSelectedHandBrakeReviewTitles(
|
|
|
|
|
dvdTitleScanJson,
|
|
|
|
|
seriesTitleIds,
|
|
|
|
|