Compare commits
17 Commits
frontend-n
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c3875803ff | |||
| d75d9eb4c8 | |||
| 59bcb54492 | |||
| 241b097ea9 | |||
| e140a9fa8c | |||
| 5580d3be98 | |||
| 43dfdbf33e | |||
| 24b63d390a | |||
| 49fcca72af | |||
| ba91f83722 | |||
| 466e7a7a3d | |||
| e67c0d316d | |||
| 1da5ee3e34 | |||
| 4d377f3eb4 | |||
| df708485b5 | |||
| b6cac5efb4 | |||
| f38081649f |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -79,5 +79,12 @@ Thumbs.db
|
|||||||
# ----------------------------
|
# ----------------------------
|
||||||
# Scripts
|
# Scripts
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
deploy-ripster.sh
|
/scripts/
|
||||||
build-handbrake-nvdec.sh
|
/deploy-ripster.sh
|
||||||
|
/setup.sh
|
||||||
|
/install.sh
|
||||||
|
/install-dev.sh
|
||||||
|
/build-handbrake-nvdec.sh
|
||||||
|
/gitea_setup.sh
|
||||||
|
/gitea_install.sh
|
||||||
|
/release.sh
|
||||||
|
|||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -855,6 +855,41 @@ async function migrateSettingsSchemaMetadata(db) {
|
|||||||
logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
|
logger.info('migrate:settings-schema-category-moved', { key: move.key, category: move.category });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawDirCdLabel = 'CD RAW-Ordner';
|
||||||
|
const rawDirCdDescription = 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).';
|
||||||
|
const rawDirCdResult = await db.run(
|
||||||
|
`UPDATE settings_schema
|
||||||
|
SET label = ?, description = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = 'raw_dir_cd' AND (label != ? OR description != ?)`,
|
||||||
|
[rawDirCdLabel, rawDirCdDescription, rawDirCdLabel, rawDirCdDescription]
|
||||||
|
);
|
||||||
|
if (rawDirCdResult?.changes > 0) {
|
||||||
|
logger.info('migrate:settings-schema-cd-raw-updated', {
|
||||||
|
key: 'raw_dir_cd',
|
||||||
|
label: rawDirCdLabel
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate raw_dir_cd_owner label
|
||||||
|
await db.run(
|
||||||
|
`UPDATE settings_schema SET label = 'Eigentümer CD RAW-Ordner', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE key = 'raw_dir_cd_owner' AND label != 'Eigentümer CD RAW-Ordner'`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add movie_dir_cd if not already present
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL)`);
|
||||||
|
|
||||||
|
// Add movie_dir_cd_owner if not already present
|
||||||
|
await db.run(
|
||||||
|
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145)`
|
||||||
|
);
|
||||||
|
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDb() {
|
async function getDb() {
|
||||||
|
|||||||
@@ -91,6 +91,37 @@ router.post(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const job = await historyService.assignOmdbMetadata(id, payload);
|
const job = await historyService.assignOmdbMetadata(id, payload);
|
||||||
|
|
||||||
|
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||||
|
pipelineService.renameJobFolders(id).catch((err) => {
|
||||||
|
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ job });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:id/cd/assign',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const payload = req.body || {};
|
||||||
|
logger.info('post:job:cd:assign', {
|
||||||
|
reqId: req.reqId,
|
||||||
|
id,
|
||||||
|
mbId: payload?.mbId || null,
|
||||||
|
hasTitle: Boolean(payload?.title),
|
||||||
|
hasArtist: Boolean(payload?.artist),
|
||||||
|
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const job = await historyService.assignCdMetadata(id, payload);
|
||||||
|
|
||||||
|
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
|
||||||
|
pipelineService.renameJobFolders(id).catch((err) => {
|
||||||
|
logger.warn('post:job:cd:assign:rename-failed', { id, error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ job });
|
res.json({ job });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -321,6 +321,20 @@ function formatCommandLine(cmd, args = []) {
|
|||||||
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyFilePreservingRaw(sourcePath, targetPath) {
|
||||||
|
const rawSource = String(sourcePath || '').trim();
|
||||||
|
const rawTarget = String(targetPath || '').trim();
|
||||||
|
if (!rawSource || !rawTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = path.resolve(rawSource);
|
||||||
|
const target = path.resolve(rawTarget);
|
||||||
|
if (source === target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.copyFileSync(source, target);
|
||||||
|
}
|
||||||
|
|
||||||
async function runProcessTracked({
|
async function runProcessTracked({
|
||||||
cmd,
|
cmd,
|
||||||
args,
|
args,
|
||||||
@@ -492,7 +506,7 @@ async function ripAndEncode(options) {
|
|||||||
|
|
||||||
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
|
||||||
if (format === 'wav') {
|
if (format === 'wav') {
|
||||||
// Just move WAV files to output dir with proper names
|
// Keep RAW WAVs in place and copy them to the final output structure.
|
||||||
for (let i = 0; i < tracksToRip.length; i++) {
|
for (let i = 0; i < tracksToRip.length; i++) {
|
||||||
assertNotCancelled(isCancelled);
|
assertNotCancelled(isCancelled);
|
||||||
const track = tracksToRip[i];
|
const track = tracksToRip[i];
|
||||||
@@ -508,8 +522,8 @@ async function ripAndEncode(options) {
|
|||||||
percent: 50 + ((i / tracksToRip.length) * 50)
|
percent: 50 + ((i / tracksToRip.length) * 50)
|
||||||
});
|
});
|
||||||
ensureDir(path.dirname(outFile));
|
ensureDir(path.dirname(outFile));
|
||||||
log('info', `Promptkette [Move ${i + 1}/${tracksToRip.length}]: mv ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
|
||||||
fs.renameSync(wavFile, outFile);
|
copyFilePreservingRaw(wavFile, outFile);
|
||||||
onProgress && onProgress({
|
onProgress && onProgress({
|
||||||
phase: 'encode',
|
phase: 'encode',
|
||||||
trackEvent: 'complete',
|
trackEvent: 'complete',
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ class HardwareMonitorService {
|
|||||||
} else {
|
} else {
|
||||||
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
|
addPath('raw_dir', 'RAW-Verzeichnis', blurayRawPath || dvdRawPath || sourceMap.raw_dir);
|
||||||
}
|
}
|
||||||
addPath('raw_dir_cd', 'CD-Verzeichnis', cdRawPath || sourceMap.raw_dir_cd);
|
addPath('raw_dir_cd', 'CD RAW-Ordner', cdRawPath || sourceMap.raw_dir_cd);
|
||||||
|
|
||||||
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
|
if (blurayMoviePath && dvdMoviePath && blurayMoviePath !== dvdMoviePath) {
|
||||||
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
|
addPath('movie_dir_bluray', 'Movie-Verzeichnis (Blu-ray)', blurayMoviePath);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function parseJsonSafe(raw, fallback = null) {
|
|||||||
|
|
||||||
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
const PROCESS_LOG_TAIL_MAX_BYTES = 1024 * 1024;
|
||||||
const processLogStreams = new Map();
|
const processLogStreams = new Map();
|
||||||
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'other'];
|
const PROFILE_PATH_SUFFIXES = ['bluray', 'dvd', 'cd', 'other'];
|
||||||
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
const RAW_INCOMPLETE_PREFIX = 'Incomplete_';
|
||||||
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
const RAW_RIP_COMPLETE_PREFIX = 'Rip_Complete_';
|
||||||
|
|
||||||
@@ -161,6 +161,41 @@ function hasBlurayStructure(rawPath) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasCdStructure(rawPath) {
|
||||||
|
const basePath = String(rawPath || '').trim();
|
||||||
|
if (!basePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(basePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stat = fs.statSync(basePath);
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entries = fs.readdirSync(basePath);
|
||||||
|
const audioExtensions = new Set(['.flac', '.wav', '.mp3', '.opus', '.ogg', '.aiff', '.aif']);
|
||||||
|
return entries.some((entry) => audioExtensions.has(path.extname(entry).toLowerCase()));
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectOrphanMediaType(rawPath) {
|
||||||
|
if (hasBlurayStructure(rawPath)) {
|
||||||
|
return 'bluray';
|
||||||
|
}
|
||||||
|
if (hasDvdStructure(rawPath)) {
|
||||||
|
return 'dvd';
|
||||||
|
}
|
||||||
|
if (hasCdStructure(rawPath)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
function hasDvdStructure(rawPath) {
|
function hasDvdStructure(rawPath) {
|
||||||
const basePath = String(rawPath || '').trim();
|
const basePath = String(rawPath || '').trim();
|
||||||
if (!basePath) {
|
if (!basePath) {
|
||||||
@@ -356,12 +391,46 @@ function toProcessLogStreamKey(jobId) {
|
|||||||
return String(Math.trunc(normalizedId));
|
return String(Math.trunc(normalizedId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEffectiveRawPath(storedPath, rawDir) {
|
function resolveEffectiveRawPath(storedPath, rawDir, extraDirs = []) {
|
||||||
const stored = String(storedPath || '').trim();
|
const stored = String(storedPath || '').trim();
|
||||||
if (!stored || !rawDir) return stored;
|
if (!stored) return stored;
|
||||||
const folderName = path.basename(stored);
|
const folderName = path.basename(stored);
|
||||||
if (!folderName) return stored;
|
if (!folderName) return stored;
|
||||||
return path.join(String(rawDir).trim(), folderName);
|
|
||||||
|
const candidates = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const pushCandidate = (candidatePath) => {
|
||||||
|
const normalized = String(candidatePath || '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const comparable = normalizeComparablePath(normalized);
|
||||||
|
if (!comparable || seen.has(comparable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(comparable);
|
||||||
|
candidates.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
pushCandidate(stored);
|
||||||
|
if (rawDir) {
|
||||||
|
pushCandidate(path.join(String(rawDir).trim(), folderName));
|
||||||
|
}
|
||||||
|
for (const extraDir of Array.isArray(extraDirs) ? extraDirs : []) {
|
||||||
|
pushCandidate(path.join(String(extraDir || '').trim(), folderName));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore fs errors and continue with fallbacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawDir ? path.join(String(rawDir).trim(), folderName) : stored;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveEffectiveOutputPath(storedPath, movieDir) {
|
function resolveEffectiveOutputPath(storedPath, movieDir) {
|
||||||
@@ -405,17 +474,16 @@ function resolveEffectiveStoragePathsForJob(settings = null, job = {}, parsed =
|
|||||||
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
|
const effectiveSettings = settingsService.resolveEffectiveToolSettings(settings || {}, mediaType);
|
||||||
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
|
const rawDir = String(effectiveSettings?.raw_dir || '').trim();
|
||||||
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
|
const configuredMovieDir = String(effectiveSettings?.movie_dir || '').trim();
|
||||||
const movieDir = mediaType === 'cd' ? rawDir : configuredMovieDir;
|
const movieDir = configuredMovieDir || rawDir;
|
||||||
const effectiveRawPath = mediaType === 'cd'
|
const rawLookupDirs = getConfiguredMediaPathList(settings || {}, 'raw_dir')
|
||||||
? (job?.raw_path || null)
|
.filter((candidate) => normalizeComparablePath(candidate) !== normalizeComparablePath(rawDir));
|
||||||
: (rawDir && job?.raw_path
|
const effectiveRawPath = job?.raw_path
|
||||||
? resolveEffectiveRawPath(job.raw_path, rawDir)
|
? resolveEffectiveRawPath(job.raw_path, rawDir, rawLookupDirs)
|
||||||
: (job?.raw_path || null));
|
: (job?.raw_path || null);
|
||||||
const effectiveOutputPath = mediaType === 'cd'
|
// For CD, output_path is a directory (album folder) — skip path-relocation heuristic
|
||||||
? (job?.output_path || null)
|
const effectiveOutputPath = (mediaType !== 'cd' && configuredMovieDir && job?.output_path)
|
||||||
: (configuredMovieDir && job?.output_path
|
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
||||||
? resolveEffectiveOutputPath(job.output_path, configuredMovieDir)
|
: (job?.output_path || null);
|
||||||
: (job?.output_path || null));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaType,
|
mediaType,
|
||||||
@@ -573,11 +641,23 @@ function buildRawPathForJobId(rawPath, jobId) {
|
|||||||
|
|
||||||
const absRawPath = normalizeComparablePath(rawPath);
|
const absRawPath = normalizeComparablePath(rawPath);
|
||||||
const folderName = path.basename(absRawPath);
|
const folderName = path.basename(absRawPath);
|
||||||
|
|
||||||
|
// Replace existing job ID suffix if present
|
||||||
const replaced = folderName.replace(/(\s-\sRAW\s-\sjob-)\d+\s*$/i, `$1${Math.trunc(normalizedJobId)}`);
|
const replaced = folderName.replace(/(\s-\sRAW\s-\sjob-)\d+\s*$/i, `$1${Math.trunc(normalizedJobId)}`);
|
||||||
if (replaced === folderName) {
|
if (replaced !== folderName) {
|
||||||
return absRawPath;
|
return path.join(path.dirname(absRawPath), replaced);
|
||||||
}
|
}
|
||||||
return path.join(path.dirname(absRawPath), replaced);
|
|
||||||
|
// No existing job ID suffix — add canonical suffix
|
||||||
|
// Strip any state prefix (Rip_Complete_ / Incomplete_), append suffix, restore prefix
|
||||||
|
const statePrefix = /^Rip_Complete_/i.test(folderName)
|
||||||
|
? RAW_RIP_COMPLETE_PREFIX
|
||||||
|
: /^Incomplete_/i.test(folderName)
|
||||||
|
? RAW_INCOMPLETE_PREFIX
|
||||||
|
: '';
|
||||||
|
const stripped = stripRawFolderStatePrefix(folderName);
|
||||||
|
const withJobId = `${statePrefix}${stripped} - RAW - job-${Math.trunc(normalizedJobId)}`;
|
||||||
|
return path.join(path.dirname(absRawPath), withJobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteFilesRecursively(rootPath, keepRoot = true) {
|
function deleteFilesRecursively(rootPath, keepRoot = true) {
|
||||||
@@ -1364,6 +1444,7 @@ class HistoryService {
|
|||||||
|
|
||||||
const stat = fs.statSync(rawPath);
|
const stat = fs.statSync(rawPath);
|
||||||
const metadata = parseRawFolderMetadata(entry.name);
|
const metadata = parseRawFolderMetadata(entry.name);
|
||||||
|
const detectedMediaType = detectOrphanMediaType(rawPath);
|
||||||
orphanRows.push({
|
orphanRows.push({
|
||||||
rawPath,
|
rawPath,
|
||||||
folderName: entry.name,
|
folderName: entry.name,
|
||||||
@@ -1372,7 +1453,10 @@ class HistoryService {
|
|||||||
imdbId: metadata.imdbId,
|
imdbId: metadata.imdbId,
|
||||||
folderJobId: metadata.folderJobId,
|
folderJobId: metadata.folderJobId,
|
||||||
entryCount: Number(dirInfo.entryCount || 0),
|
entryCount: Number(dirInfo.entryCount || 0),
|
||||||
hasBlurayStructure: fs.existsSync(path.join(rawPath, 'BDMV', 'STREAM')),
|
detectedMediaType,
|
||||||
|
hasBlurayStructure: detectedMediaType === 'bluray',
|
||||||
|
hasDvdStructure: detectedMediaType === 'dvd',
|
||||||
|
hasCdStructure: detectedMediaType === 'cd',
|
||||||
lastModifiedAt: stat.mtime.toISOString()
|
lastModifiedAt: stat.mtime.toISOString()
|
||||||
});
|
});
|
||||||
seenOrphanPaths.add(normalizedPath);
|
seenOrphanPaths.add(normalizedPath);
|
||||||
@@ -1509,6 +1593,7 @@ class HistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detectedMediaType = detectOrphanMediaType(finalRawPath);
|
||||||
const orphanPosterUrl = omdbById?.poster || null;
|
const orphanPosterUrl = omdbById?.poster || null;
|
||||||
await this.updateJob(created.id, {
|
await this.updateJob(created.id, {
|
||||||
status: 'FINISHED',
|
status: 'FINISHED',
|
||||||
@@ -1533,7 +1618,11 @@ class HistoryService {
|
|||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
source: 'orphan_raw_import',
|
source: 'orphan_raw_import',
|
||||||
importedAt,
|
importedAt,
|
||||||
rawPath: finalRawPath
|
rawPath: finalRawPath,
|
||||||
|
mediaProfile: detectedMediaType,
|
||||||
|
analyzeContext: {
|
||||||
|
mediaProfile: detectedMediaType
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1551,8 +1640,8 @@ class HistoryService {
|
|||||||
created.id,
|
created.id,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
renameSteps.length > 0
|
renameSteps.length > 0
|
||||||
? `Historieneintrag aus RAW erstellt. Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
|
? `Historieneintrag aus RAW erstellt (Medientyp: ${detectedMediaType}). Ordner umbenannt: ${renameSteps.map((step) => `${step.from} -> ${step.to}`).join(' | ')}`
|
||||||
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath}`
|
: `Historieneintrag aus bestehendem RAW-Ordner erstellt: ${finalRawPath} (Medientyp: ${detectedMediaType})`
|
||||||
);
|
);
|
||||||
if (metadata.imdbId) {
|
if (metadata.imdbId) {
|
||||||
await this.appendLog(
|
await this.appendLog(
|
||||||
@@ -1566,7 +1655,8 @@ class HistoryService {
|
|||||||
|
|
||||||
logger.info('job:import-orphan-raw', {
|
logger.info('job:import-orphan-raw', {
|
||||||
jobId: created.id,
|
jobId: created.id,
|
||||||
rawPath: absRawPath
|
rawPath: absRawPath,
|
||||||
|
detectedMediaType
|
||||||
});
|
});
|
||||||
|
|
||||||
const imported = await this.getJobById(created.id);
|
const imported = await this.getJobById(created.id);
|
||||||
@@ -1584,11 +1674,10 @@ class HistoryService {
|
|||||||
const imdbIdInput = String(payload.imdbId || '').trim().toLowerCase();
|
const imdbIdInput = String(payload.imdbId || '').trim().toLowerCase();
|
||||||
let omdb = null;
|
let omdb = null;
|
||||||
if (imdbIdInput) {
|
if (imdbIdInput) {
|
||||||
omdb = await omdbService.fetchByImdbId(imdbIdInput);
|
try {
|
||||||
if (!omdb) {
|
omdb = await omdbService.fetchByImdbId(imdbIdInput);
|
||||||
const error = new Error(`OMDb Eintrag für ${imdbIdInput} nicht gefunden.`);
|
} catch (omdbErr) {
|
||||||
error.statusCode = 404;
|
logger.warn('assignOmdbMetadata:fetch-failed', { jobId, imdbId: imdbIdInput, message: omdbErr.message });
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,7 +1696,7 @@ class HistoryService {
|
|||||||
const year = Number.isFinite(Number(omdb?.year))
|
const year = Number.isFinite(Number(omdb?.year))
|
||||||
? Number(omdb.year)
|
? Number(omdb.year)
|
||||||
: (manualYear !== null ? manualYear : (job.year ?? null));
|
: (manualYear !== null ? manualYear : (job.year ?? null));
|
||||||
const imdbId = omdb?.imdbId || (imdbIdInput || job.imdb_id || null);
|
const imdbId = omdb?.imdbId || imdbIdInput || job.imdb_id || null;
|
||||||
const posterUrl = omdb?.poster || manualPoster || job.poster_url || null;
|
const posterUrl = omdb?.poster || manualPoster || job.poster_url || null;
|
||||||
const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0);
|
const selectedFromOmdb = omdb ? 1 : Number(payload.fromOmdb ? 1 : 0);
|
||||||
|
|
||||||
@@ -1620,9 +1709,14 @@ class HistoryService {
|
|||||||
selected_from_omdb: selectedFromOmdb
|
selected_from_omdb: selectedFromOmdb
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bild in Cache laden (async, blockiert nicht)
|
// Bild herunterladen, in persistenten Ordner verschieben und poster_url aktualisieren
|
||||||
if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) {
|
if (posterUrl && !thumbnailService.isLocalUrl(posterUrl)) {
|
||||||
thumbnailService.cacheJobThumbnail(jobId, posterUrl).catch(() => {});
|
thumbnailService.cacheJobThumbnail(jobId, posterUrl)
|
||||||
|
.then(() => {
|
||||||
|
const promotedUrl = thumbnailService.promoteJobThumbnail(jobId);
|
||||||
|
if (promotedUrl) return this.updateJob(jobId, { poster_url: promotedUrl });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.appendLog(
|
await this.appendLog(
|
||||||
@@ -1640,6 +1734,90 @@ class HistoryService {
|
|||||||
return enrichJobRow(updated, settings);
|
return enrichJobRow(updated, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async assignCdMetadata(jobId, payload = {}) {
|
||||||
|
const job = await this.getJobById(jobId);
|
||||||
|
if (!job) {
|
||||||
|
const error = new Error('Job nicht gefunden.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = String(payload.title || '').trim() || null;
|
||||||
|
const artist = String(payload.artist || '').trim() || null;
|
||||||
|
const yearRaw = Number(payload.year);
|
||||||
|
const year = Number.isFinite(yearRaw) && yearRaw > 0 ? Math.trunc(yearRaw) : null;
|
||||||
|
const mbId = String(payload.mbId || '').trim() || null;
|
||||||
|
const coverUrl = String(payload.coverUrl || '').trim() || null;
|
||||||
|
const selectedTracks = Array.isArray(payload.tracks) ? payload.tracks : null;
|
||||||
|
|
||||||
|
if (!title && !artist && !mbId) {
|
||||||
|
const error = new Error('Keine CD-Metadaten zum Aktualisieren angegeben.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdInfo = parseJsonSafe(job.makemkv_info_json, {});
|
||||||
|
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
|
||||||
|
|
||||||
|
let mergedTracks = tocTracks;
|
||||||
|
if (selectedTracks && tocTracks.length > 0) {
|
||||||
|
mergedTracks = tocTracks.map((t) => {
|
||||||
|
const selected = selectedTracks.find((st) => Number(st.position) === Number(t.position));
|
||||||
|
const resolvedTitle = String(selected?.title || t.title || `Track ${t.position}`).replace(/\s+/g, ' ').trim();
|
||||||
|
const resolvedArtist = String(selected?.artist || t.artist || artist || '').replace(/\s+/g, ' ').trim() || null;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
title: resolvedTitle,
|
||||||
|
artist: resolvedArtist,
|
||||||
|
selected: selected ? Boolean(selected.selected) : true
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevSelected = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object' ? cdInfo.selectedMetadata : {};
|
||||||
|
const updatedCdInfo = {
|
||||||
|
...cdInfo,
|
||||||
|
tracks: mergedTracks,
|
||||||
|
selectedMetadata: {
|
||||||
|
...prevSelected,
|
||||||
|
title: title || prevSelected.title || null,
|
||||||
|
artist: artist || prevSelected.artist || null,
|
||||||
|
year: year !== null ? year : (prevSelected.year || null),
|
||||||
|
mbId: mbId || prevSelected.mbId || null,
|
||||||
|
coverUrl: coverUrl || prevSelected.coverUrl || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.updateJob(jobId, {
|
||||||
|
title: title || null,
|
||||||
|
year: year || null,
|
||||||
|
imdb_id: mbId || null,
|
||||||
|
poster_url: coverUrl || null,
|
||||||
|
makemkv_info_json: JSON.stringify(updatedCdInfo)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (coverUrl && !thumbnailService.isLocalUrl(coverUrl)) {
|
||||||
|
thumbnailService.cacheJobThumbnail(jobId, coverUrl)
|
||||||
|
.then(() => {
|
||||||
|
const promotedUrl = thumbnailService.promoteJobThumbnail(jobId);
|
||||||
|
if (promotedUrl) return this.updateJob(jobId, { poster_url: promotedUrl });
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.appendLog(
|
||||||
|
jobId,
|
||||||
|
'USER_ACTION',
|
||||||
|
`CD-Metadaten aktualisiert: album="${title || '-'}", artist="${artist || '-'}", year="${year || '-'}", mbId="${mbId || '-'}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updated, settings] = await Promise.all([
|
||||||
|
this.getJobById(jobId),
|
||||||
|
settingsService.getSettingsMap()
|
||||||
|
]);
|
||||||
|
return enrichJobRow(updated, settings);
|
||||||
|
}
|
||||||
|
|
||||||
async _resolveRelatedJobsForDeletion(jobId, options = {}) {
|
async _resolveRelatedJobsForDeletion(jobId, options = {}) {
|
||||||
const includeRelated = options?.includeRelated !== false;
|
const includeRelated = options?.includeRelated !== false;
|
||||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||||
|
|||||||
@@ -3694,6 +3694,46 @@ class PipelineService extends EventEmitter {
|
|||||||
return existingDirectories[0];
|
return existingDirectories[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildRawPathLookupConfig(settingsMap = {}, mediaProfile = null) {
|
||||||
|
const sourceMap = settingsMap && typeof settingsMap === 'object' ? settingsMap : {};
|
||||||
|
const normalizedMediaProfile = normalizeMediaProfile(mediaProfile);
|
||||||
|
const effectiveSettings = settingsService.resolveEffectiveToolSettings(sourceMap, normalizedMediaProfile);
|
||||||
|
const preferredDefaultRawDir = normalizedMediaProfile === 'cd'
|
||||||
|
? settingsService.DEFAULT_CD_DIR
|
||||||
|
: settingsService.DEFAULT_RAW_DIR;
|
||||||
|
const uniqueRawDirs = Array.from(
|
||||||
|
new Set(
|
||||||
|
[
|
||||||
|
effectiveSettings?.raw_dir,
|
||||||
|
sourceMap?.raw_dir,
|
||||||
|
sourceMap?.raw_dir_bluray,
|
||||||
|
sourceMap?.raw_dir_dvd,
|
||||||
|
sourceMap?.raw_dir_cd,
|
||||||
|
preferredDefaultRawDir,
|
||||||
|
settingsService.DEFAULT_RAW_DIR,
|
||||||
|
settingsService.DEFAULT_CD_DIR
|
||||||
|
]
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveSettings,
|
||||||
|
rawBaseDir: uniqueRawDirs[0] || String(preferredDefaultRawDir || '').trim() || null,
|
||||||
|
rawExtraDirs: uniqueRawDirs.slice(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveCurrentRawPathForSettings(settingsMap = {}, mediaProfile = null, storedRawPath = null) {
|
||||||
|
const stored = String(storedRawPath || '').trim();
|
||||||
|
if (!stored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { rawBaseDir, rawExtraDirs } = this.buildRawPathLookupConfig(settingsMap, mediaProfile);
|
||||||
|
return this.resolveCurrentRawPath(rawBaseDir, stored, rawExtraDirs);
|
||||||
|
}
|
||||||
|
|
||||||
async migrateRawFolderNamingOnStartup(db) {
|
async migrateRawFolderNamingOnStartup(db) {
|
||||||
const settings = await settingsService.getSettingsMap();
|
const settings = await settingsService.getSettingsMap();
|
||||||
const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim();
|
const rawBaseDir = String(settings?.raw_dir || settingsService.DEFAULT_RAW_DIR || '').trim();
|
||||||
@@ -5385,15 +5425,17 @@ class PipelineService extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingPlan = this.safeParseJson(job.encode_plan_json);
|
||||||
const refreshSettings = await settingsService.getSettingsMap();
|
const refreshSettings = await settingsService.getSettingsMap();
|
||||||
const refreshRawBaseDir = settingsService.DEFAULT_RAW_DIR;
|
const refreshMediaProfile = this.resolveMediaProfileForJob(job, {
|
||||||
const refreshRawExtraDirs = [
|
encodePlan: existingPlan,
|
||||||
refreshSettings?.raw_dir_bluray,
|
rawPath: job.raw_path
|
||||||
refreshSettings?.raw_dir_dvd
|
});
|
||||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
const resolvedRefreshRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
const resolvedRefreshRawPath = job.raw_path
|
refreshSettings,
|
||||||
? this.resolveCurrentRawPath(refreshRawBaseDir, job.raw_path, refreshRawExtraDirs)
|
refreshMediaProfile,
|
||||||
: null;
|
job.raw_path
|
||||||
|
);
|
||||||
|
|
||||||
if (!resolvedRefreshRawPath) {
|
if (!resolvedRefreshRawPath) {
|
||||||
return {
|
return {
|
||||||
@@ -5409,7 +5451,6 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.updateJob(activeJobId, { raw_path: resolvedRefreshRawPath });
|
await historyService.updateJob(activeJobId, { raw_path: resolvedRefreshRawPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingPlan = this.safeParseJson(job.encode_plan_json);
|
|
||||||
const mode = existingPlan?.mode || this.snapshot.context?.mode || 'rip';
|
const mode = existingPlan?.mode || this.snapshot.context?.mode || 'rip';
|
||||||
const sourceJobId = existingPlan?.sourceJobId || this.snapshot.context?.sourceJobId || null;
|
const sourceJobId = existingPlan?.sourceJobId || this.snapshot.context?.sourceJobId || null;
|
||||||
|
|
||||||
@@ -7339,14 +7380,11 @@ class PipelineService extends EventEmitter {
|
|||||||
encodePlan: confirmedPlan
|
encodePlan: confirmedPlan
|
||||||
});
|
});
|
||||||
const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
const confirmSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
||||||
const confirmRawBaseDir = String(confirmSettings?.raw_dir || '').trim();
|
const resolvedConfirmRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
const confirmRawExtraDirs = [
|
confirmSettings,
|
||||||
confirmSettings?.raw_dir_bluray,
|
readyMediaProfile,
|
||||||
confirmSettings?.raw_dir_dvd
|
job.raw_path
|
||||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
);
|
||||||
const resolvedConfirmRawPath = job.raw_path
|
|
||||||
? this.resolveCurrentRawPath(confirmRawBaseDir, job.raw_path, confirmRawExtraDirs)
|
|
||||||
: null;
|
|
||||||
const activeConfirmRawPath = resolvedConfirmRawPath || String(job.raw_path || '').trim() || null;
|
const activeConfirmRawPath = resolvedConfirmRawPath || String(job.raw_path || '').trim() || null;
|
||||||
|
|
||||||
let inputPath = isPreRipMode
|
let inputPath = isPreRipMode
|
||||||
@@ -7460,13 +7498,16 @@ class PipelineService extends EventEmitter {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reencodeMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
||||||
|
makemkvInfo: mkInfo,
|
||||||
|
rawPath: sourceJob.raw_path
|
||||||
|
});
|
||||||
const reencodeSettings = await settingsService.getSettingsMap();
|
const reencodeSettings = await settingsService.getSettingsMap();
|
||||||
const reencodeRawBaseDir = settingsService.DEFAULT_RAW_DIR;
|
const resolvedReencodeRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
const reencodeRawExtraDirs = [
|
reencodeSettings,
|
||||||
reencodeSettings?.raw_dir_bluray,
|
reencodeMediaProfile,
|
||||||
reencodeSettings?.raw_dir_dvd
|
sourceJob.raw_path
|
||||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
);
|
||||||
const resolvedReencodeRawPath = this.resolveCurrentRawPath(reencodeRawBaseDir, sourceJob.raw_path, reencodeRawExtraDirs);
|
|
||||||
if (!resolvedReencodeRawPath) {
|
if (!resolvedReencodeRawPath) {
|
||||||
const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
const error = new Error(`Re-Encode nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -8339,14 +8380,7 @@ class PipelineService extends EventEmitter {
|
|||||||
rawPath: job.raw_path
|
rawPath: job.raw_path
|
||||||
});
|
});
|
||||||
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const rawBaseDir = String(settings.raw_dir || '').trim();
|
const resolvedRawPath = this.resolveCurrentRawPathForSettings(settings, mediaProfile, job.raw_path);
|
||||||
const rawExtraDirs = [
|
|
||||||
settings.raw_dir_bluray,
|
|
||||||
settings.raw_dir_dvd
|
|
||||||
].map((item) => String(item || '').trim()).filter(Boolean);
|
|
||||||
const resolvedRawPath = job.raw_path
|
|
||||||
? this.resolveCurrentRawPath(rawBaseDir, job.raw_path, rawExtraDirs)
|
|
||||||
: null;
|
|
||||||
const activeRawPath = resolvedRawPath || String(job.raw_path || '').trim() || null;
|
const activeRawPath = resolvedRawPath || String(job.raw_path || '').trim() || null;
|
||||||
if (activeRawPath && normalizeComparablePath(activeRawPath) !== normalizeComparablePath(job.raw_path)) {
|
if (activeRawPath && normalizeComparablePath(activeRawPath) !== normalizeComparablePath(job.raw_path)) {
|
||||||
await historyService.updateJob(jobId, { raw_path: activeRawPath });
|
await historyService.updateJob(jobId, { raw_path: activeRawPath });
|
||||||
@@ -9251,14 +9285,15 @@ class PipelineService extends EventEmitter {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
const retrySettings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
const retryRawBaseDir = String(retrySettings?.raw_dir || '').trim();
|
const { rawBaseDir: retryRawBaseDir, rawExtraDirs: retryRawExtraDirs } = this.buildRawPathLookupConfig(
|
||||||
const retryRawExtraDirs = [
|
retrySettings,
|
||||||
retrySettings?.raw_dir_bluray,
|
mediaProfile
|
||||||
retrySettings?.raw_dir_dvd
|
);
|
||||||
].map((dirPath) => String(dirPath || '').trim()).filter(Boolean);
|
const resolvedOldRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
const resolvedOldRawPath = sourceJob.raw_path
|
retrySettings,
|
||||||
? this.resolveCurrentRawPath(retryRawBaseDir, sourceJob.raw_path, retryRawExtraDirs)
|
mediaProfile,
|
||||||
: null;
|
sourceJob.raw_path
|
||||||
|
);
|
||||||
|
|
||||||
if (resolvedOldRawPath) {
|
if (resolvedOldRawPath) {
|
||||||
const oldRawFolderName = path.basename(resolvedOldRawPath);
|
const oldRawFolderName = path.basename(resolvedOldRawPath);
|
||||||
@@ -9419,15 +9454,13 @@ class PipelineService extends EventEmitter {
|
|||||||
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
|
||||||
const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
const isPreRipMode = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
|
||||||
const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
const reviewConfirmed = Boolean(Number(job.encode_review_confirmed || 0) || encodePlan?.reviewConfirmed);
|
||||||
const resumeSettings = await settingsService.getEffectiveSettingsMap(this.resolveMediaProfileForJob(job, { encodePlan }));
|
const readyMediaProfile = this.resolveMediaProfileForJob(job, { encodePlan });
|
||||||
const resumeRawBaseDir = String(resumeSettings?.raw_dir || '').trim();
|
const resumeSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
||||||
const resumeRawExtraDirs = [
|
const resolvedResumeRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
resumeSettings?.raw_dir_bluray,
|
resumeSettings,
|
||||||
resumeSettings?.raw_dir_dvd
|
readyMediaProfile,
|
||||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
job.raw_path
|
||||||
const resolvedResumeRawPath = job.raw_path
|
);
|
||||||
? this.resolveCurrentRawPath(resumeRawBaseDir, job.raw_path, resumeRawExtraDirs)
|
|
||||||
: null;
|
|
||||||
const activeResumeRawPath = resolvedResumeRawPath || String(job.raw_path || '').trim() || null;
|
const activeResumeRawPath = resolvedResumeRawPath || String(job.raw_path || '').trim() || null;
|
||||||
|
|
||||||
let inputPath = isPreRipMode
|
let inputPath = isPreRipMode
|
||||||
@@ -9463,10 +9496,6 @@ class PipelineService extends EventEmitter {
|
|||||||
imdbId: job.imdb_id || null,
|
imdbId: job.imdb_id || null,
|
||||||
poster: job.poster_url || null
|
poster: job.poster_url || null
|
||||||
};
|
};
|
||||||
const readyMediaProfile = this.resolveMediaProfileForJob(job, {
|
|
||||||
encodePlan
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.setState('READY_TO_ENCODE', {
|
await this.setState('READY_TO_ENCODE', {
|
||||||
activeJobId: jobId,
|
activeJobId: jobId,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
@@ -9613,14 +9642,11 @@ class PipelineService extends EventEmitter {
|
|||||||
encodePlan: restartPlan
|
encodePlan: restartPlan
|
||||||
});
|
});
|
||||||
const restartSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
const restartSettings = await settingsService.getEffectiveSettingsMap(readyMediaProfile);
|
||||||
const restartRawBaseDir = String(restartSettings?.raw_dir || '').trim();
|
const resolvedRestartRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
const restartRawExtraDirs = [
|
restartSettings,
|
||||||
restartSettings?.raw_dir_bluray,
|
readyMediaProfile,
|
||||||
restartSettings?.raw_dir_dvd
|
job.raw_path
|
||||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
);
|
||||||
const resolvedRestartRawPath = job.raw_path
|
|
||||||
? this.resolveCurrentRawPath(restartRawBaseDir, job.raw_path, restartRawExtraDirs)
|
|
||||||
: null;
|
|
||||||
const activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null;
|
const activeRestartRawPath = resolvedRestartRawPath || String(job.raw_path || '').trim() || null;
|
||||||
|
|
||||||
let inputPath = isPreRipMode
|
let inputPath = isPreRipMode
|
||||||
@@ -9761,13 +9787,19 @@ class PipelineService extends EventEmitter {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviewMakemkvInfo = this.safeParseJson(sourceJob.makemkv_info_json);
|
||||||
|
const reviewEncodePlan = this.safeParseJson(sourceJob.encode_plan_json);
|
||||||
|
const reviewMediaProfile = this.resolveMediaProfileForJob(sourceJob, {
|
||||||
|
makemkvInfo: reviewMakemkvInfo,
|
||||||
|
encodePlan: reviewEncodePlan,
|
||||||
|
rawPath: sourceJob.raw_path
|
||||||
|
});
|
||||||
const reviewSettings = await settingsService.getSettingsMap();
|
const reviewSettings = await settingsService.getSettingsMap();
|
||||||
const reviewRawBaseDir = settingsService.DEFAULT_RAW_DIR;
|
const resolvedReviewRawPath = this.resolveCurrentRawPathForSettings(
|
||||||
const reviewRawExtraDirs = [
|
reviewSettings,
|
||||||
reviewSettings?.raw_dir_bluray,
|
reviewMediaProfile,
|
||||||
reviewSettings?.raw_dir_dvd
|
sourceJob.raw_path
|
||||||
].map((d) => String(d || '').trim()).filter(Boolean);
|
);
|
||||||
const resolvedReviewRawPath = this.resolveCurrentRawPath(reviewRawBaseDir, sourceJob.raw_path, reviewRawExtraDirs);
|
|
||||||
if (!resolvedReviewRawPath) {
|
if (!resolvedReviewRawPath) {
|
||||||
const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
const error = new Error(`Review-Neustart nicht möglich: RAW-Pfad existiert nicht (${sourceJob.raw_path}).`);
|
||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
@@ -9855,11 +9887,9 @@ class PipelineService extends EventEmitter {
|
|||||||
encode_plan_json: null,
|
encode_plan_json: null,
|
||||||
encode_input_path: normalizedReviewInputPath || null,
|
encode_input_path: normalizedReviewInputPath || null,
|
||||||
encode_review_confirmed: 0,
|
encode_review_confirmed: 0,
|
||||||
makemkv_info_json: nextMakemkvInfoJson
|
makemkv_info_json: nextMakemkvInfoJson,
|
||||||
|
raw_path: resolvedReviewRawPath
|
||||||
};
|
};
|
||||||
if (resolvedReviewRawPath !== sourceJob.raw_path) {
|
|
||||||
jobUpdatePayload.raw_path = resolvedReviewRawPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const replacementJob = await historyService.createJob({
|
const replacementJob = await historyService.createJob({
|
||||||
discDevice: sourceJob.disc_device || null,
|
discDevice: sourceJob.disc_device || null,
|
||||||
@@ -10345,10 +10375,10 @@ class PipelineService extends EventEmitter {
|
|||||||
logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) });
|
logger.error('command:failed', { jobId, stage, source, error: errorToMeta(error) });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await historyService.closeProcessLog(jobId);
|
|
||||||
this.activeProcesses.delete(Number(normalizedJobId));
|
this.activeProcesses.delete(Number(normalizedJobId));
|
||||||
this.syncPrimaryActiveProcess();
|
|
||||||
this.cancelRequestedByJob.delete(Number(normalizedJobId));
|
this.cancelRequestedByJob.delete(Number(normalizedJobId));
|
||||||
|
this.syncPrimaryActiveProcess();
|
||||||
|
await historyService.closeProcessLog(jobId);
|
||||||
await this.emitQueueChanged();
|
await this.emitQueueChanged();
|
||||||
void this.pumpQueue();
|
void this.pumpQueue();
|
||||||
}
|
}
|
||||||
@@ -10398,16 +10428,16 @@ class PipelineService extends EventEmitter {
|
|||||||
hasRawPath = false;
|
hasRawPath = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedStage === 'ENCODING' && hasConfirmedPlan) {
|
if (normalizedStage === 'ENCODING' && hasConfirmedPlan && !isCancelled) {
|
||||||
try {
|
try {
|
||||||
await historyService.appendLog(
|
await historyService.appendLog(
|
||||||
jobId,
|
jobId,
|
||||||
'SYSTEM',
|
'SYSTEM',
|
||||||
`${isCancelled ? 'Abbruch' : 'Fehler'} in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.`
|
`Fehler in ${stage}: ${message}. Letzte Encode-Auswahl wird zur direkten Anpassung geladen.`
|
||||||
);
|
);
|
||||||
await this.restartEncodeWithLastSettings(jobId, {
|
await this.restartEncodeWithLastSettings(jobId, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
triggerReason: isCancelled ? 'cancelled_encode' : 'failed_encode'
|
triggerReason: 'failed_encode'
|
||||||
});
|
});
|
||||||
this.cancelRequestedByJob.delete(Number(jobId));
|
this.cancelRequestedByJob.delete(Number(jobId));
|
||||||
return;
|
return;
|
||||||
@@ -10736,6 +10766,91 @@ class PipelineService extends EventEmitter {
|
|||||||
return historyService.getJobById(jobId);
|
return historyService.getJobById(jobId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameJobFolders(jobId) {
|
||||||
|
const job = await historyService.getJobById(jobId);
|
||||||
|
if (!job) {
|
||||||
|
return { renamed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const renamed = [];
|
||||||
|
const mediaProfile = this.resolveMediaProfileForJob(job);
|
||||||
|
const isCd = mediaProfile === 'cd';
|
||||||
|
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
|
||||||
|
|
||||||
|
// Rename raw folder
|
||||||
|
const currentRawPath = job.raw_path ? path.resolve(job.raw_path) : null;
|
||||||
|
if (currentRawPath && fs.existsSync(currentRawPath)) {
|
||||||
|
const rawBaseDir = path.dirname(currentRawPath);
|
||||||
|
const newMetadataBase = buildRawMetadataBase({
|
||||||
|
title: job.title || job.detected_title || null,
|
||||||
|
year: job.year || null
|
||||||
|
}, jobId);
|
||||||
|
const currentState = resolveRawFolderStateFromPath(currentRawPath);
|
||||||
|
const newRawDirName = buildRawDirName(newMetadataBase, jobId, { state: currentState });
|
||||||
|
const newRawPath = path.join(rawBaseDir, newRawDirName);
|
||||||
|
|
||||||
|
if (normalizeComparablePath(currentRawPath) !== normalizeComparablePath(newRawPath) && !fs.existsSync(newRawPath)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(currentRawPath, newRawPath);
|
||||||
|
await historyService.updateJob(jobId, { raw_path: newRawPath });
|
||||||
|
renamed.push({ type: 'raw', from: currentRawPath, to: newRawPath });
|
||||||
|
logger.info('rename-job-folders:raw', { jobId, from: currentRawPath, to: newRawPath });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('rename-job-folders:raw-failed', { jobId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename output file (film) or output directory (CD)
|
||||||
|
const currentOutputPath = job.output_path ? path.resolve(job.output_path) : null;
|
||||||
|
if (currentOutputPath && fs.existsSync(currentOutputPath)) {
|
||||||
|
try {
|
||||||
|
if (isCd) {
|
||||||
|
const cdInfo = this.safeParseJson(job.makemkv_info_json) || {};
|
||||||
|
const selectedMeta = cdInfo.selectedMetadata && typeof cdInfo.selectedMetadata === 'object'
|
||||||
|
? cdInfo.selectedMetadata
|
||||||
|
: {};
|
||||||
|
const cdMeta = {
|
||||||
|
artist: String(selectedMeta.artist || '').trim() || String(job.title || '').trim() || null,
|
||||||
|
album: String(job.title || selectedMeta.title || '').trim() || null,
|
||||||
|
year: job.year || selectedMeta.year || null
|
||||||
|
};
|
||||||
|
const cdOutputBaseDir = String(settings.movie_dir || '').trim();
|
||||||
|
const cdOutputTemplate = String(settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE).trim();
|
||||||
|
if (cdOutputBaseDir) {
|
||||||
|
const newCdOutputDir = cdRipService.buildOutputDir(cdMeta, cdOutputBaseDir, cdOutputTemplate);
|
||||||
|
if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newCdOutputDir) && !fs.existsSync(newCdOutputDir)) {
|
||||||
|
fs.mkdirSync(path.dirname(newCdOutputDir), { recursive: true });
|
||||||
|
fs.renameSync(currentOutputPath, newCdOutputDir);
|
||||||
|
await historyService.updateJob(jobId, { output_path: newCdOutputDir });
|
||||||
|
renamed.push({ type: 'output', from: currentOutputPath, to: newCdOutputDir });
|
||||||
|
logger.info('rename-job-folders:cd-output', { jobId, from: currentOutputPath, to: newCdOutputDir });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newOutputPath = buildFinalOutputPathFromJob(settings, job, jobId);
|
||||||
|
if (normalizeComparablePath(currentOutputPath) !== normalizeComparablePath(newOutputPath) && !fs.existsSync(newOutputPath)) {
|
||||||
|
fs.mkdirSync(path.dirname(newOutputPath), { recursive: true });
|
||||||
|
moveFileWithFallback(currentOutputPath, newOutputPath);
|
||||||
|
try {
|
||||||
|
const oldParentDir = path.dirname(currentOutputPath);
|
||||||
|
if (fs.readdirSync(oldParentDir).length === 0) {
|
||||||
|
fs.rmdirSync(oldParentDir);
|
||||||
|
}
|
||||||
|
} catch (_ignoreErr) {}
|
||||||
|
await historyService.updateJob(jobId, { output_path: newOutputPath });
|
||||||
|
renamed.push({ type: 'output', from: currentOutputPath, to: newOutputPath });
|
||||||
|
logger.info('rename-job-folders:film-output', { jobId, from: currentOutputPath, to: newOutputPath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('rename-job-folders:output-failed', { jobId, isCd, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { renamed };
|
||||||
|
}
|
||||||
|
|
||||||
async startCdRip(jobId, ripConfig) {
|
async startCdRip(jobId, ripConfig) {
|
||||||
this.ensureNotBusy('startCdRip', jobId);
|
this.ensureNotBusy('startCdRip', jobId);
|
||||||
this.cancelRequestedByJob.delete(Number(jobId));
|
this.cancelRequestedByJob.delete(Number(jobId));
|
||||||
@@ -10960,20 +11075,22 @@ class PipelineService extends EventEmitter {
|
|||||||
const cdOutputTemplate = String(
|
const cdOutputTemplate = String(
|
||||||
settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
|
settings.cd_output_template || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE
|
||||||
).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
).trim() || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
|
||||||
const cdBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR;
|
const cdRawBaseDir = String(settings.raw_dir || '').trim() || settingsService.DEFAULT_CD_DIR;
|
||||||
const cdOutputOwner = String(settings.raw_dir_owner || '').trim();
|
const cdOutputBaseDir = String(settings.movie_dir || '').trim() || cdRawBaseDir;
|
||||||
|
const cdRawOwner = String(settings.raw_dir_owner || '').trim();
|
||||||
|
const cdOutputOwner = String(settings.movie_dir_owner || settings.raw_dir_owner || '').trim();
|
||||||
const cdMetadataBase = buildRawMetadataBase({
|
const cdMetadataBase = buildRawMetadataBase({
|
||||||
title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null,
|
title: effectiveSelectedMeta?.album || effectiveSelectedMeta?.title || null,
|
||||||
year: effectiveSelectedMeta?.year || null
|
year: effectiveSelectedMeta?.year || null
|
||||||
}, activeJobId);
|
}, activeJobId);
|
||||||
const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE });
|
const rawDirName = buildRawDirName(cdMetadataBase, activeJobId, { state: RAW_FOLDER_STATES.INCOMPLETE });
|
||||||
const rawJobDir = path.join(cdBaseDir, rawDirName);
|
const rawJobDir = path.join(cdRawBaseDir, rawDirName);
|
||||||
const rawWavDir = rawJobDir;
|
const rawWavDir = rawJobDir;
|
||||||
const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdBaseDir, cdOutputTemplate);
|
const outputDir = cdRipService.buildOutputDir(effectiveSelectedMeta, cdOutputBaseDir, cdOutputTemplate);
|
||||||
ensureDir(cdBaseDir);
|
ensureDir(cdRawBaseDir);
|
||||||
ensureDir(rawJobDir);
|
ensureDir(rawJobDir);
|
||||||
ensureDir(outputDir);
|
ensureDir(outputDir);
|
||||||
chownRecursive(rawJobDir, cdOutputOwner);
|
chownRecursive(rawJobDir, cdRawOwner);
|
||||||
chownRecursive(outputDir, cdOutputOwner);
|
chownRecursive(outputDir, cdOutputOwner);
|
||||||
const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1;
|
const previewTrackPos = effectiveSelectedTrackPositions[0] || mergedTracks[0]?.position || 1;
|
||||||
const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`);
|
const previewWavPath = path.join(rawWavDir, `track${String(previewTrackPos).padStart(2, '0')}.cdda.wav`);
|
||||||
@@ -11076,12 +11193,13 @@ class PipelineService extends EventEmitter {
|
|||||||
devicePath,
|
devicePath,
|
||||||
cdparanoiaCmd,
|
cdparanoiaCmd,
|
||||||
rawWavDir,
|
rawWavDir,
|
||||||
rawBaseDir: cdBaseDir,
|
rawBaseDir: cdRawBaseDir,
|
||||||
cdMetadataBase,
|
cdMetadataBase,
|
||||||
outputDir,
|
outputDir,
|
||||||
format,
|
format,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
outputTemplate: cdOutputTemplate,
|
outputTemplate: cdOutputTemplate,
|
||||||
|
rawOwner: cdRawOwner,
|
||||||
outputOwner: cdOutputOwner,
|
outputOwner: cdOutputOwner,
|
||||||
selectedTrackPositions: effectiveSelectedTrackPositions,
|
selectedTrackPositions: effectiveSelectedTrackPositions,
|
||||||
tocTracks: mergedTracks,
|
tocTracks: mergedTracks,
|
||||||
@@ -11110,6 +11228,7 @@ class PipelineService extends EventEmitter {
|
|||||||
format,
|
format,
|
||||||
formatOptions,
|
formatOptions,
|
||||||
outputTemplate,
|
outputTemplate,
|
||||||
|
rawOwner,
|
||||||
outputOwner,
|
outputOwner,
|
||||||
selectedTrackPositions,
|
selectedTrackPositions,
|
||||||
tocTracks,
|
tocTracks,
|
||||||
@@ -11365,7 +11484,7 @@ class PipelineService extends EventEmitter {
|
|||||||
await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {});
|
await historyService.updateJob(jobId, { poster_url: cdPromotedUrl }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
chownRecursive(activeRawDir, outputOwner);
|
chownRecursive(activeRawDir, rawOwner || outputOwner);
|
||||||
chownRecursive(outputDir, outputOwner);
|
chownRecursive(outputDir, outputOwner);
|
||||||
await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`);
|
await historyService.appendLog(jobId, 'SYSTEM', `CD-Rip abgeschlossen. Ausgabe: ${outputDir}`);
|
||||||
const finishedStatusText = postEncodeScriptsSummary.failed > 0
|
const finishedStatusText = postEncodeScriptsSummary.failed > 0
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ const PROFILED_SETTINGS = {
|
|||||||
},
|
},
|
||||||
movie_dir: {
|
movie_dir: {
|
||||||
bluray: 'movie_dir_bluray',
|
bluray: 'movie_dir_bluray',
|
||||||
dvd: 'movie_dir_dvd'
|
dvd: 'movie_dir_dvd',
|
||||||
|
cd: 'movie_dir_cd'
|
||||||
},
|
},
|
||||||
movie_dir_owner: {
|
movie_dir_owner: {
|
||||||
bluray: 'movie_dir_bluray_owner',
|
bluray: 'movie_dir_bluray_owner',
|
||||||
dvd: 'movie_dir_dvd_owner'
|
dvd: 'movie_dir_dvd_owner',
|
||||||
|
cd: 'movie_dir_cd_owner'
|
||||||
},
|
},
|
||||||
mediainfo_extra_args: {
|
mediainfo_extra_args: {
|
||||||
bluray: 'mediainfo_extra_args_bluray',
|
bluray: 'mediainfo_extra_args_bluray',
|
||||||
@@ -690,7 +692,7 @@ class SettingsService {
|
|||||||
if (legacyKey === 'raw_dir') {
|
if (legacyKey === 'raw_dir') {
|
||||||
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR;
|
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_RAW_DIR;
|
||||||
} else if (legacyKey === 'movie_dir') {
|
} else if (legacyKey === 'movie_dir') {
|
||||||
resolvedValue = DEFAULT_MOVIE_DIR;
|
resolvedValue = normalizedRequestedProfile === 'cd' ? DEFAULT_CD_DIR : DEFAULT_MOVIE_DIR;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effective[legacyKey] = resolvedValue;
|
effective[legacyKey] = resolvedValue;
|
||||||
@@ -725,7 +727,7 @@ class SettingsService {
|
|||||||
return {
|
return {
|
||||||
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
bluray: { raw: bluray.raw_dir, movies: bluray.movie_dir },
|
||||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||||
cd: { raw: cd.raw_dir },
|
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||||
defaults: {
|
defaults: {
|
||||||
raw: DEFAULT_RAW_DIR,
|
raw: DEFAULT_RAW_DIR,
|
||||||
movies: DEFAULT_MOVIE_DIR,
|
movies: DEFAULT_MOVIE_DIR,
|
||||||
|
|||||||
@@ -373,13 +373,21 @@ VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} -
|
|||||||
|
|
||||||
-- Pfade – CD
|
-- Pfade – CD
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für CD-Rips. Enthält die WAV-Rohdaten (RAW) sowie den encodierten Audio-Output. Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
|
VALUES ('raw_dir_cd', 'Pfade', 'CD RAW-Ordner', 'path', 0, 'Basisordner für rohe CD-WAV-Dateien (cdparanoia-Output). Leer = Standardpfad (data/output/cd).', NULL, '[]', '{}', 104);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd', NULL);
|
||||||
|
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
|
VALUES ('raw_dir_cd_owner', 'Pfade', 'Eigentümer CD RAW-Ordner', 'string', 0, 'Eigentümer der Dateien im Format user:gruppe. Nur aktiv wenn ein Pfad gesetzt ist. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1045);
|
||||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL);
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('raw_dir_cd_owner', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('movie_dir_cd', 'Pfade', 'CD Output-Ordner', 'path', 0, 'Zielordner für encodierte CD-Ausgaben (FLAC, MP3 usw.). Leer = gleicher Ordner wie CD RAW-Ordner.', NULL, '[]', '{}', 114);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd', NULL);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
|
VALUES ('movie_dir_cd_owner', 'Pfade', 'Eigentümer CD Output-Ordner', 'string', 0, 'Eigentümer der encodierten CD-Ausgaben im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1145);
|
||||||
|
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_cd_owner', NULL);
|
||||||
|
|
||||||
-- Metadaten
|
-- Metadaten
|
||||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||||
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
VALUES ('omdb_api_key', 'Metadaten', 'OMDb API Key', 'string', 0, 'API Key für Metadatensuche.', NULL, '[]', '{}', 400);
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import HistoryPage from './pages/HistoryPage';
|
|||||||
import DatabasePage from './pages/DatabasePage';
|
import DatabasePage from './pages/DatabasePage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const appVersion = __APP_VERSION__;
|
||||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
const [hardwareMonitoring, setHardwareMonitoring] = useState(null);
|
||||||
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
const [lastDiscEvent, setLastDiscEvent] = useState(null);
|
||||||
@@ -115,7 +116,12 @@ function App() {
|
|||||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||||
<div className="brand-copy">
|
<div className="brand-copy">
|
||||||
<h1>Ripster</h1>
|
<h1>Ripster</h1>
|
||||||
<p>Disc Ripping Control Center</p>
|
<div className="brand-meta">
|
||||||
|
<p>Disc Ripping Control Center</p>
|
||||||
|
<span className="app-version" aria-label={`Version ${appVersion}`}>
|
||||||
|
v{appVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="nav-buttons">
|
<div className="nav-buttons">
|
||||||
|
|||||||
@@ -435,6 +435,14 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history']);
|
afterMutationInvalidate(['/history']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
async assignJobCdMetadata(jobId, payload = {}) {
|
||||||
|
const result = await request(`/history/${jobId}/cd/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
afterMutationInvalidate(['/history']);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
async deleteJobFiles(jobId, target = 'both') {
|
async deleteJobFiles(jobId, target = 'both') {
|
||||||
const result = await request(`/history/${jobId}/delete-files`, {
|
const result = await request(`/history/${jobId}/delete-files`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export default function CdMetadataDialog({
|
|||||||
}
|
}
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setManualTitle(context?.detectedTitle || '');
|
setManualTitle('');
|
||||||
setManualArtist('');
|
setManualArtist('');
|
||||||
setManualYear(null);
|
setManualYear(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ function buildToolSections(settings) {
|
|||||||
// Path keys per medium — _owner keys are rendered inline
|
// Path keys per medium — _owner keys are rendered inline
|
||||||
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
|
||||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||||
const CD_PATH_KEYS = ['raw_dir_cd', 'cd_output_template'];
|
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
||||||
const LOG_PATH_KEYS = ['log_dir'];
|
const LOG_PATH_KEYS = ['log_dir'];
|
||||||
|
|
||||||
function buildSectionsForCategory(categoryName, settings) {
|
function buildSectionsForCategory(categoryName, settings) {
|
||||||
@@ -380,7 +380,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
const blurayMovies = ep.bluray?.movies || defaultMovies;
|
||||||
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
const dvdRaw = ep.dvd?.raw || defaultRaw;
|
||||||
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
const dvdMovies = ep.dvd?.movies || defaultMovies;
|
||||||
const cdOutput = ep.cd?.raw || defaultCd;
|
const cdRaw = ep.cd?.raw || defaultCd;
|
||||||
|
const cdMovies = ep.cd?.movies || cdRaw;
|
||||||
|
|
||||||
const isDefault = (path, def) => path === def;
|
const isDefault = (path, def) => path === def;
|
||||||
|
|
||||||
@@ -425,9 +426,13 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>CD / Audio</strong></td>
|
<td><strong>CD / Audio</strong></td>
|
||||||
<td colSpan={2}>
|
<td>
|
||||||
<code>{cdOutput}</code>
|
<code>{cdRaw}</code>
|
||||||
{isDefault(cdOutput, defaultCd) && <span className="path-default-badge">Standard</span>}
|
{isDefault(cdRaw, defaultCd) && <span className="path-default-badge">Standard</span>}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>{cdMovies}</code>
|
||||||
|
{isDefault(cdMovies, cdRaw) && <span className="path-default-badge">Standard</span>}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ export default function JobDetailDialog({
|
|||||||
onLoadLog,
|
onLoadLog,
|
||||||
logLoadingMode = null,
|
logLoadingMode = null,
|
||||||
onAssignOmdb,
|
onAssignOmdb,
|
||||||
|
onAssignCdMetadata,
|
||||||
onResumeReady,
|
onResumeReady,
|
||||||
onRestartEncode,
|
onRestartEncode,
|
||||||
onRestartReview,
|
onRestartReview,
|
||||||
@@ -389,6 +390,7 @@ export default function JobDetailDialog({
|
|||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
isQueued = false,
|
isQueued = false,
|
||||||
omdbAssignBusy = false,
|
omdbAssignBusy = false,
|
||||||
|
cdMetadataAssignBusy = false,
|
||||||
actionBusy = false,
|
actionBusy = false,
|
||||||
reencodeBusy = false,
|
reencodeBusy = false,
|
||||||
deleteEntryBusy = false
|
deleteEntryBusy = false
|
||||||
@@ -748,7 +750,17 @@ export default function JobDetailDialog({
|
|||||||
loading={omdbAssignBusy}
|
loading={omdbAssignBusy}
|
||||||
disabled={running || typeof onAssignOmdb !== 'function'}
|
disabled={running || typeof onAssignOmdb !== 'function'}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<Button
|
||||||
|
label="MusicBrainz neu zuordnen"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onAssignCdMetadata?.(job)}
|
||||||
|
loading={cdMetadataAssignBusy}
|
||||||
|
disabled={running || typeof onAssignCdMetadata !== 'function'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!isCd && canResumeReady ? (
|
{!isCd && canResumeReady ? (
|
||||||
<Button
|
<Button
|
||||||
label="Im Dashboard öffnen"
|
label="Im Dashboard öffnen"
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export default function PipelineStatusCard({
|
|||||||
onAnalyze,
|
onAnalyze,
|
||||||
onReanalyze,
|
onReanalyze,
|
||||||
onOpenMetadata,
|
onOpenMetadata,
|
||||||
|
onReassignOmdb,
|
||||||
onStart,
|
onStart,
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onRestartEncode,
|
onRestartEncode,
|
||||||
@@ -653,6 +654,17 @@ export default function PipelineStatusCard({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{!running && state !== 'METADATA_SELECTION' && state !== 'WAITING_FOR_USER_DECISION' && state !== 'IDLE' && state !== 'DISC_DETECTED' && retryJobId && typeof onReassignOmdb === 'function' ? (
|
||||||
|
<Button
|
||||||
|
label="OMDb neu zuordnen"
|
||||||
|
icon="pi pi-search"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onReassignOmdb?.(retryJobId)}
|
||||||
|
loading={busy}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{state === 'READY_TO_START' && retryJobId ? (
|
{state === 'READY_TO_START' && retryJobId ? (
|
||||||
<Button
|
<Button
|
||||||
label="Job starten"
|
label="Job starten"
|
||||||
|
|||||||
@@ -728,6 +728,7 @@ export default function DashboardPage({
|
|||||||
};
|
};
|
||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
|
const [metadataDialogReassignMode, setMetadataDialogReassignMode] = useState(false);
|
||||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
|
const [cdRipPanelJobId, setCdRipPanelJobId] = useState(null);
|
||||||
@@ -1040,6 +1041,18 @@ export default function DashboardPage({
|
|||||||
showError(new Error('Kein Job mit offener Metadaten-Auswahl gefunden.'));
|
showError(new Error('Kein Job mit offener Metadaten-Auswahl gefunden.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setMetadataDialogReassignMode(false);
|
||||||
|
setMetadataDialogContext(context);
|
||||||
|
setMetadataDialogVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenReassignOmdbDialog = (jobId) => {
|
||||||
|
const context = buildMetadataContextForJob(jobId);
|
||||||
|
if (!context?.jobId) {
|
||||||
|
showError(new Error('Job nicht gefunden.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMetadataDialogReassignMode(true);
|
||||||
setMetadataDialogContext(context);
|
setMetadataDialogContext(context);
|
||||||
setMetadataDialogVisible(true);
|
setMetadataDialogVisible(true);
|
||||||
};
|
};
|
||||||
@@ -1516,11 +1529,16 @@ export default function DashboardPage({
|
|||||||
const handleMetadataSubmit = async (payload) => {
|
const handleMetadataSubmit = async (payload) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.selectMetadata(payload);
|
if (metadataDialogReassignMode) {
|
||||||
|
await api.assignJobOmdb(payload.jobId, payload);
|
||||||
|
} else {
|
||||||
|
await api.selectMetadata(payload);
|
||||||
|
}
|
||||||
await refreshPipeline();
|
await refreshPipeline();
|
||||||
await loadDashboardJobs();
|
await loadDashboardJobs();
|
||||||
setMetadataDialogVisible(false);
|
setMetadataDialogVisible(false);
|
||||||
setMetadataDialogContext(null);
|
setMetadataDialogContext(null);
|
||||||
|
setMetadataDialogReassignMode(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error);
|
showError(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2288,6 +2306,11 @@ export default function DashboardPage({
|
|||||||
return (
|
return (
|
||||||
<div key={jobId} className="dashboard-job-expanded">
|
<div key={jobId} className="dashboard-job-expanded">
|
||||||
<div className="dashboard-job-expanded-head">
|
<div className="dashboard-job-expanded-head">
|
||||||
|
{job?.poster_url && job.poster_url !== 'N/A' ? (
|
||||||
|
<img src={job.poster_url} alt={jobTitle} className="poster-thumb" />
|
||||||
|
) : (
|
||||||
|
<div className="poster-thumb dashboard-job-poster-fallback">Kein Poster</div>
|
||||||
|
)}
|
||||||
<div className="dashboard-job-expanded-title">
|
<div className="dashboard-job-expanded-title">
|
||||||
<strong className="dashboard-job-title-line">
|
<strong className="dashboard-job-title-line">
|
||||||
<img
|
<img
|
||||||
@@ -2348,6 +2371,7 @@ export default function DashboardPage({
|
|||||||
onAnalyze={handleAnalyze}
|
onAnalyze={handleAnalyze}
|
||||||
onReanalyze={handleReanalyze}
|
onReanalyze={handleReanalyze}
|
||||||
onOpenMetadata={handleOpenMetadataDialog}
|
onOpenMetadata={handleOpenMetadataDialog}
|
||||||
|
onReassignOmdb={handleOpenReassignOmdbDialog}
|
||||||
onStart={handleStartJob}
|
onStart={handleStartJob}
|
||||||
onRestartEncode={handleRestartEncodeWithLastSettings}
|
onRestartEncode={handleRestartEncodeWithLastSettings}
|
||||||
onRestartReview={handleRestartReviewFromRaw}
|
onRestartReview={handleRestartReviewFromRaw}
|
||||||
@@ -2469,6 +2493,7 @@ export default function DashboardPage({
|
|||||||
onHide={() => {
|
onHide={() => {
|
||||||
setMetadataDialogVisible(false);
|
setMetadataDialogVisible(false);
|
||||||
setMetadataDialogContext(null);
|
setMetadataDialogContext(null);
|
||||||
|
setMetadataDialogReassignMode(false);
|
||||||
}}
|
}}
|
||||||
onSubmit={handleMetadataSubmit}
|
onSubmit={handleMetadataSubmit}
|
||||||
onSearch={handleOmdbSearch}
|
onSearch={handleOmdbSearch}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Toast } from 'primereact/toast';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import JobDetailDialog from '../components/JobDetailDialog';
|
import JobDetailDialog from '../components/JobDetailDialog';
|
||||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||||
|
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||||
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
import blurayIndicatorIcon from '../assets/media-bluray.svg';
|
||||||
import discIndicatorIcon from '../assets/media-disc.svg';
|
import discIndicatorIcon from '../assets/media-disc.svg';
|
||||||
import otherIndicatorIcon from '../assets/media-other.svg';
|
import otherIndicatorIcon from '../assets/media-other.svg';
|
||||||
@@ -42,6 +43,9 @@ function resolveMediaType(row) {
|
|||||||
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
if (['dvd', 'disc', 'dvdvideo', 'dvd-video', 'dvdrom', 'dvd-rom', 'video_ts', 'iso9660'].includes(raw)) {
|
||||||
return 'dvd';
|
return 'dvd';
|
||||||
}
|
}
|
||||||
|
if (['cd', 'audio_cd'].includes(raw)) {
|
||||||
|
return 'cd';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,9 @@ export default function DatabasePage() {
|
|||||||
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
|
||||||
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
|
||||||
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
|
const [metadataDialogBusy, setMetadataDialogBusy] = useState(false);
|
||||||
|
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||||
|
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||||
|
const [cdMetadataDialogBusy, setCdMetadataDialogBusy] = useState(false);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
const [reencodeBusyJobId, setReencodeBusyJobId] = useState(null);
|
||||||
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
const [deleteEntryBusyJobId, setDeleteEntryBusyJobId] = useState(null);
|
||||||
@@ -467,7 +474,7 @@ export default function DatabasePage() {
|
|||||||
|
|
||||||
const handleImportOrphanRaw = async (row) => {
|
const handleImportOrphanRaw = async (row) => {
|
||||||
const target = row?.rawPath || row?.folderName || '-';
|
const target = row?.rawPath || row?.folderName || '-';
|
||||||
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen?`);
|
const confirmed = window.confirm(`Für RAW-Ordner "${target}" einen neuen Historienjob anlegen und direkt scannen?`);
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -475,12 +482,32 @@ export default function DatabasePage() {
|
|||||||
setOrphanImportBusyPath(row.rawPath);
|
setOrphanImportBusyPath(row.rawPath);
|
||||||
try {
|
try {
|
||||||
const response = await api.importOrphanRawFolder(row.rawPath);
|
const response = await api.importOrphanRawFolder(row.rawPath);
|
||||||
toastRef.current?.show({
|
const newJobId = response?.job?.id;
|
||||||
severity: 'success',
|
if (newJobId) {
|
||||||
summary: 'Job angelegt',
|
try {
|
||||||
detail: `Historieneintrag #${response?.job?.id || '-'} wurde erstellt.`,
|
await api.reencodeJob(newJobId);
|
||||||
life: 3500
|
toastRef.current?.show({
|
||||||
});
|
severity: 'success',
|
||||||
|
summary: 'Job angelegt & Scan gestartet',
|
||||||
|
detail: `Historieneintrag #${newJobId} erstellt, Mediainfo-Scan läuft.`,
|
||||||
|
life: 4000
|
||||||
|
});
|
||||||
|
} catch (scanError) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Job angelegt',
|
||||||
|
detail: `Historieneintrag #${newJobId} erstellt. Scan konnte nicht automatisch gestartet werden: ${scanError.message}`,
|
||||||
|
life: 6000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Job angelegt',
|
||||||
|
detail: `Historieneintrag wurde erstellt.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
@@ -504,6 +531,77 @@ export default function DatabasePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMusicBrainzSearch = async (query) => {
|
||||||
|
try {
|
||||||
|
const response = await api.searchMusicBrainz(query);
|
||||||
|
return response.results || [];
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Suche fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMusicBrainzReleaseFetch = async (mbId) => {
|
||||||
|
try {
|
||||||
|
const response = await api.getMusicBrainzRelease(mbId);
|
||||||
|
return response.release || null;
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({ severity: 'error', summary: 'MusicBrainz Release fehlgeschlagen', detail: error.message, life: 4500 });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCdMetadataAssignDialog = (row) => {
|
||||||
|
if (!row?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const makemkvInfo = row.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
|
||||||
|
const tocTracks = Array.isArray(makemkvInfo.tracks) ? makemkvInfo.tracks : [];
|
||||||
|
const selectedMetadata = makemkvInfo.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
|
||||||
|
? makemkvInfo.selectedMetadata
|
||||||
|
: {};
|
||||||
|
setCdMetadataDialogContext({
|
||||||
|
jobId: row.id,
|
||||||
|
detectedTitle: row.title || row.detected_title || selectedMetadata.title || '',
|
||||||
|
tracks: tocTracks
|
||||||
|
});
|
||||||
|
setCdMetadataDialogVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCdMetadataAssignSubmit = async (payload) => {
|
||||||
|
const jobId = Number(payload?.jobId || cdMetadataDialogContext?.jobId || 0);
|
||||||
|
if (!jobId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCdMetadataDialogBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await api.assignJobCdMetadata(jobId, payload);
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'CD-Metadaten aktualisiert',
|
||||||
|
detail: `Job #${jobId} wurde aktualisiert.`,
|
||||||
|
life: 3500
|
||||||
|
});
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
await load();
|
||||||
|
if (detailVisible && selectedJob?.id === jobId && response?.job) {
|
||||||
|
setSelectedJob(response.job);
|
||||||
|
} else {
|
||||||
|
await refreshDetailIfOpen(jobId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toastRef.current?.show({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'CD-Metadaten fehlgeschlagen',
|
||||||
|
detail: error.message,
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCdMetadataDialogBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openMetadataAssignDialog = (row) => {
|
const openMetadataAssignDialog = (row) => {
|
||||||
if (!row?.id) {
|
if (!row?.id) {
|
||||||
return;
|
return;
|
||||||
@@ -728,6 +826,7 @@ export default function DatabasePage() {
|
|||||||
setLogLoadingMode(null);
|
setLogLoadingMode(null);
|
||||||
}}
|
}}
|
||||||
onAssignOmdb={openMetadataAssignDialog}
|
onAssignOmdb={openMetadataAssignDialog}
|
||||||
|
onAssignCdMetadata={openCdMetadataAssignDialog}
|
||||||
onResumeReady={handleResumeReady}
|
onResumeReady={handleResumeReady}
|
||||||
onRestartEncode={handleRestartEncode}
|
onRestartEncode={handleRestartEncode}
|
||||||
onRestartReview={handleRestartReview}
|
onRestartReview={handleRestartReview}
|
||||||
@@ -737,6 +836,7 @@ export default function DatabasePage() {
|
|||||||
onRemoveFromQueue={handleRemoveFromQueue}
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||||
omdbAssignBusy={metadataDialogBusy}
|
omdbAssignBusy={metadataDialogBusy}
|
||||||
|
cdMetadataAssignBusy={cdMetadataDialogBusy}
|
||||||
actionBusy={actionBusy}
|
actionBusy={actionBusy}
|
||||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||||
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
deleteEntryBusy={deleteEntryBusyJobId === selectedJob?.id}
|
||||||
@@ -750,6 +850,19 @@ export default function DatabasePage() {
|
|||||||
onSearch={handleOmdbSearch}
|
onSearch={handleOmdbSearch}
|
||||||
busy={metadataDialogBusy}
|
busy={metadataDialogBusy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CdMetadataDialog
|
||||||
|
visible={cdMetadataDialogVisible}
|
||||||
|
context={cdMetadataDialogContext || {}}
|
||||||
|
onHide={() => {
|
||||||
|
setCdMetadataDialogVisible(false);
|
||||||
|
setCdMetadataDialogContext(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleCdMetadataAssignSubmit}
|
||||||
|
onSearch={handleMusicBrainzSearch}
|
||||||
|
onFetchRelease={handleMusicBrainzReleaseFetch}
|
||||||
|
busy={cdMetadataDialogBusy}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,28 @@ body {
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.35rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border: 1px solid rgba(58, 29, 18, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 250, 240, 0.45);
|
||||||
|
color: rgba(58, 29, 18, 0.72);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-buttons {
|
.nav-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -2530,6 +2552,10 @@ body {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
}
|
||||||
|
|
||||||
.metadata-grid,
|
.metadata-grid,
|
||||||
.device-meta,
|
.device-meta,
|
||||||
.hardware-monitor-grid,
|
.hardware-monitor-grid,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||||
const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
|
const publicOrigin = (process.env.VITE_PUBLIC_ORIGIN || '').trim();
|
||||||
const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
|
const parsedAllowedHosts = (process.env.VITE_ALLOWED_HOSTS || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -24,6 +26,9 @@ if (publicOrigin) {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(appPackage.version)
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
960
install-dev.sh
960
install-dev.sh
@@ -1,960 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# =============================================================================
|
|
||||||
# Ripster – Installationsskript
|
|
||||||
# Unterstützt: Debian 11/12, Ubuntu 22.04/24.04
|
|
||||||
# Benötigt: sudo / root
|
|
||||||
#
|
|
||||||
# Verwendung:
|
|
||||||
# chmod +x install.sh
|
|
||||||
# sudo ./install.sh [Optionen]
|
|
||||||
#
|
|
||||||
# Optionen:
|
|
||||||
# --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
|
|
||||||
# --build-handbrake HandBrake aus Quellcode mit NVDEC-Unterstützung bauen
|
|
||||||
# --handbrake-version HandBrake-Version für Source-Build (Standard: 1.9.0)
|
|
||||||
# --handbrake-update-policy <keep|prompt|build>
|
|
||||||
# Bei NVDEC-Self-Build: bei neuer Git-Release behalten,
|
|
||||||
# nachfragen oder direkt neu bauen (Standard: keep)
|
|
||||||
# --no-nginx Nginx-Einrichtung überspringen (Frontend läuft dann auf Port 5173)
|
|
||||||
# --reinstall Vorhandene Installation ersetzen (Daten bleiben erhalten)
|
|
||||||
# -h, --help Diese Hilfe anzeigen
|
|
||||||
# =============================================================================
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# --- 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 --------------------------------------------------------
|
|
||||||
INSTALL_DIR="/opt/ripster"
|
|
||||||
SERVICE_USER="ripster"
|
|
||||||
BACKEND_PORT="3001"
|
|
||||||
FRONTEND_HOST="" # wird automatisch ermittelt, wenn leer
|
|
||||||
SKIP_MAKEMKV=false
|
|
||||||
SKIP_HANDBRAKE=false
|
|
||||||
BUILD_HANDBRAKE_NVDEC=false
|
|
||||||
HANDBRAKE_MODE_SELECTED=false
|
|
||||||
HANDBRAKE_VERSION="1.9.0"
|
|
||||||
HANDBRAKE_UPDATE_POLICY="keep"
|
|
||||||
HANDBRAKE_UPDATE_POLICY_SELECTED=false
|
|
||||||
SKIP_NGINX=false
|
|
||||||
REINSTALL=false
|
|
||||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
HANDBRAKE_SELFBUILD_MARKER="/usr/local/share/ripster/handbrake-selfbuild.env"
|
|
||||||
|
|
||||||
# --- Argumente parsen ---------------------------------------------------------
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--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 ;;
|
|
||||||
--build-handbrake) BUILD_HANDBRAKE_NVDEC=true; HANDBRAKE_MODE_SELECTED=true; shift ;;
|
|
||||||
--handbrake-version) HANDBRAKE_VERSION="$2"; shift 2 ;;
|
|
||||||
--handbrake-update-policy)
|
|
||||||
case "$2" in
|
|
||||||
keep|prompt|build) HANDBRAKE_UPDATE_POLICY="$2"; HANDBRAKE_UPDATE_POLICY_SELECTED=true ;;
|
|
||||||
*) fatal "Ungültige --handbrake-update-policy: $2 (erlaubt: keep|prompt|build)" ;;
|
|
||||||
esac
|
|
||||||
shift 2 ;;
|
|
||||||
--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"
|
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
fatal "Dieses Skript muss als root ausgeführt werden (sudo ./install.sh)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# OS-Erkennung
|
|
||||||
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
|
|
||||||
|
|
||||||
# Host-IP ermitteln
|
|
||||||
if [[ -z "$FRONTEND_HOST" ]]; then
|
|
||||||
FRONTEND_HOST=$(hostname -I | awk '{print $1}')
|
|
||||||
info "Erkannte IP: $FRONTEND_HOST"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Quelldirectory prüfen
|
|
||||||
[[ -f "$SOURCE_DIR/backend/package.json" ]] || \
|
|
||||||
fatal "Ripster-Quellen nicht gefunden in: $SOURCE_DIR"
|
|
||||||
|
|
||||||
info "Quellverzeichnis: $SOURCE_DIR"
|
|
||||||
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; }
|
|
||||||
|
|
||||||
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
|
|
||||||
local mk_version_line
|
|
||||||
mk_version_line="$(makemkvcon --version 2>&1 | head -1 || true)"
|
|
||||||
if [[ -z "$mk_version_line" || "$mk_version_line" == *"unrecognized option"* ]]; then
|
|
||||||
mk_version_line="$(makemkvcon 2>&1 | head -1 || true)"
|
|
||||||
fi
|
|
||||||
ok "makemkvcon bereits installiert (${mk_version_line:-Version unbekannt})"
|
|
||||||
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
|
|
||||||
|
|
||||||
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 "Gefundene 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"
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_has_nvdec() {
|
|
||||||
command_exists HandBrakeCLI || return 1
|
|
||||||
HandBrakeCLI --help 2>&1 | grep -qi "nvdec"
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_installed_version() {
|
|
||||||
command_exists HandBrakeCLI || return 1
|
|
||||||
HandBrakeCLI --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+){1,3}' | head -1
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_latest_git_version() {
|
|
||||||
local latest=""
|
|
||||||
latest=$(curl -fsSL --max-time 10 "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" 2>/dev/null \
|
|
||||||
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
|
||||||
| head -1 \
|
|
||||||
| sed -E 's/.*"([^"]+)".*/\1/' \
|
|
||||||
| sed 's/^v//')
|
|
||||||
[[ -n "$latest" ]] || return 1
|
|
||||||
[[ "$latest" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]] || return 1
|
|
||||||
printf '%s\n' "$latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
handbrake_is_self_built() {
|
|
||||||
local hb_path="${1:-$(command -v HandBrakeCLI 2>/dev/null || true)}"
|
|
||||||
local resolved_path=""
|
|
||||||
[[ -n "$hb_path" ]] || return 1
|
|
||||||
[[ "$hb_path" == "/usr/local/bin/HandBrakeCLI" ]] || return 1
|
|
||||||
[[ -f "$HANDBRAKE_SELFBUILD_MARKER" ]] && return 0
|
|
||||||
resolved_path=$(readlink -f "$hb_path" 2>/dev/null || true)
|
|
||||||
dpkg -S "$hb_path" >/dev/null 2>&1 && return 1
|
|
||||||
[[ -n "$resolved_path" ]] && dpkg -S "$resolved_path" >/dev/null 2>&1 && return 1
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
remove_non_selfbuilt_handbrake() {
|
|
||||||
info "Entferne nicht-selbst-gebaute HandBrake-Installationen..."
|
|
||||||
apt-get remove -y handbrake-cli handbrake 2>/dev/null || true
|
|
||||||
snap remove handbrake-cli 2>/dev/null || true
|
|
||||||
|
|
||||||
rm -f /usr/bin/HandBrakeCLI \
|
|
||||||
/snap/bin/handbrake-cli \
|
|
||||||
/snap/bin/HandBrakeCLI
|
|
||||||
|
|
||||||
if [[ -e /usr/local/bin/HandBrakeCLI ]] && ! handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
|
|
||||||
warn "Entferne fremdes /usr/local/bin/HandBrakeCLI"
|
|
||||||
rm -f /usr/local/bin/HandBrakeCLI
|
|
||||||
fi
|
|
||||||
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
ok "Bereinigung abgeschlossen"
|
|
||||||
}
|
|
||||||
|
|
||||||
remove_selfbuilt_handbrake() {
|
|
||||||
if [[ -e /usr/local/bin/HandBrakeCLI ]] && handbrake_is_self_built "/usr/local/bin/HandBrakeCLI"; then
|
|
||||||
warn "Entferne selbst gebautes /usr/local/bin/HandBrakeCLI"
|
|
||||||
rm -f /usr/local/bin/HandBrakeCLI
|
|
||||||
fi
|
|
||||||
rm -f "$HANDBRAKE_SELFBUILD_MARKER"
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
}
|
|
||||||
|
|
||||||
build_handbrake_nvdec() {
|
|
||||||
header "HandBrake ${HANDBRAKE_VERSION} mit NVDEC aus Quellcode bauen"
|
|
||||||
|
|
||||||
local cache_dir="/var/cache/ripster/handbrake"
|
|
||||||
local src_url="https://github.com/HandBrake/HandBrake/releases/download/${HANDBRAKE_VERSION}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
|
|
||||||
local tarball="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}-source.tar.bz2"
|
|
||||||
local src_dir="${cache_dir}/HandBrake-${HANDBRAKE_VERSION}"
|
|
||||||
|
|
||||||
if [[ -t 0 && -d "$cache_dir" ]]; then
|
|
||||||
local clear_cache_answer=""
|
|
||||||
warn "Build-Cache gefunden: $cache_dir"
|
|
||||||
warn "Wenn du ihn löschst, startet der Source-Build wieder komplett von vorne."
|
|
||||||
read -r -p "Build-Cache jetzt löschen (sudo rm -rf $cache_dir)? [y/N] " clear_cache_answer
|
|
||||||
case "${clear_cache_answer,,}" in
|
|
||||||
y|yes|j|ja)
|
|
||||||
rm -rf "$cache_dir"
|
|
||||||
info "Build-Cache gelöscht."
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
mkdir -p "$cache_dir"
|
|
||||||
|
|
||||||
# Build-Abhängigkeiten
|
|
||||||
info "Installiere Build-Abhängigkeiten..."
|
|
||||||
apt-get install -y \
|
|
||||||
autoconf automake build-essential clang cmake git \
|
|
||||||
libass-dev libbz2-dev libdvdnav-dev libdvdread-dev \
|
|
||||||
libfontconfig-dev libfreetype-dev libfribidi-dev libharfbuzz-dev \
|
|
||||||
libjansson-dev liblzma-dev libmp3lame-dev libnuma-dev libogg-dev \
|
|
||||||
libopus-dev libsamplerate0-dev libspeex-dev libtheora-dev libtool libtool-bin libva-dev \
|
|
||||||
libturbojpeg0-dev libvorbis-dev libvpx-dev libx264-dev libxml2-dev \
|
|
||||||
m4 meson nasm ninja-build patch pkg-config python3 tar yasm zlib1g-dev \
|
|
||||||
>/dev/null
|
|
||||||
|
|
||||||
# CUDA Toolkit für NVDEC-Header
|
|
||||||
info "Installiere CUDA Toolkit (für NVDEC-Header)..."
|
|
||||||
if ! dpkg -l 2>/dev/null | grep -q '^ii.*nvidia-cuda-toolkit'; then
|
|
||||||
apt-get install -y nvidia-cuda-toolkit >/dev/null 2>&1 || {
|
|
||||||
warn "nvidia-cuda-toolkit nicht verfügbar – versuche Fallback-Header..."
|
|
||||||
local cuda_keyring="/tmp/cuda-keyring.deb"
|
|
||||||
local ubuntu_ver="${VERSION_ID//./}"
|
|
||||||
curl -fsSL "https://developer.download.nvidia.com/compute/cuda/repos/ubuntu${ubuntu_ver}/x86_64/cuda-keyring_1.1-1_all.deb" \
|
|
||||||
-o "$cuda_keyring" 2>/dev/null && \
|
|
||||||
dpkg -i "$cuda_keyring" 2>/dev/null && \
|
|
||||||
apt-get update -qq && \
|
|
||||||
apt-get install -y cuda-cudart-dev-12-8 >/dev/null 2>&1 || \
|
|
||||||
warn "CUDA-Header konnten nicht installiert werden – NVDEC wird möglicherweise nicht verfügbar sein."
|
|
||||||
}
|
|
||||||
fi
|
|
||||||
ok "Build-Abhängigkeiten installiert"
|
|
||||||
|
|
||||||
if [[ ! -d "$src_dir" ]]; then
|
|
||||||
if [[ ! -f "$tarball" ]]; then
|
|
||||||
info "Lade HandBrake ${HANDBRAKE_VERSION} herunter..."
|
|
||||||
curl -fsSL "$src_url" -o "$tarball" 2>/dev/null || \
|
|
||||||
wget -q "$src_url" -O "$tarball" || \
|
|
||||||
fatal "HandBrake-Quellcode konnte nicht heruntergeladen werden (${src_url})"
|
|
||||||
else
|
|
||||||
info "Nutze vorhandenes HandBrake-Source-Archiv: $tarball"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Entpacke Quellcode..."
|
|
||||||
tar xjf "$tarball" -C "$cache_dir"
|
|
||||||
[[ -d "$src_dir" ]] || src_dir=$(find "$cache_dir" -maxdepth 1 -type d -name "HandBrake*" | head -1)
|
|
||||||
[[ -d "$src_dir" ]] || fatal "HandBrake-Quellverzeichnis nicht gefunden in $cache_dir"
|
|
||||||
else
|
|
||||||
info "Nutze vorhandenen HandBrake-Source-Baum: $src_dir"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$src_dir"
|
|
||||||
|
|
||||||
local configure_log="${src_dir}/build/configure-ripster.log"
|
|
||||||
local configure_stamp="${src_dir}/build/.ripster-config"
|
|
||||||
local configure_args="--enable-nvdec --disable-gtk --prefix=/usr/local"
|
|
||||||
local need_configure="false"
|
|
||||||
local configure_cmd=(
|
|
||||||
./configure
|
|
||||||
--launch-jobs="$(nproc)"
|
|
||||||
--enable-nvdec
|
|
||||||
--disable-gtk
|
|
||||||
--prefix=/usr/local
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ ! -f "$src_dir/build/GNUmakefile" ]]; then
|
|
||||||
need_configure="true"
|
|
||||||
elif [[ ! -f "$configure_stamp" ]]; then
|
|
||||||
need_configure="true"
|
|
||||||
elif ! grep -qx "args=${configure_args}" "$configure_stamp"; then
|
|
||||||
need_configure="true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$need_configure" == "true" ]]; then
|
|
||||||
if [[ -d "$src_dir/build" ]]; then
|
|
||||||
configure_cmd=(./configure --force "${configure_cmd[@]:1}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f "$src_dir/build/GNUmakefile" ]]; then
|
|
||||||
info "Vorhandener Build gefunden – aktualisiere Konfiguration (CLI-only)."
|
|
||||||
else
|
|
||||||
info "Konfiguriere HandBrake mit NVDEC (CLI-only)..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! "${configure_cmd[@]}" >"$configure_log" 2>&1; then
|
|
||||||
tail -n 50 "$configure_log" >&2 || true
|
|
||||||
fatal "HandBrake-Konfiguration fehlgeschlagen. Vollständiges Log: $configure_log"
|
|
||||||
fi
|
|
||||||
tail -n 10 "$configure_log"
|
|
||||||
|
|
||||||
mkdir -p "${src_dir}/build"
|
|
||||||
cat > "$configure_stamp" <<EOF
|
|
||||||
args=${configure_args}
|
|
||||||
version=${HANDBRAKE_VERSION}
|
|
||||||
configured_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
EOF
|
|
||||||
else
|
|
||||||
info "Vorhandene CLI-only-Konfiguration erkannt – überspringe configure."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -x "$src_dir/build/HandBrakeCLI" ]]; then
|
|
||||||
info "Vorhandenes HandBrakeCLI-Build-Artefakt gefunden – versuche direkte Installation."
|
|
||||||
if ! make --directory=build install; then
|
|
||||||
warn "Direkte Installation fehlgeschlagen – setze Build fort."
|
|
||||||
make --directory=build -j"$(nproc)"
|
|
||||||
make --directory=build install
|
|
||||||
fi
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
if ! command_exists HandBrakeCLI; then
|
|
||||||
warn "HandBrakeCLI nach direkter Installation nicht gefunden – setze Build fort."
|
|
||||||
make --directory=build -j"$(nproc)"
|
|
||||||
make --directory=build install
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
info "Baue HandBrake ($(nproc) Threads – bitte warten)..."
|
|
||||||
make --directory=build -j"$(nproc)"
|
|
||||||
info "Installiere HandBrake nach /usr/local/bin..."
|
|
||||||
make --directory=build install
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd /
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
|
|
||||||
if command_exists HandBrakeCLI; then
|
|
||||||
local ver
|
|
||||||
ver=$(HandBrakeCLI --version 2>&1 | head -1)
|
|
||||||
handbrake_has_nvdec || fatal "HandBrakeCLI ist installiert, aber ohne NVDEC-Unterstützung."
|
|
||||||
|
|
||||||
mkdir -p "$(dirname "$HANDBRAKE_SELFBUILD_MARKER")"
|
|
||||||
cat > "$HANDBRAKE_SELFBUILD_MARKER" <<EOF
|
|
||||||
version=${HANDBRAKE_VERSION}
|
|
||||||
source_dir=${src_dir}
|
|
||||||
binary_path=$(command -v HandBrakeCLI)
|
|
||||||
installed_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
EOF
|
|
||||||
|
|
||||||
ok "HandBrakeCLI mit NVDEC installiert: ${ver}"
|
|
||||||
if ldconfig -p 2>/dev/null | grep -q libnvcuvid; then
|
|
||||||
ok "libnvcuvid gefunden – NVDEC ist zur Laufzeit verfügbar."
|
|
||||||
else
|
|
||||||
warn "libnvcuvid NICHT gefunden. NVDEC benötigt den installierten NVIDIA-Treiber."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
fatal "HandBrakeCLI nach dem Build nicht gefunden – Build fehlgeschlagen."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
has_nvidia_gpu() {
|
|
||||||
[[ -e /dev/nvidia0 ]] && return 0
|
|
||||||
command_exists nvidia-smi && nvidia-smi &>/dev/null && return 0
|
|
||||||
command_exists lspci && lspci 2>/dev/null | grep -qi "nvidia" && return 0
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
install_handbrake() {
|
|
||||||
header "HandBrake CLI installieren"
|
|
||||||
|
|
||||||
local hb_path
|
|
||||||
local current_mode="none"
|
|
||||||
hb_path=$(command -v HandBrakeCLI 2>/dev/null || true)
|
|
||||||
if [[ -n "$hb_path" ]]; then
|
|
||||||
if handbrake_has_nvdec; then
|
|
||||||
current_mode="nvdec"
|
|
||||||
else
|
|
||||||
current_mode="standard"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$BUILD_HANDBRAKE_NVDEC" == true ]]; then
|
|
||||||
info "Installmodus: Source-Build mit NVDEC-Support"
|
|
||||||
if has_nvidia_gpu; then
|
|
||||||
info "NVIDIA-GPU erkannt – NVDEC-Build wird verwendet."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if handbrake_has_nvdec; then
|
|
||||||
if handbrake_is_self_built "$hb_path"; then
|
|
||||||
local installed_ver latest_ver answer
|
|
||||||
installed_ver=$(handbrake_installed_version || true)
|
|
||||||
latest_ver=$(handbrake_latest_git_version || true)
|
|
||||||
|
|
||||||
if [[ -n "$installed_ver" && -n "$latest_ver" ]] && dpkg --compare-versions "$latest_ver" gt "$installed_ver"; then
|
|
||||||
case "$HANDBRAKE_UPDATE_POLICY" in
|
|
||||||
keep)
|
|
||||||
warn "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – behalte aktuelle Installation."
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
prompt)
|
|
||||||
if [[ -t 0 ]]; then
|
|
||||||
read -r -p "Neue HandBrake-Version ${latest_ver} verfügbar (installiert ${installed_ver}). Neu bauen? [y/N] " answer
|
|
||||||
case "${answer,,}" in
|
|
||||||
y|yes|j|ja)
|
|
||||||
info "Aktualisiere auf HandBrake ${latest_ver}."
|
|
||||||
HANDBRAKE_VERSION="$latest_ver"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
info "Behalte bestehende Installation (${installed_ver})."
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
warn "Neuere HandBrake-Version verfügbar (${latest_ver}), aber kein TTY für Rückfrage – behalte aktuelle Installation."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
build)
|
|
||||||
info "Neuere HandBrake-Version verfügbar (${latest_ver}, installiert: ${installed_ver}) – aktualisiere."
|
|
||||||
HANDBRAKE_VERSION="$latest_ver"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
else
|
|
||||||
ok "Selbst gebautes HandBrakeCLI mit NVDEC bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
warn "HandBrakeCLI mit NVDEC gefunden (${hb_path}), aber nicht als Selbst-Build erkannt."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$hb_path" ]] && ! handbrake_has_nvdec; then
|
|
||||||
warn "HandBrakeCLI ohne NVDEC gefunden (${hb_path}) – wird ersetzt durch Selbst-Build."
|
|
||||||
elif [[ -z "$hb_path" ]]; then
|
|
||||||
info "Kein HandBrakeCLI gefunden – baue aus Quellcode."
|
|
||||||
fi
|
|
||||||
|
|
||||||
remove_non_selfbuilt_handbrake
|
|
||||||
build_handbrake_nvdec
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Installmodus: Standard (APT, ohne NVDEC-Zwang)"
|
|
||||||
if [[ "$current_mode" == "standard" ]] && ! handbrake_is_self_built "$hb_path"; then
|
|
||||||
ok "HandBrakeCLI bereits installiert: $(HandBrakeCLI --version 2>&1 | head -1)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$current_mode" == "nvdec" ]]; then
|
|
||||||
warn "Umschalten von NVDEC-Build auf Standard-Installation."
|
|
||||||
fi
|
|
||||||
|
|
||||||
remove_selfbuilt_handbrake
|
|
||||||
remove_non_selfbuilt_handbrake
|
|
||||||
|
|
||||||
info "Installiere HandBrakeCLI aus den Standard-Repositories..."
|
|
||||||
apt_update
|
|
||||||
apt-get install -y handbrake-cli >/dev/null
|
|
||||||
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
|
|
||||||
ln -sf "$(command -v handbrake-cli)" /usr/local/bin/HandBrakeCLI
|
|
||||||
hash -r 2>/dev/null || true
|
|
||||||
ok "HandBrakeCLI Alias angelegt auf: $(command -v handbrake-cli)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
fatal "HandBrake wurde installiert, aber kein CLI-Befehl wurde gefunden."
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- apt-Hilfsfunktionen ------------------------------------------------------
|
|
||||||
|
|
||||||
apt_update() {
|
|
||||||
local output
|
|
||||||
if output=$(apt-get update 2>&1); then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if echo "$output" | grep -q "no longer has a Release file\|does not have a Release file"; then
|
|
||||||
warn "apt-Sources fehlerhaft. Versuche Reparatur..."
|
|
||||||
|
|
||||||
if apt-get update --allow-releaseinfo-change -qq 2>/dev/null; then
|
|
||||||
ok "apt-Update mit --allow-releaseinfo-change erfolgreich"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${VERSION_CODENAME:-}" ]]; then
|
|
||||||
warn "Schreibe minimale sources.list für $VERSION_CODENAME..."
|
|
||||||
local main_list=/etc/apt/sources.list
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Systemabhängigkeiten -----------------------------------------------------
|
|
||||||
header "Systemabhängigkeiten installieren"
|
|
||||||
|
|
||||||
info "Paketlisten aktualisieren..."
|
|
||||||
apt_update
|
|
||||||
|
|
||||||
info "Installiere Basispakete..."
|
|
||||||
apt-get install -y \
|
|
||||||
curl wget git \
|
|
||||||
mediainfo \
|
|
||||||
util-linux udev \
|
|
||||||
ca-certificates gnupg \
|
|
||||||
lsb-release
|
|
||||||
|
|
||||||
ok "Basispakete installiert"
|
|
||||||
|
|
||||||
# Node.js
|
|
||||||
install_node
|
|
||||||
|
|
||||||
# MakeMKV
|
|
||||||
if [[ "$SKIP_MAKEMKV" == false ]]; then
|
|
||||||
install_makemkv
|
|
||||||
else
|
|
||||||
warn "MakeMKV-Installation übersprungen (--no-makemkv)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# HandBrake
|
|
||||||
if [[ "$SKIP_HANDBRAKE" == false ]]; then
|
|
||||||
install_handbrake
|
|
||||||
else
|
|
||||||
warn "HandBrake-Installation übersprungen (--no-handbrake)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Nginx
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Optisches Laufwerk: Benutzer zur cdrom/optical-Gruppe hinzufügen
|
|
||||||
for grp in cdrom optical disk; 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
|
|
||||||
|
|
||||||
# --- Dateien kopieren ---------------------------------------------------------
|
|
||||||
header "Ripster-Dateien installieren"
|
|
||||||
|
|
||||||
if [[ -d "$INSTALL_DIR" && "$REINSTALL" == false ]]; then
|
|
||||||
fatal "Verzeichnis $INSTALL_DIR existiert bereits.\nVerwende --reinstall um zu überschreiben (Daten bleiben erhalten)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Bei Reinstall: Daten sichern
|
|
||||||
if [[ -d "$INSTALL_DIR/backend/data" ]]; then
|
|
||||||
info "Sichere vorhandene Datenbank..."
|
|
||||||
cp -a "$INSTALL_DIR/backend/data" "/tmp/ripster-data-backup-$(date +%Y%m%d%H%M%S)"
|
|
||||||
ok "Datenbank gesichert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Kopiere Quellen nach $INSTALL_DIR..."
|
|
||||||
mkdir -p "$INSTALL_DIR"
|
|
||||||
|
|
||||||
rsync -a --delete \
|
|
||||||
--exclude='.git' \
|
|
||||||
--exclude='node_modules' \
|
|
||||||
--exclude='backend/node_modules' \
|
|
||||||
--exclude='frontend/node_modules' \
|
|
||||||
--exclude='backend/data' \
|
|
||||||
--exclude='backend/logs' \
|
|
||||||
--exclude='frontend/dist' \
|
|
||||||
--exclude='*.sh' \
|
|
||||||
--exclude='deploy-ripster.sh' \
|
|
||||||
--exclude='debug/' \
|
|
||||||
--exclude='site/' \
|
|
||||||
--exclude='docs/' \
|
|
||||||
"$SOURCE_DIR/" "$INSTALL_DIR/"
|
|
||||||
|
|
||||||
# Datenbank-/Log-Verzeichnisse anlegen
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/data"
|
|
||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
|
||||||
|
|
||||||
# Bei Reinstall: Daten wiederherstellen
|
|
||||||
if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
|
|
||||||
cp -a /tmp/ripster-data-backup-*/ "$INSTALL_DIR/backend/data/" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
ok "Dateien kopiert"
|
|
||||||
|
|
||||||
# --- npm-Abhängigkeiten installieren -----------------------------------------
|
|
||||||
header "npm-Abhängigkeiten installieren"
|
|
||||||
|
|
||||||
info "Installiere Root-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR" --omit=dev --silent
|
|
||||||
|
|
||||||
info "Installiere Backend-Abhängigkeiten..."
|
|
||||||
npm install --prefix "$INSTALL_DIR/backend" --omit=dev --silent
|
|
||||||
|
|
||||||
info "Installiere 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" == false ]]; then
|
|
||||||
warn "Backend .env existiert bereits – wird nicht überschrieben"
|
|
||||||
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}
|
|
||||||
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"
|
|
||||||
|
|
||||||
# MakeMKV erwartet pro Benutzer ein eigenes Konfigurationsverzeichnis.
|
|
||||||
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
|
|
||||||
Documentation=https://github.com/your-repo/ripster
|
|
||||||
After=network.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[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
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=3
|
|
||||||
|
|
||||||
# Umgebung
|
|
||||||
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
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=ripster-backend
|
|
||||||
|
|
||||||
# Sicherheit
|
|
||||||
# Für Skriptausführung via GUI (inkl. optionalem sudo in User-Skripten)
|
|
||||||
# darf no_new_privileges nicht aktiv sein.
|
|
||||||
NoNewPrivileges=false
|
|
||||||
ProtectSystem=full
|
|
||||||
ProtectHome=read-only
|
|
||||||
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"
|
|
||||||
|
|
||||||
cat > /etc/nginx/sites-available/ripster <<EOF
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name ${FRONTEND_HOST} _;
|
|
||||||
|
|
||||||
# Frontend (statische Dateien)
|
|
||||||
root ${INSTALL_DIR}/frontend/dist;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# SPA: alle unbekannten Pfade → index.html
|
|
||||||
location / {
|
|
||||||
try_files \$uri \$uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API → Backend
|
|
||||||
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 300s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSocket → Backend
|
|
||||||
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
|
|
||||||
|
|
||||||
# Alte Default-Seite deaktivieren, Ripster aktivieren
|
|
||||||
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!"
|
|
||||||
|
|
||||||
ok "nginx konfiguriert"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Dienste aktivieren und 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 ""
|
|
||||||
|
|
||||||
# Warnungen zu fehlenden Tools
|
|
||||||
missing_tools=()
|
|
||||||
command_exists makemkvcon || missing_tools+=("makemkvcon")
|
|
||||||
command_exists HandBrakeCLI || missing_tools+=("HandBrakeCLI")
|
|
||||||
command_exists mediainfo || missing_tools+=("mediainfo")
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
761
install.sh
761
install.sh
@@ -1,761 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 \
|
|
||||||
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/cd"
|
|
||||||
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_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
|
||||||
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/logs"
|
|
||||||
chmod -R 775 \
|
|
||||||
"$INSTALL_DIR/backend/data/output" \
|
|
||||||
"$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
|
|
||||||
|
|
||||||
[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
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=3
|
|
||||||
|
|
||||||
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
|
|
||||||
ProtectHome=read-only
|
|
||||||
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"
|
|
||||||
|
|
||||||
cat > /etc/nginx/sites-available/ripster <<EOF
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name ${FRONTEND_HOST} _;
|
|
||||||
|
|
||||||
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 300s;
|
|
||||||
proxy_connect_timeout 10s;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "0.9.1-4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
"dev:frontend": "npm run dev --prefix frontend",
|
"dev:frontend": "npm run dev --prefix frontend",
|
||||||
"start": "npm run start --prefix backend",
|
"start": "npm run start --prefix backend",
|
||||||
"build:frontend": "npm run build --prefix frontend"
|
"build:frontend": "npm run build --prefix frontend",
|
||||||
|
"release:interactive": "bash ./scripts/release.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
|
|||||||
154
setup.sh
154
setup.sh
@@ -1,154 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
REPO_OWNER="Mboehmlaender"
|
|
||||||
REPO_NAME="ripster"
|
|
||||||
REPO_RAW_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
|
|
||||||
BRANCHES_API_URL="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/branches?per_page=100"
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<'EOF'
|
|
||||||
Verwendung:
|
|
||||||
bash setup.sh [Optionen]
|
|
||||||
|
|
||||||
Optionen (wie install.sh):
|
|
||||||
--branch <branch> Branch direkt setzen (ohne Auswahlmenue)
|
|
||||||
--dir <pfad> Installationsverzeichnis
|
|
||||||
--user <benutzer> Systembenutzer fuer den Dienst
|
|
||||||
--port <port> Backend-Port
|
|
||||||
--host <hostname> Hostname/IP fuer die Weboberflaeche
|
|
||||||
--no-makemkv MakeMKV-Installation ueberspringen
|
|
||||||
--no-handbrake HandBrake-Installation ueberspringen
|
|
||||||
--no-nginx Nginx-Einrichtung ueberspringen
|
|
||||||
--reinstall Vorhandene Installation aktualisieren
|
|
||||||
-h, --help Hilfe anzeigen
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
SELECTED_BRANCH=""
|
|
||||||
FORWARDED_ARGS=()
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case "$1" in
|
|
||||||
--branch)
|
|
||||||
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer --branch" >&2; exit 1; }
|
|
||||||
SELECTED_BRANCH="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--dir|--user|--port|--host)
|
|
||||||
[[ $# -ge 2 ]] || { echo "Fehlender Wert fuer $1" >&2; exit 1; }
|
|
||||||
FORWARDED_ARGS+=("$1" "$2")
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--no-makemkv|--no-handbrake|--no-nginx|--reinstall)
|
|
||||||
FORWARDED_ARGS+=("$1")
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unbekannter Parameter: $1" >&2
|
|
||||||
usage >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
fetch_url() {
|
|
||||||
local url="$1"
|
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
|
||||||
curl -fsSL "$url"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v wget >/dev/null 2>&1; then
|
|
||||||
wget -qO- "$url"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Weder curl noch wget gefunden. Bitte eines davon installieren." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
download_file() {
|
|
||||||
local url="$1"
|
|
||||||
local target="$2"
|
|
||||||
fetch_url "$url" > "$target"
|
|
||||||
}
|
|
||||||
|
|
||||||
select_branch() {
|
|
||||||
local branches_json
|
|
||||||
local -a branches
|
|
||||||
local selection
|
|
||||||
|
|
||||||
branches_json="$(fetch_url "$BRANCHES_API_URL")"
|
|
||||||
mapfile -t branches < <(
|
|
||||||
printf '%s\n' "$branches_json" \
|
|
||||||
| grep -oE '"name"[[:space:]]*:[[:space:]]*"[^"]+"' \
|
|
||||||
| sed -E 's/"name"[[:space:]]*:[[:space:]]*"([^"]+)"/\1/'
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ ${#branches[@]} -eq 0 ]]; then
|
|
||||||
echo "Keine Branches gefunden oder API-Antwort ungültig." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$SELECTED_BRANCH" ]]; then
|
|
||||||
local found=false
|
|
||||||
for branch in "${branches[@]}"; do
|
|
||||||
if [[ "$branch" == "$SELECTED_BRANCH" ]]; then
|
|
||||||
found=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [[ "$found" == false ]]; then
|
|
||||||
echo "Branch '$SELECTED_BRANCH' nicht gefunden." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -t 0 ]]; then
|
|
||||||
echo "Kein interaktives Terminal für die Branch-Auswahl verfügbar." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Verfügbare Branches:"
|
|
||||||
for i in "${!branches[@]}"; do
|
|
||||||
printf " %2d) %s\n" "$((i + 1))" "${branches[$i]}"
|
|
||||||
done
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
read -r -p "Bitte Branch auswählen [1-${#branches[@]}]: " selection
|
|
||||||
if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#branches[@]} )); then
|
|
||||||
SELECTED_BRANCH="${branches[$((selection - 1))]}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "Ungültige Auswahl."
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
TMP_DIR="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
|
||||||
|
|
||||||
select_branch
|
|
||||||
|
|
||||||
INSTALL_SCRIPT="${TMP_DIR}/install.sh"
|
|
||||||
INSTALL_URL="${REPO_RAW_BASE}/${SELECTED_BRANCH}/install.sh"
|
|
||||||
|
|
||||||
echo "Lade install.sh aus Branch '${SELECTED_BRANCH}'..."
|
|
||||||
download_file "$INSTALL_URL" "$INSTALL_SCRIPT"
|
|
||||||
chmod +x "$INSTALL_SCRIPT"
|
|
||||||
|
|
||||||
if [[ $EUID -eq 0 ]]; then
|
|
||||||
bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
|
|
||||||
else
|
|
||||||
if ! command -v sudo >/dev/null 2>&1; then
|
|
||||||
echo "sudo nicht gefunden. Bitte als root ausführen." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sudo bash "$INSTALL_SCRIPT" --branch "$SELECTED_BRANCH" "${FORWARDED_ARGS[@]}"
|
|
||||||
fi
|
|
||||||
Reference in New Issue
Block a user