diff --git a/.claude/settings.json b/.claude/settings.json
deleted file mode 100644
index 4a6d001..0000000
--- a/.claude/settings.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "permissions": {
- "allow": [
- "Bash(ls /home/michael/ripster/*.sh)",
- "Bash(ls /home/michael/ripster/backend/*.sh)",
- "Bash(systemctl list-units --type=service)",
- "Bash(pip install -q -r requirements-docs.txt)",
- "Bash(mkdocs build --strict)",
- "Read(//mnt/external/media/**)",
- "WebFetch(domain:www.makemkv.com)",
- "Bash(node --check backend/src/services/pipelineService.js)",
- "Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)",
- "Bash(find /home/michael/ripster -name *.db)",
- "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, category, label, type, default_value, options_json FROM settings_schema WHERE category=''Laufwerk'' ORDER BY order_index\")",
- "Bash(node --check /home/michael/ripster/backend/src/services/diskDetectionService.js)",
- "Bash(node --check /home/michael/ripster/backend/src/services/settingsService.js)",
- "Bash(node --check /home/michael/ripster/backend/src/services/pipelineService.js)",
- "Bash(node --check /home/michael/ripster/backend/src/db/database.js)",
- "Bash(node --check /home/michael/ripster/backend/src/routes/settingsRoutes.js)",
- "Bash(sqlite3 /home/michael/ripster/backend/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
- "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, value FROM settings_values WHERE key LIKE ''%drive%'' OR key LIKE ''%disc%'' ORDER BY key;\")",
- "Bash(lsblk -J -o NAME,PATH,TYPE,MOUNTPOINT,FSTYPE,LABEL,MODEL)",
- "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(e\\) for e in d.get\\(''''blockdevices'''',[]\\)]\")",
- "Bash(lsblk -o NAME,TYPE,MODEL)",
- "Bash(node --check /home/michael/ripster/backend/src/routes/pipelineRoutes.js)",
- "Bash(grep -n \"inferMediaProfile\" /home/michael/ripster/backend/src/services/*.js)",
- "Bash(grep -rn \"PROCESS_LOG\\\\|LOG_UPDATE\\\\|processLog\" /home/michael/ripster/backend/src --include=*.js)",
- "Bash(grep -n \"buildHandBrakeConfig\" /home/michael/ripster/backend/src/services/*.js)",
- "Bash(grep -n \"class.*Plugin\\\\|get id\\(\\)\\\\|get name\\(\\)\" /home/michael/ripster/backend/src/plugins/*.js)",
- "Bash(/home/michael/ripster/db/schema.sql:*)",
- "Read(//home/michael/**)",
- "Bash(find /home/michael/ripster/AddOns/klangkiste -name *.jsx -o -name *.js -o -name *.vue)",
- "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key, label, order_index FROM settings_schema WHERE key LIKE ''converter%'' ORDER BY order_index\")",
- "Bash(find /home/michael/ripster/backend -name *.db)",
- "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT count\\(*\\) FROM settings_schema\")",
- "Bash(sqlite3 /home/michael/ripster/backend/data/ripster.db \"SELECT key FROM settings_schema WHERE key LIKE ''%converter%''\")",
- "Bash(grep -n \"renderMediaTree\\\\|renderMediaContent\\\\|activeSection === ''media''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)",
- "Bash(grep -n \"activeModal === ''new-folder''\" /home/michael/ripster/AddOns/klangkiste/gui/frontend/src/App.jsx)",
- "Bash(sqlite3 /home/michael/ripster_data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
- "Bash(sqlite3 /opt/ripster_data/ripster.db \"SELECT id, status, last_state FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
- "Read(//opt/**)",
- "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path, substr\\(encode_plan_json,1,500\\) as plan FROM jobs WHERE id IN \\(212, 213\\) ORDER BY id;\")",
- "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state FROM jobs ORDER BY id DESC LIMIT 10;\")",
- "Bash(sqlite3 /home/michael/ripster/debug/backend/data/ripster.db \"SELECT id, status, last_state, encode_input_path FROM jobs WHERE title LIKE ''%Kill%'' OR id IN \\(69, 212, 213\\) ORDER BY id DESC;\")",
- "Bash(find /home/michael/ripster -name *.log -newer /home/michael/ripster/debug/markus.log)",
- "Bash(pm2 logs:*)",
- "Bash(journalctl -u ripster --no-pager -n 50)",
- "Bash(pm2 list:*)",
- "Bash(journalctl -u ripster-smoketest-http --no-pager -n 40)"
- ]
- }
-}
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 369ef41..e46ad31 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-backend",
- "version": "0.13.1-9",
+ "version": "0.13.1-10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-backend",
- "version": "0.13.1-9",
+ "version": "0.13.1-10",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
diff --git a/backend/package.json b/backend/package.json
index b10cf22..9101d68 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-backend",
- "version": "0.13.1-9",
+ "version": "0.13.1-10",
"private": true,
"type": "commonjs",
"scripts": {
diff --git a/backend/src/db/database.js b/backend/src/db/database.js
index d3e7e41..1a9a9e6 100644
--- a/backend/src/db/database.js
+++ b/backend/src/db/database.js
@@ -844,13 +844,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
{
key: 'output_template_dvd_series_episode',
required: 1,
- description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.',
+ description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
validation_json: '{"minLength":1}'
},
{
key: 'output_template_dvd_series_multi_episode',
required: 1,
- description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
+ description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
validation_json: '{"minLength":1}'
}
];
@@ -1076,13 +1076,13 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
- VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
+ VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
- VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
+ VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
);
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js
index e6e54ab..5640eea 100644
--- a/backend/src/routes/historyRoutes.js
+++ b/backend/src/routes/historyRoutes.js
@@ -174,10 +174,11 @@ router.post(
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
- const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
- const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined
+ const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
+ const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
+ const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
- : !resetDriveState;
+ : !requestedResetDriveState;
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
? req.body.selectedMoviePaths
.map((item) => String(item || '').trim())
@@ -189,20 +190,41 @@ router.post(
id,
target,
includeRelated,
- resetDriveState,
- keepDetectedDevice,
+ requestedResetDriveState,
+ preserveRawForImportJobs,
+ requestedKeepDetectedDevice,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
- const preview = resetDriveState
- ? await historyService.getJobDeletePreview(id, { includeRelated })
- : null;
+ const preview = await historyService.getJobDeletePreview(id, { includeRelated });
+ const containsOrphanRawImportJob = Boolean(
+ preview?.flags?.containsOrphanRawImportJob
+ || (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some((row) => Boolean(row?.orphanRawImport)))
+ );
+ const resetDriveState = containsOrphanRawImportJob
+ ? false
+ : requestedResetDriveState;
+ const keepDetectedDevice = containsOrphanRawImportJob
+ ? true
+ : requestedKeepDetectedDevice;
+
+ if (containsOrphanRawImportJob && requestedResetDriveState) {
+ logger.info('post:delete-job:orphan-drive-reset-skipped', {
+ reqId: req.reqId,
+ id
+ });
+ }
+
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.map((row) => String(row?.discDevice || '').trim())
.filter(Boolean)
: [];
- const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
+ const result = await historyService.deleteJob(id, target, {
+ includeRelated,
+ selectedMoviePaths,
+ preserveRawForImportJobs
+ });
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
resetDriveState,
devicePaths: relatedDevicePaths
@@ -212,7 +234,15 @@ router.post(
const uiReset = await pipelineService.resetFrontendState('history_delete', {
keepDetectedDevice
});
- res.json({ ...result, uiReset });
+ res.json({
+ ...result,
+ uiReset,
+ safeguards: {
+ containsOrphanRawImportJob,
+ resetDriveStateApplied: resetDriveState,
+ keepDetectedDeviceApplied: keepDetectedDevice
+ }
+ });
})
);
diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js
index 052ec17..2646473 100644
--- a/backend/src/services/historyService.js
+++ b/backend/src/services/historyService.js
@@ -133,6 +133,16 @@ function parseInfoFromValue(value, fallback = null) {
return parseJsonSafe(value, fallback);
}
+function isOrphanRawImportMakemkvInfo(makemkvInfo = null) {
+ const info = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
+ const source = String(
+ info?.source
+ || info?.importRecovery?.source
+ || ''
+ ).trim().toLowerCase();
+ return source === 'orphan_raw_import';
+}
+
function hasBlurayStructure(rawPath) {
const basePath = String(rawPath || '').trim();
if (!basePath) {
@@ -5180,6 +5190,7 @@ class HistoryService {
? Math.trunc(manualEpisodeCountRaw)
: null;
const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null;
+ const hasExplicitManualEpisodes = Boolean(manualEpisodes && manualEpisodes.length > 0);
const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null;
const manualProviderId = String(payload.providerId || '').trim() || null;
@@ -5240,7 +5251,7 @@ class HistoryService {
let episodeCount = manualEpisodeCount !== null
? manualEpisodeCount
: (Number(existingSelectedMetadata?.episodeCount || 0) || 0);
- let episodes = manualEpisodes && manualEpisodes.length > 0
+ let episodes = hasExplicitManualEpisodes
? manualEpisodes
: (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []);
const discNumber = manualDiscNumber !== null
@@ -5280,6 +5291,13 @@ class HistoryService {
? `tmdb:${effectiveTmdbId}:season:${seasonNumber}`
: `tmdb:${effectiveTmdbId}`;
}
+ let tmdbLanguage = null;
+ try {
+ const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
+ tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
+ } catch (_settingsError) {
+ tmdbLanguage = null;
+ }
let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object'
? { ...existingSelectedMetadata.tmdbDetails }
@@ -5287,6 +5305,7 @@ class HistoryService {
if (effectiveTmdbId !== null) {
try {
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
+ language: tmdbLanguage,
appendToResponse: ['credits', 'external_ids']
});
if (seriesDetails) {
@@ -5325,12 +5344,16 @@ class HistoryService {
if (effectiveTmdbId !== null && seasonNumber !== null) {
try {
- const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber);
- const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber);
+ const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, {
+ language: tmdbLanguage
+ });
+ const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, {
+ language: tmdbLanguage
+ });
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
if (seasonSummary) {
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
- if (fetchedEpisodes.length > 0 && (!Array.isArray(episodes) || episodes.length === 0)) {
+ if (fetchedEpisodes.length > 0 && !hasExplicitManualEpisodes) {
episodes = fetchedEpisodes;
}
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
@@ -6177,15 +6200,20 @@ class HistoryService {
lineageArtifactsByJobId,
outputFoldersByJobId
);
- const relatedJobs = jobs.map((job) => ({
- id: Number(job.id),
- parentJobId: normalizeJobIdValue(job.parent_job_id),
- title: buildJobDisplayTitle(job),
- status: String(job.status || '').trim() || null,
- discDevice: String(job.disc_device || '').trim() || null,
- isPrimary: Number(job.id) === normalizedJobId,
- createdAt: String(job.created_at || '').trim() || null
- }));
+ const relatedJobs = jobs.map((job) => {
+ const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
+ return {
+ id: Number(job.id),
+ parentJobId: normalizeJobIdValue(job.parent_job_id),
+ title: buildJobDisplayTitle(job),
+ status: String(job.status || '').trim() || null,
+ discDevice: String(job.disc_device || '').trim() || null,
+ isPrimary: Number(job.id) === normalizedJobId,
+ createdAt: String(job.created_at || '').trim() || null,
+ orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
+ };
+ });
+ const orphanRawImportJobs = relatedJobs.filter((row) => Boolean(row?.orphanRawImport)).length;
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
@@ -6195,8 +6223,12 @@ class HistoryService {
relatedJobs,
pathCandidates: preview.pathCandidates,
protectedRoots: preview.protectedRoots,
+ flags: {
+ containsOrphanRawImportJob: orphanRawImportJobs > 0
+ },
counts: {
relatedJobs: relatedJobs.length,
+ orphanRawImportJobs,
rawCandidates: preview.pathCandidates.raw.length,
movieCandidates: preview.pathCandidates.movie.length,
existingRawCandidates,
@@ -6887,6 +6919,28 @@ class HistoryService {
throw error;
}
+ const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
+ const isOrphanRawImportJobRow = (row) => {
+ if (!row || typeof row !== 'object') {
+ return false;
+ }
+ const mkInfo = parseJsonSafe(row?.makemkv_info_json, {});
+ return isOrphanRawImportMakemkvInfo(mkInfo);
+ };
+ const resolveEffectiveFileTarget = (requestedTarget, row) => {
+ if (!preserveRawForImportJobs || !isOrphanRawImportJobRow(row)) {
+ return requestedTarget;
+ }
+ const normalizedRequested = String(requestedTarget || 'none').trim().toLowerCase();
+ if (normalizedRequested === 'raw') {
+ return 'none';
+ }
+ if (normalizedRequested === 'both') {
+ return 'movie';
+ }
+ return normalizedRequested;
+ };
+
const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) {
const resolved = await this.getJobByIdOrReplacement(jobId);
@@ -6903,10 +6957,11 @@ class HistoryService {
throw error;
}
+ const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let fileSummary = null;
- if (fileTarget !== 'none') {
+ if (effectiveFileTarget !== 'none') {
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
- fileSummary = this._deletePathsFromPreview(preview, fileTarget, {
+ fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths
});
}
@@ -6968,7 +7023,8 @@ class HistoryService {
logger.warn('job:deleted', {
jobId: normalizedJobId,
- fileTarget,
+ fileTarget: effectiveFileTarget,
+ requestedFileTarget: fileTarget,
includeRelated: false,
pipelineStateReset: isActivePipelineJob,
filesDeleted: fileSummary
@@ -6982,7 +7038,8 @@ class HistoryService {
return {
deleted: true,
jobId: normalizedJobId,
- fileTarget,
+ fileTarget: effectiveFileTarget,
+ requestedFileTarget: fileTarget,
includeRelated: false,
deletedJobIds: [Number(normalizedJobId)],
fileSummary
@@ -6990,6 +7047,7 @@ class HistoryService {
}
const resolved = await this.getJobByIdOrReplacement(jobId);
+ const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
const deleteJobIds = Array.isArray(preview?.relatedJobs)
@@ -7014,9 +7072,10 @@ class HistoryService {
throw error;
}
+ const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let fileSummary = null;
- if (fileTarget !== 'none') {
- fileSummary = this._deletePathsFromPreview(preview, fileTarget, {
+ if (effectiveFileTarget !== 'none') {
+ fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths
});
}
@@ -7068,7 +7127,8 @@ class HistoryService {
logger.warn('job:deleted', {
jobId: normalizedJobId,
- fileTarget,
+ fileTarget: effectiveFileTarget,
+ requestedFileTarget: fileTarget,
includeRelated: true,
deletedJobIds: deleteJobIds,
deletedJobCount: deleteJobIds.length,
@@ -7084,7 +7144,8 @@ class HistoryService {
return {
deleted: true,
jobId: normalizedJobId,
- fileTarget,
+ fileTarget: effectiveFileTarget,
+ requestedFileTarget: fileTarget,
includeRelated: true,
deletedJobIds: deleteJobIds,
deletedJobs: preview.relatedJobs,
diff --git a/backend/src/services/pipelineService.js b/backend/src/services/pipelineService.js
index 25f8cba..0fc138a 100644
--- a/backend/src/services/pipelineService.js
+++ b/backend/src/services/pipelineService.js
@@ -94,11 +94,29 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
]);
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);
+const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000;
+const tmdbSeriesSearchCache = new Map();
function nowIso() {
return new Date().toISOString();
}
+function cloneTmdbSeriesSearchResults(rows = []) {
+ if (!Array.isArray(rows)) {
+ return [];
+ }
+ return rows.map((row) => ({
+ ...(row && typeof row === 'object' ? row : {}),
+ episodes: Array.isArray(row?.episodes)
+ ? row.episodes.map((episode) => (
+ episode && typeof episode === 'object'
+ ? { ...episode }
+ : episode
+ ))
+ : []
+ }));
+}
+
function normalizeCdTrackText(value) {
return String(value || '')
.normalize('NFC')
@@ -948,15 +966,16 @@ function formatTemplateTwoDigit(rawValue) {
return String(rawValue || '').trim() || String(numeric);
}
-function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1) {
+function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1, options = {}) {
const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {};
+ const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false;
const episodeStart = normalizeTemplateNumberValue(
assignmentSource?.episodeNumberStart
?? assignmentSource?.episodeNoStart
?? assignmentSource?.episodeNumber
?? assignmentSource?.number
- ?? selectedTitleSource?.episodeNumber
+ ?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null)
?? null,
fallbackEpisodeNumber
);
@@ -987,6 +1006,167 @@ function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitl
};
}
+function hasExplicitSeriesEpisodeAssignmentRange(assignment = null) {
+ const source = assignment && typeof assignment === 'object' ? assignment : null;
+ if (!source) {
+ return false;
+ }
+ const hasStart = normalizeEpisodeNumberValue(
+ source?.episodeNumberStart
+ ?? source?.episodeNoStart
+ ?? source?.episodeNumber
+ ?? source?.number
+ ?? null
+ ) !== null;
+ const hasEnd = normalizeEpisodeNumberValue(
+ source?.episodeNumberEnd
+ ?? source?.episodeNoEnd
+ ?? null
+ ) !== null;
+ const hasSpan = normalizePositiveInteger(
+ source?.episodeSpan
+ ?? source?.episodeCount
+ ?? null
+ ) !== null;
+ const hasRangeToken = String(source?.episodeRange || '').trim().length > 0;
+ return Boolean(hasStart || hasEnd || hasSpan || hasRangeToken);
+}
+
+function resolveSeriesSelectedTitleIdOrder(encodePlan = null, titles = []) {
+ const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
+ const explicitSelected = normalizeReviewTitleIdList(
+ Array.isArray(plan?.selectedTitleIds)
+ ? plan.selectedTitleIds
+ : [plan?.encodeInputTitleId]
+ );
+ if (explicitSelected.length > 0) {
+ return explicitSelected;
+ }
+ return normalizeReviewTitleIdList(
+ (Array.isArray(titles) ? titles : [])
+ .filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
+ .map((title) => title?.id)
+ );
+}
+
+function resolveSeriesEpisodeRangeForPlanTitle(encodePlan = null, titleId = null, fallbackEpisodeNumber = 1) {
+ const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
+ const titles = Array.isArray(plan?.titles) ? plan.titles : [];
+ const titlesById = new Map();
+ for (const title of titles) {
+ const normalizedId = normalizeReviewTitleId(title?.id);
+ if (!normalizedId) {
+ continue;
+ }
+ titlesById.set(Number(normalizedId), title);
+ }
+
+ const normalizedTitleId = normalizeReviewTitleId(titleId)
+ || normalizeReviewTitleId(plan?.encodeInputTitleId)
+ || null;
+ let orderedTitleIds = resolveSeriesSelectedTitleIdOrder(plan, titles);
+ if (normalizedTitleId && !orderedTitleIds.some((id) => Number(id) === Number(normalizedTitleId))) {
+ orderedTitleIds = [...orderedTitleIds, Number(normalizedTitleId)];
+ }
+ if (orderedTitleIds.length === 0 && normalizedTitleId) {
+ orderedTitleIds = [Number(normalizedTitleId)];
+ }
+
+ const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object'
+ ? plan.episodeAssignments
+ : {};
+ const fallbackStart = normalizePositiveInteger(fallbackEpisodeNumber) || 1;
+ let cursor = fallbackStart;
+ let targetRange = null;
+ let targetAssignment = null;
+ let targetTitle = null;
+
+ for (const rawId of orderedTitleIds) {
+ const currentId = normalizeReviewTitleId(rawId);
+ if (!currentId) {
+ continue;
+ }
+ const assignment = assignmentMap[String(currentId)] || assignmentMap[currentId] || null;
+ const selectedTitle = titlesById.get(Number(currentId)) || null;
+ const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(assignment);
+ const range = resolveSeriesEpisodeRangeFromAssignment(
+ assignment,
+ selectedTitle,
+ cursor,
+ { allowSelectedTitleEpisodeNumber: hasExplicitRange }
+ );
+ const normalizedEnd = normalizeEpisodeNumberValue(range?.end);
+ if (normalizedEnd !== null) {
+ cursor = normalizedEnd + 1;
+ }
+ if (normalizedTitleId && Number(currentId) === Number(normalizedTitleId)) {
+ targetRange = range;
+ targetAssignment = assignment;
+ targetTitle = selectedTitle;
+ break;
+ }
+ }
+
+ if (!targetRange) {
+ const fallbackTitle = normalizedTitleId ? (titlesById.get(Number(normalizedTitleId)) || null) : null;
+ const fallbackAssignment = normalizedTitleId
+ ? (assignmentMap[String(normalizedTitleId)] || assignmentMap[normalizedTitleId] || null)
+ : null;
+ const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(fallbackAssignment);
+ targetRange = resolveSeriesEpisodeRangeFromAssignment(
+ fallbackAssignment,
+ fallbackTitle,
+ cursor,
+ { allowSelectedTitleEpisodeNumber: hasExplicitRange }
+ );
+ targetAssignment = fallbackAssignment;
+ targetTitle = fallbackTitle;
+ }
+
+ return {
+ range: targetRange,
+ assignment: targetAssignment,
+ title: targetTitle
+ };
+}
+
+function templateUsesLeadingZeroForToken(template, token) {
+ const source = String(template || '').trim();
+ const normalizedToken = String(token || '').trim();
+ if (!source || !normalizedToken) {
+ return false;
+ }
+ const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i');
+ return pattern.test(source);
+}
+
+function formatSeriesEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') {
+ const numeric = normalizeEpisodeNumberValue(rawValue);
+ if (numeric === null) {
+ return String(rawValue || '').trim() || '0';
+ }
+ if (Number.isInteger(numeric)) {
+ const normalized = String(Math.trunc(numeric));
+ if (templateUsesLeadingZeroForToken(template, token)) {
+ return normalized.padStart(2, '0');
+ }
+ return normalized;
+ }
+ return String(numeric);
+}
+
+function buildSeriesFallbackEpisodeTitle(episodeRangeInfo = null, template = '') {
+ const start = normalizeEpisodeNumberValue(episodeRangeInfo?.start) ?? 1;
+ const end = normalizeEpisodeNumberValue(episodeRangeInfo?.end) ?? start;
+ if (end > start) {
+ const startToken = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr');
+ const endToken = formatSeriesEpisodeNumberByTemplate(end, template, 'episodeNr');
+ return `Folge ${startToken}-${endToken}`;
+ }
+ const token = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr');
+ return `Folge ${token}`;
+}
+
function buildSeriesEpisodeRangeToken(start, end) {
const startToken = formatTemplateTwoDigit(start);
const endToken = formatTemplateTwoDigit(end);
@@ -1178,19 +1358,39 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
const assignment = primaryTitleId
? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null)
: null;
+ const selectedTitlePosition = primaryTitleId
+ ? selectedTitleIds.findIndex((id) => Number(id) === Number(primaryTitleId))
+ : -1;
+ const fallbackEpisodeNumber = normalizePositiveInteger(
+ encodePlan?.seriesBatchChildIndex
+ ?? null
+ ) || (selectedTitlePosition >= 0 ? selectedTitlePosition + 1 : 1);
+ const episodeResolution = resolveSeriesEpisodeRangeForPlanTitle(
+ encodePlan,
+ primaryTitleId,
+ fallbackEpisodeNumber
+ );
+ const resolvedAssignment = episodeResolution?.assignment || assignment || null;
+ const resolvedTitle = episodeResolution?.title || selectedTitle || null;
+ const episodeRangeInfo = (
+ episodeResolution?.range
+ && typeof episodeResolution.range === 'object'
+ )
+ ? episodeResolution.range
+ : resolveSeriesEpisodeRangeFromAssignment(
+ resolvedAssignment,
+ resolvedTitle,
+ fallbackEpisodeNumber,
+ { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(resolvedAssignment) }
+ );
const seasonNumber = normalizePositiveInteger(
- assignment?.seasonNumber
- ?? selectedTitle?.seasonNumber
+ resolvedAssignment?.seasonNumber
+ ?? resolvedTitle?.seasonNumber
?? selectedMetadata?.seasonNumber
?? analyzeContext?.seriesLookupHint?.seasonNumber
?? null
) || 1;
- const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment(
- assignment,
- selectedTitle,
- primaryTitleId || 1
- );
const episodeTemplateValue = episodeRangeInfo.start;
const episodeRangeToken = buildSeriesEpisodeRangeToken(
episodeRangeInfo.start,
@@ -1199,7 +1399,7 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo);
const discNumber = normalizePositiveInteger(
selectedMetadata?.discNumber
- ?? assignment?.discNumber
+ ?? resolvedAssignment?.discNumber
?? analyzeContext?.seriesLookupHint?.discNumber
?? null
);
@@ -1210,16 +1410,27 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|| job?.detected_title
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series')
) || (fallbackJobId ? `job-${fallbackJobId}` : 'series');
+ const singleTemplateRaw = String(
+ settings?.output_template_dvd_series_episode
+ || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
+ ).trim();
+ const multiTemplateRaw = String(
+ settings?.output_template_dvd_series_multi_episode
+ || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE
+ ).trim();
+ const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
+ const multiTemplate = multiTemplateRaw || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE;
+ const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate;
+ const fallbackEpisodeTitle = buildSeriesFallbackEpisodeTitle(episodeRangeInfo, template);
const rawEpisodeTitle = normalizeCdTrackText(
- assignment?.episodeTitle
- || selectedTitle?.episodeTitle
- || selectedTitle?.fileName
- || `Episode ${episodeRangeToken}`
- ) || `Episode ${episodeRangeToken}`;
+ resolvedAssignment?.episodeTitle
+ || resolvedTitle?.episodeTitle
+ || fallbackEpisodeTitle
+ ) || fallbackEpisodeTitle;
const episodeTitle = normalizeSeriesEpisodeTitleForOutput(
rawEpisodeTitle,
episodeRangeInfo
- );
+ ) || fallbackEpisodeTitle;
const language = String(
settings?.dvd_series_language
|| selectedMetadata?.language
@@ -1227,21 +1438,10 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
).trim() || 'unknown';
const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear();
- const singleTemplate = String(
- settings?.output_template_dvd_series_episode
- || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
- ).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
- const multiTemplate = String(
- settings?.output_template_dvd_series_multi_episode
- || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE
- ).trim() || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE;
- const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate;
const rendered = renderTemplate(template, {
seriesTitle,
seasonNr: seasonNumber,
- seasonNo: String(seasonNumber),
episodeNr: episodeTemplateValue,
- episodeNo: String(episodeTemplateValue),
episodeNoStart: episodeRangeInfo.start,
episodeNoEnd: episodeRangeInfo.end,
episodeNumberStart: episodeRangeInfo.start,
@@ -5283,15 +5483,16 @@ function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, chi
const episodeRange = resolveSeriesEpisodeRangeFromAssignment(
assignment,
selectedTitle,
- childIndex + 1
+ childIndex + 1,
+ { allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) }
);
+ const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`;
const rawEpisodeLabel = String(
assignment?.episodeTitle
|| selectedTitle?.episodeTitle
- || selectedTitle?.fileName
- || ''
+ || fallbackEpisodeLabel
).trim();
- const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange);
+ const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel;
const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null;
const episodeToken = buildSeriesEpisodeRangeToken(
@@ -5326,6 +5527,25 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
? plan.episodeAssignments
: {};
const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null;
+ const inferredRangeForChild = resolveSeriesEpisodeRangeForPlanTitle(plan, titleId, childIndex + 1)?.range || null;
+ const selectedAssignmentHasRange = hasExplicitSeriesEpisodeAssignmentRange(selectedAssignment);
+ const resolvedChildAssignment = (
+ selectedAssignmentHasRange
+ || !inferredRangeForChild
+ )
+ ? selectedAssignment
+ : {
+ ...(selectedAssignment && typeof selectedAssignment === 'object' ? selectedAssignment : {}),
+ titleId: Number(titleId),
+ episodeNumber: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumber) ?? inferredRangeForChild.start,
+ episodeNumberStart: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberStart) ?? inferredRangeForChild.start,
+ episodeNumberEnd: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberEnd) ?? inferredRangeForChild.end,
+ episodeSpan: normalizePositiveInteger(selectedAssignment?.episodeSpan)
+ || Math.max(1, Math.trunc(Number(inferredRangeForChild.end) - Number(inferredRangeForChild.start) + 1)),
+ episodeRange: String(selectedAssignment?.episodeRange || '').trim()
+ || buildSeriesEpisodeRangeToken(inferredRangeForChild.start, inferredRangeForChild.end),
+ seasonNumber: normalizePositiveInteger(selectedAssignment?.seasonNumber) ?? normalizePositiveInteger(selectedTitle?.seasonNumber) ?? null
+ };
return {
...plan,
@@ -5343,12 +5563,12 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [],
titleSelectionRequired: false,
titles: remappedTitles,
- episodeAssignments: selectedAssignment
+ episodeAssignments: resolvedChildAssignment
? {
[String(titleId)]: {
- ...selectedAssignment,
+ ...resolvedChildAssignment,
titleId: Number(titleId),
- filePath: selectedTitle?.filePath || selectedAssignment?.filePath || null
+ filePath: selectedTitle?.filePath || resolvedChildAssignment?.filePath || null
}
}
: {},
@@ -8759,6 +8979,10 @@ class PipelineService extends EventEmitter {
if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
continue;
}
+ const status = String(job?.status || job?.last_state || '').trim().toUpperCase();
+ if (status !== 'ENCODING' && status !== 'CD_ENCODING') {
+ continue;
+ }
const poolType = this.resolveQueuePoolTypeForJob(job);
if (poolType === 'audio') {
audioRunning += 1;
@@ -10759,49 +10983,52 @@ class PipelineService extends EventEmitter {
const filmRunning = runningUsage.filmRunning;
const cdRunning = runningUsage.audioRunning;
const totalRunning = runningUsage.totalRunning;
- // Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
- const anyActiveJobs = totalRunning;
// Find next startable entry
let entryIndex = -1;
- for (let i = 0; i < this.queueEntries.length; i++) {
- const candidate = this.queueEntries[i];
- const isNonJob = candidate.type && candidate.type !== 'job';
-
- if (isNonJob) {
- // Non-job entries (script, chain, wait) only start when no jobs are actively running.
- // anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
- // activeProcesses provides a second safety net for race conditions during state transitions.
- if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
- entryIndex = i;
- }
- break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
- }
-
- // Job entry: check hierarchical limits
- if (totalRunning >= maxTotal) {
- // Total limit reached – nothing can start
- break;
- }
-
- const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
- if (candidatePoolType === 'audio') {
- if (cdRunning < maxCd) {
- entryIndex = i;
+ const independentEntryIndex = this.queueEntries.findIndex((candidate) => {
+ const type = String(candidate?.type || '').trim().toLowerCase();
+ return type === 'script' || type === 'chain';
+ });
+ if (independentEntryIndex >= 0) {
+ entryIndex = independentEntryIndex;
+ } else {
+ for (let i = 0; i < this.queueEntries.length; i++) {
+ const candidate = this.queueEntries[i];
+ const isWaitEntry = String(candidate?.type || '').trim().toLowerCase() === 'wait';
+ if (isWaitEntry) {
+ // Wait keeps queue order and blocks until no tracked job process is active.
+ if (this.activeProcesses.size === 0) {
+ entryIndex = i;
+ }
break;
}
- // CD limit reached
- if (!cdBypass) break; // Strict FIFO: stop scanning
- continue; // Bypass mode: skip this blocked CD entry
- } else {
- // Film/video job entry
- if (filmRunning < maxFilm) {
- entryIndex = i;
+
+ // Job entry: check hierarchical limits (caps apply only to encode jobs).
+ if (totalRunning >= maxTotal) {
+ // Total encode cap reached – nothing can start.
break;
}
- // Film limit reached
- if (!cdBypass) break; // Strict FIFO: stop scanning
- continue; // Bypass mode: skip this blocked film entry
+
+ const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
+ if (candidatePoolType === 'audio') {
+ if (cdRunning < maxCd) {
+ entryIndex = i;
+ break;
+ }
+ // CD/audio encode cap reached
+ if (!cdBypass) break; // Strict FIFO: stop scanning
+ continue; // Bypass mode: skip this blocked CD entry
+ } else {
+ // Film/video encode job entry
+ if (filmRunning < maxFilm) {
+ entryIndex = i;
+ break;
+ }
+ // Film encode cap reached
+ if (!cdBypass) break; // Strict FIFO: stop scanning
+ continue; // Bypass mode: skip this blocked film entry
+ }
}
}
@@ -12493,7 +12720,36 @@ class PipelineService extends EventEmitter {
seasonNumber: seasonFilter || null
});
- let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery);
+ let tmdbLanguage = null;
+ try {
+ const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
+ tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
+ } catch (_settingsError) {
+ tmdbLanguage = null;
+ }
+
+ const cacheKey = [
+ normalizedQuery.toLowerCase(),
+ seasonFilter.toLowerCase(),
+ String(tmdbLanguage || '').trim().toLowerCase()
+ ].join('::');
+ const cacheEntry = tmdbSeriesSearchCache.get(cacheKey);
+ const nowTs = Date.now();
+ if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) {
+ logger.info('tmdb:series-search:cache-hit', {
+ query: normalizedQuery,
+ seasonNumber: seasonFilter || null,
+ count: cacheEntry.results.length
+ });
+ return cloneTmdbSeriesSearchResults(cacheEntry.results);
+ }
+ if (cacheEntry && cacheEntry.expiresAt <= nowTs) {
+ tmdbSeriesSearchCache.delete(cacheKey);
+ }
+
+ let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
+ language: tmdbLanguage
+ });
if (!Array.isArray(normalizedResults)) {
normalizedResults = [];
}
@@ -12518,6 +12774,26 @@ class PipelineService extends EventEmitter {
throw error;
}
+ const cacheNowTs = Date.now();
+ if (tmdbSeriesSearchCache.size >= 200) {
+ for (const [key, entry] of tmdbSeriesSearchCache.entries()) {
+ if (!entry || entry.expiresAt <= cacheNowTs) {
+ tmdbSeriesSearchCache.delete(key);
+ }
+ }
+ if (tmdbSeriesSearchCache.size >= 200) {
+ const oldestKey = tmdbSeriesSearchCache.keys().next().value;
+ if (oldestKey) {
+ tmdbSeriesSearchCache.delete(oldestKey);
+ }
+ }
+ }
+
+ tmdbSeriesSearchCache.set(cacheKey, {
+ results: cloneTmdbSeriesSearchResults(normalizedResults),
+ expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS
+ });
+
return normalizedResults;
}
@@ -14174,12 +14450,22 @@ class PipelineService extends EventEmitter {
|| mkInfo?.analyzeContext?.selectedMetadata?.episodeCount
|| 0
) || 0;
- let effectiveEpisodes = Array.isArray(episodes)
+ const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0;
+ let effectiveEpisodes = hasExplicitEpisodePayload
? episodes
: (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes)
? mkInfo.analyzeContext.selectedMetadata.episodes
: []);
let tmdbDetails = null;
+ let tmdbLanguage = null;
+ if (effectiveMetadataProvider === 'tmdb') {
+ try {
+ const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
+ tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
+ } catch (_settingsError) {
+ tmdbLanguage = null;
+ }
+ }
const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series';
if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') {
@@ -14218,6 +14504,7 @@ class PipelineService extends EventEmitter {
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) {
try {
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
+ language: tmdbLanguage,
appendToResponse: ['credits', 'external_ids']
});
tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails);
@@ -14248,15 +14535,17 @@ class PipelineService extends EventEmitter {
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) {
try {
- seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber);
- seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber);
+ seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, {
+ language: tmdbLanguage
+ });
+ seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, {
+ language: tmdbLanguage
+ });
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
if (seasonSummary) {
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
- if (fetchedEpisodes.length > 0) {
- if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) {
- effectiveEpisodes = fetchedEpisodes;
- }
+ if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) {
+ effectiveEpisodes = fetchedEpisodes;
}
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
if (fetchedEpisodeCount > 0) {
@@ -17981,6 +18270,98 @@ class PipelineService extends EventEmitter {
};
}
+ buildPostEncodeScriptsSummaryPlaceholder(encodePlan) {
+ const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
+ const chainIds = normalizeChainIdList(encodePlan?.postEncodeChainIds || []);
+ return {
+ configured: scriptIds.length + chainIds.length,
+ attempted: 0,
+ succeeded: 0,
+ failed: 0,
+ skipped: 0,
+ aborted: false,
+ abortReason: null,
+ failedScriptId: null,
+ failedScriptName: null,
+ pending: scriptIds.length + chainIds.length > 0,
+ results: []
+ };
+ }
+
+ async persistPostEncodeScriptsSummary(jobId, summary) {
+ try {
+ const job = await historyService.getJobById(jobId);
+ if (!job) {
+ return;
+ }
+ const handbrakeInfo = this.safeParseJson(job.handbrake_info_json);
+ const nextInfo = handbrakeInfo && typeof handbrakeInfo === 'object'
+ ? handbrakeInfo
+ : {};
+ await historyService.updateJob(jobId, {
+ handbrake_info_json: JSON.stringify({
+ ...nextInfo,
+ postEncodeScripts: {
+ ...(summary && typeof summary === 'object' ? summary : {}),
+ pending: false
+ }
+ })
+ });
+ } catch (error) {
+ logger.warn('encode:post-script:persist-summary-failed', { jobId, error: errorToMeta(error) });
+ }
+ }
+
+ runPostEncodeScriptsDetached(jobId, encodePlan, context = {}, options = {}) {
+ const placeholderSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
+ if (placeholderSummary.configured <= 0) {
+ return { started: false, summary: placeholderSummary };
+ }
+
+ const phaseLabel = String(options?.phaseLabel || 'Post-Encode').trim() || 'Post-Encode';
+ void historyService.appendLog(
+ jobId,
+ 'SYSTEM',
+ `${phaseLabel} Skripte/Ketten werden unabhängig im Hintergrund ausgeführt.`
+ );
+
+ void (async () => {
+ let finalSummary = {
+ ...placeholderSummary,
+ pending: false
+ };
+ try {
+ finalSummary = {
+ ...(await this.runPostEncodeScripts(jobId, encodePlan, context, null)),
+ pending: false
+ };
+ } catch (error) {
+ logger.warn('encode:post-script:detached-failed', { jobId, error: errorToMeta(error) });
+ finalSummary = {
+ ...placeholderSummary,
+ attempted: placeholderSummary.configured,
+ failed: placeholderSummary.configured,
+ aborted: true,
+ abortReason: error?.message || 'unknown',
+ pending: false
+ };
+ }
+
+ await historyService.appendLog(
+ jobId,
+ 'SYSTEM',
+ `${phaseLabel} Skripte/Ketten abgeschlossen: ${finalSummary.succeeded} erfolgreich, `
+ + `${finalSummary.failed} fehlgeschlagen, ${finalSummary.skipped} übersprungen.`
+ );
+ await this.persistPostEncodeScriptsSummary(jobId, finalSummary);
+ })();
+
+ return {
+ started: true,
+ summary: placeholderSummary
+ };
+ }
+
async startEncodingFromPrepared(jobId, options = {}) {
this.ensureNotBusy('startEncodingFromPrepared', jobId);
logger.info('encode:start-from-prepared', { jobId });
@@ -18568,40 +18949,15 @@ class PipelineService extends EventEmitter {
historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => {
logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message });
});
- let postEncodeScriptsSummary = {
- configured: 0,
- attempted: 0,
- succeeded: 0,
- failed: 0,
- skipped: 0,
- results: []
+ const postEncodeExecutionContext = {
+ mode,
+ jobTitle: job.title || job.detected_title || null,
+ inputPath,
+ outputPath: finalizedOutputPath,
+ rawPath: activeRawPath,
+ pipelineStage: 'ENCODING'
};
- try {
- postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
- mode,
- jobTitle: job.title || job.detected_title || null,
- inputPath,
- outputPath: finalizedOutputPath,
- rawPath: activeRawPath
- }, encodeScriptProgressTracker);
- } catch (error) {
- logger.warn('encode:post-script:summary-failed', {
- jobId,
- error: errorToMeta(error)
- });
- await historyService.appendLog(
- jobId,
- 'SYSTEM',
- `Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
- );
- }
- if (postEncodeScriptsSummary.configured > 0) {
- await historyService.appendLog(
- jobId,
- 'SYSTEM',
- `Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
- );
- }
+ const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
let finalizedRawPath = activeRawPath || null;
if (activeRawPath) {
const currentRawPath = String(activeRawPath || '').trim();
@@ -18662,6 +19018,9 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath,
error_message: null
});
+ this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
+ phaseLabel: 'Post-Encode'
+ });
return {
outputPath: finalizedOutputPath,
handbrakeInfo: handbrakeInfoWithPostScripts,
@@ -18723,6 +19082,9 @@ class PipelineService extends EventEmitter {
logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId });
void this.pumpQueue();
}
+ this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
+ phaseLabel: 'Post-Encode'
+ });
if (mode === 'reencode') {
void this.notifyPushover('reencode_finished', {
@@ -23532,8 +23894,6 @@ class PipelineService extends EventEmitter {
const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []);
const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []);
- const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []);
- const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []);
let preEncodeScriptsSummary = {
configured: 0,
attempted: 0,
@@ -23755,35 +24115,17 @@ class PipelineService extends EventEmitter {
}
const { encodeResults: cdEncodeResults = [] } = cdRipResult || {};
settleLifecycle();
-
- if (postScriptIds.length > 0 || postChainIds.length > 0) {
- await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...');
- try {
- postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, {
- mode: 'cd_rip',
- jobId,
- jobTitle: selectedMeta?.title || `Job #${jobId}`,
- inputPath: devicePath || null,
- outputPath: outputDir || null,
- rawPath: rawWavDir || null,
- mediaProfile: 'cd',
- pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
- });
- } catch (error) {
- logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) });
- await historyService.appendLog(
- jobId,
- 'SYSTEM',
- `Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
- );
- }
- await historyService.appendLog(
- jobId,
- 'SYSTEM',
- `Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, `
- + `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.`
- );
- }
+ postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan);
+ let postCdScriptContext = {
+ mode: 'cd_rip',
+ jobId,
+ jobTitle: selectedMeta?.title || `Job #${jobId}`,
+ inputPath: devicePath || null,
+ outputPath: outputDir || null,
+ rawPath: rawWavDir || null,
+ mediaProfile: 'cd',
+ pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
+ };
// RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen
let activeRawDir = rawWavDir;
@@ -23800,6 +24142,11 @@ class PipelineService extends EventEmitter {
// ignore – raw dir bleibt unter bestehendem Namen zugänglich
}
}
+ postCdScriptContext = {
+ ...postCdScriptContext,
+ outputPath: outputDir || null,
+ rawPath: activeRawDir || rawWavDir || null
+ };
const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso();
const persistedEncodePlan = {
@@ -23963,6 +24310,9 @@ class PipelineService extends EventEmitter {
void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
}
}
+ this.runPostEncodeScriptsDetached(jobId, normalizedEncodePlan, postCdScriptContext, {
+ phaseLabel: 'Post-Rip'
+ });
} catch (error) {
settleLifecycle();
const failedCdLive = buildLiveContext(currentTrackPosition || null);
diff --git a/db/schema.sql b/db/schema.sql
index 4ba6fc2..666767f 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -464,11 +464,11 @@ VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'T
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
-VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
+VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
-VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
+VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
-- Tools – CD
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d590361..966d619 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
- "version": "0.13.1-9",
+ "version": "0.13.1-10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
- "version": "0.13.1-9",
+ "version": "0.13.1-10",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
diff --git a/frontend/package.json b/frontend/package.json
index 169047c..5afe0a8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
- "version": "0.13.1-9",
+ "version": "0.13.1-10",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 9eec20f..30dbe7c 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -823,6 +823,7 @@ export const api = {
async deleteJobEntry(jobId, target = 'none', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const resetDriveState = Boolean(options?.resetDriveState);
+ const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
@@ -838,6 +839,7 @@ export const api = {
target,
includeRelated,
resetDriveState,
+ preserveRawForImportJobs,
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
diff --git a/frontend/src/components/MetadataSelectionDialog.jsx b/frontend/src/components/MetadataSelectionDialog.jsx
index 9460c44..11b5195 100644
--- a/frontend/src/components/MetadataSelectionDialog.jsx
+++ b/frontend/src/components/MetadataSelectionDialog.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
@@ -45,11 +45,13 @@ export default function MetadataSelectionDialog({
const [manualImdb, setManualImdb] = useState('');
const [manualDiscNumber, setManualDiscNumber] = useState('');
const [extraResults, setExtraResults] = useState([]);
+ const [hasSearchOverride, setHasSearchOverride] = useState(false);
const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
+ const activeSearchRequestRef = useRef(0);
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
@@ -102,12 +104,14 @@ export default function MetadataSelectionDialog({
setManualImdb(defaultImdb);
setManualDiscNumber('');
setExtraResults([]);
+ setHasSearchOverride(false);
setSearchBusy(false);
setSearchError('');
setSubmitError('');
setSeasonFilter('');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
+ activeSearchRequestRef.current += 1;
}, [visible, context]);
useEffect(() => {
@@ -129,7 +133,8 @@ export default function MetadataSelectionDialog({
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: (context?.omdbCandidates || []);
- const all = [...base, ...extraResults];
+ const sourceRows = hasSearchOverride ? extraResults : base;
+ const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map();
all.forEach((item) => {
@@ -149,7 +154,7 @@ export default function MetadataSelectionDialog({
});
return Array.from(map.values());
- }, [context, extraResults]);
+ }, [context, extraResults, hasSearchOverride]);
const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') {
@@ -192,18 +197,28 @@ export default function MetadataSelectionDialog({
if (!trimmedQuery || typeof onSearch !== 'function') {
return;
}
+ const requestId = activeSearchRequestRef.current + 1;
+ activeSearchRequestRef.current = requestId;
+ setHasSearchOverride(true);
setSearchBusy(true);
setSearchError('');
try {
const results = await onSearch(trimmedQuery, {
metadataProvider
});
+ if (activeSearchRequestRef.current !== requestId) {
+ return;
+ }
setExtraResults(Array.isArray(results) ? results : []);
} catch (error) {
- setExtraResults([]);
+ if (activeSearchRequestRef.current !== requestId) {
+ return;
+ }
setSearchError(error?.message || 'Suche fehlgeschlagen.');
} finally {
- setSearchBusy(false);
+ if (activeSearchRequestRef.current === requestId) {
+ setSearchBusy(false);
+ }
}
};
@@ -262,10 +277,6 @@ export default function MetadataSelectionDialog({
};
const handleHideRequest = () => {
- if (requiresDiscNumber && !hasValidDiscNumber) {
- setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
- return;
- }
onHide?.();
};
@@ -291,6 +302,7 @@ export default function MetadataSelectionDialog({
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
+ setHasSearchOverride(false);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
@@ -331,7 +343,7 @@ export default function MetadataSelectionDialog({
/>
{requiresDiscNumber
- ? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht verlassen werden.'
+ ? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht uebernommen werden.'
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
@@ -394,7 +406,6 @@ export default function MetadataSelectionDialog({
severity="secondary"
text
onClick={handleHideRequest}
- disabled={requiresDiscNumber && !hasValidDiscNumber}
/>