0.12.0-15 Fix DVD Review

This commit is contained in:
2026-03-24 07:05:45 +00:00
parent 3e3f3231be
commit 76b020d5c0
30 changed files with 5148 additions and 44 deletions
+742 -16
View File
@@ -19,7 +19,7 @@ const notificationService = require('./notificationService');
const logger = require('./logger').child('PIPELINE');
const { spawnTrackedProcess } = require('./processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, renderTemplate, findMediaFiles } = require('../utils/files');
const { ensureDir, sanitizeFileName, sanitizeFileNameWithExtension, renderTemplate, findMediaFiles } = require('../utils/files');
const { buildMediainfoReview } = require('../utils/encodePlan');
const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/playlistAnalysis');
const { errorToMeta } = require('../utils/errorMeta');
@@ -340,11 +340,14 @@ function normalizeMediaProfile(value) {
if (raw === 'audiobook' || raw === 'audio_book' || raw === 'audio book' || raw === 'book') {
return 'audiobook';
}
if (raw === 'converter') {
return 'converter';
}
return null;
}
function isSpecificMediaProfile(value) {
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook';
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter';
}
function inferMediaProfileFromFsTypeAndModel(rawFsType, rawModel) {
@@ -2653,7 +2656,7 @@ function resolveRawFolderStateFromOptions(options = {}) {
function buildRawDirName(metadataBase, jobId, options = {}) {
const state = resolveRawFolderStateFromOptions(options);
const baseName = sanitizeFileName(`${metadataBase} - RAW - job-${jobId}`);
return sanitizeFileName(applyRawFolderStateToName(baseName, state));
return applyRawFolderStateToName(baseName, state);
}
function buildRawPathForState(rawPath, state) {
@@ -3987,11 +3990,13 @@ class PipelineService extends EventEmitter {
const { DVDPlugin } = require('../plugins/DVDPlugin');
const { CdPlugin } = require('../plugins/CdPlugin');
const { AudiobookPlugin } = require('../plugins/AudiobookPlugin');
const { ConverterPlugin } = require('../plugins/ConverterPlugin');
const plugins = [
new BluRayPlugin(),
new DVDPlugin(),
new CdPlugin(),
new AudiobookPlugin()
new AudiobookPlugin(),
new ConverterPlugin()
];
for (const plugin of plugins) {
if (!registry.getPlugin(plugin.id)) {
@@ -6087,15 +6092,20 @@ class PipelineService extends EventEmitter {
try {
while (this.queueEntries.length > 0) {
// Get current running counts and limits
const [filmRunning, cdRunning, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
historyService.getRunningFilmEncodeJobs().then((r) => r.length),
historyService.getRunningCdEncodeJobs().then((r) => r.length),
const [allRunningJobs, maxFilm, maxCd, maxTotal, cdBypass] = await Promise.all([
historyService.getRunningJobs(),
this.getMaxParallelJobs(),
this.getMaxParallelCdEncodes(),
this.getMaxTotalEncodes(),
this.getCdBypassesQueue()
]);
const filmRunning = allRunningJobs.filter((j) => j.status === 'ENCODING').length;
const cdRunning = allRunningJobs.filter((j) => ['CD_RIPPING', 'CD_ENCODING'].includes(j.status)).length;
const totalRunning = filmRunning + cdRunning;
// Counts all actively processing jobs (excludes waiting states like READY_TO_ENCODE).
const anyActiveJobs = allRunningJobs.filter(
(j) => !['READY_TO_ENCODE', 'CD_READY_TO_RIP'].includes(j.status)
).length;
// Find next startable entry
let entryIndex = -1;
@@ -6104,10 +6114,10 @@ class PipelineService extends EventEmitter {
const isNonJob = candidate.type && candidate.type !== 'job';
if (isNonJob) {
// Non-job entries (script, chain, wait) only start when no jobs are running.
// Use both DB count and activeProcesses to cover all running states
// (ANALYZING, RIPPING, MEDIAINFO_CHECK etc. are not reflected in totalRunning).
if (totalRunning === 0 && this.activeProcesses.size === 0) {
// Non-job entries (script, chain, wait) only start when no jobs are actively running.
// anyActiveJobs covers all active states (ANALYZING, RIPPING, ENCODING, MEDIAINFO_CHECK etc.)
// activeProcesses provides a second safety net for race conditions during state transitions.
if (anyActiveJobs === 0 && this.activeProcesses.size === 0) {
entryIndex = i;
}
break; // FIFO: stop scanning regardless (non-job blocks everything behind it)
@@ -8654,6 +8664,9 @@ class PipelineService extends EventEmitter {
if (preloadedMediaProfile === 'audiobook') {
return this.startAudiobookEncode(jobId, { ...options, preloadedJob });
}
if (preloadedMediaProfile === 'converter') {
return this.startConverterEncode(jobId, { ...options, preloadedJob });
}
const isReadyToEncode = preloadedJob.status === 'READY_TO_ENCODE' || preloadedJob.last_state === 'READY_TO_ENCODE';
if (isReadyToEncode) {
@@ -8711,6 +8724,9 @@ class PipelineService extends EventEmitter {
if (jobMediaProfile === 'audiobook') {
return this.startAudiobookEncode(jobId, { ...options, immediate: true, preloadedJob: job });
}
if (jobMediaProfile === 'converter') {
return this.startConverterEncode(jobId, { ...options, immediate: true, preloadedJob: job });
}
this.ensureNotBusy('startPreparedJob', jobId);
logger.info('startPreparedJob:requested', { jobId });
@@ -9878,6 +9894,7 @@ class PipelineService extends EventEmitter {
let dvdHandBrakeScanJson = null;
let dvdHandBrakeScanInputPath = null;
let dvdSelectedTitleInfo = null; // set when HandBrake scan succeeds; used to skip re-selection
let dvdScannedCandidates = null; // set when scan yields multiple MIN_LENGTH candidates needing user selection
if (isDvdSource && mediaFiles.length > 0) {
// For 'dvd': scan the folder that contains VIDEO_TS (parent of the VIDEO_TS dir).
@@ -9911,9 +9928,26 @@ class PipelineService extends EventEmitter {
dvdHandBrakeScanJson = parseMediainfoJsonOutput(dvdScanLines.join('\n'));
if (dvdHandBrakeScanJson) {
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson);
const minLengthMinutes = Number(settings?.makemkv_min_length_minutes || 0);
const minLengthSeconds = Math.max(0, Math.round(minLengthMinutes * 60));
const allDvdTitles = parseHandBrakeTitleList(dvdHandBrakeScanJson);
const filteredDvdCandidates = minLengthSeconds > 0
? allDvdTitles.filter((t) => t.durationSeconds >= minLengthSeconds)
: allDvdTitles;
await historyService.appendLog(
jobId,
'SYSTEM',
`HandBrake DVD-Scan: ${allDvdTitles.length} Titel gesamt, ${filteredDvdCandidates.length}${minLengthMinutes} min.`
);
// For synthetic mediaInfo: prefer the single MIN_LENGTH candidate; otherwise fall back to
// the best available title (longest / main feature) as a placeholder for the review.
const preferredTitleId = filteredDvdCandidates.length === 1
? filteredDvdCandidates[0].handBrakeTitleId
: null;
const titleInfo = parseHandBrakeSelectedTitleInfo(dvdHandBrakeScanJson, {
handBrakeTitleId: preferredTitleId || undefined
});
if (titleInfo) {
dvdSelectedTitleInfo = titleInfo;
const syntheticMediaInfo = buildSyntheticMediaInfoFromMakeMkvTitle(titleInfo);
mediaInfoByPath[dvdHandBrakeScanInputPath] = syntheticMediaInfo;
effectiveMediaFiles = [{ path: dvdHandBrakeScanInputPath, size: 0 }];
@@ -9924,6 +9958,16 @@ class PipelineService extends EventEmitter {
'SYSTEM',
`HandBrake DVD-Scan: Audio=${audioCount}, Subtitle=${subtitleCount}, Dauer=${formatDurationClock(titleInfo.durationSeconds)}`
);
if (filteredDvdCandidates.length === 1) {
// Exactly one title passes MIN_LENGTH → auto-select it.
dvdSelectedTitleInfo = titleInfo;
} else if (filteredDvdCandidates.length > 1) {
// Multiple candidates → user must choose; store for downstream selection dialog.
dvdScannedCandidates = filteredDvdCandidates;
} else {
// No title passes MIN_LENGTH filter → auto-select best available (degraded mode).
dvdSelectedTitleInfo = titleInfo;
}
} else {
const error = new Error('HandBrake DVD-Scan: Kein Titel erkannt.');
error.runInfo = dvdScanRunInfo;
@@ -10092,9 +10136,8 @@ class PipelineService extends EventEmitter {
`HandBrake Titel-Auswahl (manuell) übernommen: -t ${preSelectedHandBrakeTitleId}`
);
} else if (dvdSelectedTitleInfo) {
// HandBrake already identified the valid title during the DVD analysis scan above.
// Use it directly — no need to re-parse all titles from TitleList, which would
// include short/invalid titles and incorrectly trigger manual title selection.
// Exactly one title passed the MIN_LENGTH filter (or fallback to best available) —
// auto-select it without requiring user input.
enrichedReview = {
...enrichedReview,
handBrakeTitleId: dvdSelectedTitleInfo.handBrakeTitleId,
@@ -10105,6 +10148,77 @@ class PipelineService extends EventEmitter {
'SYSTEM',
`HandBrake DVD Titel automatisch gewählt (aus Scan): -t ${dvdSelectedTitleInfo.handBrakeTitleId}`
);
} else if (dvdScannedCandidates && dvdScannedCandidates.length > 0) {
// Multiple titles passed the MIN_LENGTH filter during the initial DVD scan —
// present them to the user without running a second HandBrake scan.
const candidatesData = dvdScannedCandidates.map((t) => ({
handBrakeTitleId: t.handBrakeTitleId,
durationSeconds: t.durationSeconds,
durationMinutes: Number((t.durationSeconds / 60).toFixed(1)),
audioTrackCount: t.audioTrackCount,
subtitleTrackCount: t.subtitleTrackCount,
sizeBytes: t.sizeBytes || 0
}));
enrichedReview = {
...enrichedReview,
handBrakeTitleDecisionRequired: true,
handBrakeTitleCandidates: candidatesData,
handBrakeDvdInputPath: dvdScanInputPath,
encodeInputPath: null
};
const waitingMediainfoInfo = this.withPluginExecutionMeta({
generatedAt: nowIso(),
files: mediaInfoRuns
}, reviewPluginExecution);
await historyService.updateJob(jobId, {
status: 'WAITING_FOR_USER_DECISION',
last_state: 'WAITING_FOR_USER_DECISION',
error_message: null,
mediainfo_info_json: JSON.stringify(waitingMediainfoInfo),
encode_plan_json: JSON.stringify(enrichedReview),
encode_input_path: null,
encode_review_confirmed: 0
});
await historyService.appendLog(
jobId,
'SYSTEM',
`HandBrake DVD Titelauswahl erforderlich: ${candidatesData.length} Kandidaten. Nutzer muss Titel wählen.`
);
const dvdWaitingStatusText = `DVD Titelauswahl: ${candidatesData.length} Kandidaten gefunden`;
const dvdWaitingContextPatch = {
jobId,
rawPath,
handBrakeTitleDecisionRequired: true,
handBrakeTitleCandidates: candidatesData,
handBrakeDvdInputPath: dvdScanInputPath,
reviewConfirmed: false,
mode: options.mode || 'rip',
mediaProfile,
sourceJobId: options.sourceJobId || null,
mediaInfoReview: enrichedReview,
selectedMetadata
};
if (this.isPrimaryJob(jobId)) {
await this.setState('WAITING_FOR_USER_DECISION', {
activeJobId: jobId,
progress: 0,
eta: null,
statusText: dvdWaitingStatusText,
context: {
...dvdWaitingContextPatch,
...(reviewPluginExecution ? { pluginExecution: this.mergePluginExecutionState(mkInfo?.pluginExecution, reviewPluginExecution) } : {})
}
});
} else {
await this.updateProgress('WAITING_FOR_USER_DECISION', 0, null, dvdWaitingStatusText, jobId, {
contextPatch: dvdWaitingContextPatch
});
}
void this.notifyPushover('metadata_ready', {
title: 'Ripster - DVD Titelauswahl',
message: `Job #${jobId}: ${candidatesData.length} DVD-Titel gefunden, manuelle Auswahl erforderlich`
});
return enrichedReview;
} else {
// No pre-scanned result available — run a fresh HandBrake scan for title selection.
let dvdTitleScanJson = null;
@@ -14185,6 +14299,209 @@ class PipelineService extends EventEmitter {
return { started: true, stage: 'ENCODING', outputPath: incompleteOutputPath };
}
// ── Converter Pipeline ───────────────────────────────────────────────────────
async startConverterEncode(jobId, options = {}) {
const immediate = Boolean(options?.immediate);
if (!immediate) {
return this.enqueueOrStartAction(
QUEUE_ACTIONS.START_PREPARED,
jobId,
() => this.startConverterEncode(jobId, { ...options, immediate: true })
);
}
this.ensureNotBusy('startConverterEncode', jobId);
logger.info('converter:encode:start', { jobId });
this.cancelRequestedByJob.delete(Number(jobId));
const job = options?.preloadedJob || await historyService.getJobById(jobId);
if (!job) {
const error = new Error(`Job ${jobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const encodePlan = this.safeParseJson(job.encode_plan_json) || {};
const inputPath = encodePlan.inputPath || job.raw_path;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`Converter-Eingabedatei nicht gefunden: ${inputPath}`);
error.statusCode = 404;
throw error;
}
const outputPath = encodePlan.outputPath || null;
const outputDir = encodePlan.outputDir || null;
await historyService.resetProcessLog(jobId);
await this.setState('ANALYZING', {
activeJobId: jobId,
progress: 0,
eta: null,
statusText: 'Converter: Analyse läuft …',
context: {
jobId,
mode: 'converter',
mediaProfile: 'converter',
inputPath,
converterMediaType: encodePlan.converterMediaType || 'video'
}
});
await historyService.updateJob(jobId, {
status: 'ANALYZING',
last_state: 'ANALYZING',
start_time: nowIso(),
end_time: null,
error_message: null
});
void (async () => {
try {
const { ConverterPlugin } = require('../plugins/ConverterPlugin');
const converterPlugin = new ConverterPlugin();
const analyzeCtx = await this.buildPluginContext('converter', {
jobId,
filePath: inputPath,
encodePlan,
emitProgress: (pct, statusText) => {
void this.updateProgress('ANALYZING', pct, null, statusText, jobId);
}
});
await converterPlugin.analyze(inputPath, job, analyzeCtx);
if (this.cancelRequestedByJob.has(Number(jobId))) {
const cancelErr = new Error('Job wurde vom Benutzer abgebrochen.');
cancelErr.runInfo = { status: 'CANCELLED' };
throw cancelErr;
}
await this.setState('ENCODING', {
activeJobId: jobId,
progress: 0,
eta: null,
statusText: 'Converter: Encoding läuft …',
context: {
jobId,
mode: 'converter',
mediaProfile: 'converter',
inputPath,
outputPath: outputPath || outputDir,
converterMediaType: encodePlan.converterMediaType || 'video'
}
});
await historyService.updateJob(jobId, {
status: 'ENCODING',
last_state: 'ENCODING'
});
await historyService.appendLog(
jobId,
'SYSTEM',
`Converter-Encoding gestartet: ${path.basename(inputPath)}${encodePlan.converterMediaType || 'video'} / ${encodePlan.outputFormat || '?'}`
);
void this.notifyPushover('encoding_started', {
title: 'Ripster - Converter gestartet',
message: `${job.title || job.detected_title || `Job #${jobId}`}${outputPath || outputDir || '?'}`
});
const encodeCtx = await this.buildPluginContext('converter', {
jobId,
inputPath,
outputPath,
outputDir,
encodePlan,
isCancelled: () => this.cancelRequestedByJob.has(Number(jobId)),
onProcessHandle: (handle) => {
if (handle) {
this.activeProcesses.set(Number(jobId), handle);
}
},
emitProgress: (pct, statusText) => {
void this.updateProgress('ENCODING', pct, null, statusText, jobId);
}
});
await converterPlugin.encode(job, encodeCtx);
this.activeProcesses.delete(Number(jobId));
const finalOutputPath = outputPath || outputDir || null;
// Eigentümer der Ausgabedateien setzen
if (finalOutputPath) {
const converterSettings = await settingsService.getSettingsMap();
const converterMediaType = encodePlan.converterMediaType || 'video';
const outputOwner = converterMediaType === 'audio'
? String(converterSettings?.converter_audio_dir_owner || converterSettings?.converter_movie_dir_owner || '').trim()
: String(converterSettings?.converter_movie_dir_owner || '').trim();
if (outputOwner) {
chownRecursive(finalOutputPath, outputOwner);
}
}
await historyService.updateJob(jobId, {
status: 'FINISHED',
last_state: 'FINISHED',
end_time: nowIso(),
rip_successful: 1,
output_path: finalOutputPath,
error_message: null
});
await historyService.appendLog(
jobId,
'SYSTEM',
`Converter-Encoding abgeschlossen: ${finalOutputPath}`
);
if (Number(this.snapshot.activeJobId) === Number(jobId)) {
await this.setState('FINISHED', {
activeJobId: jobId,
progress: 100,
eta: null,
statusText: 'Converter abgeschlossen',
context: {
jobId,
mode: 'converter',
mediaProfile: 'converter',
outputPath: finalOutputPath
}
});
} else {
void this.pumpQueue();
}
void this.notifyPushover('job_finished', {
title: 'Ripster - Converter abgeschlossen',
message: `${job.title || job.detected_title || `Job #${jobId}`}${finalOutputPath}`
});
setTimeout(async () => {
if (this.snapshot.state === 'FINISHED' && this.snapshot.activeJobId === jobId) {
await this.setState('IDLE', {
finishingJobId: jobId,
activeJobId: null,
progress: 0,
eta: null,
statusText: 'Bereit',
context: {}
});
}
}, 3000);
} catch (error) {
this.activeProcesses.delete(Number(jobId));
logger.error('converter:encode:failed', { jobId, error: errorToMeta(error) });
await this.failJob(jobId, 'ENCODING', error);
}
})();
return { started: true, stage: 'ANALYZING', outputPath: outputPath || outputDir };
}
// ── CD Pipeline ─────────────────────────────────────────────────────────────
async analyzeCd(device, options = {}) {
@@ -15634,6 +15951,415 @@ class PipelineService extends EventEmitter {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CONVERTER
// ═══════════════════════════════════════════════════════════════════════════
/**
* Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl).
*
* @param {string} relPath - Relativer Pfad innerhalb von converter_raw_dir
* @param {object} options
* @param {string} options.converterMediaType - 'video'|'audio'|'iso'
* @param {boolean} options.isFolder - Ordner-Job?
* @returns {Promise<object>} Job-Objekt
*/
async createConverterJobFromEntry(relPath, options = {}) {
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
if (!rawDir) {
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
const fullPath = path.join(rawDir, relPath);
if (!fs.existsSync(fullPath)) {
const error = new Error(`Eingabedatei nicht gefunden: ${fullPath}`);
error.statusCode = 404;
throw error;
}
const stat = fs.statSync(fullPath);
const isDirectory = stat.isDirectory();
const baseName = path.basename(relPath, path.extname(relPath));
const detectedTitle = baseName || 'Converter Job';
const converterMediaType = options.converterMediaType
|| converterScanService.detectMediaType(path.basename(relPath));
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
detectedTitle
});
await historyService.updateJob(job.id, {
media_type: 'converter',
raw_path: fullPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
converterMediaType,
inputPath: fullPath,
isFolder: isDirectory,
audioFiles: isDirectory && converterMediaType === 'audio'
? require('./converterScanService').detectFormat
? null // wird beim Start gefüllt
: null
: null
}),
last_state: 'READY_TO_START'
});
// Scan-Eintrag als Job markieren
await converterScanService.markEntryAsJob(relPath, job.id);
await historyService.appendLog(
job.id,
'SYSTEM',
`Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType}`
);
logger.info('converter:job:created-from-entry', {
jobId: job.id, relPath, converterMediaType, fullPath
});
return historyService.getJobById(job.id);
}
/**
* Lädt Converter-Dateien hoch und legt Unterordner in converter_raw_dir an.
* Erstellt KEINE Jobs das geschieht separat via createConverterJobsFromSelection.
*
* @param {Array<{path, originalname, size}>} files - Multer-Dateiobjekte
* @returns {Promise<{folders: Array<{folderRelPath, fileCount}>}>}
*/
async uploadConverterFiles(files, options = {}) {
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
if (!rawDir) {
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
ensureDir(rawDir);
const normalizedFiles = Array.isArray(files) ? files : [files];
if (normalizedFiles.length === 0) {
const error = new Error('Keine Dateien zum Upload.');
error.statusCode = 400;
throw error;
}
const folderName = options?.folderName ? String(options.folderName).trim() : null;
if (folderName) {
// ── Ordner-Upload (wie Klangkiste target_path): alle Dateien in EINEN Ordner ──
const safeFolderName = sanitizeFileName(folderName) || `upload_${Date.now()}`;
const uniqueFolderName = `${safeFolderName}_${Date.now()}`;
const targetDir = path.join(rawDir, uniqueFolderName);
ensureDir(targetDir);
for (const file of normalizedFiles) {
const tempPath = String(file?.path || '').trim();
if (!tempPath || !fs.existsSync(tempPath)) continue;
const rawName = String(file?.originalname || '').trim();
const safeFileName = sanitizeFileNameWithExtension(rawName) || rawName || path.basename(tempPath);
const targetFile = path.join(targetDir, safeFileName);
// Traversal-Schutz
if (!targetFile.startsWith(targetDir + path.sep)) {
logger.warn('converter:upload:traversal-blocked', { rawName });
continue;
}
moveFileWithFallback(tempPath, targetFile);
}
logger.info('converter:upload:folder-placed', {
folderName, targetDir, fileCount: normalizedFiles.length
});
return { folders: [{ folderRelPath: uniqueFolderName, fileCount: normalizedFiles.length }] };
}
// ── Einzeldatei-Upload ─────────────────────────────────────────────────
const createdFolders = [];
for (const file of normalizedFiles) {
const tempPath = String(file?.path || '').trim();
const originalName = String(file?.originalname || file?.originalName || '').trim()
|| path.basename(tempPath || 'upload');
if (!tempPath || !fs.existsSync(tempPath)) {
logger.warn('converter:upload:temp-missing', { originalName, tempPath });
continue;
}
const baseNameClean = sanitizeFileName(
path.basename(originalName, path.extname(originalName))
) || `upload_${Date.now()}`;
const subfolder = `${baseNameClean}_${Date.now()}`;
const targetDir = path.join(rawDir, subfolder);
ensureDir(targetDir);
const safeFileName = sanitizeFileNameWithExtension(originalName) || originalName;
const targetFile = path.join(targetDir, safeFileName);
moveFileWithFallback(tempPath, targetFile);
logger.info('converter:upload:file-placed', { originalName, targetFile });
createdFolders.push({ folderRelPath: subfolder, fileCount: 1 });
}
return { folders: createdFolders };
}
/**
* Erstellt Converter-Jobs aus ausgewählten Dateipfaden (relativ zu converter_raw_dir).
* Audio-Dateien können als einzelne Jobs oder als ein gemeinsamer Job angelegt werden.
*
* @param {string[]} relPaths - Dateipfade relativ zum rawDir
* @param {'individual'|'shared'} audioMode - Modus für Audio-Dateien
* @returns {Promise<object[]>} Erstellte Jobs
*/
async createConverterJobsFromSelection(relPaths, audioMode = 'individual') {
const AUDIO_EXTS = new Set(['.flac', '.mp3', '.aac', '.wav', '.m4a', '.ogg', '.opus', '.wma', '.ape']);
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
if (!rawDir) {
const error = new Error('Converter Raw-Ordner nicht konfiguriert.');
error.statusCode = 400;
throw error;
}
const audioRelPaths = [];
const nonAudioRelPaths = [];
for (const relPath of relPaths) {
const ext = path.extname(String(relPath || '')).toLowerCase();
if (AUDIO_EXTS.has(ext)) {
audioRelPaths.push(relPath);
} else {
nonAudioRelPaths.push(relPath);
}
}
const createdJobs = [];
// Nicht-Audio (Video, ISO, Ordner): immer ein Job pro Eintrag
for (const relPath of nonAudioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, {});
createdJobs.push(job);
}
if (audioMode === 'shared' && audioRelPaths.length > 0) {
// Ein gemeinsamer Job für alle Audio-Dateien
const fullPaths = audioRelPaths.map((p) => path.join(rawDir, p));
const rawPath = path.dirname(fullPaths[0]);
const detectedTitle = path.basename(rawPath) || 'Converter Audio Job';
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
detectedTitle
});
await historyService.updateJob(job.id, {
media_type: 'converter',
raw_path: rawPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
converterMediaType: 'audio',
inputPath: rawPath,
inputPaths: fullPaths,
isFolder: false,
isSharedAudio: true
}),
last_state: 'READY_TO_START'
});
await historyService.appendLog(
job.id, 'SYSTEM',
`Converter-Job (gemeinsam) erstellt: ${audioRelPaths.length} Audio-Datei(en)`
);
logger.info('converter:job:created-shared-audio', {
jobId: job.id, fileCount: audioRelPaths.length
});
createdJobs.push(await historyService.getJobById(job.id));
} else {
// Einzelne Jobs für jede Audio-Datei
for (const relPath of audioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, { converterMediaType: 'audio' });
createdJobs.push(job);
}
}
return createdJobs;
}
/**
* Startet einen Converter-Job mit der finalen Konfiguration (Preset, Format etc.).
*
* @param {number} jobId
* @param {object} config
* @param {string} config.converterMediaType - 'video'|'audio'|'iso'
* @param {string} config.outputFormat - Ausgabeformat (mkv, mp4, flac, mp3, ...)
* @param {object} [config.userPreset] - HandBrake User-Preset (Video)
* @param {object} [config.trackSelection] - Audio/Subtitle-Selektion (Video)
* @param {number} [config.handBrakeTitleId] - HandBrake Titel-ID (Video)
* @param {object} [config.audioFormatOptions] - Format-Optionen (Audio)
* @returns {Promise<object>}
*/
async startConverterJob(jobId, config = {}) {
const normalizedJobId = Number(jobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
const error = new Error('Ungültige Job-ID für Converter-Start.');
error.statusCode = 400;
throw error;
}
const job = await historyService.getJobById(normalizedJobId);
if (!job) {
const error = new Error(`Job ${normalizedJobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const existingPlan = this.safeParseJson(job.encode_plan_json) || {};
const converterMediaType = config.converterMediaType
|| existingPlan.converterMediaType
|| 'video';
const converterScanService = require('./converterScanService');
const settings = await settingsService.getSettingsMap();
const rawDir = String(
settings?.converter_raw_dir || require('../config').defaultConverterRawDir || ''
).trim();
const movieDir = String(
settings?.converter_movie_dir || require('../config').defaultConverterMovieDir || ''
).trim();
const audioDir = String(
settings?.converter_audio_dir || require('../config').defaultConverterAudioDir || ''
).trim();
const inputPath = existingPlan.inputPath || job.raw_path;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`Eingabedatei nicht gefunden: ${inputPath}`);
error.statusCode = 404;
throw error;
}
// Ausgabepfad bestimmen
let outputPath = null;
let outputDir = null;
const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase();
const baseName = sanitizeFileName(
path.basename(inputPath, path.extname(inputPath))
) || 'output';
const title = job.title || job.detected_title || baseName;
const safeTitle = sanitizeFileName(title) || baseName;
if (converterMediaType === 'video' || converterMediaType === 'iso') {
const videoTemplate = String(
settings?.converter_output_template_video || '{title}'
).replace('{title}', safeTitle);
outputPath = path.join(movieDir, `${videoTemplate}.${outputFormat}`);
} else if (converterMediaType === 'audio') {
const isFolder = Boolean(existingPlan.isFolder || config.isFolder);
if (isFolder) {
const audioTemplate = String(
settings?.converter_output_template_audio || '{artist} - {title}'
).replace('{title}', safeTitle).replace('{artist}', safeTitle);
outputDir = path.join(audioDir, sanitizeFileName(audioTemplate) || safeTitle);
} else {
const audioTemplate = String(
settings?.converter_output_template_audio || '{artist} - {title}'
).replace('{title}', safeTitle).replace('{artist}', safeTitle);
outputPath = path.join(audioDir, `${sanitizeFileName(audioTemplate) || safeTitle}.${outputFormat}`);
}
}
// Audiodateien im Ordner laden
let audioFiles = existingPlan.audioFiles;
if (converterMediaType === 'audio' && existingPlan.isFolder && !audioFiles) {
const { readdirSync } = fs;
const AUDIO_EXTS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
try {
audioFiles = readdirSync(inputPath)
.filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
.sort();
} catch (_err) {
audioFiles = [];
}
}
const nextEncodePlan = {
...existingPlan,
mediaProfile: 'converter',
converterMediaType,
inputPath,
outputPath: outputPath || null,
outputDir: outputDir || null,
outputFormat,
userPreset: config.userPreset || existingPlan.userPreset || null,
trackSelection: config.trackSelection || existingPlan.trackSelection || null,
handBrakeTitleId: config.handBrakeTitleId || existingPlan.handBrakeTitleId || null,
audioFormatOptions: config.audioFormatOptions || existingPlan.audioFormatOptions || {},
audioFiles: audioFiles || existingPlan.audioFiles || null,
reviewConfirmed: true
};
await historyService.updateJob(normalizedJobId, {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
output_path: outputPath || outputDir || null,
encode_plan_json: JSON.stringify(nextEncodePlan),
encode_review_confirmed: 1,
error_message: null,
end_time: null
});
await historyService.appendLog(
normalizedJobId,
'USER_ACTION',
`Converter konfiguriert: Typ ${converterMediaType} | Format ${outputFormat}`
);
const startResult = await this.startPreparedJob(normalizedJobId);
return {
jobId: normalizedJobId,
...(startResult && typeof startResult === 'object' ? startResult : {})
};
}
/**
* Gibt alle Converter-Jobs zurück (für die Converter-Seite).
*/
async getConverterJobs() {
const db = await require('../db/database').getDb();
const rows = await db.all(`
SELECT id, title, detected_title, status, last_state, media_type,
encode_plan_json, output_path, raw_path, error_message,
start_time, end_time, created_at, updated_at
FROM jobs
WHERE media_type = 'converter'
ORDER BY created_at DESC
LIMIT 200
`);
return rows.map((r) => ({
...r,
encodePlan: this.safeParseJson(r.encode_plan_json)
}));
}
}
module.exports = new PipelineService();