0.16.1-1 release snapshot

This commit is contained in:
2026-04-29 08:35:24 +00:00
parent 6115090da1
commit 901a3b1ad7
269 changed files with 127336 additions and 1 deletions
+752
View File
@@ -0,0 +1,752 @@
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
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();
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) => {
logger.debug('get:state', { reqId: req.reqId });
res.json({
pipeline: pipelineService.getSnapshot(),
hardwareMonitoring: hardwareMonitorService.getSnapshot()
});
})
);
router.post(
'/analyze',
asyncHandler(async (req, res) => {
const devicePath = String(req.body?.devicePath || '').trim() || null;
logger.info('post:analyze', { reqId: req.reqId, devicePath });
const result = await pipelineService.analyzeDisc(devicePath);
res.json({ result });
})
);
router.get(
'/cd/drives',
asyncHandler(async (req, res) => {
logger.debug('get:cd:drives', { reqId: req.reqId });
const snapshot = pipelineService.getSnapshot();
res.json({ cdDrives: snapshot.cdDrives || {} });
})
);
router.post(
'/rescan-disc',
asyncHandler(async (req, res) => {
logger.info('post:rescan-disc', { reqId: req.reqId });
const result = await diskDetectionService.rescanAndEmit();
res.json({ result });
})
);
router.post(
'/rescan-drive',
asyncHandler(async (req, res) => {
const devicePath = String(req.body?.devicePath || '').trim();
logger.info('post:rescan-drive', { reqId: req.reqId, devicePath });
if (!devicePath) {
const err = new Error('devicePath ist erforderlich');
err.statusCode = 400;
throw err;
}
const result = await diskDetectionService.rescanDriveAndEmit(devicePath);
res.json({ result });
})
);
router.get(
'/omdb/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
logger.info('get:omdb:search', { reqId: req.reqId, query });
const results = await pipelineService.searchOmdb(String(query));
res.json({ results });
})
);
router.get(
'/tmdb/series/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
const seasonNumber = req.query.season || null;
logger.info('get:tmdb:series-search', { reqId: req.reqId, query, seasonNumber });
const results = await pipelineService.searchTmdbSeries(String(query), seasonNumber);
res.json({ results });
})
);
router.get(
'/cd/musicbrainz/search',
asyncHandler(async (req, res) => {
const query = req.query.q || '';
logger.info('get:cd:musicbrainz:search', { reqId: req.reqId, query });
const results = await pipelineService.searchMusicBrainz(String(query));
res.json({ results });
})
);
router.get(
'/cd/musicbrainz/release/:mbId',
asyncHandler(async (req, res) => {
const mbId = String(req.params.mbId || '').trim();
if (!mbId) {
const error = new Error('mbId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('get:cd:musicbrainz:release', { reqId: req.reqId, mbId });
const release = await pipelineService.getMusicBrainzReleaseById(mbId);
res.json({ release });
})
);
router.post(
'/cd/select-metadata',
asyncHandler(async (req, res) => {
const { jobId, title, artist, year, mbId, coverUrl, tracks } = req.body;
if (!jobId) {
const error = new Error('jobId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('post:cd:select-metadata', { reqId: req.reqId, jobId, title, artist, year, mbId });
const job = await pipelineService.selectCdMetadata({
jobId: Number(jobId),
title,
artist,
year,
mbId,
coverUrl,
tracks
});
res.json({ job });
})
);
router.post(
'/cd/start/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const ripConfig = req.body || {};
logger.info('post:cd:start', {
reqId: req.reqId,
jobId,
format: ripConfig.format,
selectedPreEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPreEncodeScriptIds)
? ripConfig.selectedPreEncodeScriptIds.length
: 0,
selectedPostEncodeScriptIdsCount: Array.isArray(ripConfig?.selectedPostEncodeScriptIds)
? ripConfig.selectedPostEncodeScriptIds.length
: 0,
selectedPreEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPreEncodeChainIds)
? ripConfig.selectedPreEncodeChainIds.length
: 0,
selectedPostEncodeChainIdsCount: Array.isArray(ripConfig?.selectedPostEncodeChainIds)
? ripConfig.selectedPostEncodeChainIds.length
: 0
});
const result = await pipelineService.enqueueOrStartCdAction(
jobId,
ripConfig,
() => pipelineService.startCdRip(jobId, ripConfig)
);
res.json({ result });
})
);
router.post(
'/audiobook/upload',
audiobookUpload.single('file'),
asyncHandler(async (req, res) => {
if (!req.file) {
const error = new Error('Upload-Datei fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('post:audiobook:upload', {
reqId: req.reqId,
originalName: req.file.originalname,
sizeBytes: Number(req.file.size || 0),
mimeType: String(req.file.mimetype || '').trim() || null,
tempPath: String(req.file.path || '').trim() || null
});
const result = await pipelineService.createFileJob({
kind: 'audiobook_upload',
file: req.file,
options: {
format: req.body?.format,
startImmediately: req.body?.startImmediately
}
});
res.json({ result });
})
);
router.get(
'/audiobook/pending-activation',
asyncHandler(async (req, res) => {
const db = await getDb();
// Jobs die eine Checksum haben, aber noch keine Activation Bytes im Cache
const pending = await db.all(`
SELECT j.id AS jobId, j.aax_checksum AS checksum
FROM jobs j
WHERE j.aax_checksum IS NOT NULL
AND j.status NOT IN ('DONE', 'ERROR', 'CANCELLED')
AND NOT EXISTS (
SELECT 1 FROM aax_activation_bytes ab WHERE ab.checksum = j.aax_checksum
)
ORDER BY j.created_at DESC
`);
res.json({ pending });
})
);
router.post(
'/audiobook/start/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const config = req.body || {};
logger.info('post:audiobook:start', {
reqId: req.reqId,
jobId,
format: config?.format,
formatOptions: config?.formatOptions && typeof config.formatOptions === 'object'
? config.formatOptions
: null
});
const result = await pipelineService.startAudiobookWithConfig(jobId, config);
res.json({ result });
})
);
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) => {
const {
jobId,
title,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
metadataProvider,
providerId,
tmdbId,
metadataKind,
workflowKind,
seasonNumber,
seasonName,
episodeCount,
episodes,
discNumber,
duplicateAction,
existingJobId,
existingDiscNumber
} = req.body;
if (!jobId) {
const error = new Error('jobId fehlt.');
error.statusCode = 400;
throw error;
}
logger.info('post:select-metadata', {
reqId: req.reqId,
jobId,
title,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds: Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : null,
metadataProvider,
providerId,
tmdbId,
workflowKind,
seasonNumber,
discNumber,
duplicateAction,
existingJobId,
existingDiscNumber
});
const job = await pipelineService.selectMetadata({
jobId: Number(jobId),
title,
year,
imdbId,
poster,
fromOmdb,
selectedPlaylist,
selectedHandBrakeTitleId,
selectedHandBrakeTitleIds,
metadataProvider,
providerId,
tmdbId,
metadataKind,
workflowKind,
seasonNumber,
seasonName,
episodeCount,
episodes,
discNumber,
duplicateAction,
existingJobId,
existingDiscNumber
});
res.json({ job });
})
);
router.post(
'/jobs/:jobId/raw-decision',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const { decision } = req.body;
logger.info('post:raw-decision', { reqId: req.reqId, jobId, decision });
const result = await pipelineService.submitRawDecision(jobId, decision);
res.json({ result });
})
);
router.post(
'/start/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:start-job', { reqId: req.reqId, jobId });
const result = await pipelineService.startPreparedJob(jobId);
res.json({ result });
})
);
router.post(
'/multipart-merge/:jobId/reorder',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const orderedSourceJobIds = Array.isArray(req.body?.orderedSourceJobIds)
? req.body.orderedSourceJobIds
: [];
logger.info('post:multipart-merge:reorder', {
reqId: req.reqId,
jobId,
orderedCount: orderedSourceJobIds.length
});
const job = await pipelineService.updateMultipartMergeSourceOrder(jobId, orderedSourceJobIds);
res.json({ job });
})
);
router.post(
'/multipart-merge/:jobId/settings',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const deleteInputsAfterMerge = Boolean(req.body?.deleteInputsAfterMerge);
logger.info('post:multipart-merge:settings', {
reqId: req.reqId,
jobId,
deleteInputsAfterMerge
});
const job = await pipelineService.updateMultipartMergeSettings(jobId, {
deleteInputsAfterMerge
});
res.json({ job });
})
);
router.get(
'/multipart-merge/:jobId/preview',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('get:multipart-merge:preview', {
reqId: req.reqId,
jobId
});
const preview = await pipelineService.getMultipartMergePreview(jobId);
res.json({ preview });
})
);
router.post(
'/multipart-merge/:containerJobId/restore',
asyncHandler(async (req, res) => {
const containerJobId = Number(req.params.containerJobId);
logger.info('post:multipart-merge:restore', {
reqId: req.reqId,
containerJobId
});
const job = await pipelineService.restoreMultipartMergeJobForContainer(containerJobId);
await pipelineService.emitQueueChanged().catch((error) => {
logger.warn('post:multipart-merge:restore:queue-emit-failed', {
reqId: req.reqId,
containerJobId,
error: error?.message || String(error)
});
});
res.json({
result: {
restored: true,
mergeJobId: Number(job?.id || 0) || null
},
job
});
})
);
router.post(
'/confirm-encode/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const selectedEncodeTitleId = req.body?.selectedEncodeTitleId ?? null;
const selectedEncodeTitleIds = req.body?.selectedEncodeTitleIds ?? null;
const selectedTrackSelection = req.body?.selectedTrackSelection ?? null;
const episodeAssignments = req.body?.episodeAssignments ?? null;
const selectedPostEncodeScriptIds = req.body?.selectedPostEncodeScriptIds;
const selectedPreEncodeScriptIds = req.body?.selectedPreEncodeScriptIds;
const selectedPostEncodeChainIds = req.body?.selectedPostEncodeChainIds;
const selectedPreEncodeChainIds = req.body?.selectedPreEncodeChainIds;
const skipPipelineStateUpdate = Boolean(req.body?.skipPipelineStateUpdate);
const selectedUserPresetId = req.body?.selectedUserPresetId ?? null;
const selectedHandBrakePreset = req.body?.selectedHandBrakePreset ?? null;
logger.info('post:confirm-encode', {
reqId: req.reqId,
jobId,
selectedEncodeTitleId,
selectedEncodeTitleIdsCount: Array.isArray(selectedEncodeTitleIds) ? selectedEncodeTitleIds.length : 0,
selectedTrackSelectionProvided: Boolean(selectedTrackSelection),
episodeAssignmentsProvided: Boolean(episodeAssignments && typeof episodeAssignments === 'object'),
skipPipelineStateUpdate,
selectedUserPresetId,
selectedHandBrakePreset,
selectedPostEncodeScriptIdsCount: Array.isArray(selectedPostEncodeScriptIds)
? selectedPostEncodeScriptIds.length
: 0,
selectedPreEncodeScriptIdsCount: Array.isArray(selectedPreEncodeScriptIds)
? selectedPreEncodeScriptIds.length
: 0,
selectedPostEncodeChainIdsCount: Array.isArray(selectedPostEncodeChainIds)
? selectedPostEncodeChainIds.length
: 0,
selectedPreEncodeChainIdsCount: Array.isArray(selectedPreEncodeChainIds)
? selectedPreEncodeChainIds.length
: 0
});
const job = await pipelineService.confirmEncodeReview(jobId, {
selectedEncodeTitleId,
selectedEncodeTitleIds,
selectedTrackSelection,
episodeAssignments,
selectedPostEncodeScriptIds,
selectedPreEncodeScriptIds,
selectedPostEncodeChainIds,
selectedPreEncodeChainIds,
skipPipelineStateUpdate,
selectedUserPresetId,
selectedHandBrakePreset
});
res.json({ job });
})
);
router.post(
'/cancel',
asyncHandler(async (req, res) => {
const rawJobId = req.body?.jobId;
const jobId = rawJobId === null || rawJobId === undefined || String(rawJobId).trim() === ''
? null
: Number(rawJobId);
logger.warn('post:cancel', { reqId: req.reqId, jobId });
const result = await pipelineService.cancel(jobId);
res.json({ result });
})
);
router.post(
'/retry/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:retry', { reqId: req.reqId, jobId });
const result = await pipelineService.retry(jobId);
res.json({ result });
})
);
router.post(
'/resume-ready/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:resume-ready', { reqId: req.reqId, jobId });
const job = await pipelineService.resumeReadyToEncodeJob(jobId);
res.json({ job });
})
);
router.get(
'/output-folders/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const folders = await historyService.getJobOutputFoldersForLineage(jobId);
res.json({ folders });
})
);
router.post(
'/delete-output-folders/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const folderPaths = Array.isArray(req.body?.folderPaths) ? req.body.folderPaths : [];
logger.info('post:delete-output-folders', { reqId: req.reqId, jobId, count: folderPaths.length });
const result = await historyService.deleteSpecificOutputFolders(jobId, folderPaths);
res.json({ result });
})
);
router.post(
'/reencode/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
logger.info('post:reencode', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
const result = await pipelineService.reencodeFromRaw(jobId, { keepBoth, deleteFolders });
res.json({ result });
})
);
router.post(
'/restart-review/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
const reuseCurrentJob = Boolean(req.body?.reuseCurrentJob);
logger.info('post:restart-review', {
reqId: req.reqId,
jobId,
keepBoth,
reuseCurrentJob,
deleteFolderCount: deleteFolders.length
});
const result = await pipelineService.restartReviewFromRaw(jobId, { keepBoth, deleteFolders, reuseCurrentJob });
res.json({ result });
})
);
router.post(
'/restart-encode/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
const restartMode = String(req.body?.restartMode || 'all').trim().toLowerCase() || 'all';
logger.info('post:restart-encode', {
reqId: req.reqId,
jobId,
keepBoth,
restartMode,
deleteFolderCount: deleteFolders.length
});
const result = await pipelineService.restartEncodeWithLastSettings(jobId, {
keepBoth,
deleteFolders,
restartMode
});
res.json({ result });
})
);
router.post(
'/restart-cd-review/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const keepBoth = Boolean(req.body?.keepBoth);
const deleteFolders = Array.isArray(req.body?.deleteFolders) ? req.body.deleteFolders : [];
logger.info('post:restart-cd-review', { reqId: req.reqId, jobId, keepBoth, deleteFolderCount: deleteFolders.length });
const result = await pipelineService.restartCdReviewFromRaw(jobId, { keepBoth, deleteFolders });
res.json({ result });
})
);
router.get(
'/queue',
asyncHandler(async (req, res) => {
logger.debug('get:queue', { reqId: req.reqId });
const queue = await pipelineService.getQueueSnapshot();
res.json({ queue });
})
);
router.post(
'/queue/reorder',
asyncHandler(async (req, res) => {
// Accept orderedEntryIds (new) or orderedJobIds (legacy fallback for job-only queues).
const orderedEntryIds = Array.isArray(req.body?.orderedEntryIds)
? req.body.orderedEntryIds
: (Array.isArray(req.body?.orderedJobIds) ? req.body.orderedJobIds : []);
logger.info('post:queue:reorder', { reqId: req.reqId, orderedEntryIds });
const queue = await pipelineService.reorderQueue(orderedEntryIds);
res.json({ queue });
})
);
router.post(
'/queue/entry',
asyncHandler(async (req, res) => {
const { type, scriptId, chainId, waitSeconds, insertAfterEntryId } = req.body || {};
logger.info('post:queue:entry', { reqId: req.reqId, type });
const result = await pipelineService.enqueueNonJobEntry(
type,
{ scriptId, chainId, waitSeconds },
insertAfterEntryId ?? null
);
const queue = await pipelineService.getQueueSnapshot();
res.json({ result, queue });
})
);
router.delete(
'/queue/entry/:entryId',
asyncHandler(async (req, res) => {
const entryId = req.params.entryId;
logger.info('delete:queue:entry', { reqId: req.reqId, entryId });
const queue = await pipelineService.removeQueueEntry(entryId);
res.json({ queue });
})
);
module.exports = router;