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
+3 -5
View File
@@ -7,11 +7,11 @@ const { spawnTrackedProcess } = require('../services/processRunner');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
const ISO_EXTENSIONS = new Set(['iso']);
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'aac', 'opus', 'wav', 'ogg']);
const AUDIO_OUTPUT_FORMATS = new Set(['flac', 'mp3', 'opus', 'wav', 'ogg']);
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
/**
@@ -62,8 +62,6 @@ function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {
} else {
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
}
} else if (format === 'aac') {
args.push('-codec:a', 'aac', '-b:a', `${Number(opts.aacBitrate ?? 256)}k`);
} else if (format === 'opus') {
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
} else if (format === 'ogg') {
+6 -2
View File
@@ -90,8 +90,12 @@ router.post(
for (const entry of entries) {
const relPath = String(entry?.relPath || '').trim();
if (!relPath) continue;
const job = await pipelineService.createConverterJobFromEntry(relPath, {
converterMediaType: entry?.converterMediaType || null
const job = await pipelineService.createFileJob({
kind: 'converter_entry',
relPath,
options: {
converterMediaType: entry?.converterMediaType || null
}
});
jobs.push(job);
}
+121 -3
View File
@@ -1,4 +1,5 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
@@ -7,8 +8,10 @@ const pipelineService = require('../services/pipelineService');
const historyService = require('../services/historyService');
const diskDetectionService = require('../services/diskDetectionService');
const hardwareMonitorService = require('../services/hardwareMonitorService');
const settingsService = require('../services/settingsService');
const logger = require('../services/logger').child('PIPELINE_ROUTE');
const activationBytesService = require('../services/activationBytesService');
const { defaultAudiobookDir } = require('../config');
const { getDb } = require('../db/database');
const router = express.Router();
@@ -16,6 +19,78 @@ const audiobookUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-audiobook-uploads')
});
const AUDIOBOOK_TREE_MAX_DEPTH = 8;
function buildAudiobookOutputTree(rootDir, relPath = '', depth = 0) {
if (depth >= AUDIOBOOK_TREE_MAX_DEPTH) {
return [];
}
const absDir = relPath ? path.join(rootDir, relPath) : rootDir;
let dirents = [];
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_error) {
return [];
}
dirents.sort((left, right) => {
const leftOrder = left.isDirectory() ? 0 : 1;
const rightOrder = right.isDirectory() ? 0 : 1;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
if (dirent.name.startsWith('.')) {
continue;
}
const childRelPath = relPath ? `${relPath}/${dirent.name}` : dirent.name;
const childAbsPath = path.join(rootDir, childRelPath);
if (dirent.isDirectory()) {
const children = buildAudiobookOutputTree(rootDir, childRelPath, depth + 1);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
let mtime = null;
try {
mtime = fs.statSync(childAbsPath).mtime.toISOString();
} catch (_error) {
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'folder',
path: childRelPath,
size,
mtime,
children
});
continue;
}
if (dirent.isFile()) {
let size = 0;
let mtime = null;
try {
const stat = fs.statSync(childAbsPath);
size = Number(stat?.size || 0);
mtime = stat?.mtime ? stat.mtime.toISOString() : null;
} catch (_error) {
size = 0;
mtime = null;
}
nodes.push({
name: dirent.name,
type: 'file',
path: childRelPath,
size,
mtime
});
}
}
return nodes;
}
router.get(
'/state',
asyncHandler(async (req, res) => {
@@ -175,9 +250,13 @@ router.post(
mimeType: String(req.file.mimetype || '').trim() || null,
tempPath: String(req.file.path || '').trim() || null
});
const result = await pipelineService.uploadAudiobookFile(req.file, {
format: req.body?.format,
startImmediately: req.body?.startImmediately
const result = await pipelineService.createFileJob({
kind: 'audiobook_upload',
file: req.file,
options: {
format: req.body?.format,
startImmediately: req.body?.startImmediately
}
});
res.json({ result });
})
@@ -220,6 +299,45 @@ router.post(
})
);
router.get(
'/audiobook/jobs',
asyncHandler(async (_req, res) => {
const jobs = await pipelineService.getAudiobookJobs();
res.json({ jobs });
})
);
router.get(
'/audiobook/output-tree',
asyncHandler(async (_req, res) => {
const settings = await settingsService.getEffectiveSettingsMap('audiobook');
const configuredOutputDir = String(settings?.movie_dir || '').trim();
const rawOutputDir = configuredOutputDir || defaultAudiobookDir || null;
const outputDir = rawOutputDir
? (path.isAbsolute(rawOutputDir) ? rawOutputDir : path.resolve(process.cwd(), rawOutputDir))
: null;
if (!outputDir || !fs.existsSync(outputDir)) {
res.json({ outputDir, tree: null });
return;
}
const children = buildAudiobookOutputTree(outputDir, '', 0);
const size = children.reduce((sum, item) => sum + Number(item?.size || 0), 0);
res.json({
outputDir,
tree: {
name: path.basename(outputDir) || 'audiobooks',
type: 'folder',
path: '',
size,
mtime: null,
children
}
});
})
);
router.post(
'/select-metadata',
asyncHandler(async (req, res) => {
+12 -8
View File
@@ -10,9 +10,10 @@ const {
defaultConverterRawDir
} = require('../config');
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov', 'm4v', 'wmv', 'ts']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'aac', 'wav', 'm4a', 'ogg', 'opus', 'wma', 'ape']);
const VIDEO_EXTENSIONS = new Set(['mkv', 'mp4', 'm2ts', 'avi', 'mov']);
const AUDIO_EXTENSIONS = new Set(['flac', 'mp3', 'wav', 'm4a', 'ogg', 'opus']);
const ISO_EXTENSIONS = new Set(['iso']);
const SUPPORTED_SCAN_EXTENSIONS = new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
let _pollingTimer = null;
let _pollingEnabled = false;
@@ -33,13 +34,16 @@ function detectFormat(fileName) {
function getConfiguredExtensions(settings) {
const raw = String(settings?.converter_scan_extensions || '').trim();
if (!raw) {
return new Set([...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...ISO_EXTENSIONS]);
return new Set(SUPPORTED_SCAN_EXTENSIONS);
}
return new Set(
raw.split(',')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean)
);
const configured = raw.split(',')
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean);
const filtered = configured.filter((ext) => SUPPORTED_SCAN_EXTENSIONS.has(ext));
if (filtered.length === 0) {
return new Set(SUPPORTED_SCAN_EXTENSIONS);
}
return new Set(filtered);
}
function getFileSize(fullPath) {
+71 -5
View File
@@ -306,6 +306,35 @@ function normalizeMediaTypeValue(value) {
return null;
}
function normalizeJobKindValue(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 inferMediaTypeFromJobKind(value) {
const normalized = normalizeJobKindValue(value);
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(value || '').trim().toLowerCase();
if (legacy === 'converter') {
return 'converter';
}
return null;
}
function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeInfo = null) {
const mkInfo = parseInfoFromValue(makemkvInfo, null);
const miInfo = parseInfoFromValue(mediainfoInfo, null);
@@ -326,8 +355,21 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
return profileHint;
}
const profileFromJobKind = inferMediaTypeFromJobKind(
job?.job_kind
|| plan?.jobKind
|| mkInfo?.jobKind
|| mkInfo?.analyzeContext?.jobKind
|| miInfo?.jobKind
|| hbInfo?.jobKind
);
if (profileFromJobKind) {
return profileFromJobKind;
}
// Converter jobs: detected via media_type field (plan.mediaProfile = 'converter')
const rawMediaType = normalizeMediaTypeValue(job?.media_type);
const rawMediaTypeLegacy = String(job?.media_type || '').trim().toLowerCase();
const rawPlanProfile = String(plan?.mediaProfile || '').trim().toLowerCase();
const converterMediaTypeHint = String(plan?.converterMediaType || '').trim().toLowerCase();
const hasConverterPathHint = (value) => {
@@ -343,6 +385,7 @@ function inferMediaType(job, makemkvInfo, mediainfoInfo, encodePlan, handbrakeIn
if (
rawPlanProfile === 'converter'
|| rawMediaType === 'converter'
|| rawMediaTypeLegacy === 'converter'
|| converterMediaTypeHint === 'video'
|| converterMediaTypeHint === 'audio'
|| converterMediaTypeHint === 'iso'
@@ -1897,22 +1940,39 @@ function isFilesystemRootPath(inputPath) {
}
class HistoryService {
async createJob({ discDevice = null, status = 'ANALYZING', detectedTitle = null }) {
async createJob({
discDevice = null,
status = 'ANALYZING',
detectedTitle = null,
mediaType = null,
jobKind = null
}) {
const db = await getDb();
const startTime = new Date().toISOString();
const normalizedMediaType = (() => {
const normalized = normalizeMediaTypeValue(mediaType);
if (normalized) {
return normalized;
}
const legacy = String(mediaType || '').trim().toLowerCase();
return legacy === 'converter' ? 'converter' : null;
})();
const normalizedJobKind = normalizeJobKindValue(jobKind);
const result = await db.run(
`
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
INSERT INTO jobs (disc_device, status, start_time, detected_title, last_state, media_type, job_kind, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[discDevice, status, startTime, detectedTitle, status]
[discDevice, status, startTime, detectedTitle, status, normalizedMediaType, normalizedJobKind]
);
logger.info('job:created', {
jobId: result.lastID,
discDevice,
status,
detectedTitle
detectedTitle,
mediaType: normalizedMediaType,
jobKind: normalizedJobKind
});
return this.getJobById(result.lastID);
@@ -2635,6 +2695,7 @@ class HistoryService {
const rowMediaProfile = normalizeMediaTypeValue(
makemkvInfo?.mediaProfile
|| makemkvInfo?.analyzeContext?.mediaProfile
|| inferMediaTypeFromJobKind(row?.job_kind)
|| row?.media_type
);
const posterCandidate = String(
@@ -2764,6 +2825,7 @@ class HistoryService {
const mediaProfile = normalizeMediaTypeValue(
makemkvInfo?.mediaProfile
|| makemkvInfo?.analyzeContext?.mediaProfile
|| inferMediaTypeFromJobKind(job?.job_kind)
|| job?.media_type
);
if (String(makemkvInfo?.source || '').trim().toLowerCase() !== 'orphan_raw_import' || mediaProfile !== 'cd') {
@@ -3616,6 +3678,10 @@ class HistoryService {
|| ''
).trim() || null;
await this.updateJob(created.id, {
...(detectedMediaType === 'audiobook' ? { job_kind: 'audiobook', media_type: 'audiobook' } : {}),
...(detectedMediaType === 'cd' ? { job_kind: 'cd' } : {}),
...(detectedMediaType === 'dvd' ? { job_kind: 'dvd' } : {}),
...(detectedMediaType === 'bluray' ? { job_kind: 'bluray' } : {}),
status: 'FINISHED',
last_state: 'FINISHED',
title: omdbById?.title
+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 (