0.13.1-5 Adjust Detection

This commit is contained in:
2026-04-14 05:54:48 +00:00
parent 3feb780e16
commit cac6442b5b
14 changed files with 937 additions and 156 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-4", "version": "0.13.1-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-4", "version": "0.13.1-5",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-4", "version": "0.13.1-5",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+43 -3
View File
@@ -850,7 +850,7 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
{ {
key: 'output_template_dvd_series_multi_episode', key: 'output_template_dvd_series_multi_episode',
required: 1, 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}' 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 // Migrate raw_dir_cd_owner label
await db.run( await db.run(
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP `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( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `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( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
+16 -3
View File
@@ -56,9 +56,22 @@ router.post(
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const rawPath = String(req.body?.rawPath || '').trim(); const rawPath = String(req.body?.rawPath || '').trim();
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath }); logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
const job = await historyService.importOrphanRawFolder(rawPath); const importedJob = await historyService.importOrphanRawFolder(rawPath);
const uiReset = await pipelineService.resetFrontendState('history_orphan_import'); const importedJobId = Number(importedJob?.id || 0);
res.json({ job, uiReset }); 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
});
}) })
); );
+6 -124
View File
@@ -4967,114 +4967,6 @@ class HistoryService {
const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object' const sourceSelectedMetadata = sourceJobContext?.selectedMetadata && typeof sourceJobContext.selectedMetadata === 'object'
? sourceJobContext.selectedMetadata ? 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 sourcePosterCandidate = this.getPreferredPosterCandidateForImport(sourceJobContext, null);
const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null; const recoveredCdEncodePlan = cdRecovery ? this.buildRecoveredCdEncodePlan(cdRecovery) : null;
const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({ const orphanImportInfo = this.buildOrphanRawImportMakemkvInfo({
@@ -5083,12 +4975,13 @@ class HistoryService {
requestedRawPath: absRawPath, requestedRawPath: absRawPath,
sourceFolderJobId: metadata.folderJobId || null, sourceFolderJobId: metadata.folderJobId || null,
mediaProfile: effectiveDetectedMediaType, 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, recovery: cdRecovery,
selectedMetadata: effectiveSeriesImportHints?.selectedMetadata || null, selectedMetadata: null,
analyzeContextPatch: effectiveSeriesImportHints?.analyzeContextPatch || null analyzeContextPatch: null
}); });
const inferredSeriesContainerId = normalizeJobIdValue(inferredSeriesContainer?.id);
const recoveredPosterUrl = orphanPosterUrl const recoveredPosterUrl = orphanPosterUrl
|| (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate) || (thumbnailService.isLocalUrl(sourcePosterCandidate) ? null : sourcePosterCandidate)
|| null; || null;
@@ -5108,11 +5001,7 @@ class HistoryService {
await this.updateJob(created.id, { await this.updateJob(created.id, {
...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}), ...(effectiveDetectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}), ...(effectiveDetectedMediaType === 'cd' ? { job_kind: 'cd', media_type: 'cd' } : {}),
...(effectiveDetectedMediaType === 'dvd' ...(effectiveDetectedMediaType === 'dvd' ? { job_kind: 'dvd', media_type: 'dvd', parent_job_id: null } : {}),
? (inferredSeriesContainerId
? { job_kind: 'dvd_series_child', media_type: 'dvd', parent_job_id: inferredSeriesContainerId }
: { job_kind: 'dvd', media_type: 'dvd' })
: {}),
...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}), ...(effectiveDetectedMediaType === 'bluray' ? { job_kind: 'bluray', media_type: 'bluray' } : {}),
status: 'FINISHED', status: 'FINISHED',
last_state: 'FINISHED', last_state: 'FINISHED',
@@ -5191,13 +5080,6 @@ class HistoryService {
`Metadaten aus vorhandenem Job #${sourceJob.id} wiederhergestellt.` `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', { logger.info('job:import-orphan-raw', {
jobId: created.id, jobId: created.id,
+801 -6
View File
@@ -874,7 +874,7 @@ function resolveOutputTemplateValues(job, fallbackJobId = null) {
const DEFAULT_OUTPUT_TEMPLATE = '${title} (${year})/${title} (${year})'; 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_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) { function parseJsonObjectSafe(raw) {
if (!raw) { if (!raw) {
@@ -1034,7 +1034,10 @@ function stripSeriesEpisodePartSuffix(value) {
if (!source) { if (!source) {
return ''; 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 normalized = source;
let changed = false; let changed = false;
for (let i = 0; i < 2; i += 1) { 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 = {}) { function parseHandBrakeSelectedTitleInfo(scanJson, options = {}) {
const titleList = pickScanTitleList(scanJson); const titleList = pickScanTitleList(scanJson);
if (!Array.isArray(titleList) || titleList.length === 0) { 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) { function hasBluRayBackupStructure(rawPath) {
if (!rawPath) { if (!rawPath) {
return false; 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) { async analyzeDisc(devicePath = null) {
this.ensureNotBusy('analyze'); this.ensureNotBusy('analyze');
logger.info('analyze:start', { devicePath }); logger.info('analyze:start', { devicePath });
@@ -13908,7 +14407,18 @@ class PipelineService extends EventEmitter {
|| '' || ''
).trim(); ).trim();
const existingRawPath = findExistingRawDirectory(rawBaseDir, metadataBase); 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) { if (existingRawPath) {
const existingRawState = resolveRawFolderStateFromPath(existingRawPath); const existingRawState = resolveRawFolderStateFromPath(existingRawPath);
const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE const renameState = existingRawState === RAW_FOLDER_STATES.INCOMPLETE
@@ -13943,6 +14453,7 @@ class PipelineService extends EventEmitter {
playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null playlistDecision.playlistDecisionRequired && playlistDecision.selectedTitleId === null
); );
const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START'; const nextStatus = requiresManualPlaylistSelection ? 'WAITING_FOR_USER_DECISION' : 'READY_TO_START';
const isRawImportMetadataJob = String(mkInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import';
const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({ const updatedMakemkvInfo = this.withAnalyzeContextMediaProfile({
...mkInfo, ...mkInfo,
@@ -13987,6 +14498,18 @@ class PipelineService extends EventEmitter {
'SYSTEM', 'SYSTEM',
`Vorhandenes RAW-Verzeichnis erkannt: ${existingRawPath}` `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 { } else {
await historyService.appendLog( await historyService.appendLog(
jobId, jobId,
@@ -14009,7 +14532,7 @@ class PipelineService extends EventEmitter {
eta: null, eta: null,
statusText: requiresManualPlaylistSelection statusText: requiresManualPlaylistSelection
? 'waiting_for_manual_playlist_selection' ? 'waiting_for_manual_playlist_selection'
: (existingRawPath : (shouldAutoReviewFromRaw
? 'Metadaten übernommen - vorhandenes RAW erkannt' ? 'Metadaten übernommen - vorhandenes RAW erkannt'
: 'Metadaten übernommen - bereit zum Start'), : 'Metadaten übernommen - bereit zum Start'),
context: { context: {
@@ -14068,11 +14591,11 @@ class PipelineService extends EventEmitter {
); );
} }
if (existingRawPath) { if (shouldAutoReviewFromRaw) {
await historyService.appendLog( await historyService.appendLog(
jobId, jobId,
'SYSTEM', '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); const startResult = await this.startPreparedJob(jobId);
logger.info('metadata:auto-track-review-started', { logger.info('metadata:auto-track-review-started', {
@@ -14085,6 +14608,12 @@ class PipelineService extends EventEmitter {
return historyService.getJobById(jobId); 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( await historyService.appendLog(
jobId, jobId,
'SYSTEM', 'SYSTEM',
@@ -14119,6 +14648,7 @@ class PipelineService extends EventEmitter {
const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json); const preloadedMakemkvInfo = this.safeParseJson(preloadedJob.makemkv_info_json);
const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json); const preloadedEncodePlan = this.safeParseJson(preloadedJob.encode_plan_json);
const isRawImportPreparedJob = String(preloadedMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import';
const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, { const preloadedMediaProfile = this.resolveMediaProfileForJob(preloadedJob, {
makemkvInfo: preloadedMakemkvInfo, makemkvInfo: preloadedMakemkvInfo,
encodePlan: preloadedEncodePlan encodePlan: preloadedEncodePlan
@@ -14174,6 +14704,11 @@ class PipelineService extends EventEmitter {
} }
if (!hasUsableRawInput) { 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. // No raw input yet → will rip from disc. Bypass the encode queue entirely.
return this.startPreparedJob(jobId, { ...options, immediate: true }); return this.startPreparedJob(jobId, { ...options, immediate: true });
} }
@@ -14185,6 +14720,7 @@ class PipelineService extends EventEmitter {
const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json); const jobMakemkvInfo = this.safeParseJson(job.makemkv_info_json);
const jobEncodePlan = this.safeParseJson(job.encode_plan_json); const jobEncodePlan = this.safeParseJson(job.encode_plan_json);
const isRawImportPreparedJob = String(jobMakemkvInfo?.source || '').trim().toLowerCase() === 'orphan_raw_import';
const jobMediaProfile = this.resolveMediaProfileForJob(job, { const jobMediaProfile = this.resolveMediaProfileForJob(job, {
makemkvInfo: jobMakemkvInfo, makemkvInfo: jobMakemkvInfo,
encodePlan: jobEncodePlan encodePlan: jobEncodePlan
@@ -14276,6 +14812,12 @@ class PipelineService extends EventEmitter {
return { started: true, stage: 'ENCODING', reusedRaw: true }; 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, { await historyService.updateJob(jobId, {
status: 'RIPPING', status: 'RIPPING',
last_state: '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, { await historyService.updateJob(jobId, {
status: 'RIPPING', status: 'RIPPING',
last_state: 'RIPPING', last_state: 'RIPPING',
@@ -15726,6 +16274,7 @@ class PipelineService extends EventEmitter {
let dvdHandBrakeScanInputPath = null; let dvdHandBrakeScanInputPath = null;
let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection 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 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) { if (isDvdSource && mediaFiles.length > 0) {
// For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir). // 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')); dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
if (dvdHandBrakeScanJson) { if (dvdHandBrakeScanJson) {
const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson); const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson);
dvdAllTitles = allDvdTitles;
const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines); const seriesScanContextResult = enrichSeriesAnalyzeContextFromScan(effectiveAnalyzeContext, dvdScanLines);
effectiveAnalyzeContext = seriesScanContextResult.analyzeContext; effectiveAnalyzeContext = seriesScanContextResult.analyzeContext;
const wasSeriesDvdReview = effectiveIsSeriesDvdReview; const wasSeriesDvdReview = effectiveIsSeriesDvdReview;
@@ -16118,6 +16668,130 @@ class PipelineService extends EventEmitter {
const seriesTitleIds = normalizeReviewTitleIdList( const seriesTitleIds = normalizeReviewTitleIdList(
dvdScannedCandidates.map((item) => item?.handBrakeTitleId) 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( const selectedReviewTitles = buildSelectedHandBrakeReviewTitles(
dvdHandBrakeScanJson, dvdHandBrakeScanJson,
seriesTitleIds, seriesTitleIds,
@@ -16322,6 +16996,127 @@ class PipelineService extends EventEmitter {
const seriesTitleIds = normalizeReviewTitleIdList( const seriesTitleIds = normalizeReviewTitleIdList(
titleCandidates.map((item) => item?.handBrakeTitleId) 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( const selectedReviewTitles = buildSelectedHandBrakeReviewTitles(
dvdTitleScanJson, dvdTitleScanJson,
seriesTitleIds, seriesTitleIds,
+2 -2
View File
@@ -468,8 +468,8 @@ VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Ser
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) 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);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'); 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})');
-- Tools CD -- Tools CD
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-4", "version": "0.13.1-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-4", "version": "0.13.1-5",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-4", "version": "0.13.1-5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -4,6 +4,7 @@ import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable'; import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column'; import { Column } from 'primereact/column';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { Dropdown } from 'primereact/dropdown';
export default function MetadataSelectionDialog({ export default function MetadataSelectionDialog({
visible, visible,
@@ -36,12 +37,20 @@ export default function MetadataSelectionDialog({
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState(''); const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState(''); const [discValidationError, setDiscValidationError] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb'; const contextMetadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const [selectedMetadataProvider, setSelectedMetadataProvider] = useState(contextMetadataProvider);
const mediaProfile = String( const mediaProfile = String(
context?.mediaProfile context?.mediaProfile
|| context?.selectedMetadata?.mediaProfile || context?.selectedMetadata?.mediaProfile
|| '' || ''
).trim().toLowerCase(); ).trim().toLowerCase();
const metadataProvider = selectedMetadataProvider === 'tmdb' ? 'tmdb' : 'omdb';
const providerOptions = mediaProfile === 'dvd'
? [
{ label: 'Film (OMDb)', value: 'omdb' },
{ label: 'Serie (TMDb)', value: 'tmdb' }
]
: [{ label: contextMetadataProvider === 'tmdb' ? 'TMDb' : 'OMDb', value: contextMetadataProvider }];
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'; const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb'; const supportsExternalIdInput = metadataProvider === 'omdb';
const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile); const requiresDiscNumber = metadataProvider === 'tmdb' && (mediaProfile === 'dvd' || !mediaProfile);
@@ -70,6 +79,7 @@ export default function MetadataSelectionDialog({
setSubmitError(''); setSubmitError('');
setSeasonFilter(''); setSeasonFilter('');
setDiscValidationError(''); setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
}, [visible, context]); }, [visible, context]);
useEffect(() => { useEffect(() => {
@@ -85,7 +95,7 @@ export default function MetadataSelectionDialog({
if (submitError) { if (submitError) {
setSubmitError(''); setSubmitError('');
} }
}, [manualDiscNumber, seasonFilter, selected]); }, [manualDiscNumber, seasonFilter, selected, metadataProvider]);
const rows = useMemo(() => { const rows = useMemo(() => {
const base = Array.isArray(context?.metadataCandidates) const base = Array.isArray(context?.metadataCandidates)
@@ -240,6 +250,25 @@ export default function MetadataSelectionDialog({
modal modal
> >
<div className="search-row"> <div className="search-row">
{providerOptions.length > 1 ? (
<Dropdown
value={metadataProvider}
options={providerOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => {
const nextProvider = String(event?.value || 'omdb').trim().toLowerCase() || 'omdb';
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
}}
disabled={searchBusy || busy}
style={{ minWidth: '12.5rem' }}
/>
) : null}
<InputText <InputText
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
+17 -5
View File
@@ -985,10 +985,10 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
).trim(); ).trim();
const multiTemplateRaw = String( const multiTemplateRaw = String(
settings?.output_template_dvd_series_multi_episode settings?.output_template_dvd_series_multi_episode
|| '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}' || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'
).trim(); ).trim();
const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}';
const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'; const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const template = episodeRange.isMulti ? multiTemplate : singleTemplate; const template = episodeRange.isMulti ? multiTemplate : singleTemplate;
const rendered = renderTemplate(template, { const rendered = renderTemplate(template, {
seriesTitle, seriesTitle,
@@ -1936,6 +1936,13 @@ export default function PipelineStatusCard({
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION' const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired); && Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
const handBrakeDecisionMode = String(pipeline?.context?.handBrakeDecisionMode || '').trim().toLowerCase();
const isSeriesPlayAllDecisionMode = handBrakeDecisionMode === 'series_playall_or_double';
const handBrakeDecisionSummary = pipeline?.context?.handBrakeDecisionSummary
&& typeof pipeline.context.handBrakeDecisionSummary === 'object'
? pipeline.context.handBrakeDecisionSummary
: null;
const ambiguousLongestTitleId = normalizeTitleId(handBrakeDecisionSummary?.longestCandidateTitleId);
const waitingHandBrakeTitleRows = useMemo(() => { const waitingHandBrakeTitleRows = useMemo(() => {
const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates) const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
@@ -2849,7 +2856,7 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && retryJobId && ( {handBrakeTitleDecisionRequired && retryJobId && (
<Button <Button
label="DVD Titel bestätigen" label={isSeriesPlayAllDecisionMode ? 'PlayAll/Doppelfolge bestätigen' : 'DVD Titel bestätigen'}
icon="pi pi-check" icon="pi pi-check"
severity="warning" severity="warning"
outlined outlined
@@ -3077,9 +3084,11 @@ export default function PipelineStatusCard({
{handBrakeTitleDecisionRequired && !queueLocked ? ( {handBrakeTitleDecisionRequired && !queueLocked ? (
<div className="playlist-decision-block"> <div className="playlist-decision-block">
<h3>DVD Titel-Auswahl erforderlich</h3> <h3>{isSeriesPlayAllDecisionMode ? 'Serien-Grenzfall: PlayAll oder Doppelfolge?' : 'DVD Titel-Auswahl erforderlich'}</h3>
<small> <small>
Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen. {isSeriesPlayAllDecisionMode
? 'Grenzfall erkannt: Der längste Titel passt zeitlich zur Summe der kürzeren Episoden. Bitte entscheiden, ob er als PlayAll ausgeschlossen oder als Doppelfolge mit encodiert werden soll.'
: 'Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte einen oder mehrere Episoden-Titel auswählen.'}
</small> </small>
{waitingHandBrakeTitleRows.length > 0 ? ( {waitingHandBrakeTitleRows.length > 0 ? (
<div className="playlist-decision-list"> <div className="playlist-decision-list">
@@ -3102,6 +3111,9 @@ export default function PipelineStatusCard({
/> />
<span> <span>
{`Titel -t ${row.handBrakeTitleId}`} {`Titel -t ${row.handBrakeTitleId}`}
{isSeriesPlayAllDecisionMode && ambiguousLongestTitleId && row.handBrakeTitleId === ambiguousLongestTitleId
? ' | Längster Titel (PlayAll-/Doppelfolge-Kandidat)'
: ''}
{row.durationLabel ? ` | ${row.durationLabel}` : ''} {row.durationLabel ? ` | ${row.durationLabel}` : ''}
{row.audioTrackCount > 0 ? ` | Audio: ${row.audioTrackCount}` : ''} {row.audioTrackCount > 0 ? ` | Audio: ${row.audioTrackCount}` : ''}
{row.subtitleTrackCount > 0 ? ` | Untertitel: ${row.subtitleTrackCount}` : ''} {row.subtitleTrackCount > 0 ? ` | Untertitel: ${row.subtitleTrackCount}` : ''}
+12 -2
View File
@@ -5,6 +5,7 @@ import { Column } from 'primereact/column';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client'; import { api } from '../api/client';
import { confirmModal } from '../utils/confirmModal'; import { confirmModal } from '../utils/confirmModal';
@@ -26,6 +27,7 @@ function formatDetectedMediaType(value) {
} }
export default function DatabasePage() { export default function DatabasePage() {
const navigate = useNavigate();
const [orphanRows, setOrphanRows] = useState([]); const [orphanRows, setOrphanRows] = useState([]);
const [orphanLoading, setOrphanLoading] = useState(false); const [orphanLoading, setOrphanLoading] = useState(false);
const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null); const [orphanImportBusyPath, setOrphanImportBusyPath] = useState(null);
@@ -63,13 +65,21 @@ export default function DatabasePage() {
try { try {
const response = await api.importOrphanRawFolder(row.rawPath); const response = await api.importOrphanRawFolder(row.rawPath);
const newJobId = response?.job?.id; const newJobId = response?.job?.id;
const activationStarted = Boolean(response?.activation?.started);
toastRef.current?.show({ toastRef.current?.show({
severity: 'success', severity: 'success',
summary: 'Job angelegt', summary: activationStarted ? 'Analyse gestartet' : 'Job angelegt',
detail: newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.', detail: activationStarted
? (newJobId
? `Job #${newJobId} läuft jetzt im Ripper-View.`
: 'Job läuft jetzt im Ripper-View.')
: (newJobId ? `Historieneintrag #${newJobId} erstellt.` : 'Historieneintrag wurde erstellt.'),
life: 3500 life: 3500
}); });
await loadOrphans(); await loadOrphans();
if (activationStarted) {
navigate('/ripper');
}
} catch (error) { } catch (error) {
toastRef.current?.show({ toastRef.current?.show({
severity: 'error', severity: 'error',
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.13.1-4", "version": "0.13.1-5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.13.1-4", "version": "0.13.1-5",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.13.1-4", "version": "0.13.1-5",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",