0.13.1-10 Misc fixes
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
|
||||
@@ -844,13 +844,13 @@ const SETTINGS_SCHEMA_METADATA_UPDATES = [
|
||||
{
|
||||
key: 'output_template_dvd_series_episode',
|
||||
required: 1,
|
||||
description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.',
|
||||
description: 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
},
|
||||
{
|
||||
key: 'output_template_dvd_series_multi_episode',
|
||||
required: 1,
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.',
|
||||
description: 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.',
|
||||
validation_json: '{"minLength":1}'
|
||||
}
|
||||
];
|
||||
@@ -1076,13 +1076,13 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})')`);
|
||||
|
||||
|
||||
@@ -174,10 +174,11 @@ router.post(
|
||||
const id = Number(req.params.id);
|
||||
const target = String(req.body?.target || 'none');
|
||||
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
|
||||
const resetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||
const keepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
||||
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
|
||||
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
|
||||
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
|
||||
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
|
||||
: !resetDriveState;
|
||||
: !requestedResetDriveState;
|
||||
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
|
||||
? req.body.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -189,20 +190,41 @@ router.post(
|
||||
id,
|
||||
target,
|
||||
includeRelated,
|
||||
resetDriveState,
|
||||
keepDetectedDevice,
|
||||
requestedResetDriveState,
|
||||
preserveRawForImportJobs,
|
||||
requestedKeepDetectedDevice,
|
||||
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
|
||||
});
|
||||
|
||||
const preview = resetDriveState
|
||||
? await historyService.getJobDeletePreview(id, { includeRelated })
|
||||
: null;
|
||||
const preview = await historyService.getJobDeletePreview(id, { includeRelated });
|
||||
const containsOrphanRawImportJob = Boolean(
|
||||
preview?.flags?.containsOrphanRawImportJob
|
||||
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some((row) => Boolean(row?.orphanRawImport)))
|
||||
);
|
||||
const resetDriveState = containsOrphanRawImportJob
|
||||
? false
|
||||
: requestedResetDriveState;
|
||||
const keepDetectedDevice = containsOrphanRawImportJob
|
||||
? true
|
||||
: requestedKeepDetectedDevice;
|
||||
|
||||
if (containsOrphanRawImportJob && requestedResetDriveState) {
|
||||
logger.info('post:delete-job:orphan-drive-reset-skipped', {
|
||||
reqId: req.reqId,
|
||||
id
|
||||
});
|
||||
}
|
||||
|
||||
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
|
||||
? preview.relatedJobs
|
||||
.map((row) => String(row?.discDevice || '').trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const result = await historyService.deleteJob(id, target, { includeRelated, selectedMoviePaths });
|
||||
const result = await historyService.deleteJob(id, target, {
|
||||
includeRelated,
|
||||
selectedMoviePaths,
|
||||
preserveRawForImportJobs
|
||||
});
|
||||
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
|
||||
resetDriveState,
|
||||
devicePaths: relatedDevicePaths
|
||||
@@ -212,7 +234,15 @@ router.post(
|
||||
const uiReset = await pipelineService.resetFrontendState('history_delete', {
|
||||
keepDetectedDevice
|
||||
});
|
||||
res.json({ ...result, uiReset });
|
||||
res.json({
|
||||
...result,
|
||||
uiReset,
|
||||
safeguards: {
|
||||
containsOrphanRawImportJob,
|
||||
resetDriveStateApplied: resetDriveState,
|
||||
keepDetectedDeviceApplied: keepDetectedDevice
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -133,6 +133,16 @@ function parseInfoFromValue(value, fallback = null) {
|
||||
return parseJsonSafe(value, fallback);
|
||||
}
|
||||
|
||||
function isOrphanRawImportMakemkvInfo(makemkvInfo = null) {
|
||||
const info = makemkvInfo && typeof makemkvInfo === 'object' ? makemkvInfo : {};
|
||||
const source = String(
|
||||
info?.source
|
||||
|| info?.importRecovery?.source
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
return source === 'orphan_raw_import';
|
||||
}
|
||||
|
||||
function hasBlurayStructure(rawPath) {
|
||||
const basePath = String(rawPath || '').trim();
|
||||
if (!basePath) {
|
||||
@@ -5180,6 +5190,7 @@ class HistoryService {
|
||||
? Math.trunc(manualEpisodeCountRaw)
|
||||
: null;
|
||||
const manualEpisodes = Array.isArray(payload.episodes) ? payload.episodes : null;
|
||||
const hasExplicitManualEpisodes = Boolean(manualEpisodes && manualEpisodes.length > 0);
|
||||
const manualMetadataKind = String(payload.metadataKind || '').trim().toLowerCase() || null;
|
||||
const manualProviderId = String(payload.providerId || '').trim() || null;
|
||||
|
||||
@@ -5240,7 +5251,7 @@ class HistoryService {
|
||||
let episodeCount = manualEpisodeCount !== null
|
||||
? manualEpisodeCount
|
||||
: (Number(existingSelectedMetadata?.episodeCount || 0) || 0);
|
||||
let episodes = manualEpisodes && manualEpisodes.length > 0
|
||||
let episodes = hasExplicitManualEpisodes
|
||||
? manualEpisodes
|
||||
: (Array.isArray(existingSelectedMetadata?.episodes) ? existingSelectedMetadata.episodes : []);
|
||||
const discNumber = manualDiscNumber !== null
|
||||
@@ -5280,6 +5291,13 @@ class HistoryService {
|
||||
? `tmdb:${effectiveTmdbId}:season:${seasonNumber}`
|
||||
: `tmdb:${effectiveTmdbId}`;
|
||||
}
|
||||
let tmdbLanguage = null;
|
||||
try {
|
||||
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
|
||||
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
|
||||
} catch (_settingsError) {
|
||||
tmdbLanguage = null;
|
||||
}
|
||||
|
||||
let tmdbDetails = existingSelectedMetadata?.tmdbDetails && typeof existingSelectedMetadata.tmdbDetails === 'object'
|
||||
? { ...existingSelectedMetadata.tmdbDetails }
|
||||
@@ -5287,6 +5305,7 @@ class HistoryService {
|
||||
if (effectiveTmdbId !== null) {
|
||||
try {
|
||||
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
|
||||
language: tmdbLanguage,
|
||||
appendToResponse: ['credits', 'external_ids']
|
||||
});
|
||||
if (seriesDetails) {
|
||||
@@ -5325,12 +5344,16 @@ class HistoryService {
|
||||
|
||||
if (effectiveTmdbId !== null && seasonNumber !== null) {
|
||||
try {
|
||||
const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber);
|
||||
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber);
|
||||
const seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, seasonNumber, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
const seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, seasonNumber, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
|
||||
if (seasonSummary) {
|
||||
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
|
||||
if (fetchedEpisodes.length > 0 && (!Array.isArray(episodes) || episodes.length === 0)) {
|
||||
if (fetchedEpisodes.length > 0 && !hasExplicitManualEpisodes) {
|
||||
episodes = fetchedEpisodes;
|
||||
}
|
||||
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
|
||||
@@ -6177,15 +6200,20 @@ class HistoryService {
|
||||
lineageArtifactsByJobId,
|
||||
outputFoldersByJobId
|
||||
);
|
||||
const relatedJobs = jobs.map((job) => ({
|
||||
id: Number(job.id),
|
||||
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
||||
title: buildJobDisplayTitle(job),
|
||||
status: String(job.status || '').trim() || null,
|
||||
discDevice: String(job.disc_device || '').trim() || null,
|
||||
isPrimary: Number(job.id) === normalizedJobId,
|
||||
createdAt: String(job.created_at || '').trim() || null
|
||||
}));
|
||||
const relatedJobs = jobs.map((job) => {
|
||||
const mkInfo = parseJsonSafe(job?.makemkv_info_json, {});
|
||||
return {
|
||||
id: Number(job.id),
|
||||
parentJobId: normalizeJobIdValue(job.parent_job_id),
|
||||
title: buildJobDisplayTitle(job),
|
||||
status: String(job.status || '').trim() || null,
|
||||
discDevice: String(job.disc_device || '').trim() || null,
|
||||
isPrimary: Number(job.id) === normalizedJobId,
|
||||
createdAt: String(job.created_at || '').trim() || null,
|
||||
orphanRawImport: isOrphanRawImportMakemkvInfo(mkInfo)
|
||||
};
|
||||
});
|
||||
const orphanRawImportJobs = relatedJobs.filter((row) => Boolean(row?.orphanRawImport)).length;
|
||||
const existingRawCandidates = preview.pathCandidates.raw.filter((row) => row.exists).length;
|
||||
const existingMovieCandidates = preview.pathCandidates.movie.filter((row) => row.exists).length;
|
||||
|
||||
@@ -6195,8 +6223,12 @@ class HistoryService {
|
||||
relatedJobs,
|
||||
pathCandidates: preview.pathCandidates,
|
||||
protectedRoots: preview.protectedRoots,
|
||||
flags: {
|
||||
containsOrphanRawImportJob: orphanRawImportJobs > 0
|
||||
},
|
||||
counts: {
|
||||
relatedJobs: relatedJobs.length,
|
||||
orphanRawImportJobs,
|
||||
rawCandidates: preview.pathCandidates.raw.length,
|
||||
movieCandidates: preview.pathCandidates.movie.length,
|
||||
existingRawCandidates,
|
||||
@@ -6887,6 +6919,28 @@ class HistoryService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
|
||||
const isOrphanRawImportJobRow = (row) => {
|
||||
if (!row || typeof row !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const mkInfo = parseJsonSafe(row?.makemkv_info_json, {});
|
||||
return isOrphanRawImportMakemkvInfo(mkInfo);
|
||||
};
|
||||
const resolveEffectiveFileTarget = (requestedTarget, row) => {
|
||||
if (!preserveRawForImportJobs || !isOrphanRawImportJobRow(row)) {
|
||||
return requestedTarget;
|
||||
}
|
||||
const normalizedRequested = String(requestedTarget || 'none').trim().toLowerCase();
|
||||
if (normalizedRequested === 'raw') {
|
||||
return 'none';
|
||||
}
|
||||
if (normalizedRequested === 'both') {
|
||||
return 'movie';
|
||||
}
|
||||
return normalizedRequested;
|
||||
};
|
||||
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
if (!includeRelated) {
|
||||
const resolved = await this.getJobByIdOrReplacement(jobId);
|
||||
@@ -6903,10 +6957,11 @@ class HistoryService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let fileSummary = null;
|
||||
if (fileTarget !== 'none') {
|
||||
if (effectiveFileTarget !== 'none') {
|
||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: false });
|
||||
fileSummary = this._deletePathsFromPreview(preview, fileTarget, {
|
||||
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
||||
selectedMoviePaths: options?.selectedMoviePaths
|
||||
});
|
||||
}
|
||||
@@ -6968,7 +7023,8 @@ class HistoryService {
|
||||
|
||||
logger.warn('job:deleted', {
|
||||
jobId: normalizedJobId,
|
||||
fileTarget,
|
||||
fileTarget: effectiveFileTarget,
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: false,
|
||||
pipelineStateReset: isActivePipelineJob,
|
||||
filesDeleted: fileSummary
|
||||
@@ -6982,7 +7038,8 @@ class HistoryService {
|
||||
return {
|
||||
deleted: true,
|
||||
jobId: normalizedJobId,
|
||||
fileTarget,
|
||||
fileTarget: effectiveFileTarget,
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: false,
|
||||
deletedJobIds: [Number(normalizedJobId)],
|
||||
fileSummary
|
||||
@@ -6990,6 +7047,7 @@ class HistoryService {
|
||||
}
|
||||
|
||||
const resolved = await this.getJobByIdOrReplacement(jobId);
|
||||
const existing = resolved?.job || null;
|
||||
const normalizedJobId = normalizeJobIdValue(resolved?.resolvedJobId || jobId);
|
||||
const preview = await this.getJobDeletePreview(normalizedJobId, { includeRelated: true });
|
||||
const deleteJobIds = Array.isArray(preview?.relatedJobs)
|
||||
@@ -7014,9 +7072,10 @@ class HistoryService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const effectiveFileTarget = resolveEffectiveFileTarget(fileTarget, existing);
|
||||
let fileSummary = null;
|
||||
if (fileTarget !== 'none') {
|
||||
fileSummary = this._deletePathsFromPreview(preview, fileTarget, {
|
||||
if (effectiveFileTarget !== 'none') {
|
||||
fileSummary = this._deletePathsFromPreview(preview, effectiveFileTarget, {
|
||||
selectedMoviePaths: options?.selectedMoviePaths
|
||||
});
|
||||
}
|
||||
@@ -7068,7 +7127,8 @@ class HistoryService {
|
||||
|
||||
logger.warn('job:deleted', {
|
||||
jobId: normalizedJobId,
|
||||
fileTarget,
|
||||
fileTarget: effectiveFileTarget,
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: true,
|
||||
deletedJobIds: deleteJobIds,
|
||||
deletedJobCount: deleteJobIds.length,
|
||||
@@ -7084,7 +7144,8 @@ class HistoryService {
|
||||
return {
|
||||
deleted: true,
|
||||
jobId: normalizedJobId,
|
||||
fileTarget,
|
||||
fileTarget: effectiveFileTarget,
|
||||
requestedFileTarget: fileTarget,
|
||||
includeRelated: true,
|
||||
deletedJobIds: deleteJobIds,
|
||||
deletedJobs: preview.relatedJobs,
|
||||
|
||||
@@ -94,11 +94,29 @@ const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
|
||||
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
|
||||
]);
|
||||
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);
|
||||
const TMDB_SERIES_SEARCH_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
const tmdbSeriesSearchCache = new Map();
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function cloneTmdbSeriesSearchResults(rows = []) {
|
||||
if (!Array.isArray(rows)) {
|
||||
return [];
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
...(row && typeof row === 'object' ? row : {}),
|
||||
episodes: Array.isArray(row?.episodes)
|
||||
? row.episodes.map((episode) => (
|
||||
episode && typeof episode === 'object'
|
||||
? { ...episode }
|
||||
: episode
|
||||
))
|
||||
: []
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeCdTrackText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFC')
|
||||
@@ -948,15 +966,16 @@ function formatTemplateTwoDigit(rawValue) {
|
||||
return String(rawValue || '').trim() || String(numeric);
|
||||
}
|
||||
|
||||
function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1) {
|
||||
function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitle = null, fallbackEpisodeNumber = 1, options = {}) {
|
||||
const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
|
||||
const selectedTitleSource = selectedTitle && typeof selectedTitle === 'object' ? selectedTitle : {};
|
||||
const allowSelectedTitleEpisodeNumber = options?.allowSelectedTitleEpisodeNumber !== false;
|
||||
const episodeStart = normalizeTemplateNumberValue(
|
||||
assignmentSource?.episodeNumberStart
|
||||
?? assignmentSource?.episodeNoStart
|
||||
?? assignmentSource?.episodeNumber
|
||||
?? assignmentSource?.number
|
||||
?? selectedTitleSource?.episodeNumber
|
||||
?? (allowSelectedTitleEpisodeNumber ? selectedTitleSource?.episodeNumber : null)
|
||||
?? null,
|
||||
fallbackEpisodeNumber
|
||||
);
|
||||
@@ -987,6 +1006,167 @@ function resolveSeriesEpisodeRangeFromAssignment(assignment = null, selectedTitl
|
||||
};
|
||||
}
|
||||
|
||||
function hasExplicitSeriesEpisodeAssignmentRange(assignment = null) {
|
||||
const source = assignment && typeof assignment === 'object' ? assignment : null;
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
const hasStart = normalizeEpisodeNumberValue(
|
||||
source?.episodeNumberStart
|
||||
?? source?.episodeNoStart
|
||||
?? source?.episodeNumber
|
||||
?? source?.number
|
||||
?? null
|
||||
) !== null;
|
||||
const hasEnd = normalizeEpisodeNumberValue(
|
||||
source?.episodeNumberEnd
|
||||
?? source?.episodeNoEnd
|
||||
?? null
|
||||
) !== null;
|
||||
const hasSpan = normalizePositiveInteger(
|
||||
source?.episodeSpan
|
||||
?? source?.episodeCount
|
||||
?? null
|
||||
) !== null;
|
||||
const hasRangeToken = String(source?.episodeRange || '').trim().length > 0;
|
||||
return Boolean(hasStart || hasEnd || hasSpan || hasRangeToken);
|
||||
}
|
||||
|
||||
function resolveSeriesSelectedTitleIdOrder(encodePlan = null, titles = []) {
|
||||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
|
||||
const explicitSelected = normalizeReviewTitleIdList(
|
||||
Array.isArray(plan?.selectedTitleIds)
|
||||
? plan.selectedTitleIds
|
||||
: [plan?.encodeInputTitleId]
|
||||
);
|
||||
if (explicitSelected.length > 0) {
|
||||
return explicitSelected;
|
||||
}
|
||||
return normalizeReviewTitleIdList(
|
||||
(Array.isArray(titles) ? titles : [])
|
||||
.filter((title) => Boolean(title?.selectedForEncode || title?.encodeInput))
|
||||
.map((title) => title?.id)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSeriesEpisodeRangeForPlanTitle(encodePlan = null, titleId = null, fallbackEpisodeNumber = 1) {
|
||||
const plan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
|
||||
const titles = Array.isArray(plan?.titles) ? plan.titles : [];
|
||||
const titlesById = new Map();
|
||||
for (const title of titles) {
|
||||
const normalizedId = normalizeReviewTitleId(title?.id);
|
||||
if (!normalizedId) {
|
||||
continue;
|
||||
}
|
||||
titlesById.set(Number(normalizedId), title);
|
||||
}
|
||||
|
||||
const normalizedTitleId = normalizeReviewTitleId(titleId)
|
||||
|| normalizeReviewTitleId(plan?.encodeInputTitleId)
|
||||
|| null;
|
||||
let orderedTitleIds = resolveSeriesSelectedTitleIdOrder(plan, titles);
|
||||
if (normalizedTitleId && !orderedTitleIds.some((id) => Number(id) === Number(normalizedTitleId))) {
|
||||
orderedTitleIds = [...orderedTitleIds, Number(normalizedTitleId)];
|
||||
}
|
||||
if (orderedTitleIds.length === 0 && normalizedTitleId) {
|
||||
orderedTitleIds = [Number(normalizedTitleId)];
|
||||
}
|
||||
|
||||
const assignmentMap = plan?.episodeAssignments && typeof plan.episodeAssignments === 'object'
|
||||
? plan.episodeAssignments
|
||||
: {};
|
||||
const fallbackStart = normalizePositiveInteger(fallbackEpisodeNumber) || 1;
|
||||
let cursor = fallbackStart;
|
||||
let targetRange = null;
|
||||
let targetAssignment = null;
|
||||
let targetTitle = null;
|
||||
|
||||
for (const rawId of orderedTitleIds) {
|
||||
const currentId = normalizeReviewTitleId(rawId);
|
||||
if (!currentId) {
|
||||
continue;
|
||||
}
|
||||
const assignment = assignmentMap[String(currentId)] || assignmentMap[currentId] || null;
|
||||
const selectedTitle = titlesById.get(Number(currentId)) || null;
|
||||
const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(assignment);
|
||||
const range = resolveSeriesEpisodeRangeFromAssignment(
|
||||
assignment,
|
||||
selectedTitle,
|
||||
cursor,
|
||||
{ allowSelectedTitleEpisodeNumber: hasExplicitRange }
|
||||
);
|
||||
const normalizedEnd = normalizeEpisodeNumberValue(range?.end);
|
||||
if (normalizedEnd !== null) {
|
||||
cursor = normalizedEnd + 1;
|
||||
}
|
||||
if (normalizedTitleId && Number(currentId) === Number(normalizedTitleId)) {
|
||||
targetRange = range;
|
||||
targetAssignment = assignment;
|
||||
targetTitle = selectedTitle;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetRange) {
|
||||
const fallbackTitle = normalizedTitleId ? (titlesById.get(Number(normalizedTitleId)) || null) : null;
|
||||
const fallbackAssignment = normalizedTitleId
|
||||
? (assignmentMap[String(normalizedTitleId)] || assignmentMap[normalizedTitleId] || null)
|
||||
: null;
|
||||
const hasExplicitRange = hasExplicitSeriesEpisodeAssignmentRange(fallbackAssignment);
|
||||
targetRange = resolveSeriesEpisodeRangeFromAssignment(
|
||||
fallbackAssignment,
|
||||
fallbackTitle,
|
||||
cursor,
|
||||
{ allowSelectedTitleEpisodeNumber: hasExplicitRange }
|
||||
);
|
||||
targetAssignment = fallbackAssignment;
|
||||
targetTitle = fallbackTitle;
|
||||
}
|
||||
|
||||
return {
|
||||
range: targetRange,
|
||||
assignment: targetAssignment,
|
||||
title: targetTitle
|
||||
};
|
||||
}
|
||||
|
||||
function templateUsesLeadingZeroForToken(template, token) {
|
||||
const source = String(template || '').trim();
|
||||
const normalizedToken = String(token || '').trim();
|
||||
if (!source || !normalizedToken) {
|
||||
return false;
|
||||
}
|
||||
const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i');
|
||||
return pattern.test(source);
|
||||
}
|
||||
|
||||
function formatSeriesEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') {
|
||||
const numeric = normalizeEpisodeNumberValue(rawValue);
|
||||
if (numeric === null) {
|
||||
return String(rawValue || '').trim() || '0';
|
||||
}
|
||||
if (Number.isInteger(numeric)) {
|
||||
const normalized = String(Math.trunc(numeric));
|
||||
if (templateUsesLeadingZeroForToken(template, token)) {
|
||||
return normalized.padStart(2, '0');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return String(numeric);
|
||||
}
|
||||
|
||||
function buildSeriesFallbackEpisodeTitle(episodeRangeInfo = null, template = '') {
|
||||
const start = normalizeEpisodeNumberValue(episodeRangeInfo?.start) ?? 1;
|
||||
const end = normalizeEpisodeNumberValue(episodeRangeInfo?.end) ?? start;
|
||||
if (end > start) {
|
||||
const startToken = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr');
|
||||
const endToken = formatSeriesEpisodeNumberByTemplate(end, template, 'episodeNr');
|
||||
return `Folge ${startToken}-${endToken}`;
|
||||
}
|
||||
const token = formatSeriesEpisodeNumberByTemplate(start, template, 'episodeNr');
|
||||
return `Folge ${token}`;
|
||||
}
|
||||
|
||||
function buildSeriesEpisodeRangeToken(start, end) {
|
||||
const startToken = formatTemplateTwoDigit(start);
|
||||
const endToken = formatTemplateTwoDigit(end);
|
||||
@@ -1178,19 +1358,39 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|
||||
const assignment = primaryTitleId
|
||||
? (episodeAssignments[String(primaryTitleId)] || episodeAssignments[primaryTitleId] || null)
|
||||
: null;
|
||||
const selectedTitlePosition = primaryTitleId
|
||||
? selectedTitleIds.findIndex((id) => Number(id) === Number(primaryTitleId))
|
||||
: -1;
|
||||
const fallbackEpisodeNumber = normalizePositiveInteger(
|
||||
encodePlan?.seriesBatchChildIndex
|
||||
?? null
|
||||
) || (selectedTitlePosition >= 0 ? selectedTitlePosition + 1 : 1);
|
||||
const episodeResolution = resolveSeriesEpisodeRangeForPlanTitle(
|
||||
encodePlan,
|
||||
primaryTitleId,
|
||||
fallbackEpisodeNumber
|
||||
);
|
||||
const resolvedAssignment = episodeResolution?.assignment || assignment || null;
|
||||
const resolvedTitle = episodeResolution?.title || selectedTitle || null;
|
||||
const episodeRangeInfo = (
|
||||
episodeResolution?.range
|
||||
&& typeof episodeResolution.range === 'object'
|
||||
)
|
||||
? episodeResolution.range
|
||||
: resolveSeriesEpisodeRangeFromAssignment(
|
||||
resolvedAssignment,
|
||||
resolvedTitle,
|
||||
fallbackEpisodeNumber,
|
||||
{ allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(resolvedAssignment) }
|
||||
);
|
||||
|
||||
const seasonNumber = normalizePositiveInteger(
|
||||
assignment?.seasonNumber
|
||||
?? selectedTitle?.seasonNumber
|
||||
resolvedAssignment?.seasonNumber
|
||||
?? resolvedTitle?.seasonNumber
|
||||
?? selectedMetadata?.seasonNumber
|
||||
?? analyzeContext?.seriesLookupHint?.seasonNumber
|
||||
?? null
|
||||
) || 1;
|
||||
const episodeRangeInfo = resolveSeriesEpisodeRangeFromAssignment(
|
||||
assignment,
|
||||
selectedTitle,
|
||||
primaryTitleId || 1
|
||||
);
|
||||
const episodeTemplateValue = episodeRangeInfo.start;
|
||||
const episodeRangeToken = buildSeriesEpisodeRangeToken(
|
||||
episodeRangeInfo.start,
|
||||
@@ -1199,7 +1399,7 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|
||||
const episodePartsToken = buildSeriesEpisodePartsToken(episodeRangeInfo);
|
||||
const discNumber = normalizePositiveInteger(
|
||||
selectedMetadata?.discNumber
|
||||
?? assignment?.discNumber
|
||||
?? resolvedAssignment?.discNumber
|
||||
?? analyzeContext?.seriesLookupHint?.discNumber
|
||||
?? null
|
||||
);
|
||||
@@ -1210,16 +1410,27 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|
||||
|| job?.detected_title
|
||||
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series')
|
||||
) || (fallbackJobId ? `job-${fallbackJobId}` : 'series');
|
||||
const singleTemplateRaw = String(
|
||||
settings?.output_template_dvd_series_episode
|
||||
|| DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
|
||||
).trim();
|
||||
const multiTemplateRaw = String(
|
||||
settings?.output_template_dvd_series_multi_episode
|
||||
|| DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE
|
||||
).trim();
|
||||
const singleTemplate = singleTemplateRaw || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
|
||||
const multiTemplate = multiTemplateRaw || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE;
|
||||
const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate;
|
||||
const fallbackEpisodeTitle = buildSeriesFallbackEpisodeTitle(episodeRangeInfo, template);
|
||||
const rawEpisodeTitle = normalizeCdTrackText(
|
||||
assignment?.episodeTitle
|
||||
|| selectedTitle?.episodeTitle
|
||||
|| selectedTitle?.fileName
|
||||
|| `Episode ${episodeRangeToken}`
|
||||
) || `Episode ${episodeRangeToken}`;
|
||||
resolvedAssignment?.episodeTitle
|
||||
|| resolvedTitle?.episodeTitle
|
||||
|| fallbackEpisodeTitle
|
||||
) || fallbackEpisodeTitle;
|
||||
const episodeTitle = normalizeSeriesEpisodeTitleForOutput(
|
||||
rawEpisodeTitle,
|
||||
episodeRangeInfo
|
||||
);
|
||||
) || fallbackEpisodeTitle;
|
||||
const language = String(
|
||||
settings?.dvd_series_language
|
||||
|| selectedMetadata?.language
|
||||
@@ -1227,21 +1438,10 @@ function resolveSeriesOutputPathParts(settings, job, fallbackJobId = null) {
|
||||
).trim() || 'unknown';
|
||||
const year = Number(selectedMetadata?.year || job?.year || new Date().getFullYear()) || new Date().getFullYear();
|
||||
|
||||
const singleTemplate = String(
|
||||
settings?.output_template_dvd_series_episode
|
||||
|| DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE
|
||||
).trim() || DEFAULT_DVD_SERIES_OUTPUT_TEMPLATE;
|
||||
const multiTemplate = String(
|
||||
settings?.output_template_dvd_series_multi_episode
|
||||
|| DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE
|
||||
).trim() || DEFAULT_DVD_SERIES_MULTI_OUTPUT_TEMPLATE;
|
||||
const template = episodeRangeInfo.isMulti ? multiTemplate : singleTemplate;
|
||||
const rendered = renderTemplate(template, {
|
||||
seriesTitle,
|
||||
seasonNr: seasonNumber,
|
||||
seasonNo: String(seasonNumber),
|
||||
episodeNr: episodeTemplateValue,
|
||||
episodeNo: String(episodeTemplateValue),
|
||||
episodeNoStart: episodeRangeInfo.start,
|
||||
episodeNoEnd: episodeRangeInfo.end,
|
||||
episodeNumberStart: episodeRangeInfo.start,
|
||||
@@ -5283,15 +5483,16 @@ function resolveSeriesBatchChildDisplayTitle(parentJob, parentPlan, titleId, chi
|
||||
const episodeRange = resolveSeriesEpisodeRangeFromAssignment(
|
||||
assignment,
|
||||
selectedTitle,
|
||||
childIndex + 1
|
||||
childIndex + 1,
|
||||
{ allowSelectedTitleEpisodeNumber: hasExplicitSeriesEpisodeAssignmentRange(assignment) }
|
||||
);
|
||||
const fallbackEpisodeLabel = `Folge ${buildSeriesEpisodeRangeToken(episodeRange.start, episodeRange.end)}`;
|
||||
const rawEpisodeLabel = String(
|
||||
assignment?.episodeTitle
|
||||
|| selectedTitle?.episodeTitle
|
||||
|| selectedTitle?.fileName
|
||||
|| ''
|
||||
|| fallbackEpisodeLabel
|
||||
).trim();
|
||||
const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange);
|
||||
const episodeLabel = normalizeSeriesEpisodeTitleForOutput(rawEpisodeLabel, episodeRange) || fallbackEpisodeLabel;
|
||||
|
||||
const seasonToken = seasonNumber !== null ? String(seasonNumber).padStart(2, '0') : null;
|
||||
const episodeToken = buildSeriesEpisodeRangeToken(
|
||||
@@ -5326,6 +5527,25 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
|
||||
? plan.episodeAssignments
|
||||
: {};
|
||||
const selectedAssignment = assignmentMap[String(titleId)] || assignmentMap[titleId] || null;
|
||||
const inferredRangeForChild = resolveSeriesEpisodeRangeForPlanTitle(plan, titleId, childIndex + 1)?.range || null;
|
||||
const selectedAssignmentHasRange = hasExplicitSeriesEpisodeAssignmentRange(selectedAssignment);
|
||||
const resolvedChildAssignment = (
|
||||
selectedAssignmentHasRange
|
||||
|| !inferredRangeForChild
|
||||
)
|
||||
? selectedAssignment
|
||||
: {
|
||||
...(selectedAssignment && typeof selectedAssignment === 'object' ? selectedAssignment : {}),
|
||||
titleId: Number(titleId),
|
||||
episodeNumber: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumber) ?? inferredRangeForChild.start,
|
||||
episodeNumberStart: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberStart) ?? inferredRangeForChild.start,
|
||||
episodeNumberEnd: normalizeEpisodeNumberValue(selectedAssignment?.episodeNumberEnd) ?? inferredRangeForChild.end,
|
||||
episodeSpan: normalizePositiveInteger(selectedAssignment?.episodeSpan)
|
||||
|| Math.max(1, Math.trunc(Number(inferredRangeForChild.end) - Number(inferredRangeForChild.start) + 1)),
|
||||
episodeRange: String(selectedAssignment?.episodeRange || '').trim()
|
||||
|| buildSeriesEpisodeRangeToken(inferredRangeForChild.start, inferredRangeForChild.end),
|
||||
seasonNumber: normalizePositiveInteger(selectedAssignment?.seasonNumber) ?? normalizePositiveInteger(selectedTitle?.seasonNumber) ?? null
|
||||
};
|
||||
|
||||
return {
|
||||
...plan,
|
||||
@@ -5343,12 +5563,12 @@ function buildSeriesBatchChildPlan(parentPlan, titleId, parentJobId, childIndex,
|
||||
handBrakeTitleIds: handBrakeTitleId ? [handBrakeTitleId] : [],
|
||||
titleSelectionRequired: false,
|
||||
titles: remappedTitles,
|
||||
episodeAssignments: selectedAssignment
|
||||
episodeAssignments: resolvedChildAssignment
|
||||
? {
|
||||
[String(titleId)]: {
|
||||
...selectedAssignment,
|
||||
...resolvedChildAssignment,
|
||||
titleId: Number(titleId),
|
||||
filePath: selectedTitle?.filePath || selectedAssignment?.filePath || null
|
||||
filePath: selectedTitle?.filePath || resolvedChildAssignment?.filePath || null
|
||||
}
|
||||
}
|
||||
: {},
|
||||
@@ -8759,6 +8979,10 @@ class PipelineService extends EventEmitter {
|
||||
if (this.isSeriesContainerHistoryJob(job) || this.isSeriesBatchParentQueueAnchor(job)) {
|
||||
continue;
|
||||
}
|
||||
const status = String(job?.status || job?.last_state || '').trim().toUpperCase();
|
||||
if (status !== 'ENCODING' && status !== 'CD_ENCODING') {
|
||||
continue;
|
||||
}
|
||||
const poolType = this.resolveQueuePoolTypeForJob(job);
|
||||
if (poolType === 'audio') {
|
||||
audioRunning += 1;
|
||||
@@ -10759,49 +10983,52 @@ class PipelineService extends EventEmitter {
|
||||
const filmRunning = runningUsage.filmRunning;
|
||||
const cdRunning = runningUsage.audioRunning;
|
||||
const totalRunning = runningUsage.totalRunning;
|
||||
// Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
|
||||
const anyActiveJobs = totalRunning;
|
||||
|
||||
// Find next startable entry
|
||||
let entryIndex = -1;
|
||||
for (let i = 0; i < this.queueEntries.length; i++) {
|
||||
const candidate = this.queueEntries[i];
|
||||
const isNonJob = candidate.type && candidate.type !== 'job';
|
||||
|
||||
if (isNonJob) {
|
||||
// Non-job entries (script, chain, wait) only start when no jobs are actively running.
|
||||
// anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
|
||||
// activeProcesses provides a second safety net for race conditions during state transitions.
|
||||
if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
|
||||
entryIndex = i;
|
||||
}
|
||||
break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
|
||||
}
|
||||
|
||||
// Job entry: check hierarchical limits
|
||||
if (totalRunning >= maxTotal) {
|
||||
// Total limit reached – nothing can start
|
||||
break;
|
||||
}
|
||||
|
||||
const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
|
||||
if (candidatePoolType === 'audio') {
|
||||
if (cdRunning < maxCd) {
|
||||
entryIndex = i;
|
||||
const independentEntryIndex = this.queueEntries.findIndex((candidate) => {
|
||||
const type = String(candidate?.type || '').trim().toLowerCase();
|
||||
return type === 'script' || type === 'chain';
|
||||
});
|
||||
if (independentEntryIndex >= 0) {
|
||||
entryIndex = independentEntryIndex;
|
||||
} else {
|
||||
for (let i = 0; i < this.queueEntries.length; i++) {
|
||||
const candidate = this.queueEntries[i];
|
||||
const isWaitEntry = String(candidate?.type || '').trim().toLowerCase() === 'wait';
|
||||
if (isWaitEntry) {
|
||||
// Wait keeps queue order and blocks until no tracked job process is active.
|
||||
if (this.activeProcesses.size === 0) {
|
||||
entryIndex = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
// CD limit reached
|
||||
if (!cdBypass) break; // Strict FIFO: stop scanning
|
||||
continue; // Bypass mode: skip this blocked CD entry
|
||||
} else {
|
||||
// Film/video job entry
|
||||
if (filmRunning < maxFilm) {
|
||||
entryIndex = i;
|
||||
|
||||
// Job entry: check hierarchical limits (caps apply only to encode jobs).
|
||||
if (totalRunning >= maxTotal) {
|
||||
// Total encode cap reached – nothing can start.
|
||||
break;
|
||||
}
|
||||
// Film limit reached
|
||||
if (!cdBypass) break; // Strict FIFO: stop scanning
|
||||
continue; // Bypass mode: skip this blocked film entry
|
||||
|
||||
const candidatePoolType = this.resolveQueuePoolTypeForEntry(candidate);
|
||||
if (candidatePoolType === 'audio') {
|
||||
if (cdRunning < maxCd) {
|
||||
entryIndex = i;
|
||||
break;
|
||||
}
|
||||
// CD/audio encode cap reached
|
||||
if (!cdBypass) break; // Strict FIFO: stop scanning
|
||||
continue; // Bypass mode: skip this blocked CD entry
|
||||
} else {
|
||||
// Film/video encode job entry
|
||||
if (filmRunning < maxFilm) {
|
||||
entryIndex = i;
|
||||
break;
|
||||
}
|
||||
// Film encode cap reached
|
||||
if (!cdBypass) break; // Strict FIFO: stop scanning
|
||||
continue; // Bypass mode: skip this blocked film entry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12493,7 +12720,36 @@ class PipelineService extends EventEmitter {
|
||||
seasonNumber: seasonFilter || null
|
||||
});
|
||||
|
||||
let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery);
|
||||
let tmdbLanguage = null;
|
||||
try {
|
||||
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
|
||||
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
|
||||
} catch (_settingsError) {
|
||||
tmdbLanguage = null;
|
||||
}
|
||||
|
||||
const cacheKey = [
|
||||
normalizedQuery.toLowerCase(),
|
||||
seasonFilter.toLowerCase(),
|
||||
String(tmdbLanguage || '').trim().toLowerCase()
|
||||
].join('::');
|
||||
const cacheEntry = tmdbSeriesSearchCache.get(cacheKey);
|
||||
const nowTs = Date.now();
|
||||
if (cacheEntry && cacheEntry.expiresAt > nowTs && Array.isArray(cacheEntry.results)) {
|
||||
logger.info('tmdb:series-search:cache-hit', {
|
||||
query: normalizedQuery,
|
||||
seasonNumber: seasonFilter || null,
|
||||
count: cacheEntry.results.length
|
||||
});
|
||||
return cloneTmdbSeriesSearchResults(cacheEntry.results);
|
||||
}
|
||||
if (cacheEntry && cacheEntry.expiresAt <= nowTs) {
|
||||
tmdbSeriesSearchCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
let normalizedResults = await tmdbService.searchSeriesWithSeasons(normalizedQuery, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
if (!Array.isArray(normalizedResults)) {
|
||||
normalizedResults = [];
|
||||
}
|
||||
@@ -12518,6 +12774,26 @@ class PipelineService extends EventEmitter {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cacheNowTs = Date.now();
|
||||
if (tmdbSeriesSearchCache.size >= 200) {
|
||||
for (const [key, entry] of tmdbSeriesSearchCache.entries()) {
|
||||
if (!entry || entry.expiresAt <= cacheNowTs) {
|
||||
tmdbSeriesSearchCache.delete(key);
|
||||
}
|
||||
}
|
||||
if (tmdbSeriesSearchCache.size >= 200) {
|
||||
const oldestKey = tmdbSeriesSearchCache.keys().next().value;
|
||||
if (oldestKey) {
|
||||
tmdbSeriesSearchCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tmdbSeriesSearchCache.set(cacheKey, {
|
||||
results: cloneTmdbSeriesSearchResults(normalizedResults),
|
||||
expiresAt: cacheNowTs + TMDB_SERIES_SEARCH_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
return normalizedResults;
|
||||
}
|
||||
|
||||
@@ -14174,12 +14450,22 @@ class PipelineService extends EventEmitter {
|
||||
|| mkInfo?.analyzeContext?.selectedMetadata?.episodeCount
|
||||
|| 0
|
||||
) || 0;
|
||||
let effectiveEpisodes = Array.isArray(episodes)
|
||||
const hasExplicitEpisodePayload = Array.isArray(episodes) && episodes.length > 0;
|
||||
let effectiveEpisodes = hasExplicitEpisodePayload
|
||||
? episodes
|
||||
: (Array.isArray(mkInfo?.analyzeContext?.selectedMetadata?.episodes)
|
||||
? mkInfo.analyzeContext.selectedMetadata.episodes
|
||||
: []);
|
||||
let tmdbDetails = null;
|
||||
let tmdbLanguage = null;
|
||||
if (effectiveMetadataProvider === 'tmdb') {
|
||||
try {
|
||||
const dvdSettings = await settingsService.getEffectiveSettingsMap('dvd');
|
||||
tmdbLanguage = String(dvdSettings?.dvd_series_language || '').trim() || null;
|
||||
} catch (_settingsError) {
|
||||
tmdbLanguage = null;
|
||||
}
|
||||
}
|
||||
|
||||
const isDvdTmdbMetadataSelection = mediaProfile === 'dvd' && effectiveWorkflowKind === 'series';
|
||||
if (mediaProfile === 'dvd' && effectiveWorkflowKind === 'film') {
|
||||
@@ -14218,6 +14504,7 @@ class PipelineService extends EventEmitter {
|
||||
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId) {
|
||||
try {
|
||||
const seriesDetails = await tmdbService.getSeriesDetails(effectiveTmdbId, {
|
||||
language: tmdbLanguage,
|
||||
appendToResponse: ['credits', 'external_ids']
|
||||
});
|
||||
tmdbDetails = tmdbService.buildSeriesDetailsSummary(seriesDetails);
|
||||
@@ -14248,15 +14535,17 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
if (effectiveMetadataProvider === 'tmdb' && effectiveTmdbId && effectiveSeasonNumber) {
|
||||
try {
|
||||
seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber);
|
||||
seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber);
|
||||
seasonDetails = await tmdbService.getSeasonDetails(effectiveTmdbId, effectiveSeasonNumber, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
seasonCredits = await tmdbService.getSeasonCredits(effectiveTmdbId, effectiveSeasonNumber, {
|
||||
language: tmdbLanguage
|
||||
});
|
||||
const seasonSummary = tmdbService.buildSeasonSummary(seasonDetails);
|
||||
if (seasonSummary) {
|
||||
const fetchedEpisodes = Array.isArray(seasonSummary.episodes) ? seasonSummary.episodes : [];
|
||||
if (fetchedEpisodes.length > 0) {
|
||||
if (!Array.isArray(effectiveEpisodes) || effectiveEpisodes.length === 0) {
|
||||
effectiveEpisodes = fetchedEpisodes;
|
||||
}
|
||||
if (fetchedEpisodes.length > 0 && !hasExplicitEpisodePayload) {
|
||||
effectiveEpisodes = fetchedEpisodes;
|
||||
}
|
||||
const fetchedEpisodeCount = Number(seasonSummary.episodeCount || 0) || 0;
|
||||
if (fetchedEpisodeCount > 0) {
|
||||
@@ -17981,6 +18270,98 @@ class PipelineService extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
buildPostEncodeScriptsSummaryPlaceholder(encodePlan) {
|
||||
const scriptIds = normalizeScriptIdList(encodePlan?.postEncodeScriptIds || []);
|
||||
const chainIds = normalizeChainIdList(encodePlan?.postEncodeChainIds || []);
|
||||
return {
|
||||
configured: scriptIds.length + chainIds.length,
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
aborted: false,
|
||||
abortReason: null,
|
||||
failedScriptId: null,
|
||||
failedScriptName: null,
|
||||
pending: scriptIds.length + chainIds.length > 0,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
|
||||
async persistPostEncodeScriptsSummary(jobId, summary) {
|
||||
try {
|
||||
const job = await historyService.getJobById(jobId);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
const handbrakeInfo = this.safeParseJson(job.handbrake_info_json);
|
||||
const nextInfo = handbrakeInfo && typeof handbrakeInfo === 'object'
|
||||
? handbrakeInfo
|
||||
: {};
|
||||
await historyService.updateJob(jobId, {
|
||||
handbrake_info_json: JSON.stringify({
|
||||
...nextInfo,
|
||||
postEncodeScripts: {
|
||||
...(summary && typeof summary === 'object' ? summary : {}),
|
||||
pending: false
|
||||
}
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('encode:post-script:persist-summary-failed', { jobId, error: errorToMeta(error) });
|
||||
}
|
||||
}
|
||||
|
||||
runPostEncodeScriptsDetached(jobId, encodePlan, context = {}, options = {}) {
|
||||
const placeholderSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
|
||||
if (placeholderSummary.configured <= 0) {
|
||||
return { started: false, summary: placeholderSummary };
|
||||
}
|
||||
|
||||
const phaseLabel = String(options?.phaseLabel || 'Post-Encode').trim() || 'Post-Encode';
|
||||
void historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`${phaseLabel} Skripte/Ketten werden unabhängig im Hintergrund ausgeführt.`
|
||||
);
|
||||
|
||||
void (async () => {
|
||||
let finalSummary = {
|
||||
...placeholderSummary,
|
||||
pending: false
|
||||
};
|
||||
try {
|
||||
finalSummary = {
|
||||
...(await this.runPostEncodeScripts(jobId, encodePlan, context, null)),
|
||||
pending: false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('encode:post-script:detached-failed', { jobId, error: errorToMeta(error) });
|
||||
finalSummary = {
|
||||
...placeholderSummary,
|
||||
attempted: placeholderSummary.configured,
|
||||
failed: placeholderSummary.configured,
|
||||
aborted: true,
|
||||
abortReason: error?.message || 'unknown',
|
||||
pending: false
|
||||
};
|
||||
}
|
||||
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`${phaseLabel} Skripte/Ketten abgeschlossen: ${finalSummary.succeeded} erfolgreich, `
|
||||
+ `${finalSummary.failed} fehlgeschlagen, ${finalSummary.skipped} übersprungen.`
|
||||
);
|
||||
await this.persistPostEncodeScriptsSummary(jobId, finalSummary);
|
||||
})();
|
||||
|
||||
return {
|
||||
started: true,
|
||||
summary: placeholderSummary
|
||||
};
|
||||
}
|
||||
|
||||
async startEncodingFromPrepared(jobId, options = {}) {
|
||||
this.ensureNotBusy('startEncodingFromPrepared', jobId);
|
||||
logger.info('encode:start-from-prepared', { jobId });
|
||||
@@ -18568,40 +18949,15 @@ class PipelineService extends EventEmitter {
|
||||
historyService.addJobOutputFolder(jobId, finalizedOutputPath).catch((e) => {
|
||||
logger.warn('encode:record-output-folder-failed', { jobId, error: e?.message });
|
||||
});
|
||||
let postEncodeScriptsSummary = {
|
||||
configured: 0,
|
||||
attempted: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
results: []
|
||||
const postEncodeExecutionContext = {
|
||||
mode,
|
||||
jobTitle: job.title || job.detected_title || null,
|
||||
inputPath,
|
||||
outputPath: finalizedOutputPath,
|
||||
rawPath: activeRawPath,
|
||||
pipelineStage: 'ENCODING'
|
||||
};
|
||||
try {
|
||||
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, encodePlan, {
|
||||
mode,
|
||||
jobTitle: job.title || job.detected_title || null,
|
||||
inputPath,
|
||||
outputPath: finalizedOutputPath,
|
||||
rawPath: activeRawPath
|
||||
}, encodeScriptProgressTracker);
|
||||
} catch (error) {
|
||||
logger.warn('encode:post-script:summary-failed', {
|
||||
jobId,
|
||||
error: errorToMeta(error)
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skripte konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
|
||||
);
|
||||
}
|
||||
if (postEncodeScriptsSummary.configured > 0) {
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Encode Skripte abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, ${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.${postEncodeScriptsSummary.aborted ? ' Kette wurde abgebrochen.' : ''}`
|
||||
);
|
||||
}
|
||||
const postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(encodePlan);
|
||||
let finalizedRawPath = activeRawPath || null;
|
||||
if (activeRawPath) {
|
||||
const currentRawPath = String(activeRawPath || '').trim();
|
||||
@@ -18662,6 +19018,9 @@ class PipelineService extends EventEmitter {
|
||||
output_path: finalizedOutputPath,
|
||||
error_message: null
|
||||
});
|
||||
this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
|
||||
phaseLabel: 'Post-Encode'
|
||||
});
|
||||
return {
|
||||
outputPath: finalizedOutputPath,
|
||||
handbrakeInfo: handbrakeInfoWithPostScripts,
|
||||
@@ -18723,6 +19082,9 @@ class PipelineService extends EventEmitter {
|
||||
logger.info('encoding:finished:background', { jobId, mode, activeJobId: this.snapshot.activeJobId });
|
||||
void this.pumpQueue();
|
||||
}
|
||||
this.runPostEncodeScriptsDetached(jobId, encodePlan, postEncodeExecutionContext, {
|
||||
phaseLabel: 'Post-Encode'
|
||||
});
|
||||
|
||||
if (mode === 'reencode') {
|
||||
void this.notifyPushover('reencode_finished', {
|
||||
@@ -23532,8 +23894,6 @@ class PipelineService extends EventEmitter {
|
||||
const normalizedEncodePlan = encodePlan && typeof encodePlan === 'object' ? encodePlan : {};
|
||||
const preScriptIds = normalizeScriptIdList(normalizedEncodePlan?.preEncodeScriptIds || []);
|
||||
const preChainIds = normalizeChainIdList(normalizedEncodePlan?.preEncodeChainIds || []);
|
||||
const postScriptIds = normalizeScriptIdList(normalizedEncodePlan?.postEncodeScriptIds || []);
|
||||
const postChainIds = normalizeChainIdList(normalizedEncodePlan?.postEncodeChainIds || []);
|
||||
let preEncodeScriptsSummary = {
|
||||
configured: 0,
|
||||
attempted: 0,
|
||||
@@ -23755,35 +24115,17 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
const { encodeResults: cdEncodeResults = [] } = cdRipResult || {};
|
||||
settleLifecycle();
|
||||
|
||||
if (postScriptIds.length > 0 || postChainIds.length > 0) {
|
||||
await historyService.appendLog(jobId, 'SYSTEM', 'Post-Rip Skripte/Ketten werden ausgeführt...');
|
||||
try {
|
||||
postEncodeScriptsSummary = await this.runPostEncodeScripts(jobId, normalizedEncodePlan, {
|
||||
mode: 'cd_rip',
|
||||
jobId,
|
||||
jobTitle: selectedMeta?.title || `Job #${jobId}`,
|
||||
inputPath: devicePath || null,
|
||||
outputPath: outputDir || null,
|
||||
rawPath: rawWavDir || null,
|
||||
mediaProfile: 'cd',
|
||||
pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('cd:rip:post-script:failed', { jobId, error: errorToMeta(error) });
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Rip Skripte/Ketten konnten nicht vollständig ausgeführt werden: ${error?.message || 'unknown'}`
|
||||
);
|
||||
}
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
'SYSTEM',
|
||||
`Post-Rip Skripte/Ketten abgeschlossen: ${postEncodeScriptsSummary.succeeded} erfolgreich, `
|
||||
+ `${postEncodeScriptsSummary.failed} fehlgeschlagen, ${postEncodeScriptsSummary.skipped} übersprungen.`
|
||||
);
|
||||
}
|
||||
postEncodeScriptsSummary = this.buildPostEncodeScriptsSummaryPlaceholder(normalizedEncodePlan);
|
||||
let postCdScriptContext = {
|
||||
mode: 'cd_rip',
|
||||
jobId,
|
||||
jobTitle: selectedMeta?.title || `Job #${jobId}`,
|
||||
inputPath: devicePath || null,
|
||||
outputPath: outputDir || null,
|
||||
rawPath: rawWavDir || null,
|
||||
mediaProfile: 'cd',
|
||||
pipelineStage: skipEncode ? 'CD_RIPPING' : 'CD_ENCODING'
|
||||
};
|
||||
|
||||
// RAW-Verzeichnis von Incomplete_ → Zielzustand umbenennen
|
||||
let activeRawDir = rawWavDir;
|
||||
@@ -23800,6 +24142,11 @@ class PipelineService extends EventEmitter {
|
||||
// ignore – raw dir bleibt unter bestehendem Namen zugänglich
|
||||
}
|
||||
}
|
||||
postCdScriptContext = {
|
||||
...postCdScriptContext,
|
||||
outputPath: outputDir || null,
|
||||
rawPath: activeRawDir || rawWavDir || null
|
||||
};
|
||||
|
||||
const directReencodeReadyAt = normalizedEncodePlan?.directReencodeReadyAt || nowIso();
|
||||
const persistedEncodePlan = {
|
||||
@@ -23963,6 +24310,9 @@ class PipelineService extends EventEmitter {
|
||||
void settingsService.getSettingsMap().then((cdSettings) => this.ejectDriveIfEnabled(cdSettings, devicePath));
|
||||
}
|
||||
}
|
||||
this.runPostEncodeScriptsDetached(jobId, normalizedEncodePlan, postCdScriptContext, {
|
||||
phaseLabel: 'Post-Rip'
|
||||
});
|
||||
} catch (error) {
|
||||
settleLifecycle();
|
||||
const failedCdLive = buildLiveContext(currentTrackPosition || null);
|
||||
|
||||
+2
-2
@@ -464,11 +464,11 @@ VALUES ('output_template_dvd', 'Pfade', 'Output Template (DVD)', 'string', 1, 'T
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd', '${title} (${year})/${title} (${year})');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}. Alias: {seasonNo}/{episodeNo} entspricht {seasonNr}/{episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
|
||||
VALUES ('output_template_dvd_series_episode', 'Pfade', 'Output Template (DVD Serie, Episode)', 'string', 1, 'Template für einzelne DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNr}, {episodeTitle}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNr}.', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}', '[]', '{"minLength":1}', 536);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_episode', '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}. Alias: {seasonNo} entspricht {seasonNr}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
|
||||
VALUES ('output_template_dvd_series_multi_episode', 'Pfade', 'Output Template (DVD Serie, Multi-Episode)', 'string', 1, 'Template für zusammengefasste DVD-Serienepisoden ohne Dateiendung. Platzhalter: {seriesTitle}, {seasonNr}, {episodeNoStart}, {episodeNoEnd}, {episodeRange}, {episodeTitle}, {parts}, {discNr}, {year}, {language}. Optional mit führender Null: {0:seasonNr}, {0:episodeNoStart}, {0:episodeNoEnd}.', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})', '[]', '{"minLength":1}', 537);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_dvd_series_multi_episode', '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})');
|
||||
|
||||
-- Tools – CD
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -823,6 +823,7 @@ export const api = {
|
||||
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
const resetDriveState = Boolean(options?.resetDriveState);
|
||||
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
|
||||
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
|
||||
? options.selectedMoviePaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -838,6 +839,7 @@ export const api = {
|
||||
target,
|
||||
includeRelated,
|
||||
resetDriveState,
|
||||
preserveRawForImportJobs,
|
||||
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
|
||||
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { Button } from 'primereact/button';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
@@ -45,11 +45,13 @@ export default function MetadataSelectionDialog({
|
||||
const [manualImdb, setManualImdb] = useState('');
|
||||
const [manualDiscNumber, setManualDiscNumber] = useState('');
|
||||
const [extraResults, setExtraResults] = useState([]);
|
||||
const [hasSearchOverride, setHasSearchOverride] = useState(false);
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
const [searchError, setSearchError] = useState('');
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [seasonFilter, setSeasonFilter] = useState('');
|
||||
const [discValidationError, setDiscValidationError] = useState('');
|
||||
const activeSearchRequestRef = useRef(0);
|
||||
const contextWorkflowKind = normalizeWorkflowKind(
|
||||
context?.selectedMetadata?.workflowKind
|
||||
|| context?.workflowKind
|
||||
@@ -102,12 +104,14 @@ export default function MetadataSelectionDialog({
|
||||
setManualImdb(defaultImdb);
|
||||
setManualDiscNumber('');
|
||||
setExtraResults([]);
|
||||
setHasSearchOverride(false);
|
||||
setSearchBusy(false);
|
||||
setSearchError('');
|
||||
setSubmitError('');
|
||||
setSeasonFilter('');
|
||||
setDiscValidationError('');
|
||||
setSelectedMetadataProvider(contextMetadataProvider);
|
||||
activeSearchRequestRef.current += 1;
|
||||
}, [visible, context]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,7 +133,8 @@ export default function MetadataSelectionDialog({
|
||||
const base = Array.isArray(context?.metadataCandidates)
|
||||
? context.metadataCandidates
|
||||
: (context?.omdbCandidates || []);
|
||||
const all = [...base, ...extraResults];
|
||||
const sourceRows = hasSearchOverride ? extraResults : base;
|
||||
const all = Array.isArray(sourceRows) ? sourceRows : [];
|
||||
const map = new Map();
|
||||
|
||||
all.forEach((item) => {
|
||||
@@ -149,7 +154,7 @@ export default function MetadataSelectionDialog({
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [context, extraResults]);
|
||||
}, [context, extraResults, hasSearchOverride]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (metadataProvider !== 'tmdb') {
|
||||
@@ -192,18 +197,28 @@ export default function MetadataSelectionDialog({
|
||||
if (!trimmedQuery || typeof onSearch !== 'function') {
|
||||
return;
|
||||
}
|
||||
const requestId = activeSearchRequestRef.current + 1;
|
||||
activeSearchRequestRef.current = requestId;
|
||||
setHasSearchOverride(true);
|
||||
setSearchBusy(true);
|
||||
setSearchError('');
|
||||
try {
|
||||
const results = await onSearch(trimmedQuery, {
|
||||
metadataProvider
|
||||
});
|
||||
if (activeSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setExtraResults(Array.isArray(results) ? results : []);
|
||||
} catch (error) {
|
||||
setExtraResults([]);
|
||||
if (activeSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setSearchError(error?.message || 'Suche fehlgeschlagen.');
|
||||
} finally {
|
||||
setSearchBusy(false);
|
||||
if (activeSearchRequestRef.current === requestId) {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,10 +277,6 @@ export default function MetadataSelectionDialog({
|
||||
};
|
||||
|
||||
const handleHideRequest = () => {
|
||||
if (requiresDiscNumber && !hasValidDiscNumber) {
|
||||
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
|
||||
return;
|
||||
}
|
||||
onHide?.();
|
||||
};
|
||||
|
||||
@@ -291,6 +302,7 @@ export default function MetadataSelectionDialog({
|
||||
setSelectedMetadataProvider(nextProvider);
|
||||
setSelected(null);
|
||||
setExtraResults([]);
|
||||
setHasSearchOverride(false);
|
||||
setSearchError('');
|
||||
setSubmitError('');
|
||||
setDiscValidationError('');
|
||||
@@ -331,7 +343,7 @@ export default function MetadataSelectionDialog({
|
||||
/>
|
||||
<small className="metadata-filter-subtitle">
|
||||
{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.'}
|
||||
</small>
|
||||
</div>
|
||||
@@ -394,7 +406,6 @@ export default function MetadataSelectionDialog({
|
||||
severity="secondary"
|
||||
text
|
||||
onClick={handleHideRequest}
|
||||
disabled={requiresDiscNumber && !hasValidDiscNumber}
|
||||
/>
|
||||
<Button
|
||||
label="Auswahl übernehmen"
|
||||
|
||||
@@ -669,15 +669,16 @@ function formatTemplateTwoDigit(value) {
|
||||
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 titleSource = title && typeof title === 'object' ? title : {};
|
||||
const allowTitleEpisodeNumber = options?.allowTitleEpisodeNumber !== false;
|
||||
const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1;
|
||||
const episodeStart = normalizePositiveInt(
|
||||
assignmentSource?.episodeNumberStart
|
||||
?? assignmentSource?.episodeNoStart
|
||||
?? assignmentSource?.episodeNumber
|
||||
?? titleSource?.episodeNumber
|
||||
?? (allowTitleEpisodeNumber ? titleSource?.episodeNumber : null)
|
||||
?? fallbackNumber
|
||||
) || fallbackNumber;
|
||||
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(
|
||||
settings?.series_dir_dvd
|
||||
|| settings?.movie_dir_dvd
|
||||
@@ -953,15 +1082,28 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
|
||||
const seasonNumber = normalizePositiveInt(
|
||||
assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 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(
|
||||
assignment,
|
||||
assignment || {},
|
||||
title,
|
||||
allTitles,
|
||||
title?.id || 1
|
||||
inferredRange?.start || (fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1)
|
||||
);
|
||||
const episodeNumber = episodeRange.start;
|
||||
const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end);
|
||||
const partsToken = buildEpisodePartsToken(episodeRange);
|
||||
const effectiveEpisodeRange = episodeRange;
|
||||
const episodeNumber = effectiveEpisodeRange.start;
|
||||
const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end);
|
||||
const partsToken = buildEpisodePartsToken(effectiveEpisodeRange);
|
||||
const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null);
|
||||
const seriesTitleRaw = String(
|
||||
metadata?.title
|
||||
@@ -969,16 +1111,6 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
|
||||
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series')
|
||||
).trim();
|
||||
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 language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null);
|
||||
|
||||
@@ -992,21 +1124,28 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
|
||||
).trim();
|
||||
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 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, {
|
||||
seriesTitle,
|
||||
seasonNr: seasonNumber,
|
||||
seasonNo: String(seasonNumber),
|
||||
episodeNr: episodeNumber,
|
||||
episodeNo: String(episodeNumber),
|
||||
episodeNoStart: episodeRange.start,
|
||||
episodeNoEnd: episodeRange.end,
|
||||
episodeNumberStart: episodeRange.start,
|
||||
episodeNumberEnd: episodeRange.end,
|
||||
episodeNoStart: effectiveEpisodeRange.start,
|
||||
episodeNoEnd: effectiveEpisodeRange.end,
|
||||
episodeNumberStart: effectiveEpisodeRange.start,
|
||||
episodeNumberEnd: effectiveEpisodeRange.end,
|
||||
episodeRange: episodeRangeToken,
|
||||
episodeSpan: episodeRange.span,
|
||||
episodeSpan: effectiveEpisodeRange.span,
|
||||
parts: partsToken,
|
||||
episodeTitle,
|
||||
episodeTitle: normalizeEpisodeTitleForOutput(
|
||||
episodeTitleRaw || fallbackEpisodeTitle,
|
||||
effectiveEpisodeRange
|
||||
) || fallbackEpisodeTitle,
|
||||
discNr: discNumber || '',
|
||||
year,
|
||||
language
|
||||
@@ -2380,14 +2519,24 @@ export default function PipelineStatusCard({
|
||||
title,
|
||||
assignment,
|
||||
retryJobId,
|
||||
Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []
|
||||
Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [],
|
||||
episodeAssignmentsByTitle,
|
||||
selectedEncodeTitleIds
|
||||
);
|
||||
if (path) {
|
||||
map[titleId] = path;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, settingsMap, selectedMetadata, retryJobId]);
|
||||
}, [
|
||||
isSeriesDvdReview,
|
||||
mediaInfoReview?.titles,
|
||||
episodeAssignmentsByTitle,
|
||||
settingsMap,
|
||||
selectedMetadata,
|
||||
retryJobId,
|
||||
selectedEncodeTitleIds
|
||||
]);
|
||||
const multiEpisodeReviewWarnings = useMemo(() => {
|
||||
if (!isSeriesDvdReview) {
|
||||
return [];
|
||||
|
||||
@@ -1948,9 +1948,17 @@ export default function RipperPage({
|
||||
return;
|
||||
}
|
||||
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 deleteIncompleteRaw = !ripSuccessful && isIncompleteRawPath(job?.raw_path);
|
||||
const deleteIncompleteRaw = !isOrphanRawImportJob && !ripSuccessful && isIncompleteRawPath(job?.raw_path);
|
||||
const deleteTarget = deleteIncompleteRaw ? 'raw' : 'none';
|
||||
const resetDriveStateOnDelete = !isOrphanRawImportJob;
|
||||
const keepDetectedDeviceOnDelete = isOrphanRawImportJob;
|
||||
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Job löschen',
|
||||
@@ -1959,9 +1967,13 @@ export default function RipperPage({
|
||||
`${title}\n\n` +
|
||||
(deleteIncompleteRaw
|
||||
? '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.' +
|
||||
'\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',
|
||||
rejectLabel: 'Abbrechen',
|
||||
danger: true
|
||||
@@ -1987,8 +1999,9 @@ export default function RipperPage({
|
||||
}
|
||||
await api.deleteJobEntry(normalizedJobId, deleteTarget, {
|
||||
includeRelated: true,
|
||||
resetDriveState: true,
|
||||
keepDetectedDevice: false
|
||||
resetDriveState: resetDriveStateOnDelete,
|
||||
keepDetectedDevice: keepDetectedDeviceOnDelete,
|
||||
preserveRawForImportJobs: true
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadRipperJobs();
|
||||
|
||||
-1037
File diff suppressed because it is too large
Load Diff
+130
-1
@@ -135,6 +135,114 @@ detect_container_env() {
|
||||
|
||||
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() {
|
||||
local url="$1"
|
||||
local target="$2"
|
||||
@@ -599,6 +707,15 @@ for grp in cdrom optical disk video render; do
|
||||
fi
|
||||
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 ----------------------------------------
|
||||
header "Repository holen (Git)"
|
||||
|
||||
@@ -618,9 +735,11 @@ if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
cp -a "$INSTALL_DIR/backend/data" "$DATA_BACKUP"
|
||||
info "Datenbank gesichert nach: $DATA_BACKUP"
|
||||
fi
|
||||
backup_and_remove_backend_src
|
||||
# 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-url origin "$REPO_URL"
|
||||
git -C "$INSTALL_DIR" remote set-branches origin '*'
|
||||
git -C "$INSTALL_DIR" fetch --quiet origin
|
||||
git -C "$INSTALL_DIR" reset --hard HEAD
|
||||
@@ -654,6 +773,13 @@ if [[ -n "${DATA_BACKUP:-}" && -d "$DATA_BACKUP" ]]; then
|
||||
ok "Datenbank wiederhergestellt"
|
||||
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 -----------------------------------------
|
||||
header "npm-Abhängigkeiten installieren"
|
||||
|
||||
@@ -735,6 +861,9 @@ else
|
||||
ok "Verzeichnisse bereits $SERVICE_USER gehörig (kein SUDO_USER erkannt)"
|
||||
fi
|
||||
|
||||
enforce_default_output_dirs_permissions
|
||||
post_install_check_default_dirs
|
||||
|
||||
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
||||
# Laufzeit-relevant ist das Verzeichnis des Service-Users.
|
||||
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."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
-832
@@ -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 ""
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.13.1-9",
|
||||
"version": "0.13.1-10",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user