17 Commits

Author SHA1 Message Date
c3875803ff 0.9.1-4 Poster Fix 2026-03-14 09:40:54 +00:00
d75d9eb4c8 0.9.1-3 Metdata Fix 2026-03-14 09:38:20 +00:00
59bcb54492 0.9.1-2 Metadata Fix 2026-03-14 09:32:13 +00:00
241b097ea9 0.9.1-1 Metadata Assign 2026-03-14 09:13:22 +00:00
e140a9fa8c 0.9.1 Fix Restart 2026-03-14 08:57:25 +00:00
5580d3be98 chore: ignore local scripts 2026-03-14 08:48:43 +00:00
43dfdbf33e chore: remove local installer scripts from repo 2026-03-14 08:46:45 +00:00
24b63d390a 0.9.0-1 scripts 2026-03-14 08:42:46 +00:00
49fcca72af 0.9.0-1 Update 2026-03-14 08:34:22 +00:00
ba91f83722 0.9.0-1 Version Check 2026-03-14 08:07:49 +00:00
466e7a7a3d Remove gitea scripts from tracking 2026-03-13 22:30:46 +00:00
e67c0d316d kk 2026-03-13 22:27:59 +00:00
1da5ee3e34 Fix 2026-03-13 22:11:24 +00:00
4d377f3eb4 ignore 2026-03-13 18:43:08 +00:00
df708485b5 merge 2026-03-13 15:50:45 +00:00
b6cac5efb4 merge 2026-03-13 15:15:50 +00:00
f38081649f merge 2026-03-13 11:21:29 +00:00
28 changed files with 759 additions and 2027 deletions

11
.gitignore vendored
View File

@@ -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

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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() {

View File

@@ -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 });
}) })
); );

View File

@@ -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',

View File

@@ -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);

View File

@@ -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);

View File

@@ -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

View File

@@ -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,

View File

@@ -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);

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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">

View File

@@ -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',

View File

@@ -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([]);

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 ""

View File

@@ -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
View File

@@ -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"
} }

View File

@@ -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
View File

@@ -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