0.12.0-19 Final Build
Deploy Docs to GitHub Pages / Build Documentation (push) Has been cancelled
Deploy Docs to GitHub Pages / Deploy to GitHub Pages (push) Has been cancelled

This commit is contained in:
2026-04-07 19:12:40 +00:00
parent ccad7ec9f4
commit 43702b0138
39 changed files with 2829 additions and 460 deletions
+379 -28
View File
@@ -85,6 +85,11 @@ const FORCED_SUBTITLE_SIZE_RATIO_THRESHOLD = 0.35;
const FORCED_SUBTITLE_MAX_EVENT_COUNT = 220;
const FORCED_SUBTITLE_MIN_EVENT_GAP = 12;
const FORCED_SUBTITLE_MIN_SIZE_GAP_BYTES = 64 * 1024;
const CONVERTER_SUPPORTED_SCAN_EXTENSIONS = Object.freeze([
'mkv', 'mp4', 'm2ts', 'iso', 'avi', 'mov',
'flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus'
]);
const CONVERTER_SUPPORTED_SCAN_EXTENSION_SET = new Set(CONVERTER_SUPPORTED_SCAN_EXTENSIONS);
function nowIso() {
return new Date().toISOString();
@@ -108,6 +113,50 @@ function normalizePositiveInteger(value) {
return Math.trunc(parsed);
}
function parseConverterScanExtensions(rawValue) {
const raw = String(rawValue || '').trim();
if (!raw) {
return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS];
}
const seen = new Set();
const parsed = raw
.split(',')
.map((item) => String(item || '').trim().toLowerCase())
.filter((item) => {
if (!item || !CONVERTER_SUPPORTED_SCAN_EXTENSION_SET.has(item) || seen.has(item)) {
return false;
}
seen.add(item);
return true;
});
if (parsed.length === 0) {
return [...CONVERTER_SUPPORTED_SCAN_EXTENSIONS];
}
return parsed;
}
function getFileExtensionWithoutDot(fileName) {
const ext = path.extname(String(fileName || '')).trim().toLowerCase();
if (!ext.startsWith('.')) {
return '';
}
return ext.slice(1);
}
function cleanupTempUploads(files = []) {
for (const file of Array.isArray(files) ? files : []) {
const tempPath = String(file?.path || '').trim();
if (!tempPath || !fs.existsSync(tempPath)) {
continue;
}
try {
fs.unlinkSync(tempPath);
} catch (_error) {
// Best-effort cleanup only.
}
}
}
function normalizeCdTrackPositionList(values = []) {
const source = Array.isArray(values) ? values : [];
const seen = new Set();
@@ -356,6 +405,84 @@ function normalizeMediaProfile(value) {
return null;
}
function normalizeJobKind(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
if (raw === 'audiobook' || raw === 'cd' || raw === 'dvd' || raw === 'bluray') {
return raw;
}
if (raw === 'converter_audio' || raw === 'converter_video' || raw === 'converter_iso') {
return raw;
}
return null;
}
function resolveConverterJobKind(converterMediaType = null) {
const normalized = String(converterMediaType || '').trim().toLowerCase();
if (normalized === 'audio') {
return 'converter_audio';
}
if (normalized === 'iso') {
return 'converter_iso';
}
return 'converter_video';
}
function resolveJobKindForMediaProfile(mediaProfile, options = {}) {
const explicit = normalizeJobKind(options?.jobKind);
if (explicit) {
return explicit;
}
const normalizedProfile = normalizeMediaProfile(mediaProfile);
if (!normalizedProfile) {
return null;
}
if (normalizedProfile === 'converter') {
return resolveConverterJobKind(options?.converterMediaType);
}
if (normalizedProfile === 'audiobook' || normalizedProfile === 'cd' || normalizedProfile === 'dvd' || normalizedProfile === 'bluray') {
return normalizedProfile;
}
return null;
}
function inferMediaProfileFromJobKind(rawJobKind) {
const normalized = normalizeJobKind(rawJobKind);
if (normalized === 'converter_audio' || normalized === 'converter_video' || normalized === 'converter_iso') {
return 'converter';
}
if (normalized === 'audiobook' || normalized === 'cd' || normalized === 'dvd' || normalized === 'bluray') {
return normalized;
}
const legacy = String(rawJobKind || '').trim().toLowerCase();
if (legacy === 'converter') {
return 'converter';
}
return null;
}
function isConverterJobRecord(job = null, encodePlan = null) {
const profileFromJobKind = inferMediaProfileFromJobKind(job?.job_kind);
if (profileFromJobKind === 'converter') {
return true;
}
const plan = encodePlan && typeof encodePlan === 'object'
? encodePlan
: null;
const profileFromPlanJobKind = inferMediaProfileFromJobKind(plan?.jobKind);
if (profileFromPlanJobKind === 'converter') {
return true;
}
const mediaType = String(job?.media_type || '').trim().toLowerCase();
if (mediaType === 'converter') {
return true;
}
const planProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
return planProfile === 'converter';
}
function isSpecificMediaProfile(value) {
return value === 'bluray' || value === 'dvd' || value === 'cd' || value === 'audiobook' || value === 'converter';
}
@@ -5580,6 +5707,7 @@ class PipelineService extends EventEmitter {
FROM jobs
WHERE raw_path IS NOT NULL AND TRIM(raw_path) <> ''
AND (media_type IS NULL OR media_type <> 'converter')
AND (job_kind IS NULL OR job_kind NOT LIKE 'converter_%')
`);
if (!Array.isArray(rows) || rows.length === 0) {
return;
@@ -5751,7 +5879,7 @@ class PipelineService extends EventEmitter {
});
}
// Always start with a clean dashboard/session snapshot after server restart.
// Always start with a clean ripper/session snapshot after server restart.
const hasContextKeys = this.snapshot.context
&& typeof this.snapshot.context === 'object'
&& Object.keys(this.snapshot.context).length > 0;
@@ -5946,7 +6074,7 @@ class PipelineService extends EventEmitter {
this.cdDrives.set(devicePath, next);
// If a drive is detached from a job (or reassigned), clear stale live
// progress for the previous job so dashboards do not stay in CD_ENCODING.
// progress for the previous job so ripper views do not stay in CD_ENCODING.
const previousJobId = Number(existing?.jobId || 0);
const nextJobId = Number(next?.jobId || 0);
if (Number.isFinite(previousJobId) && previousJobId > 0) {
@@ -8008,6 +8136,14 @@ class PipelineService extends EventEmitter {
const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
? options.encodePlan
: null;
const profileFromJobKind = inferMediaProfileFromJobKind(
options?.jobKind
|| job?.job_kind
|| encodePlan?.jobKind
);
if (profileFromJobKind) {
return profileFromJobKind;
}
const profileFromPlan = pickSpecificProfile(encodePlan?.mediaProfile);
if (profileFromPlan) {
return profileFromPlan;
@@ -8067,6 +8203,28 @@ class PipelineService extends EventEmitter {
return 'other';
}
resolveJobKindForJob(job, options = {}) {
const explicitKind = normalizeJobKind(options?.jobKind);
if (explicitKind) {
return explicitKind;
}
const encodePlan = options?.encodePlan && typeof options.encodePlan === 'object'
? options.encodePlan
: this.safeParseJson(job?.encode_plan_json);
const kindFromPlan = normalizeJobKind(encodePlan?.jobKind);
if (kindFromPlan) {
return kindFromPlan;
}
const kindFromJob = normalizeJobKind(job?.job_kind);
if (kindFromJob) {
return kindFromJob;
}
const mediaProfile = this.resolveMediaProfileForJob(job, { ...options, encodePlan });
return resolveJobKindForMediaProfile(mediaProfile, {
converterMediaType: options?.converterMediaType || encodePlan?.converterMediaType || null
});
}
async getEffectiveSettingsForJob(job, options = {}) {
const mediaProfile = this.resolveMediaProfileForJob(job, options);
const settings = await settingsService.getEffectiveSettingsMap(mediaProfile);
@@ -8401,7 +8559,8 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: device.path,
status: 'METADATA_SELECTION',
detectedTitle
detectedTitle,
jobKind: resolveJobKindForMediaProfile(mediaProfile)
});
try {
@@ -10971,6 +11130,7 @@ class PipelineService extends EventEmitter {
const refreshedPlan = {
...(sourceEncodePlan && typeof sourceEncodePlan === 'object' ? sourceEncodePlan : {}),
mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook',
encodeInputPath: rawInput.path
};
@@ -10996,6 +11156,8 @@ class PipelineService extends EventEmitter {
await historyService.resetProcessLog(sourceJobId);
await historyService.updateJob(sourceJobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START',
last_state: 'READY_TO_START',
start_time: null,
@@ -13832,7 +13994,10 @@ class PipelineService extends EventEmitter {
const retryJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null,
status: isCdRetry ? 'CD_READY_TO_RIP' : (isAudiobookRetry ? 'READY_TO_START' : 'RIPPING'),
detectedTitle: sourceJob.detected_title || sourceJob.title || null
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
jobKind: this.resolveJobKindForJob(sourceJob, {
encodePlan: sourceEncodePlan
})
});
const retryJobId = Number(retryJob?.id || 0);
if (!Number.isFinite(retryJobId) || retryJobId <= 0) {
@@ -14044,7 +14209,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
jobId,
'USER_ACTION',
'READY_TO_ENCODE Job nach Neustart ins Dashboard geladen.'
'READY_TO_ENCODE Job nach Neustart in den Ripper geladen.'
);
if (
@@ -14232,7 +14397,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({
discDevice: job.disc_device || null,
status: 'READY_TO_ENCODE',
detectedTitle: job.detected_title || job.title || null
detectedTitle: job.detected_title || job.title || null,
jobKind: this.resolveJobKindForJob(job, {
encodePlan: restartPlan
})
});
const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -14562,7 +14730,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null,
status: 'MEDIAINFO_CHECK',
detectedTitle: sourceJob.detected_title || sourceJob.title || null
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
jobKind: this.resolveJobKindForJob(sourceJob, {
encodePlan: previousEncodePlan
})
});
const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -14817,7 +14988,7 @@ class PipelineService extends EventEmitter {
const cancelMessage = 'Vom Benutzer abgebrochen.';
await historyService.updateJob(normalizedJobId, {
status: 'CANCELLED',
// Preserve origin state so dashboard/history can distinguish review-cancelled rows.
// Preserve origin state so ripper/history can distinguish review-cancelled rows.
last_state: runningStatus,
end_time: nowIso(),
error_message: cancelMessage
@@ -15378,7 +15549,7 @@ class PipelineService extends EventEmitter {
}
} else {
// No drive mapping available (e.g. orphan/skipRip import path).
// Ensure stale live progress does not keep the job "running" in the dashboard.
// Ensure stale live progress does not keep the job "running" in the ripper.
this.jobProgress.delete(Number(jobId));
}
} else {
@@ -15451,7 +15622,9 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: null,
status: 'ANALYZING',
detectedTitle
detectedTitle,
mediaType: 'audiobook',
jobKind: 'audiobook'
});
let stagedRawDir = null;
@@ -15641,6 +15814,7 @@ class PipelineService extends EventEmitter {
const encodePlan = {
mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook',
sourceType: 'upload',
uploadedAt: nowIso(),
@@ -15654,6 +15828,8 @@ class PipelineService extends EventEmitter {
};
await historyService.updateJob(job.id, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START',
last_state: 'READY_TO_START',
title: resolvedMetadata.title || detectedTitle,
@@ -15704,6 +15880,8 @@ class PipelineService extends EventEmitter {
error: errorToMeta(error)
});
const updatePayload = {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'ERROR',
last_state: 'ERROR',
end_time: nowIso(),
@@ -15796,6 +15974,7 @@ class PipelineService extends EventEmitter {
const nextEncodePlan = {
...(encodePlan && typeof encodePlan === 'object' ? encodePlan : {}),
mediaProfile: 'audiobook',
jobKind: 'audiobook',
mode: 'audiobook',
format,
formatOptions,
@@ -15804,6 +15983,8 @@ class PipelineService extends EventEmitter {
};
await historyService.updateJob(normalizedJobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'READY_TO_START',
last_state: 'READY_TO_START',
title: resolvedMetadata.title || job.title || job.detected_title || 'Audiobook',
@@ -15957,6 +16138,8 @@ class PipelineService extends EventEmitter {
});
await historyService.updateJob(jobId, {
media_type: 'audiobook',
job_kind: 'audiobook',
status: 'ENCODING',
last_state: 'ENCODING',
start_time: nowIso(),
@@ -16602,7 +16785,8 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: devicePath,
status: 'CD_METADATA_SELECTION',
detectedTitle
detectedTitle,
jobKind: 'cd'
});
try {
@@ -17059,7 +17243,10 @@ class PipelineService extends EventEmitter {
const replacementJob = await historyService.createJob({
discDevice: sourceJob.disc_device || null,
status: 'CD_READY_TO_RIP',
detectedTitle: sourceJob.detected_title || sourceJob.title || null
detectedTitle: sourceJob.detected_title || sourceJob.title || null,
jobKind: this.resolveJobKindForJob(sourceJob, {
encodePlan: this.safeParseJson(sourceJob.encode_plan_json)
}) || 'cd'
});
const replacementJobId = Number(replacementJob?.id || 0);
if (!Number.isFinite(replacementJobId) || replacementJobId <= 0) {
@@ -18044,6 +18231,47 @@ class PipelineService extends EventEmitter {
// CONVERTER
// ═══════════════════════════════════════════════════════════════════════════
/**
* Zentraler Intake für dateibasierte Jobs.
* Bestehende Endpunkte bleiben erhalten und delegieren intern auf diese Methode.
*
* @param {object} payload
* @param {'audiobook_upload'|'audiobook'|'converter_entry'|'converter'} payload.kind
* @param {object} [payload.file]
* @param {string} [payload.relPath]
* @param {object} [payload.options]
* @returns {Promise<object>}
*/
async createFileJob(payload = {}) {
const inferredKind = String(payload?.kind || '').trim().toLowerCase()
|| (payload?.file ? 'audiobook_upload' : '')
|| (payload?.relPath ? 'converter_entry' : '');
const kind = inferredKind;
if (kind === 'audiobook_upload' || kind === 'audiobook') {
if (!payload?.file) {
const error = new Error('Upload-Datei fehlt.');
error.statusCode = 400;
throw error;
}
return this.uploadAudiobookFile(payload.file, payload?.options || {});
}
if (kind === 'converter_entry' || kind === 'converter') {
const relPath = String(payload?.relPath || '').trim();
if (!relPath) {
const error = new Error('relPath fehlt.');
error.statusCode = 400;
throw error;
}
return this.createConverterJobFromEntry(relPath, payload?.options || {});
}
const error = new Error(`Unbekannter File-Job-Typ: ${kind || 'n/a'}`);
error.statusCode = 400;
throw error;
}
/**
* Erstellt einen Converter-Job aus einem Scan-Eintrag (File-Explorer-Auswahl).
*
@@ -18100,21 +18328,30 @@ class PipelineService extends EventEmitter {
const isVideoLikeConverterJob = converterMediaType === 'video' || converterMediaType === 'iso';
const initialStatus = 'READY_TO_START';
const normalizedConverterMediaType = ['audio', 'video', 'iso'].includes(
String(converterMediaType || '').trim().toLowerCase()
)
? String(converterMediaType || '').trim().toLowerCase()
: 'video';
const job = await historyService.createJob({
discDevice: null,
status: initialStatus,
detectedTitle
detectedTitle,
mediaType: 'converter',
jobKind: resolveConverterJobKind(normalizedConverterMediaType)
});
await historyService.updateJob(job.id, {
media_type: 'converter',
job_kind: resolveConverterJobKind(normalizedConverterMediaType),
raw_path: fullPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
converterMediaType,
jobKind: resolveConverterJobKind(normalizedConverterMediaType),
converterMediaType: normalizedConverterMediaType,
inputPath: fullPath,
isFolder: isDirectory,
audioFiles: isDirectory && converterMediaType === 'audio'
audioFiles: isDirectory && normalizedConverterMediaType === 'audio'
? require('./converterScanService').detectFormat
? null // wird beim Start gefüllt
: null
@@ -18129,7 +18366,7 @@ class PipelineService extends EventEmitter {
await historyService.appendLog(
job.id,
'SYSTEM',
`Converter-Job erstellt aus: ${relPath} | Typ: ${converterMediaType || '-'}`
`Converter-Job erstellt aus: ${relPath} | Typ: ${normalizedConverterMediaType || '-'}`
);
if (isVideoLikeConverterJob) {
await historyService.appendLog(
@@ -18140,7 +18377,7 @@ class PipelineService extends EventEmitter {
}
logger.info('converter:job:created-from-entry', {
jobId: job.id, relPath, converterMediaType, fullPath
jobId: job.id, relPath, converterMediaType: normalizedConverterMediaType, fullPath
});
return historyService.getJobById(job.id);
@@ -18172,6 +18409,30 @@ class PipelineService extends EventEmitter {
throw error;
}
const settings = await settingsService.getSettingsMap();
const allowedExtensions = new Set(parseConverterScanExtensions(settings?.converter_scan_extensions));
const invalidFiles = [];
for (const file of normalizedFiles) {
const tempPath = String(file?.path || '').trim();
const originalName = String(file?.originalname || file?.originalName || '').trim()
|| path.basename(tempPath || 'upload');
const extension = getFileExtensionWithoutDot(originalName);
if (!extension || !allowedExtensions.has(extension)) {
invalidFiles.push(originalName);
}
}
if (invalidFiles.length > 0) {
cleanupTempUploads(normalizedFiles);
const allowedList = [...allowedExtensions].join(', ');
const preview = invalidFiles.slice(0, 6).join(', ');
const suffix = invalidFiles.length > 6 ? ` (+${invalidFiles.length - 6} weitere)` : '';
const error = new Error(
`Upload abgelehnt: Nicht erlaubte Dateiendung in ${invalidFiles.length} Datei(en): ${preview}${suffix}. Erlaubt: ${allowedList}`
);
error.statusCode = 400;
throw error;
}
const folderName = options?.folderName ? String(options.folderName).trim() : null;
if (folderName) {
@@ -18246,7 +18507,7 @@ class PipelineService extends EventEmitter {
* @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 AUDIO_EXTS = new Set(['.flac', '.mp3', '.wav', '.m4a', '.ogg', '.opus']);
const converterScanService = require('./converterScanService');
const rawDir = await converterScanService.getRawDir();
@@ -18272,7 +18533,11 @@ class PipelineService extends EventEmitter {
// Nicht-Audio (Video, Ordner, sonstige): immer ein Job pro Eintrag
for (const relPath of nonAudioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, {});
const job = await this.createFileJob({
kind: 'converter_entry',
relPath,
options: {}
});
createdJobs.push(job);
}
@@ -18286,14 +18551,18 @@ class PipelineService extends EventEmitter {
const job = await historyService.createJob({
discDevice: null,
status: 'READY_TO_START',
detectedTitle
detectedTitle,
mediaType: 'converter',
jobKind: 'converter_audio'
});
await historyService.updateJob(job.id, {
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: rawPath,
encode_plan_json: JSON.stringify({
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
inputPath: rawPath,
inputPaths: fullPaths,
@@ -18318,7 +18587,11 @@ class PipelineService extends EventEmitter {
} else {
// Einzelne Jobs für jede Audio-Datei
for (const relPath of audioRelPaths) {
const job = await this.createConverterJobFromEntry(relPath, { converterMediaType: 'audio' });
const job = await this.createFileJob({
kind: 'converter_entry',
relPath,
options: { converterMediaType: 'audio' }
});
createdJobs.push(job);
}
}
@@ -18367,7 +18640,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404;
throw error;
}
if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409;
throw error;
@@ -18481,6 +18754,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
isFolder: false,
isSharedAudio,
@@ -18494,6 +18768,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
@@ -18551,7 +18826,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404;
throw error;
}
if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409;
throw error;
@@ -18621,6 +18896,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
isFolder: false,
isSharedAudio,
@@ -18634,6 +18910,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
@@ -18718,6 +18995,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: 'converter_audio',
converterMediaType: 'audio',
isFolder: false,
isSharedAudio,
@@ -18731,6 +19009,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: 'converter_audio',
raw_path: isSharedAudio ? path.dirname(nextInputPaths[0]) : nextInputPaths[0],
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
@@ -18772,7 +19051,7 @@ class PipelineService extends EventEmitter {
error.statusCode = 404;
throw error;
}
if (String(job.media_type || '').trim().toLowerCase() !== 'converter') {
if (!isConverterJobRecord(job, this.safeParseJson(job.encode_plan_json))) {
const error = new Error(`Job ${normalizedJobId} ist kein Converter-Job.`);
error.statusCode = 409;
throw error;
@@ -18804,8 +19083,16 @@ class PipelineService extends EventEmitter {
: 'video';
const defaultFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
const outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20)
const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']);
let outputFormat = normalizeText(config.outputFormat || existingPlan.outputFormat || defaultFormat, 20)
?.toLowerCase() || defaultFormat;
if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) {
outputFormat = 'flac';
}
if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) {
outputFormat = 'mkv';
}
let userPreset = existingPlan.userPreset || null;
if (Object.prototype.hasOwnProperty.call(config, 'userPreset')) {
@@ -18905,6 +19192,7 @@ class PipelineService extends EventEmitter {
const nextPlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: resolveConverterJobKind(converterMediaType),
converterMediaType,
outputFormat,
userPreset,
@@ -18919,6 +19207,8 @@ class PipelineService extends EventEmitter {
const newCoverUrl = nextMetadata?.coverUrl || null;
const jobPatch = {
media_type: 'converter',
job_kind: resolveConverterJobKind(converterMediaType),
encode_plan_json: JSON.stringify(nextPlan),
encode_review_confirmed: 0,
error_message: null
@@ -19000,7 +19290,16 @@ class PipelineService extends EventEmitter {
let outputPath = null;
let outputDir = null;
let audioTrackTemplate = null;
const outputFormat = String(config.outputFormat || 'mkv').trim().toLowerCase();
const allowedAudioFormats = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
const allowedVideoFormats = new Set(['mkv', 'mp4', 'm4v']);
const defaultOutputFormat = converterMediaType === 'audio' ? 'flac' : 'mkv';
let outputFormat = String(config.outputFormat || existingPlan.outputFormat || defaultOutputFormat).trim().toLowerCase();
if (converterMediaType === 'audio' && !allowedAudioFormats.has(outputFormat)) {
outputFormat = 'flac';
}
if ((converterMediaType === 'video' || converterMediaType === 'iso') && !allowedVideoFormats.has(outputFormat)) {
outputFormat = 'mkv';
}
const baseName = sanitizeFileName(
path.basename(inputPath, path.extname(inputPath))
) || 'output';
@@ -19115,7 +19414,7 @@ class PipelineService extends EventEmitter {
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']);
const AUDIO_EXTS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
try {
audioFiles = readdirSync(inputPath)
.filter((f) => AUDIO_EXTS.has(path.extname(f).slice(1).toLowerCase()))
@@ -19147,6 +19446,7 @@ class PipelineService extends EventEmitter {
const nextEncodePlan = {
...existingPlan,
mediaProfile: 'converter',
jobKind: resolveConverterJobKind(converterMediaType),
converterMediaType,
inputPath,
inputPaths: existingPlan.inputPaths || null,
@@ -19181,6 +19481,7 @@ class PipelineService extends EventEmitter {
status: 'READY_TO_START',
last_state: 'READY_TO_START',
media_type: 'converter',
job_kind: resolveConverterJobKind(converterMediaType),
output_path: outputPath || outputDir || null,
encode_plan_json: JSON.stringify(nextEncodePlan),
encode_review_confirmed: converterMediaType === 'audio' ? 1 : 0,
@@ -19224,6 +19525,55 @@ class PipelineService extends EventEmitter {
};
}
/**
* Gibt alle Audiobook-Jobs zurück (für die Audiobooks-Seite).
*/
async getAudiobookJobs() {
const db = await require('../db/database').getDb();
const rows = await db.all(`
SELECT id, title, detected_title, year, poster_url,
status, last_state, media_type, job_kind,
encode_plan_json, handbrake_info_json, makemkv_info_json,
output_path, raw_path, error_message,
start_time, end_time, created_at, updated_at
FROM jobs
WHERE media_type = 'audiobook'
OR job_kind = 'audiobook'
OR (
(media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
AND (
encode_plan_json LIKE '%"mediaProfile":"audiobook"%'
OR encode_plan_json LIKE '%"mode":"audiobook"%'
)
)
ORDER BY created_at DESC
LIMIT 200
`);
return rows.map((row) => {
const encodePlan = this.safeParseJson(row.encode_plan_json);
const handbrakeInfo = this.safeParseJson(row.handbrake_info_json);
const makemkvInfo = this.safeParseJson(row.makemkv_info_json);
const rawMediaType = String(row?.media_type || '').trim().toLowerCase();
const planMode = String(encodePlan?.mode || '').trim().toLowerCase();
const planProfile = String(encodePlan?.mediaProfile || '').trim().toLowerCase();
const effectiveMediaType = rawMediaType === 'audiobook'
? 'audiobook'
: (
planProfile === 'audiobook' || planMode === 'audiobook'
? 'audiobook'
: (rawMediaType || null)
);
return {
...row,
media_type: effectiveMediaType,
encodePlan,
handbrakeInfo,
makemkvInfo
};
});
}
/**
* Gibt alle Converter-Jobs zurück (für die Converter-Seite).
*/
@@ -19231,11 +19581,12 @@ class PipelineService extends EventEmitter {
const db = await require('../db/database').getDb();
const rows = await db.all(`
SELECT id, title, detected_title, year, imdb_id, poster_url,
status, last_state, media_type, encode_review_confirmed,
status, last_state, media_type, job_kind, encode_review_confirmed,
encode_plan_json, output_path, raw_path, encode_input_path, error_message,
start_time, end_time, created_at, updated_at
FROM jobs
WHERE media_type = 'converter'
OR job_kind LIKE 'converter_%'
OR (
(media_type IS NULL OR TRIM(media_type) = '' OR media_type = 'other')
AND (