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
};
+1 -1
View File
@@ -647,7 +647,7 @@ VALUES ('pushover_timeout_ms', 'Benachrichtigungen', 'PushOver Timeout (ms)', 'n
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_timeout_ms', '7000');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei Metadaten-Auswahl senden', 'boolean', 1, 'Sendet wenn Metadaten zur Auswahl bereitstehen.', 'true', '[]', '{}', 570);
VALUES ('pushover_notify_metadata_ready', 'Benachrichtigungen', 'Bei manueller Auswahl senden', 'boolean', 1, 'Sendet, wenn ein Job eine manuelle Auswahl/Entscheidung benötigt (z.B. Metadaten, Titel oder Playlist).', 'true', '[]', '{}', 570);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('pushover_notify_metadata_ready', 'true');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.15.0-2",
"version": "0.15.0-3",
"private": true,
"type": "module",
"scripts": {
+153 -18
View File
@@ -184,6 +184,78 @@ function trackSizeFormat(bytes) {
return `${size.toFixed(2)} ${units[i]}`;
}
function resolveManualTrackSelectionForTitle(encodePlan, titleId) {
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const normalizedTitleId = normalizePositiveInteger(titleId);
if (!normalizedTitleId) {
return null;
}
const byTitle = plan?.manualTrackSelectionByTitle && typeof plan.manualTrackSelectionByTitle === 'object'
? plan.manualTrackSelectionByTitle
: {};
const directSelection = byTitle[normalizedTitleId] || byTitle[String(normalizedTitleId)] || null;
if (directSelection && typeof directSelection === 'object') {
return directSelection;
}
const fallbackSelection = plan?.manualTrackSelection && typeof plan.manualTrackSelection === 'object'
? plan.manualTrackSelection
: null;
if (!fallbackSelection) {
return null;
}
const fallbackTitleId = normalizePositiveInteger(fallbackSelection?.titleId);
return fallbackTitleId === normalizedTitleId ? fallbackSelection : null;
}
function buildEffectiveTitleTrackSelection(encodePlan, title) {
const manualSelection = resolveManualTrackSelectionForTitle(encodePlan, title?.id);
const hasManualAudio = Array.isArray(manualSelection?.audioTrackIds);
const hasManualSubtitle = Array.isArray(manualSelection?.subtitleTrackIdsOrdered) || Array.isArray(manualSelection?.subtitleTrackIds);
const selectedAudioIds = hasManualAudio
? normalizeIdList(manualSelection.audioTrackIds)
: normalizeIdList((Array.isArray(title?.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedForEncode))
.map((track) => track?.id));
const selectedSubtitleIds = hasManualSubtitle
? normalizeIdList(
Array.isArray(manualSelection?.subtitleTrackIdsOrdered) && manualSelection.subtitleTrackIdsOrdered.length > 0
? manualSelection.subtitleTrackIdsOrdered
: manualSelection?.subtitleTrackIds
)
: normalizeIdList((Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedForEncode))
.map((track) => track?.id));
return {
selectedAudioSet: new Set(selectedAudioIds.map((id) => String(id))),
selectedSubtitleSet: new Set(selectedSubtitleIds.map((id) => String(id))),
hasManualAudio,
hasManualSubtitle
};
}
function getTrackActionLabel({
selected = false,
summary = '',
manualSelection = false,
fallback = 'Übernehmen'
}) {
if (!selected) {
return 'Nicht übernommen';
}
const rawSummary = String(summary || '').trim();
if (!rawSummary) {
return manualSelection ? `${fallback} (manuell)` : fallback;
}
if (/^nicht übernommen/i.test(rawSummary)) {
return manualSelection ? `${fallback} (manuell)` : fallback;
}
if (/^preset-default\b/i.test(rawSummary) || /Preset-Default \(HandBrake\)/i.test(rawSummary)) {
return manualSelection ? `${fallback} (manuell)` : 'Übernehmen';
}
return rawSummary;
}
function buildExecutedHandBrakeCommand(handbrakeInfo) {
const cmd = String(handbrakeInfo?.cmd || '').trim();
const args = Array.isArray(handbrakeInfo?.args) ? handbrakeInfo.args : [];
@@ -1378,6 +1450,7 @@ export default function JobDetailDialog({
const selectedEncodeTitles = encodePlanTitles.filter(
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
);
const trackDisplayTitles = selectedEncodeTitles.length > 0 ? selectedEncodeTitles : encodePlanTitles;
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const posterUrl = String(
job?.poster_url
@@ -1390,7 +1463,41 @@ export default function JobDetailDialog({
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const outputFolders = (() => {
const baseFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
if (!isDiskContainer) {
return baseFolders;
}
const merged = [];
const seen = new Set();
const addFolder = (folder, fallbackId = null) => {
const outputPath = String(folder?.output_path || '').trim();
if (!outputPath || seen.has(outputPath)) {
return;
}
seen.add(outputPath);
merged.push({
id: folder?.id ?? fallbackId ?? outputPath,
output_path: outputPath
});
};
for (const folder of baseFolders) {
addFolder(folder);
}
for (const child of childJobs) {
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
for (const childFolder of childOutputFolders) {
addFolder(childFolder, `child-folder-${child?.id || 'x'}-${String(childFolder?.output_path || '')}`);
}
if (child?.output_path) {
addFolder(
{ output_path: child.output_path },
`child-output-${child?.id || 'x'}-${String(child.output_path || '')}`
);
}
}
return merged;
})();
const mergeOutputPathCandidates = new Set(
[
...(isMultipartMergeJob ? [job?.output_path] : []),
@@ -2083,7 +2190,7 @@ export default function JobDetailDialog({
</div>
) : null}
{/* Spurauswahl read-only */}
{selectedEncodeTitles.length > 0 ? (
{trackDisplayTitles.length > 0 ? (
isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
@@ -2091,9 +2198,10 @@ export default function JobDetailDialog({
</summary>
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
{trackDisplayTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title);
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
@@ -2109,9 +2217,16 @@ export default function JobDetailDialog({
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedAudioSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.encodeActionSummary || track.encodePreviewSummary,
manualSelection: trackSelection.hasManualAudio,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -2128,10 +2243,16 @@ export default function JobDetailDialog({
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.subtitleActionSummary || track.subtitlePreviewSummary,
manualSelection: trackSelection.hasManualSubtitle,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -2150,9 +2271,10 @@ export default function JobDetailDialog({
) : (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
{trackDisplayTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const trackSelection = buildEffectiveTitleTrackSelection(job?.encodePlan, title);
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
@@ -2168,9 +2290,16 @@ export default function JobDetailDialog({
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedAudioSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.encodeActionSummary || track.encodePreviewSummary,
manualSelection: trackSelection.hasManualAudio,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -2187,10 +2316,16 @@ export default function JobDetailDialog({
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
const normalizedTrackId = normalizePositiveInteger(track?.id);
const selected = normalizedTrackId
? trackSelection.selectedSubtitleSet.has(String(normalizedTrackId))
: Boolean(track?.selectedForEncode);
const actionInfo = getTrackActionLabel({
selected,
summary: track.subtitleActionSummary || track.subtitlePreviewSummary,
manualSelection: trackSelection.hasManualSubtitle,
fallback: 'Übernehmen'
});
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
@@ -179,6 +179,85 @@ function resolvePipelineMediaProfile(pipeline, mediaInfoReview) {
return null;
}
function normalizeMakeMkvRipIssueMessage(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function parseMakeMkvRipIssueLine(rawLine) {
const line = String(rawLine || '').trim();
if (!line) {
return null;
}
const sourceMatch = line.match(/^\[[^\]]+\]\s+\[([A-Z0-9_]+)\]\s+/i);
const source = String(sourceMatch?.[1] || '').trim().toUpperCase();
if (source && source !== 'MAKEMKV_RIP') {
return null;
}
const msgCodeMatch = line.match(/\bMSG:(\d+),/i);
const parsedCode = msgCodeMatch ? Number(msgCodeMatch[1]) : null;
const code = Number.isFinite(parsedCode) ? Math.trunc(parsedCode) : null;
const quotedMessageMatch = line.match(/\bMSG:\d+,[^,]*,[^,]*,"((?:[^"\\]|\\.)*)"/i);
const fallback = line
.replace(/^\[[^\]]+\]\s+\[[^\]]+\]\s*/i, '')
.trim();
const rawMessage = quotedMessageMatch?.[1]
? quotedMessageMatch[1].replace(/\\"/g, '"')
: (fallback || line);
const message = normalizeMakeMkvRipIssueMessage(rawMessage);
if (!message) {
return null;
}
const isErrorLine = /\berror\b|\bfail(?:ed|ure)?\b|\bhash check\b|\buncorrectable\b/i.test(message);
if (!isErrorLine) {
return null;
}
return { code, message };
}
function extractMakeMkvRipIssues(makemkvInfo) {
if (!makemkvInfo || typeof makemkvInfo !== 'object') {
return [];
}
const sourceLines = [];
if (Array.isArray(makemkvInfo?.highlights)) {
sourceLines.push(...makemkvInfo.highlights);
}
const stdoutTail = String(makemkvInfo?.stdoutTail || '').trim();
if (stdoutTail) {
sourceLines.push(...stdoutTail.split('\n'));
}
const stderrTail = String(makemkvInfo?.stderrTail || '').trim();
if (stderrTail) {
sourceLines.push(...stderrTail.split('\n'));
}
const seen = new Set();
const issues = [];
for (const line of sourceLines) {
const parsed = parseMakeMkvRipIssueLine(line);
if (!parsed) {
continue;
}
const key = `${parsed.code ?? '-'}|${parsed.message.toLowerCase()}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
issues.push(parsed);
if (issues.length >= 12) {
break;
}
}
return issues;
}
function isBurnedSubtitleTrack(track) {
const flags = Array.isArray(track?.subtitlePreviewFlags)
? track.subtitlePreviewFlags
@@ -2880,6 +2959,33 @@ export default function PipelineStatusCard({
+ `${runningSeriesEpisode?.title ? ` - ${runningSeriesEpisode.title}` : ''}`
)
: (pipeline?.statusText || 'Bereit');
const ripIssueEntries = useMemo(
() => extractMakeMkvRipIssues(jobRow?.makemkvInfo),
[jobRow?.makemkvInfo]
);
const ripIssueVisibleEntries = ripIssueEntries.slice(0, 8);
const hiddenRipIssueCount = Math.max(0, ripIssueEntries.length - ripIssueVisibleEntries.length);
const ripIssueSummaryLabel = ripIssueEntries.length === 1
? '1 Rip-Fehler erkannt'
: `${ripIssueEntries.length} Rip-Fehler erkannt`;
const showRipIssueNotice = Boolean(
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
&& ripCompleted
&& ripIssueEntries.length > 0
&& !running
&& (
state === 'WAITING_FOR_USER_DECISION'
|| state === 'READY_TO_ENCODE'
|| state === 'MEDIAINFO_CHECK'
|| Boolean(mediaInfoReview)
)
);
const canTriggerRipRetry = Boolean(
showRipIssueNotice
&& retryJobId
&& typeof onRetry === 'function'
&& !queueLocked
);
const isSeriesBatchParentReview = Boolean(
mediaInfoReview?.seriesBatchParent
|| pipeline?.context?.mediaInfoReview?.seriesBatchParent
@@ -3672,6 +3778,42 @@ export default function PipelineStatusCard({
</div>
) : null}
{showRipIssueNotice ? (
<div className="rip-error-notice" role="alert" aria-live="polite">
<div className="rip-error-notice-head">
<i className="pi pi-exclamation-triangle" aria-hidden="true" />
<strong>Hinweis: Beim Rip sind Fehler aufgetreten.</strong>
</div>
<small>{ripIssueSummaryLabel}. Bitte vor der Bestätigung von Playlist-/Spurauswahl prüfen.</small>
<div className="rip-error-notice-list">
{ripIssueVisibleEntries.map((entry, index) => (
<div key={`rip-issue-${entry.code ?? 'none'}-${index}`} className="rip-error-notice-item">
<span className="rip-error-notice-code">{entry.code !== null ? `MSG:${entry.code}` : 'Fehler'}</span>
<span className="rip-error-notice-message">{entry.message}</span>
</div>
))}
</div>
<small className="rip-error-notice-more">
{hiddenRipIssueCount > 0
? `+${hiddenRipIssueCount} weitere Meldung(en) im Job-Log.`
: 'Vollständige Details stehen im Job-Log.'}
</small>
{canTriggerRipRetry ? (
<div className="rip-error-notice-actions">
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
outlined
onClick={() => onRetry?.(retryJobId)}
loading={busy}
disabled={busy || queueLocked || !retryJobId}
/>
</div>
) : null}
</div>
) : null}
{rawDecisionRequiredBeforeStart && !queueLocked ? (
<div className="playlist-decision-block">
<h3>RAW Entscheidung erforderlich</h3>
+4 -4
View File
@@ -1581,8 +1581,8 @@ export default function HistoryPage({ refreshToken = 0 }) {
) {
toastRef.current?.show({
severity: 'warn',
summary: `${deleteEntryOutputShortLabel}-Ordner auswählen`,
detail: `Bitte mindestens einen ${deleteEntryOutputShortLabel}-Ordner zum Löschen auswählen.`,
summary: `${deleteEntryOutputShortLabel}-Dateien auswählen`,
detail: `Bitte mindestens eine ${deleteEntryOutputShortLabel}-Datei zum Löschen auswählen.`,
life: 3500
});
return;
@@ -2471,7 +2471,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
) : null}
<div>
<h4>{deleteEntryOutputShortLabel}</h4>
<h4>{deleteEntryOutputShortLabel} (Dateien)</h4>
{previewMoviePaths.length > 0 ? (() => {
return (
<ul className="history-delete-preview-list">
@@ -2502,7 +2502,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
</ul>
);
})() : (
<small className="history-dv-subtle">Keine Movie-Pfade.</small>
<small className="history-dv-subtle">Keine Output-Dateien.</small>
)}
</div>
</div>
+2 -2
View File
@@ -1079,7 +1079,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
tracks: (Array.isArray(existingContext.tracks) && existingContext.tracks.length > 0)
? existingContext.tracks
: computedContext.tracks,
selectedMetadata: existingContext.selectedMetadata || computedContext.selectedMetadata,
selectedMetadata: computedContext.selectedMetadata || existingContext.selectedMetadata,
canRestartEncodeFromLastSettings:
existingContext.canRestartEncodeFromLastSettings ?? computedContext.canRestartEncodeFromLastSettings,
canRestartReviewFromRaw:
@@ -1116,7 +1116,7 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
tracks: (Array.isArray(liveContext.tracks) && liveContext.tracks.length > 0)
? liveContext.tracks
: computedContext.tracks,
selectedMetadata: liveContext.selectedMetadata || computedContext.selectedMetadata,
selectedMetadata: computedContext.selectedMetadata || liveContext.selectedMetadata,
cdRipConfig: liveContext.cdRipConfig || computedContext.cdRipConfig
}
: computedContext;
+68
View File
@@ -3705,6 +3705,74 @@ body {
margin: 0;
}
.rip-error-notice {
margin-top: 1rem;
border: 1px solid #d48c7c;
border-left: 4px solid #b6452c;
border-radius: 0.5rem;
padding: 0.75rem;
display: grid;
gap: 0.5rem;
background: #fff1ec;
color: #4e2418;
}
.rip-error-notice-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.rip-error-notice-head .pi {
color: #b6452c;
}
.rip-error-notice-list {
display: grid;
gap: 0.4rem;
}
.rip-error-notice-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.5rem;
align-items: flex-start;
padding: 0.35rem 0.45rem;
border: 1px dashed #d8a79b;
border-radius: 0.4rem;
background: #fff8f6;
}
.rip-error-notice-code {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.9rem;
padding: 0.1rem 0.4rem;
border-radius: 0.3rem;
background: #b6452c;
color: #fff;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.01em;
}
.rip-error-notice-message {
font-size: 0.83rem;
line-height: 1.3;
word-break: break-word;
}
.rip-error-notice-more {
color: #7a3a2a;
}
.rip-error-notice-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.playlist-decision-list {
display: grid;
gap: 0.75rem;
+2 -1
View File
@@ -645,7 +645,8 @@ apt-get install -y \
util-linux udev \
ca-certificates gnupg \
lsb-release \
mkvtoolnix
mkvtoolnix \
coreutils
ok "Basispakete installiert"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster",
"version": "0.15.0-2",
"version": "0.15.0-3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster",
"version": "0.15.0-2",
"version": "0.15.0-3",
"devDependencies": {
"concurrently": "^9.1.2"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ripster",
"private": true,
"version": "0.15.0-2",
"version": "0.15.0-3",
"scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend",