0.13.1-10 Misc fixes

This commit is contained in:
2026-04-17 08:04:52 +00:00
parent f25b6e1181
commit bebe9eac8c
19 changed files with 979 additions and 2155 deletions
-52
View File
@@ -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)"
]
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-9", "version": "0.13.1-10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-9", "version": "0.13.1-10",
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.13.1-9", "version": "0.13.1-10",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
+4 -4
View File
@@ -844,13 +844,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
{ {
key: 'output_template_dvd_series_episode', key: 'output_template_dvd_series_episode',
required: 1, 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}' validation_json: '{"minLength":1}'
}, },
{ {
key: 'output_template_dvd_series_multi_episode', key: 'output_template_dvd_series_multi_episode',
required: 1, required: 1,
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {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}' validation_json: '{"minLength":1}'
} }
]; ];
@@ -1076,13 +1076,13 @@ async function migrateSettingsSchemaMetadata(db) {
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_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_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
await db.run( await db.run(
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) `INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {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})')`); 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})')`);
+40 -10
View File
@@ -174,10 +174,11 @@ router.post(
const id = Number(req.params.id); const id = Number(req.params.id);
const target = String(req.body?.target || 'none'); const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase()); 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 requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined 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()) ? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !resetDriveState; : !requestedResetDriveState;
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths) const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
? req.body.selectedMoviePaths ? req.body.selectedMoviePaths
.map((item) => String(item || '').trim()) .map((item) => String(item || '').trim())
@@ -189,20 +190,41 @@ router.post(
id, id,
target, target,
includeRelated, includeRelated,
resetDriveState, requestedResetDriveState,
keepDetectedDevice, preserveRawForImportJobs,
requestedKeepDetectedDevice,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0 selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
}); });
const preview = resetDriveState const preview = await historyService.getJobDeletePreview(id, { includeRelated });
? await historyService.getJobDeletePreview(id, { includeRelated }) const containsOrphanRawImportJob = Boolean(
: null; 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) const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs ? preview.relatedJobs
.map((row) => String(row?.discDevice || '').trim()) .map((row) => String(row?.discDevice || '').trim())
.filter(Boolean) .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], { await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
resetDriveState, resetDriveState,
devicePaths: relatedDevicePaths devicePaths: relatedDevicePaths
@@ -212,7 +234,15 @@ router.post(
const uiReset = await pipelineService.resetFrontendState('history_delete', { const uiReset = await pipelineService.resetFrontendState('history_delete', {
keepDetectedDevice keepDetectedDevice
}); });
res.json({ ...result, uiReset }); res.json({
...result,
uiReset,
safeguards: {
containsOrphanRawImportJob,
resetDriveStateApplied: resetDriveState,
keepDetectedDeviceApplied: keepDetectedDevice
}
});
}) })
); );
+82 -21
View File
@@ -133,6 +133,16 @@ function parseInfoFromValue(value, fallback = null) {
return parseJsonSafe(value, fallback); 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) { function hasBlurayStructure(rawPath) {
const basePath = String(rawPath || '').trim(); const basePath = String(rawPath || '').trim();
if (!basePath) { if (!basePath) {
@@ -5180,6 +5190,7 @@ class HistoryService {
? Math.trunc(manualEpisodeCountRaw) ? Math.trunc(manualEpisodeCountRaw)
: null; : null;
const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : 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 manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null;
const manualProviderId = String(payload.providerId || '').trim() || null; const manualProviderId = String(payload.providerId || '').trim() || null;
@@ -5240,7 +5251,7 @@ class HistoryService {
let episodeCount = manualEpisodeCount !== null let episodeCount = manualEpisodeCount !== null
? manualEpisodeCount ? manualEpisodeCount
: (Number(existingSelectedMetadata?.episodeCount || 0) || 0); : (Number(existingSelectedMetadata?.episodeCount || 0) || 0);
let episodes = manualEpisodes && manualEpisodes.length > 0 let episodes = hasExplicitManualEpisodes
? manualEpisodes ? manualEpisodes
: (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []); : (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []);
const discNumber = manualDiscNumber !== null const discNumber = manualDiscNumber !== null
@@ -5280,6 +5291,13 @@ class HistoryService {
? `tmdb:${effectiveTmdbId}:season:${seasonNumber}` ? `tmdb:${effectiveTmdbId}:season:${seasonNumber}`
: `tmdb:${effectiveTmdbId}`; : `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' let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object'
? { ...existingSelectedMetadata.tmdbDetails } ? { ...existingSelectedMetadata.tmdbDetails }
@@ -5287,6 +5305,7 @@ class HistoryService {
if (effectiveTmdbId !== null) { if (effectiveTmdbId !== null) {
try { try {
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
language: tmdbLanguage,
appendToResponse: ['credits', 'external_ids'] appendToResponse: ['credits', 'external_ids']
}); });
if (seriesDetails) { if (seriesDetails) {
@@ -5325,12 +5344,16 @@ class HistoryService {
if (effectiveTmdbId !== null && seasonNumber !== null) { if (effectiveTmdbId !== null && seasonNumber !== null) {
try { try {
const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber); const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, {
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber); language: tmdbLanguage
});
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, {
language: tmdbLanguage
});
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
if (seasonSummary) { if (seasonSummary) {
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; 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; episodes = fetchedEpisodes;
} }
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
@@ -6177,15 +6200,20 @@ class HistoryService {
lineageArtifactsByJobId, lineageArtifactsByJobId,
outputFoldersByJobId outputFoldersByJobId
); );
const relatedJobs = jobs.map((job) => ({ const relatedJobs = jobs.map((job) => {
id: Number(job.id), const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
parentJobId: normalizeJobIdValue(job.parent_job_id), return {
title: buildJobDisplayTitle(job), id: Number(job.id),
status: String(job.status || '').trim() || null, parentJobId: normalizeJobIdValue(job.parent_job_id),
discDevice: String(job.disc_device || '').trim() || null, title: buildJobDisplayTitle(job),
isPrimary: Number(job.id) === normalizedJobId, status: String(job.status || '').trim() || null,
createdAt: String(job.created_at || '').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 existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length; const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
@@ -6195,8 +6223,12 @@ class HistoryService {
relatedJobs, relatedJobs,
pathCandidates: preview.pathCandidates, pathCandidates: preview.pathCandidates,
protectedRoots: preview.protectedRoots, protectedRoots: preview.protectedRoots,
flags: {
containsOrphanRawImportJob: orphanRawImportJobs > 0
},
counts: { counts: {
relatedJobs: relatedJobs.length, relatedJobs: relatedJobs.length,
orphanRawImportJobs,
rawCandidates: preview.pathCandidates.raw.length, rawCandidates: preview.pathCandidates.raw.length,
movieCandidates: preview.pathCandidates.movie.length, movieCandidates: preview.pathCandidates.movie.length,
existingRawCandidates, existingRawCandidates,
@@ -6887,6 +6919,28 @@ class HistoryService {
throw error; 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); const includeRelated = Boolean(options?.includeRelated);
if (!includeRelated) { if (!includeRelated) {
const resolved = await this.getJobByIdOrReplacement(jobId); const resolved = await this.getJobByIdOrReplacement(jobId);
@@ -6903,10 +6957,11 @@ class HistoryService {
throw error; throw error;
} }
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let fileSummary = null; let fileSummary = null;
if (fileTarget !== 'none') { if (effectiveFileTarget !== 'none') {
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false }); const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
fileSummary = this._deletePathsFromPreview(preview, fileTarget, { fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths selectedMoviePaths: options?.selectedMoviePaths
}); });
} }
@@ -6968,7 +7023,8 @@ class HistoryService {
logger.warn('job:deleted', { logger.warn('job:deleted', {
jobId: normalizedJobId, jobId: normalizedJobId,
fileTarget, fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: false, includeRelated: false,
pipelineStateReset: isActivePipelineJob, pipelineStateReset: isActivePipelineJob,
filesDeleted: fileSummary filesDeleted: fileSummary
@@ -6982,7 +7038,8 @@ class HistoryService {
return { return {
deleted: true, deleted: true,
jobId: normalizedJobId, jobId: normalizedJobId,
fileTarget, fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: false, includeRelated: false,
deletedJobIds: [Number(normalizedJobId)], deletedJobIds: [Number(normalizedJobId)],
fileSummary fileSummary
@@ -6990,6 +7047,7 @@ class HistoryService {
} }
const resolved = await this.getJobByIdOrReplacement(jobId); const resolved = await this.getJobByIdOrReplacement(jobId);
const existing = resolved?.job || null;
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId); const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true }); const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
const deleteJobIds = Array.isArray(preview?.relatedJobs) const deleteJobIds = Array.isArray(preview?.relatedJobs)
@@ -7014,9 +7072,10 @@ class HistoryService {
throw error; throw error;
} }
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
let fileSummary = null; let fileSummary = null;
if (fileTarget !== 'none') { if (effectiveFileTarget !== 'none') {
fileSummary = this._deletePathsFromPreview(preview, fileTarget, { fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
selectedMoviePaths: options?.selectedMoviePaths selectedMoviePaths: options?.selectedMoviePaths
}); });
} }
@@ -7068,7 +7127,8 @@ class HistoryService {
logger.warn('job:deleted', { logger.warn('job:deleted', {
jobId: normalizedJobId, jobId: normalizedJobId,
fileTarget, fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: true, includeRelated: true,
deletedJobIds: deleteJobIds, deletedJobIds: deleteJobIds,
deletedJobCount: deleteJobIds.length, deletedJobCount: deleteJobIds.length,
@@ -7084,7 +7144,8 @@ class HistoryService {
return { return {
deleted: true, deleted: true,
jobId: normalizedJobId, jobId: normalizedJobId,
fileTarget, fileTarget: effectiveFileTarget,
requestedFileTarget: fileTarget,
includeRelated: true, includeRelated: true,
deletedJobIds: deleteJobIds, deletedJobIds: deleteJobIds,
deletedJobs: preview.relatedJobs, deletedJobs: preview.relatedJobs,
+492 -142
View File
@@ -94,11 +94,29 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus' 'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
]); ]);
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS); 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() { function nowIso() {
return new Date().toISOString(); 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) { function normalizeCdTrackText(value) {
return String(value || '') return String(value || '')
.normalize('NFC') .normalize('NFC')
@@ -948,15 +966,16 @@ function formatTemplateTwoDigit(rawValue) {
return String(rawValue || '').trim() || String(numeric); 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 assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {}; const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {};
const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false;
const episodeStart = normalizeTemplateNumberValue( const episodeStart = normalizeTemplateNumberValue(
assignmentSource?.episodeNumberStart assignmentSource?.episodeNumberStart
?? assignmentSource?.episodeNoStart ?? assignmentSource?.episodeNoStart
?? assignmentSource?.episodeNumber ?? assignmentSource?.episodeNumber
?? assignmentSource?.number ?? assignmentSource?.number
?? selectedTitleSource?.episodeNumber ?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null)
?? null, ?? null,
fallbackEpisodeNumber 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) { function buildSeriesEpisodeRangeToken(start, end) {
const startToken = formatTemplateTwoDigit(start); const startToken = formatTemplateTwoDigit(start);
const endToken = formatTemplateTwoDigit(end); const endToken = formatTemplateTwoDigit(end);
@@ -1178,19 +1358,39 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
const assignment = primaryTitleId const assignment = primaryTitleId
? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null) ? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null)
: 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( const seasonNumber = normalizePositiveInteger(
assignment?.seasonNumber resolvedAssignment?.seasonNumber
?? selectedTitle?.seasonNumber ?? resolvedTitle?.seasonNumber
?? selectedMetadata?.seasonNumber ?? selectedMetadata?.seasonNumber
?? analyzeContext?.seriesLookupHint?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber
?? null ?? null
) || 1; ) || 1;
const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment(
assignment,
selectedTitle,
primaryTitleId || 1
);
const episodeTemplateValue = episodeRangeInfo.start; const episodeTemplateValue = episodeRangeInfo.start;
const episodeRangeToken = buildSeriesEpisodeRangeToken( const episodeRangeToken = buildSeriesEpisodeRangeToken(
episodeRangeInfo.start, episodeRangeInfo.start,
@@ -1199,7 +1399,7 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo); const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo);
const discNumber = normalizePositiveInteger( const discNumber = normalizePositiveInteger(
selectedMetadata?.discNumber selectedMetadata?.discNumber
?? assignment?.discNumber ?? resolvedAssignment?.discNumber
?? analyzeContext?.seriesLookupHint?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber
?? null ?? null
); );
@@ -1210,16 +1410,27 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|| job?.detected_title || job?.detected_title
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series') || (fallbackJobId ? `job-${fallbackJobId}` : 'series')
) || (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( const rawEpisodeTitle = normalizeCdTrackText(
assignment?.episodeTitle resolvedAssignment?.episodeTitle
|| selectedTitle?.episodeTitle || resolvedTitle?.episodeTitle
|| selectedTitle?.fileName || fallbackEpisodeTitle
|| `Episode ${episodeRangeToken}` ) || fallbackEpisodeTitle;
) || `Episode ${episodeRangeToken}`;
const episodeTitle = normalizeSeriesEpisodeTitleForOutput( const episodeTitle = normalizeSeriesEpisodeTitleForOutput(
rawEpisodeTitle, rawEpisodeTitle,
episodeRangeInfo episodeRangeInfo
); ) || fallbackEpisodeTitle;
const language = String( const language = String(
settings?.dvd_series_language settings?.dvd_series_language
|| selectedMetadata?.language || selectedMetadata?.language
@@ -1227,21 +1438,10 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
).trim() || 'unknown'; ).trim() || 'unknown';
const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear(); 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, { const rendered = renderTemplate(template, {
seriesTitle, seriesTitle,
seasonNr: seasonNumber, seasonNr: seasonNumber,
seasonNo: String(seasonNumber),
episodeNr: episodeTemplateValue, episodeNr: episodeTemplateValue,
episodeNo: String(episodeTemplateValue),
episodeNoStart: episodeRangeInfo.start, episodeNoStart: episodeRangeInfo.start,
episodeNoEnd: episodeRangeInfo.end, episodeNoEnd: episodeRangeInfo.end,
episodeNumberStart: episodeRangeInfo.start, episodeNumberStart: episodeRangeInfo.start,
@@ -5283,15 +5483,16 @@ function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, chi
const episodeRange = resolveSeriesEpisodeRangeFromAssignment( const episodeRange = resolveSeriesEpisodeRangeFromAssignment(
assignment, assignment,
selectedTitle, selectedTitle,
childIndex + 1 childIndex + 1,
{ allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) }
); );
const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`;
const rawEpisodeLabel = String( const rawEpisodeLabel = String(
assignment?.episodeTitle assignment?.episodeTitle
|| selectedTitle?.episodeTitle || selectedTitle?.episodeTitle
|| selectedTitle?.fileName || fallbackEpisodeLabel
|| ''
).trim(); ).trim();
const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange); const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel;
const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null; const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null;
const episodeToken = buildSeriesEpisodeRangeToken( const episodeToken = buildSeriesEpisodeRangeToken(
@@ -5326,6 +5527,25 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
? plan.episodeAssignments ? plan.episodeAssignments
: {}; : {};
const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null; 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 { return {
...plan, ...plan,
@@ -5343,12 +5563,12 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [], handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [],
titleSelectionRequired: false, titleSelectionRequired: false,
titles: remappedTitles, titles: remappedTitles,
episodeAssignments: selectedAssignment episodeAssignments: resolvedChildAssignment
? { ? {
[String(titleId)]: { [String(titleId)]: {
...selectedAssignment, ...resolvedChildAssignment,
titleId: Number(titleId), 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)) { if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
continue; continue;
} }
const status = String(job?.status || job?.last_state || '').trim().toUpperCase();
if (status !== 'ENCODING' && status !== 'CD_ENCODING') {
continue;
}
const poolType = this.resolveQueuePoolTypeForJob(job); const poolType = this.resolveQueuePoolTypeForJob(job);
if (poolType === 'audio') { if (poolType === 'audio') {
audioRunning += 1; audioRunning += 1;
@@ -10759,49 +10983,52 @@ class PipelineService extends EventEmitter {
const filmRunning = runningUsage.filmRunning; const filmRunning = runningUsage.filmRunning;
const cdRunning = runningUsage.audioRunning; const cdRunning = runningUsage.audioRunning;
const totalRunning = runningUsage.totalRunning; const totalRunning = runningUsage.totalRunning;
// Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
const anyActiveJobs = totalRunning;
// Find next startable entry // Find next startable entry
let entryIndex = -1; let entryIndex = -1;
for (let i = 0; i < this.queueEntries.length; i++) { const independentEntryIndex = this.queueEntries.findIndex((candidate) => {
const candidate = this.queueEntries[i]; const type = String(candidate?.type || '').trim().toLowerCase();
const isNonJob = candidate.type && candidate.type !== 'job'; return type === 'script' || type === 'chain';
});
if (isNonJob) { if (independentEntryIndex >= 0) {
// Non-job entries (script, chain, wait) only start when no jobs are actively running. entryIndex = independentEntryIndex;
// anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.) } else {
// activeProcesses provides a second safety net for race conditions during state transitions. for (let i = 0; i < this.queueEntries.length; i++) {
if (anyActiveJobs === 0 && this.activeProcesses.size === 0) { const candidate = this.queueEntries[i];
entryIndex = i; const isWaitEntry = String(candidate?.type || '').trim().toLowerCase() === 'wait';
} if (isWaitEntry) {
break; // FIFO: stop scanning regardless (non-job blocks everything behind it) // Wait keeps queue order and blocks until no tracked job process is active.
} if (this.activeProcesses.size === 0) {
entryIndex = i;
// 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;
break; break;
} }
// CD limit reached
if (!cdBypass) break; // Strict FIFO: stop scanning // Job entry: check hierarchical limits (caps apply only to encode jobs).
continue; // Bypass mode: skip this blocked CD entry if (totalRunning >= maxTotal) {
} else { // Total encode cap reached nothing can start.
// Film/video job entry
if (filmRunning < maxFilm) {
entryIndex = i;
break; break;
} }
// Film limit reached
if (!cdBypass) break; // Strict FIFO: stop scanning const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
continue; // Bypass mode: skip this blocked film entry 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 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)) { if (!Array.isArray(normalizedResults)) {
normalizedResults = []; normalizedResults = [];
} }
@@ -12518,6 +12774,26 @@ class PipelineService extends EventEmitter {
throw error; 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; return normalizedResults;
} }
@@ -14174,12 +14450,22 @@ class PipelineService extends EventEmitter {
|| mkInfo?.analyzeContext?.selectedMetadata?.episodeCount || mkInfo?.analyzeContext?.selectedMetadata?.episodeCount
|| 0 || 0
) || 0; ) || 0;
let effectiveEpisodes = Array.isArray(episodes) const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0;
let effectiveEpisodes = hasExplicitEpisodePayload
? episodes ? episodes
: (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes) : (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes)
? mkInfo.analyzeContext.selectedMetadata.episodes ? mkInfo.analyzeContext.selectedMetadata.episodes
: []); : []);
let tmdbDetails = null; 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'; const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series';
if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') { if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') {
@@ -14218,6 +14504,7 @@ class PipelineService extends EventEmitter {
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) { if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) {
try { try {
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, { const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
language: tmdbLanguage,
appendToResponse: ['credits', 'external_ids'] appendToResponse: ['credits', 'external_ids']
}); });
tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails); tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails);
@@ -14248,15 +14535,17 @@ class PipelineService extends EventEmitter {
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) { if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) {
try { try {
seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber); seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, {
seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber); language: tmdbLanguage
});
seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, {
language: tmdbLanguage
});
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails); const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
if (seasonSummary) { if (seasonSummary) {
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : []; const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
if (fetchedEpisodes.length > 0) { if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) {
if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) { effectiveEpisodes = fetchedEpisodes;
effectiveEpisodes = fetchedEpisodes;
}
} }
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0; const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
if (fetchedEpisodeCount > 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 = {}) { async startEncodingFromPrepared(jobId, options = {}) {
this.ensureNotBusy('startEncodingFromPrepared', jobId); this.ensureNotBusy('startEncodingFromPrepared', jobId);
logger.info('encode:start-from-prepared', { jobId }); logger.info('encode:start-from-prepared', { jobId });
@@ -18568,40 +18949,15 @@ class PipelineService extends EventEmitter {
historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => { historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => {
logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message }); logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message });
}); });
let postEncodeScriptsSummary = { const postEncodeExecutionContext = {
configured: 0, mode,
attempted: 0, jobTitle: job.title || job.detected_title || null,
succeeded: 0, inputPath,
failed: 0, outputPath: finalizedOutputPath,
skipped: 0, rawPath: activeRawPath,
results: [] pipelineStage: 'ENCODING'
}; };
try { const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
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.' : ''}`
);
}
let finalizedRawPath = activeRawPath || null; let finalizedRawPath = activeRawPath || null;
if (activeRawPath) { if (activeRawPath) {
const currentRawPath = String(activeRawPath || '').trim(); const currentRawPath = String(activeRawPath || '').trim();
@@ -18662,6 +19018,9 @@ class PipelineService extends EventEmitter {
output_path: finalizedOutputPath, output_path: finalizedOutputPath,
error_message: null error_message: null
}); });
this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
phaseLabel: 'Post-Encode'
});
return { return {
outputPath: finalizedOutputPath, outputPath: finalizedOutputPath,
handbrakeInfo: handbrakeInfoWithPostScripts, handbrakeInfo: handbrakeInfoWithPostScripts,
@@ -18723,6 +19082,9 @@ class PipelineService extends EventEmitter {
logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId }); logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId });
void this.pumpQueue(); void this.pumpQueue();
} }
this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
phaseLabel: 'Post-Encode'
});
if (mode === 'reencode') { if (mode === 'reencode') {
void this.notifyPushover('reencode_finished', { void this.notifyPushover('reencode_finished', {
@@ -23532,8 +23894,6 @@ class PipelineService extends EventEmitter {
const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {}; const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []); const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []);
const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []); const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []);
const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []);
const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []);
let preEncodeScriptsSummary = { let preEncodeScriptsSummary = {
configured: 0, configured: 0,
attempted: 0, attempted: 0,
@@ -23755,35 +24115,17 @@ class PipelineService extends EventEmitter {
} }
const { encodeResults: cdEncodeResults = [] } = cdRipResult || {}; const { encodeResults: cdEncodeResults = [] } = cdRipResult || {};
settleLifecycle(); settleLifecycle();
postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan);
if (postScriptIds.length > 0 || postChainIds.length > 0) { let postCdScriptContext = {
await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...'); mode: 'cd_rip',
try { jobId,
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, { jobTitle: selectedMeta?.title || `Job #${jobId}`,
mode: 'cd_rip', inputPath: devicePath || null,
jobId, outputPath: outputDir || null,
jobTitle: selectedMeta?.title || `Job #${jobId}`, rawPath: rawWavDir || null,
inputPath: devicePath || null, mediaProfile: 'cd',
outputPath: outputDir || null, pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
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.`
);
}
// RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen // RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen
let activeRawDir = rawWavDir; let activeRawDir = rawWavDir;
@@ -23800,6 +24142,11 @@ class PipelineService extends EventEmitter {
// ignore raw dir bleibt unter bestehendem Namen zugänglich // ignore raw dir bleibt unter bestehendem Namen zugänglich
} }
} }
postCdScriptContext = {
...postCdScriptContext,
outputPath: outputDir || null,
rawPath: activeRawDir || rawWavDir || null
};
const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso(); const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso();
const persistedEncodePlan = { const persistedEncodePlan = {
@@ -23963,6 +24310,9 @@ class PipelineService extends EventEmitter {
void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath)); void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
} }
} }
this.runPostEncodeScriptsDetached(jobId, normalizedEncodePlan, postCdScriptContext, {
phaseLabel: 'Post-Rip'
});
} catch (error) { } catch (error) {
settleLifecycle(); settleLifecycle();
const failedCdLive = buildLiveContext(currentTrackPosition || null); const failedCdLive = buildLiveContext(currentTrackPosition || null);
+2 -2
View File
@@ -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_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) 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_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index) INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {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})'); INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
-- Tools CD -- Tools CD
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-9", "version": "0.13.1-10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-9", "version": "0.13.1-10",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.13.1-9", "version": "0.13.1-10",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2
View File
@@ -823,6 +823,7 @@ export const api = {
async deleteJobEntry(jobId, target = 'none', options = {}) { async deleteJobEntry(jobId, target = 'none', options = {}) {
const includeRelated = Boolean(options?.includeRelated); const includeRelated = Boolean(options?.includeRelated);
const resetDriveState = Boolean(options?.resetDriveState); const resetDriveState = Boolean(options?.resetDriveState);
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths) const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths ? options.selectedMoviePaths
.map((item) => String(item || '').trim()) .map((item) => String(item || '').trim())
@@ -838,6 +839,7 @@ export const api = {
target, target,
includeRelated, includeRelated,
resetDriveState, resetDriveState,
preserveRawForImportJobs,
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}), ...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {}) ...(selectedMoviePaths ? { selectedMoviePaths } : {})
}) })
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog'; import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable'; import { DataTable } from 'primereact/datatable';
@@ -45,11 +45,13 @@ export default function MetadataSelectionDialog({
const [manualImdb, setManualImdb] = useState(''); const [manualImdb, setManualImdb] = useState('');
const [manualDiscNumber, setManualDiscNumber] = useState(''); const [manualDiscNumber, setManualDiscNumber] = useState('');
const [extraResults, setExtraResults] = useState([]); const [extraResults, setExtraResults] = useState([]);
const [hasSearchOverride, setHasSearchOverride] = useState(false);
const [searchBusy, setSearchBusy] = useState(false); const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState(''); const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState(''); const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState(''); const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState(''); const [discValidationError, setDiscValidationError] = useState('');
const activeSearchRequestRef = useRef(0);
const contextWorkflowKind = normalizeWorkflowKind( const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind context?.selectedMetadata?.workflowKind
|| context?.workflowKind || context?.workflowKind
@@ -102,12 +104,14 @@ export default function MetadataSelectionDialog({
setManualImdb(defaultImdb); setManualImdb(defaultImdb);
setManualDiscNumber(''); setManualDiscNumber('');
setExtraResults([]); setExtraResults([]);
setHasSearchOverride(false);
setSearchBusy(false); setSearchBusy(false);
setSearchError(''); setSearchError('');
setSubmitError(''); setSubmitError('');
setSeasonFilter(''); setSeasonFilter('');
setDiscValidationError(''); setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider); setSelectedMetadataProvider(contextMetadataProvider);
activeSearchRequestRef.current += 1;
}, [visible, context]); }, [visible, context]);
useEffect(() => { useEffect(() => {
@@ -129,7 +133,8 @@ export default function MetadataSelectionDialog({
const base = Array.isArray(context?.metadataCandidates) const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates ? context.metadataCandidates
: (context?.omdbCandidates || []); : (context?.omdbCandidates || []);
const all = [...base, ...extraResults]; const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map(); const map = new Map();
all.forEach((item) => { all.forEach((item) => {
@@ -149,7 +154,7 @@ export default function MetadataSelectionDialog({
}); });
return Array.from(map.values()); return Array.from(map.values());
}, [context, extraResults]); }, [context, extraResults, hasSearchOverride]);
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') { if (metadataProvider !== 'tmdb') {
@@ -192,18 +197,28 @@ export default function MetadataSelectionDialog({
if (!trimmedQuery || typeof onSearch !== 'function') { if (!trimmedQuery || typeof onSearch !== 'function') {
return; return;
} }
const requestId = activeSearchRequestRef.current + 1;
activeSearchRequestRef.current = requestId;
setHasSearchOverride(true);
setSearchBusy(true); setSearchBusy(true);
setSearchError(''); setSearchError('');
try { try {
const results = await onSearch(trimmedQuery, { const results = await onSearch(trimmedQuery, {
metadataProvider metadataProvider
}); });
if (activeSearchRequestRef.current !== requestId) {
return;
}
setExtraResults(Array.isArray(results) ? results : []); setExtraResults(Array.isArray(results) ? results : []);
} catch (error) { } catch (error) {
setExtraResults([]); if (activeSearchRequestRef.current !== requestId) {
return;
}
setSearchError(error?.message || 'Suche fehlgeschlagen.'); setSearchError(error?.message || 'Suche fehlgeschlagen.');
} finally { } finally {
setSearchBusy(false); if (activeSearchRequestRef.current === requestId) {
setSearchBusy(false);
}
} }
}; };
@@ -262,10 +277,6 @@ export default function MetadataSelectionDialog({
}; };
const handleHideRequest = () => { const handleHideRequest = () => {
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
onHide?.(); onHide?.();
}; };
@@ -291,6 +302,7 @@ export default function MetadataSelectionDialog({
setSelectedMetadataProvider(nextProvider); setSelectedMetadataProvider(nextProvider);
setSelected(null); setSelected(null);
setExtraResults([]); setExtraResults([]);
setHasSearchOverride(false);
setSearchError(''); setSearchError('');
setSubmitError(''); setSubmitError('');
setDiscValidationError(''); setDiscValidationError('');
@@ -331,7 +343,7 @@ export default function MetadataSelectionDialog({
/> />
<small className="metadata-filter-subtitle"> <small className="metadata-filter-subtitle">
{requiresDiscNumber {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.'} : 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
</small> </small>
</div> </div>
@@ -394,7 +406,6 @@ export default function MetadataSelectionDialog({
severity="secondary" severity="secondary"
text text
onClick={handleHideRequest} onClick={handleHideRequest}
disabled={requiresDiscNumber && !hasValidDiscNumber}
/> />
<Button <Button
label="Auswahl übernehmen" label="Auswahl übernehmen"
+178 -29
View File
@@ -669,15 +669,16 @@ function formatTemplateTwoDigit(value) {
return String(value || '').trim() || String(numeric); return String(value || '').trim() || String(numeric);
} }
function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1) { function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1, options = {}) {
const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {}; const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
const titleSource = title && typeof title === 'object' ? title : {}; const titleSource = title && typeof title === 'object' ? title : {};
const allowTitleEpisodeNumber = options?.allowTitleEpisodeNumber !== false;
const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1; const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1;
const episodeStart = normalizePositiveInt( const episodeStart = normalizePositiveInt(
assignmentSource?.episodeNumberStart assignmentSource?.episodeNumberStart
?? assignmentSource?.episodeNoStart ?? assignmentSource?.episodeNoStart
?? assignmentSource?.episodeNumber ?? assignmentSource?.episodeNumber
?? titleSource?.episodeNumber ?? (allowTitleEpisodeNumber ? titleSource?.episodeNumber : null)
?? fallbackNumber ?? fallbackNumber
) || fallbackNumber; ) || fallbackNumber;
const explicitEpisodeEnd = normalizePositiveInt( const explicitEpisodeEnd = normalizePositiveInt(
@@ -939,7 +940,135 @@ function resolveEffectiveEpisodeRangeForTitle(assignment = null, title = null, a
}; };
} }
function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assignment = null, fallbackJobId = null, allTitles = []) { function resolveSeriesTitleOrderForPreview(allTitles = [], selectedTitleIds = [], targetTitleId = null) {
const explicitSelectedIds = normalizeTitleIdList(selectedTitleIds);
const fallbackSelectedIds = explicitSelectedIds.length > 0
? explicitSelectedIds
: normalizeTitleIdList(
(Array.isArray(allTitles) ? allTitles : [])
.filter((item) => Boolean(item?.selectedForEncode || item?.encodeInput))
.map((item) => item?.id)
);
let ordered = fallbackSelectedIds;
const normalizedTarget = normalizeTitleId(targetTitleId);
if (normalizedTarget && !ordered.some((id) => Number(id) === Number(normalizedTarget))) {
ordered = [...ordered, Number(normalizedTarget)];
}
if (ordered.length === 0 && normalizedTarget) {
ordered = [Number(normalizedTarget)];
}
return ordered;
}
function resolveEpisodeRangeForTitleFromAssignments({
assignmentByTitle = {},
allTitles = [],
selectedTitleIds = [],
targetTitle = null,
fallbackEpisodeNumber = 1
}) {
const titles = Array.isArray(allTitles) ? allTitles : [];
const titlesById = new Map();
for (const item of titles) {
const id = normalizeTitleId(item?.id);
if (!id) {
continue;
}
titlesById.set(Number(id), item);
}
const targetTitleId = normalizeTitleId(targetTitle?.id || null);
const orderedTitleIds = resolveSeriesTitleOrderForPreview(titles, selectedTitleIds, targetTitleId);
const fallbackStart = normalizePositiveInt(fallbackEpisodeNumber) || 1;
let cursor = fallbackStart;
let targetRange = null;
for (const rawId of orderedTitleIds) {
const currentId = normalizeTitleId(rawId);
if (!currentId) {
continue;
}
const assignment = assignmentByTitle?.[currentId] || assignmentByTitle?.[String(currentId)] || null;
const currentTitle = titlesById.get(Number(currentId)) || null;
const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment);
const range = resolveEpisodeAssignmentRange(
assignment,
currentTitle,
cursor,
{ allowTitleEpisodeNumber: hasExplicitRange }
);
const normalizedEnd = normalizePositiveInt(range?.end);
if (normalizedEnd !== null) {
cursor = normalizedEnd + 1;
}
if (targetTitleId && Number(currentId) === Number(targetTitleId)) {
targetRange = range;
break;
}
}
if (!targetRange) {
const assignment = targetTitleId
? (assignmentByTitle?.[targetTitleId] || assignmentByTitle?.[String(targetTitleId)] || null)
: null;
const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment);
targetRange = resolveEpisodeAssignmentRange(
assignment,
targetTitle,
cursor,
{ allowTitleEpisodeNumber: hasExplicitRange }
);
}
return targetRange;
}
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 formatEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') {
const numeric = Number(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) {
return String(rawValue || '').trim() || '0';
}
if (!Number.isInteger(numeric)) {
return String(numeric);
}
const normalized = String(Math.trunc(numeric));
if (templateUsesLeadingZeroForToken(template, token)) {
return normalized.padStart(2, '0');
}
return normalized;
}
function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '') {
const start = normalizePositiveInt(episodeRange?.start) || 1;
const end = normalizePositiveInt(episodeRange?.end) || start;
if (end > start) {
const startToken = formatEpisodeNumberByTemplate(start, template, 'episodeNr');
const endToken = formatEpisodeNumberByTemplate(end, template, 'episodeNr');
return `Folge ${startToken}-${endToken}`;
}
const token = formatEpisodeNumberByTemplate(start, template, 'episodeNr');
return `Folge ${token}`;
}
function buildDvdSeriesEpisodeOutputPathPreview(
settings,
metadata,
title,
assignment = null,
fallbackJobId = null,
allTitles = [],
assignmentByTitle = {},
selectedTitleIds = []
) {
const seriesRootDir = String( const seriesRootDir = String(
settings?.series_dir_dvd settings?.series_dir_dvd
|| settings?.movie_dir_dvd || settings?.movie_dir_dvd
@@ -953,15 +1082,28 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
const seasonNumber = normalizePositiveInt( const seasonNumber = normalizePositiveInt(
assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 1 assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 1
) || 1; ) || 1;
const normalizedSelectedIds = normalizeTitleIdList(selectedTitleIds);
const targetTitleId = normalizeTitleId(title?.id);
const fallbackEpisodeNumber = targetTitleId && normalizedSelectedIds.length > 0
? (normalizedSelectedIds.findIndex((id) => Number(id) === Number(targetTitleId)) + 1)
: 1;
const inferredRange = resolveEpisodeRangeForTitleFromAssignments({
assignmentByTitle,
allTitles,
selectedTitleIds: normalizedSelectedIds,
targetTitle: title,
fallbackEpisodeNumber: fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1
});
const episodeRange = resolveEffectiveEpisodeRangeForTitle( const episodeRange = resolveEffectiveEpisodeRangeForTitle(
assignment, assignment || {},
title, title,
allTitles, allTitles,
title?.id || 1 inferredRange?.start || (fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1)
); );
const episodeNumber = episodeRange.start; const effectiveEpisodeRange = episodeRange;
const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end); const episodeNumber = effectiveEpisodeRange.start;
const partsToken = buildEpisodePartsToken(episodeRange); const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end);
const partsToken = buildEpisodePartsToken(effectiveEpisodeRange);
const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null); const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null);
const seriesTitleRaw = String( const seriesTitleRaw = String(
metadata?.title metadata?.title
@@ -969,16 +1111,6 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series') || (fallbackJobId ? `job-${fallbackJobId}` : 'series')
).trim(); ).trim();
const seriesTitle = seriesTitleRaw || 'series'; const seriesTitle = seriesTitleRaw || 'series';
const episodeTitleRaw = String(
assignment?.episodeTitle
|| title?.episodeTitle
|| title?.fileName
|| `Episode ${episodeNumber}`
).trim();
const episodeTitle = normalizeEpisodeTitleForOutput(
episodeTitleRaw || `Episode ${episodeRangeToken}`,
episodeRange
);
const year = String(metadata?.year || new Date().getFullYear()).trim(); const year = String(metadata?.year || new Date().getFullYear()).trim();
const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null); const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null);
@@ -992,21 +1124,28 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
).trim(); ).trim();
const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}'; const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}';
const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})'; const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const template = episodeRange.isMulti ? multiTemplate : singleTemplate; const template = effectiveEpisodeRange.isMulti ? multiTemplate : singleTemplate;
const fallbackEpisodeTitle = buildFallbackEpisodeTitleForOutput(effectiveEpisodeRange, template);
const episodeTitleRaw = String(
assignment?.episodeTitle
|| title?.episodeTitle
|| fallbackEpisodeTitle
).trim();
const rendered = renderTemplate(template, { const rendered = renderTemplate(template, {
seriesTitle, seriesTitle,
seasonNr: seasonNumber, seasonNr: seasonNumber,
seasonNo: String(seasonNumber),
episodeNr: episodeNumber, episodeNr: episodeNumber,
episodeNo: String(episodeNumber), episodeNoStart: effectiveEpisodeRange.start,
episodeNoStart: episodeRange.start, episodeNoEnd: effectiveEpisodeRange.end,
episodeNoEnd: episodeRange.end, episodeNumberStart: effectiveEpisodeRange.start,
episodeNumberStart: episodeRange.start, episodeNumberEnd: effectiveEpisodeRange.end,
episodeNumberEnd: episodeRange.end,
episodeRange: episodeRangeToken, episodeRange: episodeRangeToken,
episodeSpan: episodeRange.span, episodeSpan: effectiveEpisodeRange.span,
parts: partsToken, parts: partsToken,
episodeTitle, episodeTitle: normalizeEpisodeTitleForOutput(
episodeTitleRaw || fallbackEpisodeTitle,
effectiveEpisodeRange
) || fallbackEpisodeTitle,
discNr: discNumber || '', discNr: discNumber || '',
year, year,
language language
@@ -2380,14 +2519,24 @@ export default function PipelineStatusCard({
title, title,
assignment, assignment,
retryJobId, retryJobId,
Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [] Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [],
episodeAssignmentsByTitle,
selectedEncodeTitleIds
); );
if (path) { if (path) {
map[titleId] = path; map[titleId] = path;
} }
} }
return map; return map;
}, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, settingsMap, selectedMetadata, retryJobId]); }, [
isSeriesDvdReview,
mediaInfoReview?.titles,
episodeAssignmentsByTitle,
settingsMap,
selectedMetadata,
retryJobId,
selectedEncodeTitleIds
]);
const multiEpisodeReviewWarnings = useMemo(() => { const multiEpisodeReviewWarnings = useMemo(() => {
if (!isSeriesDvdReview) { if (!isSeriesDvdReview) {
return []; return [];
+18 -5
View File
@@ -1948,9 +1948,17 @@ export default function RipperPage({
return; return;
} }
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null; const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const orphanImportSource = String(
job?.makemkvInfo?.source
|| job?.makemkvInfo?.importRecovery?.source
|| ''
).trim().toLowerCase();
const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import';
const ripSuccessful = Number(job?.rip_successful || 0) === 1; const ripSuccessful = Number(job?.rip_successful || 0) === 1;
const deleteIncompleteRaw = !ripSuccessful && isIncompleteRawPath(job?.raw_path); const deleteIncompleteRaw = !isOrphanRawImportJob && !ripSuccessful && isIncompleteRawPath(job?.raw_path);
const deleteTarget = deleteIncompleteRaw ? 'raw' : 'none'; const deleteTarget = deleteIncompleteRaw ? 'raw' : 'none';
const resetDriveStateOnDelete = !isOrphanRawImportJob;
const keepDetectedDeviceOnDelete = isOrphanRawImportJob;
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim(); const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
const confirmed = await confirmModal({ const confirmed = await confirmModal({
header: 'Job löschen', header: 'Job löschen',
@@ -1959,9 +1967,13 @@ export default function RipperPage({
`${title}\n\n` + `${title}\n\n` +
(deleteIncompleteRaw (deleteIncompleteRaw
? 'Hinweis: Unvollständiges RAW wird gelöscht. Für einen neuen Versuch bitte das Laufwerk erneut analysieren.' ? 'Hinweis: Unvollständiges RAW wird gelöscht. Für einen neuen Versuch bitte das Laufwerk erneut analysieren.'
: 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.') + : (isOrphanRawImportJob
? 'Hinweis: Dieser /database-Job löscht beim Entfernen aus dem Ripper keine RAW-Dateien.'
: 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.')) +
'\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.' + '\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.' +
'\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.', (isOrphanRawImportJob
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
: '\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'),
acceptLabel: 'Löschen', acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen', rejectLabel: 'Abbrechen',
danger: true danger: true
@@ -1987,8 +1999,9 @@ export default function RipperPage({
} }
await api.deleteJobEntry(normalizedJobId, deleteTarget, { await api.deleteJobEntry(normalizedJobId, deleteTarget, {
includeRelated: true, includeRelated: true,
resetDriveState: true, resetDriveState: resetDriveStateOnDelete,
keepDetectedDevice: false keepDetectedDevice: keepDetectedDeviceOnDelete,
preserveRawForImportJobs: true
}); });
await refreshPipeline(); await refreshPipeline();
await loadRipperJobs(); await loadRipperJobs();
-1037
View File
File diff suppressed because it is too large Load Diff
+130 -1
View File
@@ -135,6 +135,114 @@ detect_container_env() {
command_exists() { command -v "$1" &>/dev/null; } command_exists() { command -v "$1" &>/dev/null; }
service_unit_exists() {
local unit="$1"
systemctl list-unit-files --type=service --all --no-legend 2>/dev/null \
| awk '{print $1}' | grep -Fxq "$unit"
}
stop_service_if_exists() {
local service="$1"
local unit="${service}.service"
if ! command_exists systemctl; then
warn "systemctl nicht gefunden - Stop von '$service' uebersprungen"
return 0
fi
if service_unit_exists "$unit"; then
if systemctl is-active --quiet "$service"; then
info "Stoppe Dienst '$service' vor dem Update..."
systemctl stop "$service"
ok "Dienst '$service' gestoppt"
else
info "Dienst '$service' ist bereits gestoppt"
fi
else
info "Dienst '$service' nicht vorhanden - Stop uebersprungen"
fi
}
backup_and_remove_backend_src() {
local src_dir="$INSTALL_DIR/backend/src"
local backup_root="$INSTALL_DIR/backend/src_backups"
local backup_dir="${backup_root}/src_bak_$(date +%Y%m%d%H%M%S)"
local keep_count=2
local backups=()
local old_backup
if [[ ! -d "$src_dir" ]]; then
fatal "Reinstall erwartet vorhandenes Backend-Quellverzeichnis: $src_dir"
fi
mkdir -p "$backup_root"
mv "$src_dir" "$backup_dir"
ok "Backend-Quellverzeichnis gesichert nach: $backup_dir"
mapfile -t backups < <(ls -1dt "$backup_root"/src_bak_* 2>/dev/null || true)
if (( ${#backups[@]} > keep_count )); then
for old_backup in "${backups[@]:keep_count}"; do
rm -rf "$old_backup"
info "Altes src-Backup entfernt: $old_backup"
done
fi
}
enforce_default_output_dirs_permissions() {
local default_dirs=(
"$INSTALL_DIR/backend/data/output/raw"
"$INSTALL_DIR/backend/data/output/movies"
"$INSTALL_DIR/backend/data/output/series"
"$INSTALL_DIR/backend/data/output/cd"
)
local dir
for dir in "${default_dirs[@]}"; do
mkdir -p "$dir"
chown "$SERVICE_USER:$SERVICE_USER" "$dir"
chmod 755 "$dir"
done
ok "Default-Output-Ordner auf $SERVICE_USER:$SERVICE_USER (755) gesetzt"
}
post_install_check_default_dirs() {
header "Post-Install-Check (Default-Ordner)"
local default_dirs=(
"$INSTALL_DIR/backend/data/output/raw"
"$INSTALL_DIR/backend/data/output/movies"
"$INSTALL_DIR/backend/data/output/series"
"$INSTALL_DIR/backend/data/output/cd"
)
local expected_owner="${SERVICE_USER}:${SERVICE_USER}"
local expected_mode="755"
local dir
local owner
local mode
local failed=0
for dir in "${default_dirs[@]}"; do
if [[ ! -d "$dir" ]]; then
error "Fehlt: $dir"
failed=1
continue
fi
owner="$(stat -c '%U:%G' "$dir" 2>/dev/null || true)"
mode="$(stat -c '%a' "$dir" 2>/dev/null || true)"
if [[ "$owner" != "$expected_owner" || "$mode" != "$expected_mode" ]]; then
error "Falsche Rechte bei $dir (owner=$owner mode=$mode, erwartet: $expected_owner / $expected_mode)"
failed=1
else
ok "$dir -> $owner / $mode"
fi
done
if [[ "$failed" -ne 0 ]]; then
fatal "Post-Install-Check fehlgeschlagen. Rechte/Owner der Default-Ordner bitte prüfen."
fi
}
download_file() { download_file() {
local url="$1" local url="$1"
local target="$2" local target="$2"
@@ -599,6 +707,15 @@ for grp in cdrom optical disk video render; do
fi fi
done done
# --- Dienste vor Reinstall stoppen --------------------------------------------
if [[ "$REINSTALL" == true ]]; then
header "Dienste vor Reinstall stoppen"
stop_service_if_exists ripster-backend
if [[ "$SKIP_NGINX" == false ]]; then
stop_service_if_exists nginx
fi
fi
# --- Repository klonen / aktualisieren ---------------------------------------- # --- Repository klonen / aktualisieren ----------------------------------------
header "Repository holen (Git)" header "Repository holen (Git)"
@@ -618,9 +735,11 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP" cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
info "Datenbank gesichert nach: $DATA_BACKUP" info "Datenbank gesichert nach: $DATA_BACKUP"
fi fi
backup_and_remove_backend_src
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört # safe.directory nötig wenn das Verzeichnis einem anderen User gehört
# (z.B. ripster-Serviceuser nach erstem Install) # (z.B. ripster-Serviceuser nach erstem Install)
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
git -C "$INSTALL_DIR" remote set-branches origin '*' git -C "$INSTALL_DIR" remote set-branches origin '*'
git -C "$INSTALL_DIR" fetch --quiet origin git -C "$INSTALL_DIR" fetch --quiet origin
git -C "$INSTALL_DIR" reset --hard HEAD git -C "$INSTALL_DIR" reset --hard HEAD
@@ -654,6 +773,13 @@ if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
ok "Datenbank wiederhergestellt" ok "Datenbank wiederhergestellt"
fi fi
# Restore kann Altstrukturen zurückbringen Default-Ausgabeordner danach
# erneut sicherstellen.
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
# --- npm-Abhängigkeiten installieren ----------------------------------------- # --- npm-Abhängigkeiten installieren -----------------------------------------
header "npm-Abhängigkeiten installieren" header "npm-Abhängigkeiten installieren"
@@ -735,6 +861,9 @@ else
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)" ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
fi fi
enforce_default_output_dirs_permissions
post_install_check_default_dirs
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis. # MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
# Laufzeit-relevant ist das Verzeichnis des Service-Users. # Laufzeit-relevant ist das Verzeichnis des Service-Users.
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV" MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
@@ -910,4 +1039,4 @@ if [[ ${#missing_tools[@]} -gt 0 ]]; then
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden." echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
fi fi
echo "" echo ""
-832
View File
@@ -1,832 +0,0 @@
#!/usr/bin/env bash
# =============================================================================
# Ripster Installationsskript (Git)
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
# Benötigt: sudo / root, Internetzugang
#
# Verwendung:
# curl -fsSL https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
# oder:
# wget -qO- https://raw.githubusercontent.com/Mboehmlaender/ripster/main/install.sh | sudo bash
#
# Mit Optionen (nur via Datei möglich):
# sudo bash install.sh [Optionen]
#
# Optionen:
# --branch <branch> Git-Branch (Standard: main)
# --dir <pfad> Installationsverzeichnis (Standard: /opt/ripster)
# --user <benutzer> Systembenutzer für den Dienst (Standard: ripster)
# --port <port> Backend-Port (Standard: 3001)
# --host <hostname> Hostname/IP für die Weboberfläche (Standard: Maschinen-IP)
# --no-makemkv MakeMKV-Installation überspringen
# --no-handbrake HandBrake-Installation überspringen
# --no-nginx Nginx-Einrichtung überspringen
# --reinstall Vorhandene Installation aktualisieren (Daten bleiben erhalten)
# -h, --help Diese Hilfe anzeigen
# =============================================================================
set -euo pipefail
REPO_URL="https://github.com/Mboehmlaender/ripster.git"
REPO_RAW_BASE="https://raw.githubusercontent.com/Mboehmlaender/ripster"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd -P)"
BUNDLED_HANDBRAKE_CLI="${SCRIPT_DIR}/bin/HandBrakeCLI"
# --- Farben -------------------------------------------------------------------
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; BOLD='\033[1m'; RESET='\033[0m'
info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
ok() { echo -e "${GREEN}[OK]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
error() { echo -e "${RED}[FEHLER]${RESET} $*" >&2; }
header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; \
echo -e "${BOLD} $*${RESET}"; \
echo -e "${BOLD}${BLUE}══════════════════════════════════════════${RESET}"; }
fatal() { error "$*"; exit 1; }
# --- Standard-Optionen --------------------------------------------------------
GIT_BRANCH="dev"
INSTALL_DIR="/opt/ripster"
SERVICE_USER="ripster"
BACKEND_PORT="3001"
FRONTEND_HOST=""
SKIP_MAKEMKV=false
SKIP_HANDBRAKE=false
HANDBRAKE_INSTALL_MODE=""
SKIP_NGINX=false
REINSTALL=false
# --- Argumente parsen ---------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--branch) GIT_BRANCH="$2"; shift 2 ;;
--dir) INSTALL_DIR="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--port) BACKEND_PORT="$2"; shift 2 ;;
--host) FRONTEND_HOST="$2"; shift 2 ;;
--no-makemkv) SKIP_MAKEMKV=true; shift ;;
--no-handbrake) SKIP_HANDBRAKE=true; shift ;;
--no-nginx) SKIP_NGINX=true; shift ;;
--reinstall) REINSTALL=true; shift ;;
-h|--help)
sed -n '/^# Verwendung/,/^# ====/p' "$0" | head -n -1 | sed 's/^# \?//'
exit 0 ;;
*) fatal "Unbekannte Option: $1" ;;
esac
done
# --- Voraussetzungen prüfen ---------------------------------------------------
header "Ripster Installationsskript (Git)"
if [[ $EUID -ne 0 ]]; then
fatal "Dieses Skript muss als root ausgeführt werden (sudo bash install.sh)"
fi
if [[ ! -f /etc/os-release ]]; then
fatal "Betriebssystem nicht erkennbar. Nur Debian/Ubuntu wird unterstützt."
fi
. /etc/os-release
case "$ID" in
debian|ubuntu|linuxmint|pop) ok "Betriebssystem: $PRETTY_NAME" ;;
*) fatal "Nicht unterstütztes OS: $ID. Nur Debian/Ubuntu unterstützt." ;;
esac
if [[ -z "$FRONTEND_HOST" ]]; then
FRONTEND_HOST=$(hostname -I | awk '{print $1}')
info "Erkannte IP: $FRONTEND_HOST"
fi
info "Repository: $REPO_URL"
info "Branch: $GIT_BRANCH"
info "Installationsverzeichnis: $INSTALL_DIR"
info "Systembenutzer: $SERVICE_USER"
info "Backend-Port: $BACKEND_PORT"
info "Frontend-Host: $FRONTEND_HOST"
# --- Hilfsfunktionen ----------------------------------------------------------
command_exists() { command -v "$1" &>/dev/null; }
download_file() {
local url="$1"
local target="$2"
if command_exists curl; then
curl -fsSL "$url" -o "$target"
return 0
fi
if command_exists wget; then
wget -q "$url" -O "$target"
return 0
fi
return 1
}
nginx_replace_or_insert_directive() {
local file="$1"
local directive_regex="$2"
local desired_line="$3"
local anchor_regex="$4"
local directive_sed_regex="${directive_regex//\//\\/}"
local anchor_sed_regex="${anchor_regex//\//\\/}"
local desired_sed_line="${desired_line//\//\\/}"
if grep -Eq "$directive_regex" "$file"; then
sed -i -E "0,/$directive_sed_regex/s//${desired_sed_line}/" "$file"
return 0
fi
sed -i "/$anchor_sed_regex/a\\$desired_line" "$file"
}
patch_existing_ripster_nginx_site() {
local file="$1"
local backup_file="${file}.bak-$(date +%Y%m%d%H%M%S)"
[[ -f "$file" ]] || return 1
cp -a "$file" "$backup_file"
info "Bestehende nginx-Konfiguration erkannt - ergänze Upload-/Proxy-Settings"
info "Backup erstellt: $backup_file"
nginx_replace_or_insert_directive \
"$file" \
'^[[:space:]]*client_max_body_size[[:space:]]+[^;]+;' \
' client_max_body_size 8G;' \
'server_name .*;'
nginx_replace_or_insert_directive \
"$file" \
'^[[:space:]]*proxy_connect_timeout[[:space:]]+[^;]+;' \
' proxy_connect_timeout 60s;' \
'location /api/ {'
nginx_replace_or_insert_directive \
"$file" \
'^[[:space:]]*proxy_send_timeout[[:space:]]+[^;]+;' \
' proxy_send_timeout 3600s;' \
'location /api/ {'
nginx_replace_or_insert_directive \
"$file" \
'^[[:space:]]*proxy_read_timeout[[:space:]]+[^;]+;' \
' proxy_read_timeout 3600s;' \
'location /api/ {'
nginx_replace_or_insert_directive \
"$file" \
'^[[:space:]]*proxy_request_buffering[[:space:]]+[^;]+;' \
' proxy_request_buffering off;' \
'location /api/ {'
}
install_node() {
header "Node.js installieren"
local required_major=20
if command_exists node; then
local current_major
current_major=$(node -e "process.stdout.write(String(process.version.split('.')[0].replace('v','')))")
if [[ "$current_major" -ge "$required_major" ]]; then
ok "Node.js $(node --version) bereits installiert"
return
fi
warn "Node.js $(node --version) zu alt Node.js 20 wird installiert"
fi
info "Installiere Node.js 20.x über NodeSource..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
ok "Node.js $(node --version) installiert"
}
install_makemkv() {
header "MakeMKV installieren"
if command_exists makemkvcon; then
ok "makemkvcon bereits installiert ($(makemkvcon --version 2>&1 | head -1))"
return
fi
info "Installiere Build-Abhängigkeiten für MakeMKV..."
apt-get install -y \
build-essential pkg-config libc6-dev libssl-dev \
libexpat1-dev libavcodec-dev libgl1-mesa-dev \
qtbase5-dev zlib1g-dev wget
# Aktuelle Version aus dem offiziellen Linux-Forum-Thread ermitteln.
# Der Titel lautet immer: "MakeMKV X.Y.Z for Linux is available"
local makemkv_fallback="1.18.3"
info "Ermittle aktuelle MakeMKV-Version (forum.makemkv.com)..."
local makemkv_version
makemkv_version=$(curl -s --max-time 15 \
"https://forum.makemkv.com/forum/viewtopic.php?f=3&t=224" \
| grep -oP 'MakeMKV \K[0-9]+\.[0-9]+\.[0-9]+(?= for Linux)' | head -1 || true)
if [[ -z "$makemkv_version" ]]; then
warn "MakeMKV-Version konnte nicht ermittelt werden verwende Fallback $makemkv_fallback"
makemkv_version="$makemkv_fallback"
else
info "Aktuelle Version: $makemkv_version"
fi
info "Baue MakeMKV $makemkv_version..."
local tmp_dir
tmp_dir=$(mktemp -d)
cd "$tmp_dir"
local base_url="https://www.makemkv.com/download"
wget -q "${base_url}/makemkv-bin-${makemkv_version}.tar.gz"
wget -q "${base_url}/makemkv-oss-${makemkv_version}.tar.gz"
tar xf "makemkv-oss-${makemkv_version}.tar.gz"
cd "makemkv-oss-${makemkv_version}"
./configure
make -j"$(nproc)"
make install
cd "$tmp_dir"
tar xf "makemkv-bin-${makemkv_version}.tar.gz"
cd "makemkv-bin-${makemkv_version}"
mkdir -p tmp && echo "accepted" > tmp/eula_accepted
make -j"$(nproc)"
make install
cd /
rm -rf "$tmp_dir"
ok "MakeMKV $makemkv_version installiert"
warn "Hinweis: MakeMKV benötigt eine Lizenz oder den Beta-Key."
warn "Beta-Key: https://www.makemkv.com/forum/viewtopic.php?t=1053"
}
select_handbrake_mode() {
[[ "$SKIP_HANDBRAKE" == true ]] && return
local mode_answer=""
echo ""
echo "Install HandBrake:"
echo ""
echo "1. Standard version (apt install handbrake-cli)"
echo "2. GPU version with NVDEC (use bundled binary)"
if [[ -t 0 ]]; then
read -r -p "Select option [1/2]: " mode_answer
elif [[ -r /dev/tty ]]; then
read -r -p "Select option [1/2]: " mode_answer </dev/tty
else
HANDBRAKE_INSTALL_MODE="standard"
warn "Kein interaktives Terminal erkannt verwende Standardversion (apt)."
return
fi
case "$mode_answer" in
2) HANDBRAKE_INSTALL_MODE="gpu" ;;
1|"") HANDBRAKE_INSTALL_MODE="standard" ;;
*) warn "Ungültige Auswahl '$mode_answer' verwende Standardversion."; HANDBRAKE_INSTALL_MODE="standard" ;;
esac
}
install_handbrake_standard() {
info "Installiere HandBrakeCLI aus den Standard-Repositories..."
info "Aktualisiere Paketlisten..."
apt_update
apt-get install -y handbrake-cli
hash -r 2>/dev/null || true
if command_exists HandBrakeCLI; then
ok "HandBrakeCLI installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
return
fi
if command_exists handbrake-cli; then
ok "handbrake-cli installiert: $(handbrake-cli --version 2>&1 | head -1)"
return
fi
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
}
install_handbrake_gpu_bundled() {
info "Installiere gebündeltes HandBrakeCLI mit NVDEC..."
local bundled_source="$BUNDLED_HANDBRAKE_CLI"
local downloaded_tmp=""
if [[ ! -f "$bundled_source" ]]; then
local remote_url="${REPO_RAW_BASE}/${GIT_BRANCH}/bin/HandBrakeCLI"
downloaded_tmp=$(mktemp)
info "Lokale Binary fehlt lade aus Branch '$GIT_BRANCH' nach..."
if download_file "$remote_url" "$downloaded_tmp"; then
chmod 0755 "$downloaded_tmp"
bundled_source="$downloaded_tmp"
ok "Bundled HandBrakeCLI temporär heruntergeladen"
else
rm -f "$downloaded_tmp" 2>/dev/null || true
fatal "Bundled Binary fehlt lokal ($BUNDLED_HANDBRAKE_CLI) und Download schlug fehl: $remote_url"
fi
fi
install -m 0755 "$bundled_source" /usr/local/bin/HandBrakeCLI
hash -r 2>/dev/null || true
if [[ -n "$downloaded_tmp" ]]; then
rm -f "$downloaded_tmp" 2>/dev/null || true
fi
ok "Bundled HandBrakeCLI installiert nach /usr/local/bin/HandBrakeCLI"
if command_exists HandBrakeCLI; then
ok "HandBrakeCLI Version: $(HandBrakeCLI --version 2>&1 | head -1)"
fi
}
install_handbrake() {
header "HandBrake CLI installieren"
if [[ -z "$HANDBRAKE_INSTALL_MODE" ]]; then
HANDBRAKE_INSTALL_MODE="standard"
fi
case "$HANDBRAKE_INSTALL_MODE" in
standard) install_handbrake_standard ;;
gpu) install_handbrake_gpu_bundled ;;
*) fatal "Unbekannter HandBrake-Modus: $HANDBRAKE_INSTALL_MODE" ;;
esac
}
# --- apt-Hilfsfunktionen ------------------------------------------------------
# Führt apt-get update aus. Bei Release-Fehlern wird versucht, die Sources zu
# reparieren (Proxmox-Container, veraltete Spiegelserver, etc.).
apt_update() {
local output
if output=$(apt-get update 2>&1); then
return 0
fi
# Release-Datei fehlt → versuche Repair
if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
warn "apt-Sources fehlerhaft. Versuche Reparatur..."
# Strategie 1: --allow-releaseinfo-change
if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
return 0
fi
# Strategie 2: Kaputte Einträge aus sources.list.d entfernen und Fallback
# auf offizielle Spiegel schreiben
if [[ -n "${VERSION_CODENAME:-}" ]]; then
warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
local main_list=/etc/apt/sources.list
# Backup
cp "$main_list" "${main_list}.bak-$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
case "$ID" in
ubuntu)
cat > "$main_list" <<EOF
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME} main restricted universe multiverse
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-updates main restricted universe multiverse
deb http://archive.ubuntu.com/ubuntu ${VERSION_CODENAME}-security main restricted universe multiverse
EOF
;;
debian)
cat > "$main_list" <<EOF
deb http://deb.debian.org/debian ${VERSION_CODENAME} main contrib non-free
deb http://deb.debian.org/debian ${VERSION_CODENAME}-updates main contrib non-free
deb http://security.debian.org/debian-security ${VERSION_CODENAME}-security main contrib non-free
EOF
;;
esac
if apt-get update -qq 2>/dev/null; then
ok "apt-Update nach Sources-Reparatur erfolgreich"
return 0
fi
fi
# Strategie 3: Kaputte .list-Dateien in sources.list.d deaktivieren
warn "Deaktiviere fehlerhafte Eintraege in /etc/apt/sources.list.d/ ..."
local broken_files
broken_files=$(apt-get update 2>&1 | grep -oP "(?<=The repository ').*?(?=' )" | \
xargs -I{} grep -rl "{}" /etc/apt/sources.list.d/ 2>/dev/null || true)
if [[ -n "$broken_files" ]]; then
echo "$broken_files" | while read -r f; do
warn "Deaktiviere: $f"
mv "$f" "${f}.disabled" 2>/dev/null || true
done
if apt-get update -qq 2>/dev/null; then
ok "apt-Update nach Deaktivierung fehlerhafter Sources erfolgreich"
return 0
fi
fi
error "apt-Update fehlgeschlagen. Bitte Sources manuell pruefen:"
echo "$output"
fatal "Installation abgebrochen. Repariere /etc/apt/sources.list und starte erneut."
else
error "apt-Update fehlgeschlagen:"
echo "$output"
fatal "Installation abgebrochen."
fi
}
# --- HandBrake-Installmodus auswählen ----------------------------------------
select_handbrake_mode
# --- Systemabhängigkeiten -----------------------------------------------------
header "Systemabhängigkeiten installieren"
info "Paketlisten aktualisieren..."
apt_update
info "Installiere Basispakete..."
apt-get install -y \
curl wget git jq \
ffmpeg \
mediainfo \
util-linux udev \
ca-certificates gnupg \
lsb-release
ok "Basispakete installiert"
info "Installiere CD-Ripping-Tools..."
apt-get install -y \
cdparanoia \
flac \
lame \
opus-tools \
vorbis-tools
ok "CD-Ripping-Tools installiert (cdparanoia, flac, lame, opus-tools, vorbis-tools)"
install_node
if [[ "$SKIP_MAKEMKV" == false ]]; then
install_makemkv
else
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
fi
if [[ "$SKIP_HANDBRAKE" == false ]]; then
install_handbrake
else
warn "HandBrake-Installation übersprungen (--no-handbrake)"
fi
if [[ "$SKIP_NGINX" == false ]]; then
if ! command_exists nginx; then
info "Installiere nginx..."
apt-get install -y nginx
fi
ok "nginx installiert"
fi
# --- Systembenutzer anlegen ---------------------------------------------------
header "Systembenutzer anlegen"
if id "$SERVICE_USER" &>/dev/null; then
ok "Benutzer '$SERVICE_USER' existiert bereits"
else
info "Lege Systembenutzer '$SERVICE_USER' an..."
useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER"
ok "Benutzer '$SERVICE_USER' angelegt"
fi
SERVICE_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
if [[ -z "$SERVICE_HOME" || "$SERVICE_HOME" == "/" || "$SERVICE_HOME" == "/nonexistent" ]]; then
SERVICE_HOME="/home/$SERVICE_USER"
fi
mkdir -p "$SERVICE_HOME"
chown "$SERVICE_USER:$SERVICE_USER" "$SERVICE_HOME" 2>/dev/null || true
chmod 755 "$SERVICE_HOME" 2>/dev/null || true
info "Service-Home für '$SERVICE_USER': $SERVICE_HOME"
for grp in cdrom optical disk video render; do
if getent group "$grp" &>/dev/null; then
usermod -aG "$grp" "$SERVICE_USER" 2>/dev/null || true
info "Benutzer '$SERVICE_USER' zur Gruppe '$grp' hinzugefügt"
fi
done
# --- Repository klonen / aktualisieren ----------------------------------------
header "Repository holen (Git)"
# Prüfen ob der gewünschte Branch auf dem Remote existiert
info "Prüfe Branch '$GIT_BRANCH' auf Remote..."
if ! git ls-remote --exit-code --heads "$REPO_URL" "$GIT_BRANCH" &>/dev/null; then
fatal "Branch '$GIT_BRANCH' existiert nicht im Repository $REPO_URL.\nVerfügbare Branches: $(git ls-remote --heads "$REPO_URL" | awk '{print $2}' | sed 's|refs/heads/||' | tr '\n' ' ')"
fi
ok "Branch '$GIT_BRANCH' gefunden"
if [[ -d "$INSTALL_DIR/.git" ]]; then
if [[ "$REINSTALL" == true ]]; then
info "Aktualisiere bestehendes Repository..."
# Daten sichern
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
DATA_BACKUP="/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
info "Datenbank gesichert nach: $DATA_BACKUP"
fi
# safe.directory nötig wenn das Verzeichnis einem anderen User gehört
# (z.B. ripster-Serviceuser nach erstem Install)
git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true
git -C "$INSTALL_DIR" remote set-branches origin '*'
git -C "$INSTALL_DIR" fetch --quiet origin
git -C "$INSTALL_DIR" reset --hard HEAD
git -C "$INSTALL_DIR" checkout --quiet -B "$GIT_BRANCH" "origin/$GIT_BRANCH"
git -C "$INSTALL_DIR" reset --hard "origin/$GIT_BRANCH"
ok "Repository aktualisiert auf Branch '$GIT_BRANCH'"
else
fatal "$INSTALL_DIR enthält bereits ein Git-Repository.\nVerwende --reinstall um zu aktualisieren."
fi
elif [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
fatal "Verzeichnis $INSTALL_DIR existiert bereits (kein Git-Repo).\nBitte manuell entfernen oder --reinstall verwenden."
else
info "Klone $REPO_URL (Branch: $GIT_BRANCH)..."
git clone --quiet --branch "$GIT_BRANCH" --depth 1 "$REPO_URL" "$INSTALL_DIR"
ok "Repository geklont nach $INSTALL_DIR"
fi
# Daten- und Log-Verzeichnisse sicherstellen
mkdir -p "$INSTALL_DIR/backend/data"
mkdir -p "$INSTALL_DIR/backend/logs"
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
mkdir -p "$INSTALL_DIR/backend/data/output/series"
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
mkdir -p "$INSTALL_DIR/backend/data/downloads"
mkdir -p "$INSTALL_DIR/backend/data/logs"
# Gesicherte Daten zurückspielen
if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
cp -a "$DATA_BACKUP/." "$INSTALL_DIR/backend/data/"
ok "Datenbank wiederhergestellt"
fi
# --- npm-Abhängigkeiten installieren -----------------------------------------
header "npm-Abhängigkeiten installieren"
info "Root-Abhängigkeiten..."
npm install --prefix "$INSTALL_DIR" --omit=dev --silent
info "Backend-Abhängigkeiten..."
npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
info "Frontend-Abhängigkeiten..."
npm install --prefix "$INSTALL_DIR/frontend" --silent
ok "npm-Abhängigkeiten installiert"
# --- Frontend bauen -----------------------------------------------------------
header "Frontend bauen"
info "Baue Frontend für $FRONTEND_HOST..."
# Relative URLs verwenden funktioniert mit jedem Hostnamen/Domain, da nginx
# /api/ und /ws auf dem selben Host proxied. Absolute IP-URLs würden Chromes
# Private Network Access (PNA) Policy verletzen, wenn das Frontend über einen
# Domainnamen aufgerufen wird.
rm -f "$INSTALL_DIR/frontend/.env.production.local"
npm run build --prefix "$INSTALL_DIR/frontend" --silent
ok "Frontend gebaut: $INSTALL_DIR/frontend/dist"
# --- Backend-Konfiguration ---------------------------------------------------
header "Backend konfigurieren"
ENV_FILE="$INSTALL_DIR/backend/.env"
if [[ -f "$ENV_FILE" && "$REINSTALL" == true ]]; then
warn "Bestehende .env bleibt erhalten (--reinstall)"
else
info "Erstelle Backend .env..."
cat > "$ENV_FILE" <<EOF
# Ripster Backend Konfiguration
# Generiert von install.sh am $(date)
PORT=${BACKEND_PORT}
DB_PATH=./data/ripster.db
LOG_DIR=./logs
LOG_LEVEL=info
# CORS: Erlaube Anfragen vom Frontend (nginx)
CORS_ORIGIN=http://${FRONTEND_HOST}
# Standard-Ausgabepfade (Fallback wenn in den Einstellungen kein Pfad gesetzt)
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
DEFAULT_SERIES_DIR=${INSTALL_DIR}/backend/data/output/series
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
EOF
ok "Backend .env erstellt"
fi
# --- Berechtigungen setzen ---------------------------------------------------
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR"
chmod -R 755 "$INSTALL_DIR"
chmod 600 "$ENV_FILE"
# Ausgabe- und Log-Verzeichnisse dem installierenden User zuweisen
# (SUDO_USER = der echte User hinter sudo; leer wenn direkt als root ausgeführt)
ACTUAL_USER="${SUDO_USER:-}"
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
chown -R "$ACTUAL_USER:$SERVICE_USER" \
"$INSTALL_DIR/backend/data/output" \
"$INSTALL_DIR/backend/data/downloads" \
"$INSTALL_DIR/backend/data/logs"
chmod -R 775 \
"$INSTALL_DIR/backend/data/output" \
"$INSTALL_DIR/backend/data/downloads" \
"$INSTALL_DIR/backend/data/logs"
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
else
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
fi
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
# Laufzeit-relevant ist das Verzeichnis des Service-Users.
MAKEMKV_SERVICE_DIR="${SERVICE_HOME}/.MakeMKV"
if [[ ! -d "$MAKEMKV_SERVICE_DIR" ]]; then
mkdir -p "$MAKEMKV_SERVICE_DIR"
ok "MakeMKV-Verzeichnis erstellt: $MAKEMKV_SERVICE_DIR"
else
info "MakeMKV-Verzeichnis vorhanden: $MAKEMKV_SERVICE_DIR"
fi
chown "$SERVICE_USER:$SERVICE_USER" "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
chmod 700 "$MAKEMKV_SERVICE_DIR" 2>/dev/null || true
# --- Systemd-Dienst: Backend -------------------------------------------------
header "Systemd-Dienst (Backend) erstellen"
cat > /etc/systemd/system/ripster-backend.service <<EOF
[Unit]
Description=Ripster Backend API
After=network.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=3
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=${INSTALL_DIR}/backend
ExecStart=$(command -v node) src/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=HOME=${SERVICE_HOME}
Environment=LANG=C.UTF-8
Environment=LC_ALL=C.UTF-8
Environment=LANGUAGE=C.UTF-8
EnvironmentFile=${INSTALL_DIR}/backend/.env
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ripster-backend
# Kein statisches DeviceAllow: Device-Pfade unterscheiden sich je nach Host/Container.
# Damit Ripster auf unterschiedlichen Systemen funktioniert, kein Device-Cgroup-Filter.
DevicePolicy=auto
SupplementaryGroups=video render cdrom disk
# Für Skriptausführung via GUI (inkl. optionalem sudo in User-Skripten)
# darf no_new_privileges nicht aktiv sein.
NoNewPrivileges=false
ProtectSystem=full
ReadWritePaths=${INSTALL_DIR}/backend/data ${INSTALL_DIR}/backend/logs /tmp ${SERVICE_HOME} ${MAKEMKV_SERVICE_DIR}
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
ok "ripster-backend.service erstellt"
# --- nginx konfigurieren -----------------------------------------------------
if [[ "$SKIP_NGINX" == false ]]; then
header "nginx konfigurieren"
if [[ -f /etc/nginx/sites-available/ripster ]]; then
patch_existing_ripster_nginx_site /etc/nginx/sites-available/ripster
else
cat > /etc/nginx/sites-available/ripster <<EOF
server {
listen 80;
server_name ${FRONTEND_HOST} _;
client_max_body_size 8G;
root ${INSTALL_DIR}/frontend/dist;
index index.html;
location / {
try_files \$uri \$uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 60s;
proxy_request_buffering off;
}
location /ws {
proxy_pass http://127.0.0.1:${BACKEND_PORT}/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
EOF
fi
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/ripster /etc/nginx/sites-enabled/ripster
nginx -t && ok "nginx-Konfiguration gültig" || fatal "nginx-Konfiguration fehlerhaft!"
fi
# --- Dienste starten ----------------------------------------------------------
header "Dienste starten"
systemctl daemon-reload
systemctl enable ripster-backend
systemctl restart ripster-backend
sleep 2
if systemctl is-active --quiet ripster-backend; then
ok "ripster-backend läuft"
else
error "ripster-backend konnte nicht gestartet werden!"
journalctl -u ripster-backend -n 30 --no-pager
exit 1
fi
if [[ "$SKIP_NGINX" == false ]]; then
systemctl enable nginx
systemctl restart nginx
if systemctl is-active --quiet nginx; then
ok "nginx läuft"
else
error "nginx konnte nicht gestartet werden!"
journalctl -u nginx -n 20 --no-pager
fi
fi
# --- Zusammenfassung ----------------------------------------------------------
header "Installation abgeschlossen!"
echo ""
echo -e " ${GREEN}${BOLD}Ripster ist installiert und läuft.${RESET}"
echo ""
if [[ "$SKIP_NGINX" == false ]]; then
echo -e " ${BOLD}Weboberfläche:${RESET} http://${FRONTEND_HOST}"
else
echo -e " ${BOLD}Backend API:${RESET} http://${FRONTEND_HOST}:${BACKEND_PORT}/api"
warn "nginx deaktiviert Frontend nicht automatisch erreichbar."
fi
echo ""
echo -e " ${BOLD}Dienste verwalten:${RESET}"
echo -e " sudo systemctl status ripster-backend"
echo -e " sudo systemctl restart ripster-backend"
echo -e " sudo systemctl stop ripster-backend"
echo -e " sudo journalctl -u ripster-backend -f"
echo ""
echo -e " ${BOLD}Konfiguration:${RESET} $INSTALL_DIR/backend/.env"
echo -e " ${BOLD}Datenbank:${RESET} $INSTALL_DIR/backend/data/ripster.db"
echo -e " ${BOLD}Logs:${RESET} $INSTALL_DIR/backend/logs/"
echo -e " ${BOLD}Aktualisieren:${RESET} sudo bash $INSTALL_DIR/install.sh --reinstall"
echo ""
missing_tools=()
command_exists makemkvcon || missing_tools+=("makemkvcon")
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
command_exists mediainfo || missing_tools+=("mediainfo")
command_exists cdparanoia || missing_tools+=("cdparanoia")
command_exists flac || missing_tools+=("flac")
command_exists lame || missing_tools+=("lame")
command_exists opusenc || missing_tools+=("opusenc")
command_exists oggenc || missing_tools+=("oggenc")
if [[ ${#missing_tools[@]} -gt 0 ]]; then
echo -e " ${YELLOW}${BOLD}Hinweis:${RESET} Folgende Tools fehlen noch:"
for t in "${missing_tools[@]}"; do
echo -e " ${YELLOW}${RESET} $t"
done
echo -e " Diese können in den Ripster-Einstellungen konfiguriert werden."
fi
echo ""
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.13.1-9", "version": "0.13.1-10",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.13.1-9", "version": "0.13.1-10",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.13.1-9", "version": "0.13.1-10",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",