0.12.0-19 Final Build
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user