0.15.0-3 Misc Fixes
This commit is contained in:
@@ -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}'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user