0.15.0-3 Misc Fixes

This commit is contained in:
2026-04-28 09:18:56 +00:00
parent 730bc50715
commit ff8a8f67bf
19 changed files with 834 additions and 157 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"private": true,
"type": "commonjs",
"scripts": {
+20
View File
@@ -941,6 +941,26 @@ async function migrateSettingsSchemaMetadata(db) {
});
}
const metadataReadyNotifyLabel = 'Bei manueller Auswahl senden';
const metadataReadyNotifyDescription = 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).';
const metadataReadyNotifyResult = await db.run(
`UPDATE settings_schema
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE key = 'pushover_notify_metadata_ready' AND (label != ? OR description != ?)`,
[
metadataReadyNotifyLabel,
metadataReadyNotifyDescription,
metadataReadyNotifyLabel,
metadataReadyNotifyDescription
]
);
if (metadataReadyNotifyResult?.changes > 0) {
logger.info('migrate:settings-schema-metadata-ready-notify-updated', {
key: 'pushover_notify_metadata_ready',
label: metadataReadyNotifyLabel
});
}
const dvdSeriesMultiEpisodeTemplateDefault = '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const legacyDvdSeriesMultiEpisodeTemplateDefaults = [
'{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeRange}'
+5 -2
View File
@@ -230,6 +230,7 @@ class VideoDiscPlugin extends SourcePlugin {
selectedTitleId = null,
backupOutputBase = null,
disableMinLengthFilter = false,
sourceArgOverride = null,
isCancelled,
onProcessHandle
} = ctx.extra || {};
@@ -249,7 +250,8 @@ class VideoDiscPlugin extends SourcePlugin {
mediaProfile: this.mediaProfile,
settingsMap: settings,
backupOutputBase,
disableMinLengthFilter
disableMinLengthFilter,
sourceArgOverride
});
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
@@ -262,7 +264,8 @@ class VideoDiscPlugin extends SourcePlugin {
ripMode,
rawJobDir,
selectedTitleId,
disableMinLengthFilter
disableMinLengthFilter,
sourceArgOverride: sourceArgOverride || null
});
ctx.emitProgress(0, ripMode === 'backup'
+278 -61
View File
@@ -2558,6 +2558,96 @@ function deleteFilesRecursively(rootPath, keepRoot = true) {
return result;
}
function collectFilesRecursively(rootPath) {
const normalizedRoot = normalizeComparablePath(rootPath);
if (!normalizedRoot) {
return [];
}
const result = [];
const visit = (currentPath) => {
let stat = null;
try {
stat = fs.lstatSync(currentPath);
} catch (_error) {
return;
}
if (stat.isDirectory()) {
let entries = [];
try {
entries = fs.readdirSync(currentPath, { withFileTypes: true });
} catch (_error) {
return;
}
for (const entry of entries) {
visit(path.join(currentPath, entry.name));
}
return;
}
result.push(normalizeComparablePath(currentPath));
};
visit(normalizedRoot);
return Array.from(new Set(result.filter(Boolean))).sort((left, right) => left.localeCompare(right, 'de'));
}
function removeEmptyParentDirectories(startPath, options = {}) {
const start = normalizeComparablePath(startPath);
if (!start) {
return [];
}
const protectedRoots = new Set(
(Array.isArray(options?.protectedRoots) ? options.protectedRoots : [])
.map((entry) => normalizeComparablePath(entry))
.filter(Boolean)
);
const allowedRoots = Array.from(protectedRoots);
const removed = [];
const removedSet = new Set();
let current = start;
while (current && !isFilesystemRootPath(current)) {
if (protectedRoots.has(current)) {
break;
}
if (allowedRoots.length > 0 && !allowedRoots.some((rootPath) => isPathInside(rootPath, current))) {
break;
}
let stat = null;
try {
stat = fs.lstatSync(current);
} catch (_error) {
break;
}
if (!stat.isDirectory()) {
break;
}
let entries = [];
try {
entries = fs.readdirSync(current);
} catch (_error) {
break;
}
if (entries.length > 0) {
break;
}
try {
fs.rmdirSync(current);
if (!removedSet.has(current)) {
removedSet.add(current);
removed.push(current);
}
} catch (_error) {
break;
}
const parentDir = normalizeComparablePath(path.dirname(current));
if (!parentDir || parentDir === current) {
break;
}
current = parentDir;
}
return removed;
}
function normalizeJobIdValue(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -6378,7 +6468,6 @@ class HistoryService {
}
const artifactMoviePathSet = new Set(artifactMoviePaths);
const includeMovieParentCandidate = resolvedPaths?.mediaType !== 'converter';
for (const outputPath of explicitMoviePaths) {
addCandidate(
movieCandidates,
@@ -6387,16 +6476,6 @@ class HistoryService {
artifactMoviePathSet.has(outputPath) ? 'lineage_output_path' : 'output_path',
movieAllowedPaths
);
const parentDir = toNormalizedPath(path.dirname(outputPath));
if (includeMovieParentCandidate && parentDir && !movieRoots.includes(parentDir)) {
addCandidate(
movieCandidates,
'movie',
parentDir,
artifactMoviePathSet.has(outputPath) ? 'lineage_output_parent' : 'output_parent',
movieAllowedPaths
);
}
}
if (normalizedJobId && resolvedPaths?.mediaType !== 'cd') {
@@ -6505,28 +6584,117 @@ class HistoryService {
return normalizedSources.includes('output_path') || normalizedSources.includes('lineage_output_path');
};
const buildList = (target) => Array.from(candidateMap.values())
.filter((row) => row.target === target)
.map((row) => {
const buildList = (target) => {
const rows = Array.from(candidateMap.values())
.filter((row) => row.target === target);
if (target !== 'movie') {
return rows
.map((row) => {
const inspection = inspectDeletionPath(row.path);
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
return {
target,
path: row.path,
exists: Boolean(inspection.exists),
isDirectory: Boolean(inspection.isDirectory),
isFile: Boolean(inspection.isFile),
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
sources
};
})
.filter(Boolean)
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
}
const moviePathMap = new Map();
const upsertMoviePath = ({ path: candidatePath, exists, isDirectory, isFile, jobIds, sources }) => {
const normalizedPath = normalizeComparablePath(candidatePath);
if (!normalizedPath) {
return;
}
if (!moviePathMap.has(normalizedPath)) {
moviePathMap.set(normalizedPath, {
target: 'movie',
path: normalizedPath,
exists: Boolean(exists),
isDirectory: Boolean(isDirectory),
isFile: Boolean(isFile),
jobIds: new Set(),
sources: new Set()
});
}
const row = moviePathMap.get(normalizedPath);
row.exists = row.exists || Boolean(exists);
row.isDirectory = row.isDirectory || Boolean(isDirectory);
row.isFile = row.isFile || Boolean(isFile);
for (const jobId of (Array.isArray(jobIds) ? jobIds : [])) {
const normalizedJobId = normalizeJobIdValue(jobId);
if (normalizedJobId) {
row.jobIds.add(normalizedJobId);
}
}
for (const source of (Array.isArray(sources) ? sources : [])) {
const normalizedSource = String(source || '').trim();
if (normalizedSource) {
row.sources.add(normalizedSource);
}
}
};
for (const row of rows) {
const inspection = inspectDeletionPath(row.path);
const sources = Array.from(row.sources).sort((left, right) => left.localeCompare(right));
// Do not expose stale output file paths from DB/lineage in delete preview.
// They cannot be deleted anyway and show up as "ghost paths" in UI/QA checks.
if (target === 'movie' && !inspection.exists && hasTrackedOutputSource(sources)) {
return null;
// Do not expose stale output paths from DB/lineage in delete preview.
if (!inspection.exists && hasTrackedOutputSource(sources)) {
continue;
}
return {
target,
path: row.path,
exists: Boolean(inspection.exists),
isDirectory: Boolean(inspection.isDirectory),
isFile: Boolean(inspection.isFile),
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
if (!inspection.exists) {
upsertMoviePath({
path: row.path,
exists: false,
isDirectory: false,
isFile: true,
jobIds: Array.from(row.jobIds),
sources
});
continue;
}
if (inspection.isDirectory) {
const files = collectFilesRecursively(inspection.path);
for (const filePath of files) {
upsertMoviePath({
path: filePath,
exists: true,
isDirectory: false,
isFile: true,
jobIds: Array.from(row.jobIds),
sources
});
}
continue;
}
upsertMoviePath({
path: inspection.path,
exists: true,
isDirectory: false,
isFile: true,
jobIds: Array.from(row.jobIds),
sources
};
})
.filter(Boolean)
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
});
}
return Array.from(moviePathMap.values())
.map((row) => ({
target: row.target,
path: row.path,
exists: Boolean(row.exists),
isDirectory: false,
isFile: true,
jobIds: Array.from(row.jobIds).sort((left, right) => left - right),
sources: Array.from(row.sources).sort((left, right) => left.localeCompare(right))
}))
.sort((left, right) => String(left.path || '').localeCompare(String(right.path || ''), 'de'));
};
return {
pathCandidates: {
@@ -6650,7 +6818,7 @@ class HistoryService {
if (targetKey === 'raw' && selectedRawPathFilter.size > 0) {
summary[targetKey].reason = 'Keine ausgewählten RAW-Dateien/Ordner gefunden.';
} else if (targetKey === 'movie' && selectedMoviePathFilter.size > 0) {
summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Ordner gefunden.';
summary[targetKey].reason = 'Keine ausgewählten Audio/Video-Dateien gefunden.';
} else {
summary[targetKey].reason = 'Keine passenden Dateien/Ordner gefunden.';
}
@@ -6678,6 +6846,9 @@ class HistoryService {
}
if (inspection.isDirectory) {
if (targetKey === 'movie') {
continue;
}
const keepRoot = protectedRoots.has(inspection.path);
const result = deleteFilesRecursively(inspection.path, keepRoot);
const filesDeleted = Number(result?.filesDeleted || 0);
@@ -6709,6 +6880,23 @@ class HistoryService {
keepRoot: false,
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
});
if (targetKey === 'movie') {
const parentDir = normalizeComparablePath(path.dirname(inspection.path));
const removedParentDirs = removeEmptyParentDirectories(parentDir, {
protectedRoots: Array.from(protectedRoots)
});
for (const removedPath of removedParentDirs) {
summary[targetKey].dirsRemoved += 1;
summary.deletedPaths.push({
target: targetKey,
path: removedPath,
type: 'directory',
keepRoot: false,
jobIds: Array.isArray(candidate?.jobIds) ? candidate.jobIds : []
});
}
}
}
summary[targetKey].deleted = summary[targetKey].pathsDeleted > 0
@@ -6810,37 +6998,9 @@ class HistoryService {
));
if (summary.movie?.deleted) {
const movieDeleteEntries = (Array.isArray(summary?.deletedPaths) ? summary.deletedPaths : [])
.filter((entry) => String(entry?.target || '').trim().toLowerCase() === 'movie')
.map((entry) => ({
path: normalizeComparablePath(entry?.path),
type: String(entry?.type || '').trim().toLowerCase()
}))
.filter((entry) => Boolean(entry.path));
const previewMoviePaths = (Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [])
.map((candidate) => normalizeComparablePath(candidate?.path))
.filter(Boolean);
const cleanupPaths = new Set();
for (const deletedEntry of movieDeleteEntries) {
cleanupPaths.add(deletedEntry.path);
for (const candidatePath of previewMoviePaths) {
if (deletedEntry.type === 'directory') {
if (isPathInside(deletedEntry.path, candidatePath)) {
cleanupPaths.add(candidatePath);
}
} else if (candidatePath === deletedEntry.path) {
cleanupPaths.add(candidatePath);
}
}
}
for (const deletedPath of cleanupPaths) {
await this.removeJobOutputFolderFromJobs(
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId],
deletedPath
);
}
await this.removeMissingJobOutputFoldersFromJobs(
relatedJobIds.length > 0 ? relatedJobIds : [normalizedJobId]
);
}
await this.appendLog(
@@ -7340,6 +7500,57 @@ class HistoryService {
}
}
async removeMissingJobOutputFoldersFromJobs(jobIds = []) {
const normalizedJobIds = Array.isArray(jobIds)
? jobIds
.map((value) => normalizeJobIdValue(value))
.filter(Boolean)
: [];
if (normalizedJobIds.length === 0) {
return { removed: 0, removedPaths: [] };
}
const db = await getDb();
const placeholders = normalizedJobIds.map(() => '?').join(', ');
const rows = await db.all(
`SELECT id, output_path FROM job_output_folders WHERE job_id IN (${placeholders})`,
normalizedJobIds
);
const removedPaths = [];
for (const row of (Array.isArray(rows) ? rows : [])) {
const normalizedPath = normalizeComparablePath(row?.output_path);
let removeEntry = false;
if (!normalizedPath) {
removeEntry = true;
} else if (!fs.existsSync(normalizedPath)) {
removeEntry = true;
} else {
try {
const stat = fs.lstatSync(normalizedPath);
if (stat.isDirectory()) {
const entries = fs.readdirSync(normalizedPath);
if (entries.length === 0) {
fs.rmdirSync(normalizedPath);
removeEntry = true;
}
}
} catch (_error) {
removeEntry = true;
}
}
if (!removeEntry) {
continue;
}
await db.run('DELETE FROM job_output_folders WHERE id = ?', [Number(row.id)]);
if (normalizedPath) {
removedPaths.push(normalizedPath);
}
}
return {
removed: removedPaths.length,
removedPaths: Array.from(new Set(removedPaths)).sort((left, right) => left.localeCompare(right, 'de'))
};
}
async deleteSpecificOutputFolders(jobId, folderPaths = []) {
const paths = Array.isArray(folderPaths) ? folderPaths.filter((p) => typeof p === 'string' && p.trim()) : [];
if (paths.length === 0) return { deleted: [], failed: [] };
@@ -7621,6 +7832,9 @@ class HistoryService {
selectedRawPaths: selectedRawPathsForDelete,
selectedMoviePaths: selectedMoviePathsForDelete
});
if (fileSummary?.movie?.deleted) {
await this.removeMissingJobOutputFoldersFromJobs([normalizedJobId]);
}
}
const db = await getDb();
@@ -7798,6 +8012,9 @@ class HistoryService {
selectedRawPaths: selectedRawPathsForDelete,
selectedMoviePaths: selectedMoviePathsForDelete
});
if (fileSummary?.movie?.deleted) {
await this.removeMissingJobOutputFoldersFromJobs(deleteJobIds);
}
}
await db.exec('BEGIN');
+84 -57
View File
@@ -23,7 +23,7 @@ const logger = require('./logger').child('PIPELINE');
const { spawnTrackedProcess } = require('./processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress, parseMkvmergeProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
const { buildMediainfoReview } = require('../utils/encodePlan');
const { buildMediainfoReview, refreshAudioTrackActionsForPlanTitles } = require('../utils/encodePlan');
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
const { errorToMeta } = require('../utils/errorMeta');
const userPresetService = require('./userPresetService');
@@ -242,11 +242,32 @@ function resolveDiscNumberFromJobLike(job = null) {
);
}
function normalizeContextJobId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function resolveSelectedMetadataForJob(job = null, analyzeContext = null, activeContext = null) {
const jobId = normalizeContextJobId(job?.id ?? job?.jobId ?? null);
const activeContextJobId = normalizeContextJobId(
activeContext?.jobId
?? activeContext?.activeJobId
?? null
);
const allowActiveContextMetadata = (
jobId === null
? true
: (activeContextJobId !== null && activeContextJobId === jobId)
);
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: {};
const activeSelectedMetadata = activeContext?.selectedMetadata && typeof activeContext.selectedMetadata === 'object'
const activeSelectedMetadata = allowActiveContextMetadata
&& activeContext?.selectedMetadata
&& typeof activeContext.selectedMetadata === 'object'
? activeContext.selectedMetadata
: {};
const merged = {
@@ -256,7 +277,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active
const workflowKind = normalizeDvdMetadataWorkflowKind(
merged.workflowKind
|| analyzeContext?.workflowKind
|| activeContext?.workflowKind
|| (allowActiveContextMetadata ? activeContext?.workflowKind : null)
|| null
);
@@ -270,7 +291,7 @@ function resolveSelectedMetadataForJob(job = null, analyzeContext = null, active
metadataProvider: String(
merged.metadataProvider
|| analyzeContext?.metadataProvider
|| activeContext?.metadataProvider
|| (allowActiveContextMetadata ? activeContext?.metadataProvider : null)
|| 'omdb'
).trim().toLowerCase() || 'omdb'
};
@@ -11382,7 +11403,17 @@ class PipelineService extends EventEmitter {
.filter((row) => Number(row?.id) > 0 && !this.isContainerHistoryJob(row))
.filter((row) => {
if (isSeriesContainer) {
return !isSeriesBatchEpisodeSubJobRow(row);
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object'
? row.encodePlan
: this.safeParseJson(row?.encode_plan_json);
const rowJobKind = String(row?.job_kind || row?.jobKind || '').trim().toLowerCase();
const isSeriesEpisodeSubJob = (
isSeriesBatchChildPlan(encodePlan)
|| Boolean(encodePlan?.seriesBatchVirtualEpisode)
|| rowJobKind === 'dvd_series_episode'
|| rowJobKind === 'dvd_series_virtual_episode'
);
return !isSeriesEpisodeSubJob;
}
if (isMultipartContainer) {
return this.isMultipartMovieDiscChildHistoryJob(row) || this.isMultipartMovieMergeHistoryJob(row);
@@ -16035,7 +16066,7 @@ class PipelineService extends EventEmitter {
try {
let effectiveDetectedTitle = detectedTitle;
let omdbCandidates = null;
let omdbCandidates = [];
let metadataCandidates = null;
let metadataProvider = 'omdb';
let seriesAnalysis = null;
@@ -16054,9 +16085,6 @@ class PipelineService extends EventEmitter {
if (pluginDetectedTitle) {
effectiveDetectedTitle = pluginDetectedTitle;
}
if (Array.isArray(pluginResult?.omdbCandidates)) {
omdbCandidates = pluginResult.omdbCandidates;
}
logger.info('plugin:analyze:used', {
jobId: normalizedJobId,
pluginId: analyzePlugin.id,
@@ -16076,7 +16104,6 @@ class PipelineService extends EventEmitter {
if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) {
try {
const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
const rawMedia = collectRawMediaCandidates(resolvedRawPath);
const seriesScanInputPath = mediaProfile === 'dvd'
? (resolveDvdScanInputPathFromRaw(resolvedRawPath, rawMedia) || resolvedRawPath)
@@ -16183,21 +16210,6 @@ class PipelineService extends EventEmitter {
seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery
};
}
if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) {
metadataCandidates = await tmdbService.searchSeriesWithSeasons(
seriesLookupHint.query,
{ language: seriesSettings?.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', {
@@ -16220,7 +16232,7 @@ class PipelineService extends EventEmitter {
}
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
omdbCandidates = [];
}
if (metadataProvider !== 'tmdb') {
metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : [];
@@ -16262,8 +16274,8 @@ class PipelineService extends EventEmitter {
normalizedJobId,
'SYSTEM',
metadataProvider === 'tmdb'
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.`
: `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).`
: `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").`
);
await this.setState('METADATA_SELECTION', {
@@ -16294,7 +16306,7 @@ class PipelineService extends EventEmitter {
void this.notifyPushover('metadata_ready', {
title: 'Ripster - Metadaten bereit',
message: `Job #${normalizedJobId}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)`
message: `Job #${normalizedJobId}: Metadaten müssen manuell zugeordnet werden`
});
return {
@@ -16448,7 +16460,7 @@ class PipelineService extends EventEmitter {
try {
let effectiveDetectedTitle = detectedTitle;
let omdbCandidates = null;
let omdbCandidates = [];
let metadataCandidates = null;
let metadataProvider = 'omdb';
let seriesAnalysis = null;
@@ -16466,9 +16478,6 @@ class PipelineService extends EventEmitter {
if (pluginDetectedTitle) {
effectiveDetectedTitle = pluginDetectedTitle;
}
if (Array.isArray(pluginResult?.omdbCandidates)) {
omdbCandidates = pluginResult.omdbCandidates;
}
logger.info('plugin:analyze:used', {
jobId: job.id,
pluginId: analyzePlugin.id,
@@ -16486,7 +16495,6 @@ class PipelineService extends EventEmitter {
}
if (isSeriesDiscMediaProfile(mediaProfile) && analyzePlugin && ['dvd', 'bluray'].includes(analyzePlugin.id)) {
try {
const seriesSettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
const reviewResult = await this.runPluginReviewScan(analyzePlugin, job, {
jobId: job.id,
deviceInfo: deviceWithProfile,
@@ -16586,21 +16594,6 @@ class PipelineService extends EventEmitter {
seriesTitle: String(seriesLookupHint?.seriesTitle || fallbackQuery).trim() || fallbackQuery
};
}
if (seriesPluginResult?.providerConfigured && seriesLookupHint?.query) {
metadataCandidates = await tmdbService.searchSeriesWithSeasons(
seriesLookupHint.query,
{ language: seriesSettings?.dvd_series_language || null }
).catch((error) => {
logger.warn('dvd-series:tmdb-search-failed', {
jobId: job.id,
query: seriesLookupHint?.query || null,
seasonNumber: seriesLookupHint?.seasonNumber || null,
error: errorToMeta(error)
});
return [];
});
}
}
logger.info('dvd-series:analyze:done', {
@@ -16621,7 +16614,7 @@ class PipelineService extends EventEmitter {
}
}
if (metadataProvider !== 'tmdb' && !Array.isArray(omdbCandidates)) {
omdbCandidates = await omdbService.search(effectiveDetectedTitle).catch(() => []);
omdbCandidates = [];
}
if (metadataProvider !== 'tmdb') {
metadataCandidates = Array.isArray(omdbCandidates) ? omdbCandidates : [];
@@ -16662,8 +16655,8 @@ class PipelineService extends EventEmitter {
job.id,
'SYSTEM',
metadataProvider === 'tmdb'
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. TMDb-Metadaten-Suche vorbereitet mit Query "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? ` und Staffel ${seriesLookupHint.seasonNumber}` : ''}.`
: `Disk erkannt. Metadaten-Suche vorbereitet mit Query "${effectiveDetectedTitle}".`
? `Serien-${resolveSeriesDiscLabel(mediaProfile)} erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${seriesLookupHint?.query || effectiveDetectedTitle}"${seriesLookupHint?.seasonNumber ? `, Staffel ${seriesLookupHint.seasonNumber}` : ''}).`
: `Disk erkannt. Metadaten müssen manuell zugeordnet werden (Suchvorschlag: "${effectiveDetectedTitle}").`
);
const keepCurrentPipelineSession = this._hasForeignInteractiveSession(job.id);
@@ -16705,7 +16698,7 @@ class PipelineService extends EventEmitter {
void this.notifyPushover('metadata_ready', {
title: 'Ripster - Metadaten bereit',
message: `Job #${job.id}: ${effectiveDetectedTitle} (${Array.isArray(metadataCandidates) ? metadataCandidates.length : 0} ${metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb'} Treffer)`
message: `Job #${job.id}: Metadaten müssen manuell zugeordnet werden`
});
return {
@@ -21500,6 +21493,25 @@ class PipelineService extends EventEmitter {
throw error;
}
const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
const actionDisplaySettings = {
...(confirmSettings && typeof confirmSettings === 'object' ? confirmSettings : {}),
handbrake_preset: String(
resolvedUserPreset?.handbrakePreset
|| confirmSettings?.handbrake_preset
|| ''
).trim() || null,
handbrake_extra_args: String(
resolvedUserPreset?.extraArgs
|| confirmSettings?.handbrake_extra_args
|| ''
).trim()
};
confirmedPlan.titles = refreshAudioTrackActionsForPlanTitles(
confirmedPlan.titles,
actionDisplaySettings,
null
);
const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings(
confirmSettings,
readyMediaProfile,
@@ -25168,6 +25180,7 @@ class PipelineService extends EventEmitter {
selectedTitleId: effectiveSelectedTitleId,
disableMinLengthFilter,
backupOutputBase,
sourceArgOverride: null,
runCommand: this.runCommand.bind(this)
});
makemkvInfo = await ripPlugin.rip(job, ripCtx);
@@ -25325,7 +25338,6 @@ class PipelineService extends EventEmitter {
// Retry starts processing immediately (Rip/Merge) and bypasses the encode queue.
return this.retry(jobId, { ...options, immediate: true });
}
const resolvedRetryJob = await this.resolveExistingJobForAction(jobId, 'retry');
jobId = resolvedRetryJob.resolvedJobId;
this.ensureNotBusy('retry', jobId);
@@ -25357,7 +25369,9 @@ class PipelineService extends EventEmitter {
const isCdRetry = mediaProfile === 'cd';
const isAudiobookRetry = mediaProfile === 'audiobook';
const isMultipartMergeRetry = sourceJobKind === 'multipart_movie_merge';
const isVideoDiscRetry = mediaProfile === 'dvd' || mediaProfile === 'bluray';
const sourceParentJobId = this.normalizeQueueJobId(sourceJob?.parent_job_id);
let mergeContainerJobId = null;
if (isMultipartMergeRetry) {
mergeContainerJobId = this.normalizeQueueJobId(sourceEncodePlan?.containerJobId);
@@ -25391,12 +25405,21 @@ class PipelineService extends EventEmitter {
}
// CD-Jobs dürfen auch im FINISHED-Status neu gerippt werden.
const retryable = ['ERROR', 'CANCELLED'].includes(sourceStatus)
const retryableByStatus = ['ERROR', 'CANCELLED'].includes(sourceStatus)
|| ['ERROR', 'CANCELLED'].includes(sourceLastState)
|| (isCdRetry && ['FINISHED', 'ERROR', 'CANCELLED'].includes(sourceStatus));
const incompleteRipRetryableStates = ['WAITING_FOR_USER_DECISION', 'READY_TO_ENCODE', 'MEDIAINFO_CHECK'];
const retryableByIncompleteVideoRipState = isVideoDiscRetry
&& !isMultipartMergeRetry
&& (
incompleteRipRetryableStates.includes(sourceStatus)
|| incompleteRipRetryableStates.includes(sourceLastState)
);
const retryable = retryableByStatus || retryableByIncompleteVideoRipState;
if (!retryable) {
const currentStateLabel = sourceStatus || sourceLastState || '-';
const error = new Error(
`Retry nicht möglich: Job ${jobId} ist nicht im Status ERROR/CANCELLED (aktuell ${sourceStatus || sourceLastState || '-'}).`
`Retry nicht möglich: Job ${jobId} ist nicht in einem retry-fähigen Status (aktuell ${currentStateLabel}).`
);
error.statusCode = 409;
throw error;
@@ -25690,7 +25713,11 @@ class PipelineService extends EventEmitter {
});
} else {
this.startRipEncode(retryJobId).catch((error) => {
logger.error('retry:background-failed', { jobId: retryJobId, sourceJobId: jobId, error: errorToMeta(error) });
logger.error('retry:background-failed', {
jobId: retryJobId,
sourceJobId: jobId,
error: errorToMeta(error)
});
});
}
+4 -1
View File
@@ -1176,7 +1176,8 @@ class SettingsService {
const ripMode = String(map.makemkv_rip_mode || 'mkv').trim().toLowerCase() === 'backup'
? 'backup'
: 'mkv';
const sourceArg = this.resolveSourceArg(map, deviceInfo);
const sourceArgOverride = String(options?.sourceArgOverride || '').trim();
const sourceArg = sourceArgOverride || this.resolveSourceArg(map, deviceInfo);
const rawSelectedTitleId = normalizeNonNegativeInteger(options?.selectedTitleId);
const disableMinLengthFilter = Boolean(options?.disableMinLengthFilter);
const parsedExtra = splitArgs(map.makemkv_rip_extra_args);
@@ -1210,6 +1211,7 @@ class SettingsService {
if (hasExplicitTitle) {
baseArgs = [
'-r', '--progress=-same',
'--decrypt',
'mkv',
sourceArg,
targetTitle,
@@ -1221,6 +1223,7 @@ class SettingsService {
: [];
baseArgs = [
'-r', '--progress=-same',
'--decrypt',
...minLengthArgs,
'mkv',
sourceArg,
+62 -1
View File
@@ -1243,6 +1243,66 @@ function computeAudioTrackActions(track, selectedIndex, selector) {
};
}
function refreshAudioTrackActionsForPlanTitles(titles, settings = {}, presetProfile = null) {
const sourceTitles = Array.isArray(titles) ? titles : [];
if (sourceTitles.length === 0) {
return sourceTitles;
}
const selectors = buildTrackSelectors(settings || {}, presetProfile || null);
const audioSelector = selectors?.audio && typeof selectors.audio === 'object'
? selectors.audio
: {};
return sourceTitles.map((title) => {
const audioTracks = Array.isArray(title?.audioTracks) ? title.audioTracks : [];
if (audioTracks.length === 0) {
return title;
}
const selectedTracks = audioTracks.filter((track) => Boolean(track?.selectedForEncode));
const selectedIndexById = new Map(
selectedTracks
.map((track, index) => {
const trackId = Number(track?.id);
if (!Number.isFinite(trackId)) {
return null;
}
return [Math.trunc(trackId), index];
})
.filter(Boolean)
);
const nextAudioTracks = audioTracks.map((track) => {
if (!track?.selectedForEncode) {
return {
...track,
encodeActions: [],
encodeActionSummary: 'Nicht übernommen'
};
}
const numericTrackId = Number(track?.id);
const normalizedTrackId = Number.isFinite(numericTrackId) ? Math.trunc(numericTrackId) : null;
const selectedIndex = (normalizedTrackId !== null && selectedIndexById.has(normalizedTrackId))
? selectedIndexById.get(normalizedTrackId)
: 0;
const actions = computeAudioTrackActions(track, selectedIndex, audioSelector);
return {
...track,
encodePreviewActions: actions.actions,
encodePreviewSummary: actions.summary,
encodeActions: actions.actions,
encodeActionSummary: actions.summary
};
});
return {
...title,
audioTracks: nextAudioTracks
};
});
}
function computeSubtitleFlags(trackId, selectedTrackIds, selector) {
const selected = selectedTrackIds.includes(trackId);
if (!selected) {
@@ -1526,5 +1586,6 @@ function buildMediainfoReview({
module.exports = {
parseDurationSeconds,
buildMediainfoReview
buildMediainfoReview,
refreshAudioTrackActionsForPlanTitles
};