0.13.1-5 Adjust Detection
This commit is contained in:
Generated
+2
-2
@@ -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,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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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)}
|
||||||
|
|||||||
@@ -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}` : ''}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user