0.12.0-15 Fix DVD Review
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user