0.13.1-5 Adjust Detection
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-4",
|
||||
"version": "0.13.1-5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-4",
|
||||
"version": "0.13.1-5",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-4",
|
||||
"version": "0.13.1-5",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -850,7 +850,7 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
{
|
||||
key: 'output_template_dvd_series_multi_episode',
|
||||
required: 1,
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
}
|
||||
];
|
||||
@@ -925,6 +925,46 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
});
|
||||
}
|
||||
|
||||
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
|
||||
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
|
||||
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
|
||||
];
|
||||
const legacyTemplatePlaceholders = legacyDvdSeriesMultiEpisodeTemplateDefaults.map(() => '?').join(', ');
|
||||
await db.run(
|
||||
`
|
||||
UPDATE settings_schema
|
||||
SET default_value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'output_template_dvd_series_multi_episode'
|
||||
AND (
|
||||
default_value IS NULL
|
||||
OR TRIM(default_value) = ''
|
||||
OR default_value IN (${legacyTemplatePlaceholders})
|
||||
)
|
||||
`,
|
||||
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
|
||||
);
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO settings_values (key, value, updated_at)
|
||||
VALUES ('output_template_dvd_series_multi_episode', ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO NOTHING
|
||||
`,
|
||||
[dvdSeriesMultiEpisodeTemplateDefault]
|
||||
);
|
||||
await db.run(
|
||||
`
|
||||
UPDATE settings_values
|
||||
SET value = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE key = 'output_template_dvd_series_multi_episode'
|
||||
AND (
|
||||
value IS NULL
|
||||
OR TRIM(value) = ''
|
||||
OR value IN (${legacyTemplatePlaceholders})
|
||||
)
|
||||
`,
|
||||
[dvdSeriesMultiEpisodeTemplateDefault, ...legacyDvdSeriesMultiEpisodeTemplateDefaults]
|
||||
);
|
||||
|
||||
// Migrate raw_dir_cd_owner label
|
||||
await db.run(
|
||||
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
|
||||
@@ -1042,9 +1082,9 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}', '[]', '{"minLength":1}', 537)`
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}')`);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
|
||||
@@ -56,9 +56,22 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const rawPath = String(req.body?.rawPath || '').trim();
|
||||
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
|
||||
const job = await historyService.importOrphanRawFolder(rawPath);
|
||||
const uiReset = await pipelineService.resetFrontendState('history_orphan_import');
|
||||
res.json({ job, uiReset });
|
||||
const importedJob = await historyService.importOrphanRawFolder(rawPath);
|
||||
const importedJobId = Number(importedJob?.id || 0);
|
||||
const activation = (Number.isFinite(importedJobId) && importedJobId > 0)
|
||||
? await pipelineService.analyzeRawImportJob(importedJobId, {
|
||||
rawPath: importedJob?.raw_path || rawPath
|
||||
})
|
||||
: null;
|
||||
|
||||
const refreshedJob = importedJobId > 0
|
||||
? await historyService.getJobById(importedJobId)
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
job: refreshedJob || importedJob,
|
||||
activation
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -4967,114 +4967,6 @@ class HistoryService {
|
||||
const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object'
|
||||
? sourceJobContext.selectedMetadata
|
||||
: {};
|
||||
const sourceAnalyzeContext = sourceJobContext?.analyzeContext && typeof sourceJobContext.analyzeContext === 'object'
|
||||
? sourceJobContext.analyzeContext
|
||||
: {};
|
||||
const sourceHasSeriesSignals = hasSeriesMetadataSignals(sourceSelectedMetadata, sourceAnalyzeContext);
|
||||
const seriesImportHints = effectiveDetectedMediaType === 'dvd' && (seriesRawPathHint || sourceHasSeriesSignals)
|
||||
? buildSeriesImportHints({
|
||||
folderName: path.basename(finalRawPath),
|
||||
rawPath: finalRawPath,
|
||||
metadata,
|
||||
sourceSelectedMetadata,
|
||||
sourceAnalyzeContext
|
||||
})
|
||||
: null;
|
||||
let effectiveSeriesImportHints = seriesImportHints;
|
||||
let inferredSeriesContainer = null;
|
||||
if (effectiveDetectedMediaType === 'dvd') {
|
||||
const hintedTmdbId = normalizePositiveIntegerOrNull(
|
||||
effectiveSeriesImportHints?.selectedMetadata?.tmdbId
|
||||
?? effectiveSeriesImportHints?.selectedMetadata?.providerId
|
||||
?? null
|
||||
);
|
||||
const hintedSeasonNumber = normalizePositiveIntegerOrNull(
|
||||
effectiveSeriesImportHints?.selectedMetadata?.seasonNumber
|
||||
?? effectiveSeriesImportHints?.analyzeContextPatch?.seriesLookupHint?.seasonNumber
|
||||
?? null
|
||||
);
|
||||
|
||||
if (hintedTmdbId && hintedSeasonNumber) {
|
||||
inferredSeriesContainer = await this.findSeriesContainerJob(hintedTmdbId, hintedSeasonNumber);
|
||||
}
|
||||
|
||||
if (!inferredSeriesContainer && seriesRawPathHint) {
|
||||
inferredSeriesContainer = await this.findLikelySeriesContainerJob({
|
||||
title: sourceSelectedMetadata?.title || sourceSelectedMetadata?.seriesTitle || effectiveTitle || metadata.title || null,
|
||||
year: sourceSelectedMetadata?.year || sourceJob?.year || metadata.year || null
|
||||
});
|
||||
}
|
||||
|
||||
if (inferredSeriesContainer) {
|
||||
const containerInfo = parseJsonSafe(inferredSeriesContainer?.makemkv_info_json, {}) || {};
|
||||
const containerSelectedMetadata = extractSelectedMetadataFromMakemkvInfo(containerInfo);
|
||||
const containerAnalyzeContext = containerInfo?.analyzeContext && typeof containerInfo.analyzeContext === 'object'
|
||||
? containerInfo.analyzeContext
|
||||
: {};
|
||||
const containerTmdbId = normalizePositiveIntegerOrNull(
|
||||
containerSelectedMetadata?.tmdbId
|
||||
|| containerSelectedMetadata?.providerId
|
||||
|| null
|
||||
);
|
||||
const containerSeasonNumber = normalizePositiveIntegerOrNull(containerSelectedMetadata?.seasonNumber || null);
|
||||
const discNumberHint = normalizePositiveIntegerOrNull(
|
||||
effectiveSeriesImportHints?.selectedMetadata?.discNumber
|
||||
?? extractDiscNumberFromText(`${path.basename(finalRawPath)} ${path.basename(absRawPath)}`)
|
||||
?? null
|
||||
);
|
||||
|
||||
if (containerTmdbId && containerSeasonNumber) {
|
||||
const mergedTitle = String(
|
||||
effectiveSeriesImportHints?.selectedMetadata?.title
|
||||
|| containerSelectedMetadata?.title
|
||||
|| effectiveTitle
|
||||
|| inferredSeriesContainer?.title
|
||||
|| ''
|
||||
).trim() || null;
|
||||
effectiveSeriesImportHints = {
|
||||
selectedMetadata: {
|
||||
...containerSelectedMetadata,
|
||||
...(effectiveSeriesImportHints?.selectedMetadata && typeof effectiveSeriesImportHints.selectedMetadata === 'object'
|
||||
? effectiveSeriesImportHints.selectedMetadata
|
||||
: {}),
|
||||
metadataProvider: 'tmdb',
|
||||
metadataKind: String(containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series',
|
||||
tmdbId: containerTmdbId,
|
||||
seasonNumber: containerSeasonNumber,
|
||||
title: mergedTitle,
|
||||
...(discNumberHint ? { discNumber: discNumberHint } : {})
|
||||
},
|
||||
analyzeContextPatch: {
|
||||
...(effectiveSeriesImportHints?.analyzeContextPatch && typeof effectiveSeriesImportHints.analyzeContextPatch === 'object'
|
||||
? effectiveSeriesImportHints.analyzeContextPatch
|
||||
: {}),
|
||||
metadataProvider: 'tmdb',
|
||||
metadataKind: String(containerAnalyzeContext?.metadataKind || containerSelectedMetadata?.metadataKind || 'series').trim().toLowerCase() || 'series',
|
||||
seriesLookupHint: {
|
||||
...(containerAnalyzeContext?.seriesLookupHint && typeof containerAnalyzeContext.seriesLookupHint === 'object'
|
||||
? containerAnalyzeContext.seriesLookupHint
|
||||
: {}),
|
||||
query: mergedTitle,
|
||||
seasonNumber: containerSeasonNumber,
|
||||
...(discNumberHint ? { discNumber: discNumberHint } : {})
|
||||
},
|
||||
seriesAnalysis: {
|
||||
...(containerAnalyzeContext?.seriesAnalysis && typeof containerAnalyzeContext.seriesAnalysis === 'object'
|
||||
? containerAnalyzeContext.seriesAnalysis
|
||||
: {}),
|
||||
summary: {
|
||||
...(containerAnalyzeContext?.seriesAnalysis?.summary && typeof containerAnalyzeContext.seriesAnalysis.summary === 'object'
|
||||
? containerAnalyzeContext.seriesAnalysis.summary
|
||||
: {}),
|
||||
seriesLike: true,
|
||||
confidence: containerAnalyzeContext?.seriesAnalysis?.summary?.confidence || 'high'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null);
|
||||
const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null;
|
||||
const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
|
||||
@@ -5083,12 +4975,13 @@ class HistoryService {
|
||||
requestedRawPath: absRawPath,
|
||||
sourceFolderJobId: metadata.folderJobId || null,
|
||||
mediaProfile: effectiveDetectedMediaType,
|
||||
existingInfo: sourceJobContext?.makemkvInfo || null,
|
||||
// RAW-Import soll wie "Disk analysieren" starten:
|
||||
// keine geerbte Serien-/Metadatenzuordnung vor dem eigentlichen Analyse-Scan.
|
||||
existingInfo: cdRecovery ? (sourceJobContext?.makemkvInfo || null) : null,
|
||||
recovery: cdRecovery,
|
||||
selectedMetadata: effectiveSeriesImportHints?.selectedMetadata || null,
|
||||
analyzeContextPatch: effectiveSeriesImportHints?.analyzeContextPatch || null
|
||||
selectedMetadata: null,
|
||||
analyzeContextPatch: null
|
||||
});
|
||||
const inferredSeriesContainerId = normalizeJobIdValue(inferredSeriesContainer?.id);
|
||||
const recoveredPosterUrl = orphanPosterUrl
|
||||
|| (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate)
|
||||
|| null;
|
||||
@@ -5108,11 +5001,7 @@ class HistoryService {
|
||||
await this.updateJob(created.id, {
|
||||
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
|
||||
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
|
||||
...(effectiveDetectedMediaType === 'dvd'
|
||||
? (inferredSeriesContainerId
|
||||
? { job_kind: 'dvd_series_child', media_type: 'dvd', parent_job_id: inferredSeriesContainerId }
|
||||
: { job_kind: 'dvd', media_type: 'dvd' })
|
||||
: {}),
|
||||
...(effectiveDetectedMediaType === 'dvd' ? { job_kind: 'dvd', media_type: 'dvd', parent_job_id: null } : {}),
|
||||
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
|
||||
status: 'FINISHED',
|
||||
last_state: 'FINISHED',
|
||||
@@ -5191,13 +5080,6 @@ class HistoryService {
|
||||
`Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.`
|
||||
);
|
||||
}
|
||||
if (inferredSeriesContainerId) {
|
||||
await this.appendLog(
|
||||
created.id,
|
||||
'SYSTEM',
|
||||
`Automatisch dem Serien-Container #${inferredSeriesContainerId} zugeordnet.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('job:import-orphan-raw', {
|
||||
jobId: created.id,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user