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
+34
View File
@@ -0,0 +1,34 @@
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const rawDbPath = process.env.DB_PATH || path.join(rootDir, 'data', 'ripster.db');
const rawLogDir = process.env.LOG_DIR || path.join(rootDir, 'logs');
const resolvedDbPath = path.isAbsolute(rawDbPath) ? rawDbPath : path.resolve(rootDir, rawDbPath);
const dataDir = path.dirname(resolvedDbPath);
function resolveOutputPath(envValue, ...subParts) {
const raw = String(envValue || '').trim();
if (raw) {
return path.isAbsolute(raw) ? raw : path.resolve(rootDir, raw);
}
return path.join(dataDir, ...subParts);
}
module.exports = {
port: process.env.PORT ? Number(process.env.PORT) : 3001,
dbPath: resolvedDbPath,
dataDir,
corsOrigin: process.env.CORS_ORIGIN || '*',
logDir: path.isAbsolute(rawLogDir) ? rawLogDir : path.resolve(rootDir, rawLogDir),
logLevel: process.env.LOG_LEVEL || 'info',
defaultRawDir: resolveOutputPath(process.env.DEFAULT_RAW_DIR, 'output', 'raw'),
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
defaultSeriesDir: resolveOutputPath(process.env.DEFAULT_SERIES_DIR, 'output', 'series'),
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads'),
defaultConverterRawDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_RAW_DIR, 'output', 'converter-raw'),
defaultConverterMovieDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_MOVIE_DIR, 'output', 'converted-movies'),
defaultConverterAudioDir: resolveOutputPath(process.env.DEFAULT_CONVERTER_AUDIO_DIR, 'output', 'converted-audio')
};
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
require('dotenv').config();
const http = require('http');
const express = require('express');
const cors = require('cors');
const { port, corsOrigin } = require('./config');
const { initDatabase } = require('./db/database');
const errorHandler = require('./middleware/errorHandler');
const requestLogger = require('./middleware/requestLogger');
const settingsRoutes = require('./routes/settingsRoutes');
const pipelineRoutes = require('./routes/pipelineRoutes');
const historyRoutes = require('./routes/historyRoutes');
const downloadRoutes = require('./routes/downloadRoutes');
const cronRoutes = require('./routes/cronRoutes');
const runtimeRoutes = require('./routes/runtimeRoutes');
const converterRoutes = require('./routes/converterRoutes');
const wsService = require('./services/websocketService');
const pipelineService = require('./services/pipelineService');
const settingsService = require('./services/settingsService');
const converterScanService = require('./services/converterScanService');
const cronService = require('./services/cronService');
const downloadService = require('./services/downloadService');
const diskDetectionService = require('./services/diskDetectionService');
const hardwareMonitorService = require('./services/hardwareMonitorService');
const coverArtRecoveryService = require('./services/coverArtRecoveryService');
const tempCleanupService = require('./services/tempCleanupService');
const logger = require('./services/logger').child('BOOT');
const { errorToMeta } = require('./utils/errorMeta');
const { getThumbnailsDir, migrateExistingThumbnails } = require('./services/thumbnailService');
async function start() {
logger.info('backend:start:init');
await initDatabase();
try {
const runtimeSettings = await settingsService.applyRuntimeSettings();
logger.info('backend:runtime-settings:applied', runtimeSettings);
} catch (error) {
logger.warn('backend:runtime-settings:apply-failed', { error: errorToMeta(error) });
}
await tempCleanupService.init();
await pipelineService.init();
await converterScanService.startPolling();
await cronService.init();
await downloadService.init();
await coverArtRecoveryService.init();
const app = express();
app.use(cors({ origin: corsOrigin }));
app.use(express.json({ limit: '2mb' }));
app.use(requestLogger);
app.get('/api/health', (req, res) => {
res.json({ ok: true, now: new Date().toISOString() });
});
app.use('/api/settings', settingsRoutes);
app.use('/api/pipeline', pipelineRoutes);
app.use('/api/history', historyRoutes);
app.use('/api/downloads', downloadRoutes);
app.use('/api/crons', cronRoutes);
app.use('/api/runtime', runtimeRoutes);
app.use('/api/converter', converterRoutes);
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
app.use(errorHandler);
const server = http.createServer(app);
wsService.init(server);
await hardwareMonitorService.init();
diskDetectionService.on('discInserted', (device) => {
logger.info('disk:inserted:event', { device });
pipelineService.onDiscInserted(device).catch((error) => {
logger.error('pipeline:onDiscInserted:failed', { error: errorToMeta(error), device });
wsService.broadcast('PIPELINE_ERROR', { message: error.message });
});
});
diskDetectionService.on('discRemoved', (device) => {
logger.info('disk:removed:event', { device });
pipelineService.onDiscRemoved(device).catch((error) => {
logger.error('pipeline:onDiscRemoved:failed', { error: errorToMeta(error), device });
wsService.broadcast('PIPELINE_ERROR', { message: error.message });
});
});
diskDetectionService.on('error', (error) => {
logger.error('diskDetection:error:event', { error: errorToMeta(error) });
wsService.broadcast('DISK_DETECTION_ERROR', { message: error.message });
});
diskDetectionService.start();
server.listen(port, () => {
logger.info('backend:listening', { port });
// Bestehende Job-Bilder im Hintergrund migrieren (blockiert nicht den Start)
migrateExistingThumbnails().catch(() => {});
});
const shutdown = () => {
logger.warn('backend:shutdown:received');
converterScanService.stopPolling();
diskDetectionService.stop();
coverArtRecoveryService.stop();
hardwareMonitorService.stop();
cronService.stop();
tempCleanupService.stop();
server.close(() => {
logger.warn('backend:shutdown:completed');
process.exit(0);
});
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (error) => {
logger.error('process:uncaughtException', { error: errorToMeta(error) });
});
process.on('unhandledRejection', (reason) => {
logger.error('process:unhandledRejection', {
reason: reason instanceof Error ? errorToMeta(reason) : String(reason)
});
});
}
start().catch((error) => {
logger.error('backend:start:failed', { error: errorToMeta(error) });
process.exit(1);
});
+5
View File
@@ -0,0 +1,5 @@
module.exports = function asyncHandler(fn) {
return function wrapped(req, res, next) {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
+23
View File
@@ -0,0 +1,23 @@
const logger = require('../services/logger').child('ERROR_HANDLER');
const { errorToMeta } = require('../utils/errorMeta');
module.exports = function errorHandler(error, req, res, next) {
const statusCode = error.statusCode || 500;
logger.error('request:error', {
reqId: req?.reqId,
method: req?.method,
url: req?.originalUrl,
statusCode,
error: errorToMeta(error)
});
res.status(statusCode).json({
error: {
message: error.message || 'Interner Fehler',
statusCode,
reqId: req?.reqId,
details: Array.isArray(error.details) ? error.details : undefined
}
});
};
+53
View File
@@ -0,0 +1,53 @@
const { randomUUID } = require('crypto');
const logger = require('../services/logger').child('HTTP');
function truncate(value, maxLen = 1500) {
if (value === undefined) {
return undefined;
}
let str;
if (typeof value === 'string') {
str = value;
} else {
try {
str = JSON.stringify(value);
} catch (error) {
str = '[unserializable-body]';
}
}
if (str.length <= maxLen) {
return str;
}
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
}
module.exports = function requestLogger(req, res, next) {
const reqId = randomUUID();
const startedAt = Date.now();
req.reqId = reqId;
logger.info('request:start', {
reqId,
method: req.method,
url: req.originalUrl,
ip: req.ip,
query: req.query,
body: truncate(req.body)
});
res.on('close', () => {
if (!res.writableEnded) {
logger.warn('request:aborted', {
reqId,
method: req.method,
url: req.originalUrl,
durationMs: Date.now() - startedAt
});
}
});
next();
};
+460
View File
@@ -0,0 +1,460 @@
'use strict';
const path = require('path');
const fs = require('fs');
const { SourcePlugin } = require('./PluginBase');
const audiobookService = require('../services/audiobookService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { ensureDir } = require('../utils/files');
/**
* Source-Plugin für Audiobooks (AAX-Dateien).
*
* Audiobooks unterscheiden sich von Disc-Medien grundlegend:
* - Kein Laufwerk — der Nutzer lädt eine .aax-Datei hoch
* - Kein Rip-Schritt — die AAX-Datei ist der Input
* - Encode mit ffmpeg (Gesamt oder kapitelweise)
*
* Deshalb:
* rip() → No-Op (Datei liegt bereits im rawDir)
* review() → null (kein HandBrake-Preview)
* encode() → ffmpeg Single-File oder kapitelweise
*
* detect() prüft das mediaProfile-Feld (gesetzt vom Orchestrator für
* Datei-Uploads) oder eine Dateiendung im discInfo.
*
* Pflicht-Felder in ctx.extra für analyze():
* filePath - Absoluter Pfad zur .aax-Datei
*
* Pflicht-Felder in ctx.extra für encode():
* inputPath - Absoluter Pfad zur .aax-Input-Datei
* outputPath - Absoluter Pfad für die Ausgabe (Datei oder Verzeichnis)
* outputFormat - 'm4b' | 'mp3' | 'flac'
* formatOptions - Encoder-Optionen { flacCompression, mp3Mode, mp3Bitrate, ... }
* metadata - Metadata-Objekt aus analyze() oder encode_plan_json
* activationBytes - AAX-Aktivierungsbytes (String, kann null sein bei nicht-DRM)
* isSplitOutput - boolean: true = kapitelweise, false = Einzel-Datei
* chapterPlan - { outputFiles, chapters } — nur benötigt wenn isSplitOutput = true
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*/
class AudiobookPlugin extends SourcePlugin {
get id() {
return 'audiobook';
}
get name() {
return 'Audiobook (AAX)';
}
get priority() {
return 5;
}
/**
* Erkennt Audiobooks.
* Wird entweder via mediaProfile ('audiobook') oder Dateiendung erkannt.
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'audiobook' || profile === 'audio_book') {
return true;
}
const ext = path.extname(String(discInfo?.filePath || discInfo?.fileName || '')).trim().toLowerCase();
return audiobookService.SUPPORTED_INPUT_EXTENSIONS.has(ext);
}
/**
* Analysiert eine .aax-Datei mit ffprobe.
* Gibt die extrahierten Metadaten zurück (Titel, Autor, Kapitel, Cover, etc.).
*
* @param {string} filePath - Pfad zur .aax-Datei (in ctx.extra.filePath ODER als devicePath)
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} Metadata-Objekt aus audiobookService.buildMetadataFromProbe()
*/
async analyze(filePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
const inputPath = ctx.extra?.filePath || filePath;
if (!inputPath || !fs.existsSync(inputPath)) {
const error = new Error(`AudiobookPlugin.analyze(): Datei nicht gefunden: ${inputPath}`);
error.statusCode = 400;
throw error;
}
const settings = await ctx.settings.getSettingsMap();
const ffprobeCommand = String(settings.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const originalName = path.basename(inputPath);
ctx.logger.info('audiobook:analyze:start', { jobId: job?.id, inputPath, ffprobeCommand });
const probeConfig = audiobookService.buildProbeCommand(ffprobeCommand, inputPath);
const captured = await _runCaptured(probeConfig.cmd, probeConfig.args, { jobId: job?.id });
const probe = audiobookService.parseProbeOutput(captured.stdout);
if (!probe) {
const error = new Error('FFprobe-Ausgabe konnte nicht gelesen werden (kein gültiges JSON).');
error.statusCode = 500;
throw error;
}
const metadata = audiobookService.buildMetadataFromProbe(probe, originalName);
ctx.logger.info('audiobook:analyze:done', {
jobId: job?.id,
title: metadata.title,
author: metadata.author,
chapterCount: metadata.chapters?.length || 0,
durationMs: metadata.durationMs
});
return metadata;
}
/**
* No-Op: Audiobooks haben keinen Rip-Schritt.
* Die .aax-Datei wurde bereits beim Upload ins rawDir verschoben.
*/
async rip(_job, _ctx) {
_ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
// Für Audiobooks gibt es keinen separaten Rip-Schritt
}
/**
* Encodiert die .aax-Datei mit ffmpeg — als Einzel-Datei oder kapitelweise.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputPath: string, format: string, chapterCount: number}>}
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
const settings = await ctx.settings.getSettingsMap();
if (!settings.audiobook_encoding_enabled) {
ctx.logger.info('audiobook:encode:disabled', { jobId: job?.id });
return;
}
const {
inputPath,
outputPath,
outputFormat = 'mp3',
formatOptions = {},
metadata = {},
activationBytes = null,
isSplitOutput = false,
chapterPlan = null,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error('AudiobookPlugin.encode(): ctx.extra.inputPath fehlt');
}
if (!outputPath) {
throw new Error('AudiobookPlugin.encode(): ctx.extra.outputPath fehlt');
}
if (!fs.existsSync(inputPath)) {
const error = new Error(`AudiobookPlugin.encode(): Input-Datei nicht gefunden: ${inputPath}`);
error.statusCode = 400;
throw error;
}
const ffmpegCommand = String(settings.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const normalizedFormat = audiobookService.normalizeOutputFormat(outputFormat);
const normalizedOptions = audiobookService.normalizeFormatOptions(normalizedFormat, formatOptions);
ctx.logger.info('audiobook:encode:start', {
jobId: job?.id,
format: normalizedFormat,
isSplitOutput,
inputPath
});
if (isSplitOutput) {
await this._encodeChapters({
job, ctx, ffmpegCommand, inputPath, chapterPlan,
normalizedFormat, normalizedOptions, metadata, activationBytes,
isCancelled, onProcessHandle
});
} else {
await this._encodeSingleFile({
job, ctx, ffmpegCommand, inputPath, outputPath,
normalizedFormat, normalizedOptions, metadata, activationBytes,
isCancelled, onProcessHandle
});
}
ctx.logger.info('audiobook:encode:done', {
jobId: job?.id,
format: normalizedFormat,
outputPath
});
ctx.extra.encodeResult = {
outputPath,
format: normalizedFormat,
chapterCount: isSplitOutput
? (chapterPlan?.outputFiles?.length || 0)
: 1
};
return ctx.extra.encodeResult;
}
/**
* Audiobooks brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'AudiobookPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audiobooks.
*/
getSettingsSchema() {
return [
{
key: 'audiobook_encoding_enabled',
category: 'Features',
label: 'Audiobook Encoding',
type: 'boolean',
required: 1,
description: 'Audiobook-Encoding über das Plugin aktivieren.',
default_value: 'true',
options_json: '[]',
validation_json: '{}',
order_index: 100
},
{
key: 'ffmpeg_command',
category: 'Tools',
label: 'FFmpeg Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für ffmpeg. Wird für Audiobook-Encoding genutzt.',
default_value: 'ffmpeg',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 232
},
{
key: 'ffprobe_command',
category: 'Tools',
label: 'FFprobe Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für ffprobe. Wird für Audiobook-Metadaten und Kapitel genutzt.',
default_value: 'ffprobe',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 233
},
{
key: 'output_template_audiobook',
category: 'Pfade',
label: 'Output Template (Audiobook)',
type: 'string',
required: 1,
description: 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner über "/".',
default_value: audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 735
}
];
}
// ── Private Encode-Methoden ──────────────────────────────────────────────────
async _encodeSingleFile({ job, ctx, ffmpegCommand, inputPath, outputPath, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
ensureDir(path.dirname(outputPath));
const ffmpegConfig = audiobookService.buildEncodeCommand(
ffmpegCommand,
inputPath,
outputPath,
normalizedFormat,
normalizedOptions,
{ activationBytes, metadata }
);
ctx.logger.info('audiobook:encode:single-file', {
jobId: job?.id,
cmd: ffmpegConfig.cmd,
outputPath
});
const progressParser = audiobookService.buildProgressParser(metadata?.durationMs || 0);
await _spawnAndWait({
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
jobId: job?.id,
progressParser,
onProgress: (pct) => ctx.emitProgress(Math.round(pct), 'Audiobook wird encodiert …'),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'AudiobookPlugin' }
});
}
async _encodeChapters({ job, ctx, ffmpegCommand, inputPath, chapterPlan, normalizedFormat, normalizedOptions, metadata, activationBytes, isCancelled, onProcessHandle }) {
const outputFiles = Array.isArray(chapterPlan?.outputFiles) ? chapterPlan.outputFiles : [];
if (outputFiles.length === 0) {
throw new Error('AudiobookPlugin: Keine Kapitel im chapterPlan für kapitelweise Ausgabe.');
}
for (let index = 0; index < outputFiles.length; index++) {
_assertNotCancelled(isCancelled);
const entry = outputFiles[index];
const chapter = entry?.chapter || {};
const chapterTitle = String(chapter?.title || `Kapitel ${index + 1}`).trim();
const startPct = (index / outputFiles.length) * 100;
const endPct = ((index + 1) / outputFiles.length) * 100;
ensureDir(path.dirname(entry.outputPath));
const ffmpegConfig = audiobookService.buildChapterEncodeCommand(
ffmpegCommand,
inputPath,
entry.outputPath,
normalizedFormat,
normalizedOptions,
metadata,
chapter,
outputFiles.length,
{ activationBytes }
);
ctx.logger.info('audiobook:encode:chapter', {
jobId: job?.id,
chapterIndex: index + 1,
chapterTotal: outputFiles.length,
chapterTitle,
outputPath: entry.outputPath
});
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
const scaledParser = baseParser
? (line) => {
const progress = baseParser(line);
if (!progress || progress.percent == null) {
return null;
}
return {
percent: startPct + (endPct - startPct) * (progress.percent / 100),
eta: null
};
}
: null;
ctx.emitProgress(
Math.round(startPct),
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
);
await _spawnAndWait({
cmd: ffmpegConfig.cmd,
args: ffmpegConfig.args,
jobId: job?.id,
progressParser: scaledParser,
onProgress: (pct) => ctx.emitProgress(
Math.round(pct),
`Audiobook-Encoding Kapitel ${index + 1}/${outputFiles.length}: ${chapterTitle}`
),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'AudiobookPlugin', chapterIndex: index + 1 }
});
}
}
}
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
function _assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
throw error;
}
}
async function _runCaptured(cmd, args, _context = {}) {
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
try {
return await execFileAsync(cmd, args, { timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
} catch (error) {
// execFile wirft bei non-zero exit; stdout/stderr können trotzdem valide sein
return {
stdout: String(error?.stdout || ''),
stderr: String(error?.stderr || ''),
exitCode: error?.code ?? 1
};
}
}
async function _spawnAndWait({ cmd, args, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
_assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: () => {},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent);
}
}
},
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
cancelError.statusCode = 409;
throw cancelError;
}
throw error;
}
}
module.exports = { AudiobookPlugin };
+70
View File
@@ -0,0 +1,70 @@
'use strict';
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
/**
* Source-Plugin für Blu-ray Discs.
*
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
* Überschreibt nur die Identifikations-Properties und detect().
*
* Erkennungsmerkmale:
* - mediaProfile === 'bluray'
* - Dateisystem: UDF (Versionen 2.5 / 2.6)
* - Laufwerksmodell enthält 'blu-ray', 'bdrom', 'bd-r', 'bd-re'
*/
class BluRayPlugin extends VideoDiscPlugin {
get id() {
return 'bluray';
}
get name() {
return 'Blu-ray';
}
get mediaProfile() {
return 'bluray';
}
/**
* Höhere Priorität als DVD (10 > 5) damit ein UDF-Laufwerk mit
* blu-ray-Modell-Marker korrekt erkannt wird, auch wenn beide Plugins
* theoretisch auf UDF-Discs matchen könnten.
*/
get priority() {
return 10;
}
/**
* Erkennt Blu-ray Discs.
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
*
* @param {object} discInfo
* @param {string} [discInfo.mediaProfile] - 'bluray'
* @param {string} [discInfo.fstype] - 'udf'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'bluray' || profile === 'blu-ray' || profile === 'blu_ray') {
return true;
}
// Wenn mediaProfile explizit auf einen anderen Medientyp gesetzt ist
// (z.B. 'dvd' für eine DVD im Blu-ray-Laufwerk), dem DiskDetectionService
// vertrauen und nicht via Modell-Marker überschreiben.
if (profile) {
return false;
}
// Fallback: nur wenn kein mediaProfile bekannt ist.
// UDF-Dateisystem + Laufwerks-Modell enthält Blu-ray-Marker.
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
if (fstype === 'udf' && /(blu[\s-]?ray|bd[\s_-]?rom|\bbd-?r\b|\bbd-?re\b|\bbdr\b)/i.test(model)) {
return true;
}
return false;
}
}
module.exports = { BluRayPlugin };
+346
View File
@@ -0,0 +1,346 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const cdRipService = require('../services/cdRipService');
/**
* Source-Plugin für Audio-CDs.
*
* Delegiert die gesamte Arbeit an den bereits fertig ausgelagerten
* cdRipService — dieser Plugin ist ein dünner Adapter-Wrapper.
*
* CD-Besonderheit: Rip und Encode sind bei CDs Track-für-Track
* verzahnt (rip track → encode track → nächster Track).
* Deshalb ist encode() ein No-Op; die gesamte Arbeit liegt in rip().
*
* Erwartete ctx.extra-Felder für rip():
* ripConfig - Das ripConfig-Objekt aus dem API-Request (format, formatOptions,
* selectedTracks, tracks, metadata, ...)
* rawWavDir - Absoluter Pfad zum WAV-Temp-/RAW-Ordner
* outputDir - Absoluter Pfad zum finalen Ausgabeordner
* outputTemplate - CD-Output-Template String
* isCancelled - () => boolean — Abbruchsignal vom Orchestrator
* onProcessHandle - Callback mit dem Prozess-Handle (für Abbruch)
*/
class CdPlugin extends SourcePlugin {
get id() {
return 'cd';
}
get name() {
return 'Audio CD';
}
get priority() {
return 10;
}
/**
* Erkennt Audio-CDs anhand des mediaProfile-Feldes, das vom
* DiskDetectionService / diskDetectionService.inferMediaProfile() gesetzt wird.
*
* Akzeptierte Werte: 'cd', 'audio_cd'
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
return profile === 'cd' || profile === 'audio_cd' || fstype === 'audio_cd';
}
/**
* Liest das TOC der CD mit cdparanoia -Q und gibt die Trackliste zurück.
*
* @param {string} devicePath - z.B. '/dev/sr0'
* @param {object} job - Job-Record (id wird für Logging genutzt)
* @param {PluginContext} ctx
* @returns {Promise<{tracks: Array, cdparanoiaCmd: string, detectedTitle: string}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const detectedTitle = String(
ctx.extra?.discInfo?.discLabel || ctx.extra?.discInfo?.label || ctx.extra?.discInfo?.model || 'Audio CD'
).trim();
ctx.logger.info('cd:analyze:start', { devicePath, jobId: job?.id, detectedTitle });
const tracks = await cdRipService.readToc(devicePath, cdparanoiaCmd);
if (!tracks.length) {
const error = new Error('Keine Audio-Tracks erkannt. Bitte Laufwerk/Medium prüfen (cdparanoia -Q).');
error.statusCode = 400;
throw error;
}
ctx.logger.info('cd:analyze:done', { devicePath, jobId: job?.id, trackCount: tracks.length });
return {
tracks,
cdparanoiaCmd,
detectedTitle
};
}
/**
* Rippt und encodiert alle ausgewählten Tracks der CD.
*
* Bei CDs sind Rip und Encode Track-für-Track verzahnt — alles
* läuft hier in einem einzigen cdRipService.ripAndEncode()-Aufruf.
*
* Pflicht-Felder in ctx.extra:
* ripConfig - { format, formatOptions, selectedTracks, tracks, metadata, skipRip, skipEncode }
* rawWavDir - Pfad für temporäre WAV-Dateien (= RAW-Ordner)
* outputDir - Finaler Ausgabeordner (optional bei skipEncode=true)
* outputTemplate - Template-String für Dateinamen
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{outputDir, format, trackCount, encodeResults}>}
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
const {
ripConfig = {},
rawWavDir,
outputDir,
outputTemplate,
isCancelled,
onProcessHandle,
onProgress: onRawProgress,
onLog: onExternalLog
} = ctx.extra || {};
if (!rawWavDir) {
throw new Error('CdPlugin.rip(): ctx.extra.rawWavDir fehlt');
}
const skipRip = Boolean(ripConfig?.skipRip);
const skipEncode = Boolean(ripConfig?.skipEncode);
if (!skipEncode && !outputDir) {
throw new Error('CdPlugin.rip(): ctx.extra.outputDir fehlt');
}
const cdInfo = _safeParseJson(job?.makemkv_info_json) || {};
const settings = await ctx.settings.getSettingsMap();
const cdparanoiaCmd = String(cdInfo.cdparanoiaCmd || settings.cdparanoia_command || 'cdparanoia').trim() || 'cdparanoia';
const devicePath = String(job?.disc_device || '').trim();
if (!devicePath && !skipRip) {
throw new Error('CdPlugin.rip(): Kein CD-Gerätepfad im Job gefunden (disc_device leer).');
}
const format = String(ripConfig.format || 'flac').trim().toLowerCase();
const formatOptions = ripConfig.formatOptions || {};
const effectiveTemplate = outputTemplate || cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE;
// Trackliste: TOC-Tracks aus DB, optional mit Metadaten aus ripConfig überschrieben
const tocTracks = Array.isArray(cdInfo.tracks) ? cdInfo.tracks : [];
const incomingTracks = Array.isArray(ripConfig.tracks) ? ripConfig.tracks : [];
const incomingByPos = new Map(
incomingTracks
.filter((t) => Number.isFinite(Number(t?.position)) && Number(t.position) > 0)
.map((t) => [Math.trunc(Number(t.position)), t])
);
const mergedTracks = tocTracks
.map((track) => {
const pos = Math.trunc(Number(track?.position));
if (!Number.isFinite(pos) || pos <= 0) {
return null;
}
const incoming = incomingByPos.get(pos) || null;
return {
...track,
position: pos,
title: incoming?.title || track.title || `Track ${pos}`,
artist: incoming?.artist || track.artist || null,
selected: incoming ? Boolean(incoming.selected) : (track.selected !== false)
};
})
.filter(Boolean);
const selectedMeta = cdInfo.selectedMetadata || {};
const incomingMeta = (ripConfig.metadata && typeof ripConfig.metadata === 'object')
? ripConfig.metadata
: {};
const meta = {
title: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD',
artist: _normText(incomingMeta.artist) || _normText(selectedMeta.artist) || null,
year: _normYear(incomingMeta.year) ?? _normYear(selectedMeta.year) ?? _normYear(job?.year) ?? null,
album: _normText(incomingMeta.title) || _normText(selectedMeta.title) || _normText(job?.title) || 'Audio CD'
};
const rawSelectedPositions = Array.isArray(ripConfig.selectedTracks)
? ripConfig.selectedTracks
.map((v) => Math.trunc(Number(v)))
.filter((v) => Number.isFinite(v) && v > 0)
: [];
const selectedTrackPositions = rawSelectedPositions.length > 0
? rawSelectedPositions
: mergedTracks.filter((t) => t.selected !== false).map((t) => t.position);
ctx.logger.info('cd:rip:start', {
jobId: job?.id,
devicePath: devicePath || null,
skipRip,
skipEncode,
format,
trackCount: selectedTrackPositions.length
});
const result = await cdRipService.ripAndEncode({
jobId: job?.id,
devicePath: devicePath || null,
cdparanoiaCmd,
rawWavDir,
outputDir,
format,
formatOptions,
selectedTracks: selectedTrackPositions,
tracks: mergedTracks,
meta,
outputTemplate: effectiveTemplate,
skipRip,
skipEncode,
onProgress: (progressEvent) => {
if (typeof onRawProgress === 'function') {
onRawProgress(progressEvent);
}
const pct = Number(progressEvent?.percent ?? 0);
const phase = progressEvent?.phase === 'encode' ? 'Encodiere' : 'Rippe';
const trackIdx = progressEvent?.trackIndex || 1;
const trackTotal = progressEvent?.trackTotal || 1;
ctx.emitProgress(
Math.round(pct),
`${phase} Track ${trackIdx}/${trackTotal}`
);
},
onLog: (level, msg) => {
if (ctx.logger[level]) {
ctx.logger[level](msg, { jobId: job?.id });
}
if (typeof onExternalLog === 'function') {
onExternalLog(level, msg);
}
},
onProcessHandle,
isCancelled,
context: { jobId: job?.id, source: 'CdPlugin' }
});
ctx.logger.info('cd:rip:done', {
jobId: job?.id,
format,
trackCount: result.trackCount,
outputDir: result.outputDir
});
// Ergebnis in ctx.extra ablegen, damit der Orchestrator darauf zugreifen kann
ctx.extra.ripResult = result;
return result;
}
/**
* No-Op: Bei CDs ist der Encode-Schritt bereits in rip() integriert.
*/
async encode(_job, _ctx) {
_ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
// CD Rip und Encode sind in rip() verzahnt — nichts zu tun
}
/**
* CDs brauchen keine Encode-Review (kein HandBrake).
*/
async review(_job, _ctx) {
_ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'CdPlugin.js'
});
return null;
}
/**
* Plugin-spezifische Settings für Audio CDs.
*/
getSettingsSchema() {
return [
{
key: 'cdparanoia_command',
category: 'Tools',
label: 'cdparanoia Kommando',
type: 'string',
required: 1,
description: 'Pfad oder Befehl für cdparanoia. Wird für Audio-CD-Ripping genutzt.',
default_value: 'cdparanoia',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 240
},
{
key: 'cd_output_template',
category: 'Pfade',
label: 'CD Output Template',
type: 'string',
required: 1,
description: `Template für relative CD-Ausgabepfade ohne Dateiendung. Platzhalter: {artist}, {album}, {year}, {trackNr}, {title}. Unterordner über "/".`,
default_value: cdRipService.DEFAULT_CD_OUTPUT_TEMPLATE,
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 730
}
];
}
}
// ── Hilfsfunktionen (privat, nicht exportiert) ────────────────────────────────
function _safeParseJson(value) {
try {
return value ? JSON.parse(value) : null;
} catch {
return null;
}
}
function _normText(value) {
const s = String(value == null ? '' : value)
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return s || null;
}
function _normYear(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
module.exports = { CdPlugin };
+660
View File
@@ -0,0 +1,660 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const { spawnTrackedProcess } = require('../services/processRunner');
const { syncRegistrationKeyToConfig } = require('../services/makemkvKeyService');
const { parseHandBrakeProgress, parseMakeMkvProgress } = require('../utils/progressParsers');
const { ensureDir, sanitizeFileName, renderTemplate } = require('../utils/files');
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', 'opus', 'wav', 'ogg']);
const VIDEO_OUTPUT_FORMATS = new Set(['mkv', 'mp4', 'm4v']);
/**
* Erkennt den Medientyp anhand der Dateiendung.
* @returns {'video'|'audio'|'iso'|null}
*/
function detectMediaTypeFromPath(filePath) {
const ext = path.extname(String(filePath || '')).slice(1).toLowerCase();
if (ISO_EXTENSIONS.has(ext)) return 'iso';
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
return null;
}
function isAudioExt(ext) {
return AUDIO_EXTENSIONS.has(String(ext).toLowerCase());
}
/**
* Alle Audio-Dateien in einem Verzeichnis auflisten (nicht rekursiv).
*/
function listAudioFilesInDir(dirPath) {
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
return entries
.filter((e) => e.isFile() && isAudioExt(path.extname(e.name).slice(1)))
.map((e) => e.name)
.sort();
} catch (_err) {
return [];
}
}
/**
* ffmpeg-Args zum Konvertieren einer einzelnen Audio-Datei.
*/
function buildAudioFfmpegArgs(ffmpegCmd, inputFile, outputFile, format, opts = {}) {
const args = ['-y', '-i', inputFile];
if (format === 'flac') {
const level = Math.max(0, Math.min(12, Number(opts.flacCompression ?? 5)));
args.push('-codec:a', 'flac', '-compression_level', String(level));
} else if (format === 'mp3') {
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
args.push('-codec:a', 'libmp3lame');
if (mode === 'vbr') {
args.push('-q:a', String(Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)))));
} else {
args.push('-b:a', `${Number(opts.mp3Bitrate ?? 192)}k`);
}
} else if (format === 'opus') {
args.push('-codec:a', 'libopus', '-b:a', `${Number(opts.opusBitrate ?? 160)}k`);
} else if (format === 'ogg') {
args.push('-codec:a', 'libvorbis', '-q:a', String(Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)))));
} else if (format === 'wav') {
args.push('-codec:a', 'pcm_s16le');
} else {
args.push('-codec:a', 'copy');
}
// Metadata tags
const trackTitle = opts.trackTitle ? String(opts.trackTitle).trim() : null;
const trackArtist = (opts.trackArtist || opts.albumArtist)
? String(opts.trackArtist || opts.albumArtist).trim() : null;
const albumTitle = opts.albumTitle ? String(opts.albumTitle).trim() : null;
const albumYear = opts.albumYear ? String(opts.albumYear).trim() : null;
const trackNumber = opts.trackNumber != null ? String(opts.trackNumber) : null;
if (trackTitle) args.push('-metadata', `title=${trackTitle}`);
if (trackArtist) args.push('-metadata', `artist=${trackArtist}`);
if (albumTitle) args.push('-metadata', `album=${albumTitle}`);
if (albumYear) args.push('-metadata', `date=${albumYear}`);
if (trackNumber) args.push('-metadata', `track=${trackNumber}`);
args.push(outputFile);
return { cmd: ffmpegCmd, args };
}
/**
* Source-Plugin für den Converter.
*
* Unterstützte Eingaben:
* - Video-Dateien (mkv, mp4, avi, mov, ...): HandBrake-Encode
* - Audio-Dateien (flac, mp3, wav, ...): ffmpeg-Encode
* - Audio-Ordner: alle enthaltenen Audio-Dateien mit ffmpeg
* - ISO: MakeMKV-Rip → HandBrake-Encode
*
* Erkennung via mediaProfile === 'converter'.
*
* Erforderliche Felder in ctx.extra für encode():
* inputPath - Absoluter Pfad zur Eingabedatei/-ordner (nach Rip: raw_path)
* outputDir - Ausgabeverzeichnis (für Audio/ISO-Ordner) oder
* outputPath - Ausgabedatei (für einzelne Video/Audio-Dateien)
* encodePlan - aus encode_plan_json
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*/
class ConverterPlugin extends SourcePlugin {
get id() { return 'converter'; }
get name() { return 'Converter'; }
get priority() { return 3; }
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
return profile === 'converter';
}
/**
* Analysiert die Eingabedatei/-ordner.
* - Video/ISO: HandBrake --scan für Track-Info
* - Audio: ffprobe für Metadaten
* - Audio-Ordner: Dateiliste + ffprobe auf erste Datei
*/
async analyze(inputPath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const actualInput = ctx.extra?.filePath || inputPath;
const encodePlan = ctx.extra?.encodePlan || {};
const converterMediaType = encodePlan.converterMediaType
|| detectMediaTypeFromPath(actualInput);
ctx.logger.info('converter:analyze:start', {
jobId: job?.id, inputPath: actualInput, converterMediaType
});
const settings = await ctx.settings.getSettingsMap();
const ffprobeCmd = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
const handbrakeCmd = String(settings?.handbrake_command || 'HandBrakeCLI').trim() || 'HandBrakeCLI';
let analysisResult = { converterMediaType, inputPath: actualInput };
if (converterMediaType === 'video' || converterMediaType === 'iso') {
// HandBrake --scan für Track-Informationen
try {
const scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(actualInput);
const captured = await this._runCaptured(scanConfig.cmd, scanConfig.args, { jobId: job?.id });
const hbInfo = this._parseHandBrakeScan(captured.stdout);
analysisResult.handbrakeInfo = hbInfo;
ctx.logger.info('converter:analyze:handbrake-scan', {
jobId: job?.id,
titleCount: hbInfo?.TitleList?.length || 0
});
} catch (scanError) {
ctx.logger.warn('converter:analyze:handbrake-scan-failed', {
jobId: job?.id, error: scanError?.message
});
}
}
if (converterMediaType === 'audio') {
const stat = fs.statSync(actualInput);
if (stat.isDirectory()) {
// Ordner mit Audio-Dateien
const audioFiles = listAudioFilesInDir(actualInput);
analysisResult.isFolder = true;
analysisResult.audioFiles = audioFiles;
// Erster Track: ffprobe für Metadaten
if (audioFiles.length > 0) {
const firstFile = path.join(actualInput, audioFiles[0]);
try {
const meta = await this._ffprobeMetadata(ffprobeCmd, firstFile, { jobId: job?.id });
analysisResult.folderMeta = meta;
} catch (_err) {
// Metadaten optional
}
}
ctx.logger.info('converter:analyze:audio-folder', {
jobId: job?.id, fileCount: audioFiles.length
});
} else {
// Einzelne Audio-Datei
analysisResult.isFolder = false;
try {
const meta = await this._ffprobeMetadata(ffprobeCmd, actualInput, { jobId: job?.id });
analysisResult.fileMeta = meta;
} catch (_err) {
// Metadaten optional
}
ctx.logger.info('converter:analyze:audio-file', { jobId: job?.id });
}
}
return analysisResult;
}
/**
* Rip-Schritt:
* - Video/Audio-Dateien: No-Op (Datei liegt bereits vor)
* - ISO: MakeMKV backup → raw_path
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const encodePlan = ctx.extra?.encodePlan || {};
const converterMediaType = encodePlan.converterMediaType;
if (converterMediaType !== 'iso') {
ctx.logger.info('converter:rip:no-op', { jobId: job?.id, converterMediaType });
return;
}
// ISO: MakeMKV backup
const {
inputPath,
rawJobDir,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error('ConverterPlugin.rip(): ctx.extra.inputPath fehlt');
}
if (!rawJobDir) {
throw new Error('ConverterPlugin.rip(): ctx.extra.rawJobDir fehlt');
}
ensureDir(rawJobDir);
const settings = await ctx.settings.getSettingsMap();
const makemkvCmd = String(settings?.makemkv_command || 'makemkvcon').trim() || 'makemkvcon';
await syncRegistrationKeyToConfig(settings?.makemkv_registration_key || '');
const args = ['--noscan', '--decrypt', '-r', 'backup', `iso:${inputPath}`, rawJobDir];
ctx.logger.info('converter:rip:iso:start', {
jobId: job?.id, inputPath, rawJobDir, cmd: makemkvCmd
});
ctx.emitProgress(0, 'ISO: MakeMKV Backup läuft …');
const runInfo = await _spawnAndWait({
cmd: makemkvCmd, args,
jobId: job?.id,
progressParser: parseMakeMkvProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.rip', stage: 'RIPPING' }
});
ctx.logger.info('converter:rip:iso:done', {
jobId: job?.id, exitCode: runInfo?.exitCode ?? null
});
ctx.extra.ripRunInfo = runInfo;
}
/**
* Encode-Schritt:
* - Video: HandBrakeCLI
* - Audio Einzeldatei: ffmpeg
* - Audio Ordner: ffmpeg je Datei
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id, pluginName: this.name, pluginFile: 'ConverterPlugin.js'
});
const {
inputPath,
outputPath,
outputDir,
encodePlan = {},
isCancelled,
onProcessHandle
} = ctx.extra || {};
const converterMediaType = encodePlan.converterMediaType;
if (!inputPath) {
throw new Error('ConverterPlugin.encode(): ctx.extra.inputPath fehlt');
}
const settings = await ctx.settings.getSettingsMap();
if (converterMediaType === 'video' || converterMediaType === 'iso') {
await this._encodeVideo({
job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle
});
} else if (converterMediaType === 'audio') {
await this._encodeAudio({
job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle
});
} else {
throw new Error(`ConverterPlugin.encode(): Unbekannter converterMediaType: ${converterMediaType}`);
}
}
// ── Video-Encode (HandBrake) ─────────────────────────────────────────────
async _encodeVideo({ job, ctx, settings, inputPath, outputPath, encodePlan, isCancelled, onProcessHandle }) {
if (!outputPath) {
throw new Error('ConverterPlugin._encodeVideo(): outputPath fehlt');
}
ensureDir(path.dirname(outputPath));
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
trackSelection: encodePlan.trackSelection || null,
titleId: encodePlan.handBrakeTitleId || null,
mediaProfile: null,
settingsMap: settings,
userPreset: encodePlan.userPreset || null
});
ctx.logger.info('converter:encode:video:start', {
jobId: job?.id, cmd: handBrakeConfig.cmd, titleId: encodePlan.handBrakeTitleId || null
});
ctx.emitProgress(0, 'Converter: Video Encoding läuft …');
const runInfo = await _spawnAndWait({
cmd: handBrakeConfig.cmd, args: handBrakeConfig.args,
jobId: job?.id,
progressParser: parseHandBrakeProgress,
onProgress: (pct, statusText, eta) => ctx.emitProgress(
Math.round(pct),
statusText || `Encoding ${pct}%`,
eta ?? null
),
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'HANDBRAKE',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
ctx.logger.info('converter:encode:video:done', {
jobId: job?.id, outputPath, exitCode: runInfo.exitCode
});
ctx.extra.encodeRunInfo = runInfo;
}
// ── Audio-Encode (ffmpeg) ────────────────────────────────────────────────
async _encodeAudio({ job, ctx, settings, inputPath, outputPath, outputDir, encodePlan, isCancelled, onProcessHandle }) {
const ffmpegCmd = String(settings?.ffmpeg_command || 'ffmpeg').trim() || 'ffmpeg';
const outputFormat = String(encodePlan.outputFormat || encodePlan.audioFormat || 'flac').trim().toLowerCase();
const formatOptions = encodePlan.audioFormatOptions || {};
const isFolder = Boolean(encodePlan.isFolder);
const isSharedAudio = Boolean(encodePlan.isSharedAudio)
|| (Array.isArray(encodePlan.inputPaths) && encodePlan.inputPaths.length > 0 && !isFolder);
// Metadata from user config
const planMetadata = encodePlan.metadata || {};
const planTracks = Array.isArray(encodePlan.tracks) ? encodePlan.tracks : [];
if (!AUDIO_OUTPUT_FORMATS.has(outputFormat)) {
throw new Error(`Unbekanntes Audio-Ausgabeformat: ${outputFormat}`);
}
/** Build per-track metadata opts merged with album-level metadata */
function trackMetaOpts(position, defaultBaseName) {
const trackMeta = planTracks.find((t) => t.position === position) || {};
return {
...formatOptions,
trackTitle: trackMeta.title || defaultBaseName || null,
trackArtist: trackMeta.artist || planMetadata.albumArtist || null,
albumTitle: planMetadata.albumTitle || null,
albumArtist: planMetadata.albumArtist || null,
albumYear: planMetadata.albumYear ? String(planMetadata.albumYear) : null,
trackNumber: position
};
}
/** Build output filename with optional track number + title */
function buildOutputFileName(position, trackMeta, defaultBaseName) {
const numStr = String(position).padStart(2, '0');
const title = trackMeta?.title ? sanitizeFileName(trackMeta.title) : null;
const templateRaw = String(encodePlan.audioTrackTemplate || '').trim();
if (templateRaw) {
const rendered = renderTemplate(templateRaw, {
trackNr: numStr,
trackNumber: String(position),
title: title || sanitizeFileName(defaultBaseName) || `Track ${numStr}`,
artist: sanitizeFileName(trackMeta?.artist || planMetadata.albumArtist || '') || 'unknown',
album: sanitizeFileName(planMetadata.albumTitle || '') || 'unknown',
year: planMetadata.albumYear || 'unknown'
});
const sanitized = sanitizeFileName(rendered);
if (sanitized) {
return `${sanitized}.${outputFormat}`;
}
}
if (title) return `${numStr} - ${title}.${outputFormat}`;
return `${sanitizeFileName(defaultBaseName) || numStr}.${outputFormat}`;
}
if (isSharedAudio) {
// Freigegebene Audio-Dateien (mehrere Einzeldateien in einem Job)
const inputPaths = encodePlan.inputPaths || [];
if (inputPaths.length === 0) {
throw new Error('ConverterPlugin._encodeAudio(): inputPaths leer für shared-audio-Job');
}
if (!outputDir) {
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für shared-audio-Job');
}
ensureDir(outputDir);
ctx.logger.info('converter:encode:audio:shared:start', {
jobId: job?.id, fileCount: inputPaths.length, outputDir, format: outputFormat
});
for (let i = 0; i < inputPaths.length; i++) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const inputFile = inputPaths[i];
const fileName = path.basename(inputFile);
const baseName = path.basename(fileName, path.extname(fileName));
const position = i + 1;
const trackMeta = planTracks.find((t) => t.position === position) || {};
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
const outputFile = path.join(outputDir, outputFileName);
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
ctx.emitProgress(
Math.round((i / inputPaths.length) * 100),
`Audio: ${position}/${inputPaths.length}${fileName}`
);
const encodeConfig = buildAudioFfmpegArgs(
ffmpegCmd, inputFile, outputFile, outputFormat,
trackMetaOpts(position, baseName)
);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'FFMPEG',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
}
ctx.logger.info('converter:encode:audio:shared:done', {
jobId: job?.id, fileCount: inputPaths.length, outputDir
});
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: inputPaths.length };
} else if (isFolder) {
// Ordner-Modus: alle Audio-Dateien im Ordner konvertieren
const audioFiles = encodePlan.audioFiles || listAudioFilesInDir(inputPath);
if (!outputDir) {
throw new Error('ConverterPlugin._encodeAudio(): outputDir fehlt für Ordner-Job');
}
ensureDir(outputDir);
ctx.logger.info('converter:encode:audio:folder:start', {
jobId: job?.id, inputPath, outputDir, format: outputFormat, fileCount: audioFiles.length
});
for (let i = 0; i < audioFiles.length; i++) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const fileName = audioFiles[i];
const inputFile = path.join(inputPath, fileName);
const baseName = path.basename(fileName, path.extname(fileName));
const position = i + 1;
const trackMeta = planTracks.find((t) => t.position === position) || {};
const outputFileName = buildOutputFileName(position, trackMeta, baseName);
const outputFile = path.join(outputDir, outputFileName);
ctx.logger.info(`converter:encode:audio:track:${position}`, { inputFile, outputFile });
ctx.emitProgress(
Math.round((i / audioFiles.length) * 100),
`Audio: ${position}/${audioFiles.length}${fileName}`
);
const encodeConfig = buildAudioFfmpegArgs(
ffmpegCmd, inputFile, outputFile, outputFormat,
trackMetaOpts(position, baseName)
);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'FFMPEG',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
}
ctx.logger.info('converter:encode:audio:folder:done', {
jobId: job?.id, fileCount: audioFiles.length, outputDir
});
ctx.extra.encodeResult = { outputDir, format: outputFormat, fileCount: audioFiles.length };
} else {
// Einzelne Datei
if (!outputPath) {
throw new Error('ConverterPlugin._encodeAudio(): outputPath fehlt');
}
ensureDir(path.dirname(outputPath));
ctx.logger.info('converter:encode:audio:file:start', {
jobId: job?.id, inputPath, outputPath, format: outputFormat
});
ctx.emitProgress(0, `Audio: Encoding läuft …`);
const baseName = path.basename(inputPath, path.extname(inputPath));
const encodeConfig = buildAudioFfmpegArgs(
ffmpegCmd, inputPath, outputPath, outputFormat,
trackMetaOpts(1, baseName)
);
await _spawnAndWait({
cmd: encodeConfig.cmd, args: encodeConfig.args,
jobId: job?.id,
appendLogLine: ctx?.extra?.appendLogLine,
logSource: 'FFMPEG',
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: 'ConverterPlugin.encode', stage: 'ENCODING' }
});
ctx.logger.info('converter:encode:audio:file:done', {
jobId: job?.id, outputPath
});
ctx.extra.encodeResult = { outputPath, format: outputFormat };
}
}
// ── Hilfsmethoden ───────────────────────────────────────────────────────
async _runCaptured(cmd, args, options = {}) {
return new Promise((resolve, reject) => {
const { spawn } = require('child_process');
let stdout = '';
let stderr = '';
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
child.on('error', reject);
child.on('close', (code) => resolve({ exitCode: code, stdout, stderr }));
});
}
_parseHandBrakeScan(rawOutput) {
try {
const jsonStart = rawOutput.indexOf('{');
if (jsonStart < 0) return null;
return JSON.parse(rawOutput.slice(jsonStart));
} catch (_err) {
return null;
}
}
async _ffprobeMetadata(ffprobeCmd, filePath, options = {}) {
const args = [
'-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', filePath
];
const result = await this._runCaptured(ffprobeCmd, args, options);
try {
return JSON.parse(result.stdout);
} catch (_err) {
return null;
}
}
getSettingsSchema() {
return [];
}
}
async function _spawnAndWait({
cmd,
args,
jobId,
progressParser,
onProgress,
appendLogLine,
logSource = 'PROCESS',
isCancelled,
onProcessHandle,
context
}) {
if (typeof isCancelled === 'function' && isCancelled()) {
const err = new Error('Job wurde vom Benutzer abgebrochen.');
err.statusCode = 409;
throw err;
}
const handleLine = (stream, line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.detail || null, progress.eta ?? null);
}
}
if (typeof appendLogLine === 'function') {
try {
appendLogLine(logSource, line, stream);
} catch (_error) {
// ignore log append errors in process stream
}
}
};
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: (line) => handleLine('stdout', line),
onStderrLine: (line) => handleLine('stderr', line),
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
const result = await handle.promise;
return result;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
cancelError.statusCode = 409;
throw cancelError;
}
throw error;
}
}
module.exports = { ConverterPlugin };
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const { VideoDiscPlugin } = require('./VideoDiscPlugin');
/**
* Source-Plugin für DVD-Discs.
*
* Erbt die gesamte Rip/Encode-Logik von VideoDiscPlugin.
* Überschreibt nur die Identifikations-Properties und detect().
*
* Erkennungsmerkmale:
* - mediaProfile === 'dvd'
* - Dateisystem: UDF (1.02) oder ISO9660
* - Laufwerksmodell enthält 'dvd', aber KEIN Blu-ray-Marker
*/
class DVDPlugin extends VideoDiscPlugin {
get id() {
return 'dvd';
}
get name() {
return 'DVD';
}
get mediaProfile() {
return 'dvd';
}
/**
* Niedrigere Priorität als Blu-ray (5 < 10), da Blu-ray-Laufwerke
* auch DVDs lesen können — BluRayPlugin soll für BD-Discs bevorzugt werden.
*/
get priority() {
return 5;
}
/**
* Erkennt DVD-Discs.
* Prüft das vom DiskDetectionService gesetzte mediaProfile-Feld.
*
* @param {object} discInfo
* @param {string} [discInfo.mediaProfile] - 'dvd'
* @param {string} [discInfo.fstype] - 'udf' | 'iso9660'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) {
const profile = String(discInfo?.mediaProfile || '').trim().toLowerCase();
if (profile === 'dvd') {
return true;
}
// Fallback: ISO9660 oder UDF ohne Blu-ray-Modell-Marker
const fstype = String(discInfo?.fstype || '').trim().toLowerCase();
const model = String(discInfo?.driveModel || discInfo?.model || '').trim().toLowerCase();
const hasBlurayMarker = /(blu[\s-]?ray|bd[\s_-]?rom|bd-r\b|bd-re\b)/i.test(model);
if (hasBlurayMarker) {
return false; // Blu-ray-Laufwerk → BluRayPlugin übernimmt
}
if (fstype === 'iso9660' || (fstype === 'udf' && /dvd/i.test(model))) {
return true;
}
return false;
}
}
module.exports = { DVDPlugin };
+113
View File
@@ -0,0 +1,113 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const dvdSeriesScanService = require('../services/dvdSeriesScanService');
const tmdbService = require('../services/tmdbService');
/**
* Companion-Plugin für Serien-DVDs.
*
* Dieses Plugin ersetzt den bestehenden DVDPlugin-Flow nicht. Es wird in einem
* späteren Schritt explizit aus dem DVD-Flow aufgerufen, sobald ein Prescan eine
* serientypische Titelstruktur vermuten lässt.
*/
class DVDSeriesPlugin extends SourcePlugin {
get id() {
return 'dvd_series';
}
get name() {
return 'DVD Series';
}
detect() {
return false;
}
async analyze(_devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'DVDSeriesPlugin.js'
});
const fallbackSeriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
{
value: ctx.extra?.detectedTitle || '',
source: 'detected_title'
}
]);
const scanText = String(ctx.extra?.scanText || '').trim();
if (!scanText) {
return {
parsedScan: null,
seriesAnalysis: null,
seriesLookupHint: fallbackSeriesLookupHint,
providerConfigured: await tmdbService.isConfigured()
};
}
const result = dvdSeriesScanService.analyzeHandBrakeScan(scanText, ctx.extra?.seriesOptions || {});
const seriesLookupHint = dvdSeriesScanService.deriveSeriesLookupHint([
{
value: result?.parsed?.discTitle || '',
source: 'handbrake_disc_title'
},
{
value: ctx.extra?.detectedTitle || '',
source: 'detected_title'
}
]) || fallbackSeriesLookupHint;
return {
parsedScan: result.parsed,
seriesAnalysis: result.analysis,
seriesLookupHint,
providerConfigured: await tmdbService.isConfigured()
};
}
async review(_job, ctx) {
ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'DVDSeriesPlugin.js'
});
return null;
}
async rip() {
throw new Error('DVDSeriesPlugin.rip() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
}
async encode() {
throw new Error('DVDSeriesPlugin.encode() ist ein Companion-Plugin und wird nicht direkt ausgeführt.');
}
async searchSeries(query, options = {}) {
return tmdbService.searchSeries(query, options);
}
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
return tmdbService.searchSeriesWithSeason(query, seasonNumber, options);
}
getSettingsSchema() {
return [
{
key: 'tmdb_api_read_access_token',
category: 'Metadaten',
label: 'TMDb Read Access Token',
type: 'string',
required: 0,
description: 'Read Access Token für Serien-Metadaten über TheMovieDB (TMDb).',
default_value: null,
options_json: '[]',
validation_json: '{}',
order_index: 401
}
];
}
}
module.exports = { DVDSeriesPlugin };
+159
View File
@@ -0,0 +1,159 @@
'use strict';
/**
* Abstrakte Basisklasse für Source-Plugins.
* Jedes Plugin implementiert die Pipeline-Phasen für einen Medientyp.
*
* Lifecycle: detect() → analyze() → rip() → review() → encode() → finalize()
*
* Alle async-Methoden können einen Fehler werfen — der Orchestrator fängt
* diese ab und schreibt sie als Job-Fehler in die Datenbank.
*/
class SourcePlugin {
/**
* Eindeutiger Bezeichner des Plugins.
* Erlaubte Werte: 'bluray' | 'dvd' | 'cd' | 'audiobook' | eigener String
* @returns {string}
*/
get id() {
throw new Error(`${this.constructor.name}: id nicht implementiert`);
}
/**
* Anzeigename des Plugins (für Logs und UI).
* @returns {string}
*/
get name() {
throw new Error(`${this.constructor.name}: name nicht implementiert`);
}
/**
* Priorität bei detect()-Konflikten. Höhere Zahl = höhere Priorität.
* Standard: 0
* @returns {number}
*/
get priority() {
return 0;
}
/**
* Prüft, ob dieses Plugin für die erkannte Disc/Datei zuständig ist.
* Wird vom PluginRegistry.findPlugin() aufgerufen.
*
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
* @param {string} [discInfo.devicePath]
* @param {string} [discInfo.discType] - 'bluray' | 'dvd' | 'cd' | 'data'
* @param {string} [discInfo.filesystem] - z.B. 'UDF', 'ISO9660', 'CDFS'
* @param {string} [discInfo.driveModel]
* @returns {boolean}
*/
detect(discInfo) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: detect() nicht implementiert`);
}
/**
* Analysiert das Medium (z.B. makemkvcon info, cdparanoia -Q, ffprobe).
* Gibt die Rohdaten zurück, die der Orchestrator im Job speichert.
*
* @param {string} devicePath - Gerätepfad, z.B. '/dev/sr0'
* @param {object} job - Bestehender Job-Record aus der DB
* @param {PluginContext} ctx
* @returns {Promise<object>} Analyse-Ergebnis
* Empfohlene Felder: { encodePlan, handbrakeInfo, rawDiscInfo, ... }
*/
async analyze(devicePath, job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: analyze() nicht implementiert`);
}
/**
* Rippt das Medium in den RAW-Ordner.
* Fortschritt wird über ctx.emitProgress() gemeldet.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async rip(job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: rip() nicht implementiert`);
}
/**
* Bereitet die Encode-Review vor (MediaInfo-Analyse, Einstellungsvorschau).
* Optional — Plugins, die keine Review benötigen, können die Basis-Implementierung
* behalten (gibt null zurück, Orchestrator überspringt dann den Review-Schritt).
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object|null>} Review-Daten oder null
*/
async review(job, ctx) { // eslint-disable-line no-unused-vars
return null;
}
/**
* Encodiert die gerippten Dateien (HandBrake, ffmpeg, etc.).
* Fortschritt wird über ctx.emitProgress() gemeldet.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async encode(job, ctx) { // eslint-disable-line no-unused-vars
throw new Error(`${this.constructor.name}: encode() nicht implementiert`);
}
/**
* Abschlussarbeiten: Umbenennen, Aufräumen, Notifications, Post-Encode-Scripts usw.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async finalize(job, ctx) { // eslint-disable-line no-unused-vars
// Default: kein Finalize-Schritt notwendig
}
/**
* Abbruch-Handler: Laufende Prozesse beenden, temporäre Dateien aufräumen.
* Wird vom Orchestrator bei cancel() aufgerufen.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async onCancel(job, ctx) { // eslint-disable-line no-unused-vars
// Default: nichts zu tun
}
/**
* Retry-Handler: Zustand zurücksetzen vor einem erneuten Versuch.
* Wird vom Orchestrator vor retry() aufgerufen.
* Optional — Default-Implementierung tut nichts.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<void>}
*/
async onRetry(job, ctx) { // eslint-disable-line no-unused-vars
// Default: nichts zu tun
}
/**
* Plugin-spezifische Settings-Schema-Einträge.
* Diese werden vom PluginRegistry.getSettingsSchemas() aggregiert
* und können zur Laufzeit in die DB eingetragen werden.
*
* Format je Eintrag:
* { key, category, label, type, required, description,
* default_value, options_json, validation_json, order_index }
*
* @returns {Array<object>}
*/
getSettingsSchema() {
return [];
}
}
module.exports = { SourcePlugin };
+194
View File
@@ -0,0 +1,194 @@
'use strict';
function normalizeExecutionStage(value) {
const stage = String(value || '').trim().toLowerCase();
return stage || 'unknown';
}
function normalizePluginExecutionState(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const pluginId = String(value.pluginId || '').trim().toLowerCase() || 'unknown';
const pluginName = String(value.pluginName || '').trim() || pluginId;
const pluginFile = String(value.pluginFile || '').trim() || null;
const markerSource = String(value.markerSource || '').trim().toLowerCase() || 'plugin-file';
const firstMarkedAt = String(value.firstMarkedAt || '').trim() || null;
const lastMarkedAt = String(value.lastMarkedAt || '').trim() || null;
const rawLastStage = String(value.lastStage || '').trim();
const lastStage = rawLastStage ? normalizeExecutionStage(rawLastStage) : null;
const jobIdRaw = Number(value.jobId);
const jobId = Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null;
const byStageRaw = value.byStage && typeof value.byStage === 'object' && !Array.isArray(value.byStage)
? value.byStage
: {};
const byStage = {};
for (const [stageKey, stageMeta] of Object.entries(byStageRaw)) {
const normalizedStage = normalizeExecutionStage(stageKey);
const count = Number(stageMeta?.count);
byStage[normalizedStage] = {
count: Number.isFinite(count) && count > 0 ? Math.trunc(count) : 1,
lastMarkedAt: String(stageMeta?.lastMarkedAt || '').trim() || null
};
}
const explicitStages = Array.isArray(value.stages)
? value.stages.map((stage) => normalizeExecutionStage(stage)).filter(Boolean)
: [];
const stages = Array.from(new Set([
...explicitStages,
...Object.keys(byStage),
...(lastStage ? [lastStage] : [])
]));
return {
markerSource,
pluginId,
pluginName,
pluginFile,
jobId,
firstMarkedAt,
lastMarkedAt,
lastStage,
stages,
byStage
};
}
function mergePluginExecutionState(existingState, marker) {
const existing = normalizePluginExecutionState(existingState);
const normalizedMarker = marker && typeof marker === 'object' ? marker : null;
if (!normalizedMarker) {
return existing;
}
const stage = normalizeExecutionStage(normalizedMarker.stage);
const markedAt = String(normalizedMarker.markedAt || '').trim() || new Date().toISOString();
const previousStageMeta = existing?.byStage?.[stage] || null;
const nextStageCount = Number(previousStageMeta?.count || 0) + 1;
const nextByStage = {
...(existing?.byStage || {}),
[stage]: {
count: nextStageCount,
lastMarkedAt: markedAt
}
};
const nextStages = Array.from(new Set([
...(Array.isArray(existing?.stages) ? existing.stages : []),
stage
]));
return {
markerSource: 'plugin-file',
pluginId: String(normalizedMarker.pluginId || existing?.pluginId || '').trim().toLowerCase() || 'unknown',
pluginName: String(normalizedMarker.pluginName || existing?.pluginName || '').trim()
|| String(normalizedMarker.pluginId || existing?.pluginId || '').trim()
|| 'unknown',
pluginFile: String(normalizedMarker.pluginFile || existing?.pluginFile || '').trim() || null,
jobId: Number.isFinite(Number(normalizedMarker.jobId)) && Number(normalizedMarker.jobId) > 0
? Math.trunc(Number(normalizedMarker.jobId))
: (existing?.jobId || null),
firstMarkedAt: existing?.firstMarkedAt || markedAt,
lastMarkedAt: markedAt,
lastStage: stage,
stages: nextStages,
byStage: nextByStage
};
}
/**
* Kontext-Objekt, das an alle Plugin-Methoden weitergegeben wird.
* Kapselt den Zugriff auf alle Services, die ein Plugin benötigt,
* ohne direkte Abhängigkeiten auf Singletons zu erzwingen.
*
* Wird vom PluginOrchestrator befüllt und ist read-only für Plugins.
*/
class PluginContext {
/**
* @param {object} options
* @param {object} options.settings - settingsService (mit get(), getAll() etc.)
* @param {object} options.db - SQLite-Datenbankinstanz (getDb())
* @param {object} options.logger - Logger-Instanz (child-Logger empfohlen)
* @param {object} options.websocket - websocketService (broadcast() etc.)
* @param {object} options.processRunner - processRunner (spawnTrackedProcess etc.)
* @param {Function} options.emitProgress - (progress: number, statusText: string, eta?: number) => void
* @param {Function} options.emitState - (newState: string, context?: object) => void
* @param {Function} options.onPluginExecution - Callback für echte Plugin-Datei-Ausführung
* @param {object} [options.extra] - Beliebige plugin-spezifische Extras
*/
constructor({
settings,
db,
logger,
websocket,
processRunner,
emitProgress,
emitState,
onPluginExecution,
extra = {}
} = {}) {
this.settings = settings;
this.db = db;
this.logger = logger;
this.websocket = websocket;
this.processRunner = processRunner;
this.emitProgress = typeof emitProgress === 'function' ? emitProgress : () => {};
this.emitState = typeof emitState === 'function' ? emitState : () => {};
this.onPluginExecution = typeof onPluginExecution === 'function' ? onPluginExecution : () => {};
this.extra = extra;
this.pluginExecution = null;
}
markExecution(stage, payload = {}) {
const jobIdRaw = Number(payload?.jobId ?? this.extra?.jobId ?? this.extra?.job?.id ?? 0);
const marker = {
markerSource: 'plugin-file',
pluginId: String(payload?.pluginId || this.extra?.pluginId || '').trim().toLowerCase() || 'unknown',
pluginName: String(payload?.pluginName || '').trim()
|| String(payload?.pluginId || this.extra?.pluginId || '').trim()
|| 'unknown',
pluginFile: String(payload?.pluginFile || '').trim() || null,
jobId: Number.isFinite(jobIdRaw) && jobIdRaw > 0 ? Math.trunc(jobIdRaw) : null,
stage: normalizeExecutionStage(stage),
markedAt: new Date().toISOString()
};
this.pluginExecution = mergePluginExecutionState(this.pluginExecution, marker);
try {
this.onPluginExecution(marker, this.getPluginExecution());
} catch (error) {
if (this.logger && typeof this.logger.warn === 'function') {
this.logger.warn('plugin:execution:callback-failed', {
pluginId: marker.pluginId,
pluginFile: marker.pluginFile,
stage: marker.stage,
error: error?.message || String(error)
});
}
}
return this.getPluginExecution();
}
getPluginExecution() {
const normalized = normalizePluginExecutionState(this.pluginExecution);
if (!normalized) {
return null;
}
return {
...normalized,
stages: [...normalized.stages],
byStage: Object.fromEntries(
Object.entries(normalized.byStage || {}).map(([stage, meta]) => [
stage,
{
count: Number(meta?.count || 1),
lastMarkedAt: meta?.lastMarkedAt || null
}
])
)
};
}
}
module.exports = { PluginContext };
+132
View File
@@ -0,0 +1,132 @@
'use strict';
const { SourcePlugin } = require('./PluginBase');
const logger = require('../services/logger').child('PLUGIN-REGISTRY');
/**
* Registry für alle Source-Plugins.
* Plugins werden beim Start registriert und anhand von detect() ausgewählt.
*
* Verwendung:
* const { registry } = require('./PluginRegistry');
* registry.register(new BluRayPlugin());
* const plugin = registry.findPlugin(discInfo); // → BluRayPlugin | null
*/
class PluginRegistry {
constructor() {
/** @type {Map<string, SourcePlugin>} */
this._plugins = new Map();
}
/**
* Registriert ein Plugin.
* Wirft einen Fehler wenn das übergebene Objekt keine SourcePlugin-Instanz ist.
* Überschreibt ein bereits vorhandenes Plugin mit gleicher ID (mit Warning).
*
* @param {SourcePlugin} plugin
*/
register(plugin) {
if (!(plugin instanceof SourcePlugin)) {
throw new Error(
`Plugin muss eine Instanz von SourcePlugin sein: ${plugin?.constructor?.name || typeof plugin}`
);
}
const id = plugin.id;
if (!id || typeof id !== 'string') {
throw new Error(`Plugin muss eine nicht-leere String-ID haben (got: ${JSON.stringify(id)})`);
}
if (this._plugins.has(id)) {
logger.warn('registry:plugin-overwrite', { id, existingName: this._plugins.get(id).name });
}
this._plugins.set(id, plugin);
logger.info('registry:plugin-registered', { id, name: plugin.name, priority: plugin.priority });
}
/**
* Findet das passende Plugin für eine erkannte Disc.
*
* Alle Plugins werden nach priority (absteigend) sortiert. Das erste,
* dessen detect() true zurückgibt, wird verwendet.
* Fehler in detect() werden geloggt und ignoriert (Plugin übersprungen).
*
* @param {object} discInfo - Disc-Informationen vom DiskDetectionService
* @returns {SourcePlugin|null} Passendes Plugin oder null wenn keines matched
*/
findPlugin(discInfo) {
const sorted = [...this._plugins.values()]
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
for (const plugin of sorted) {
try {
if (plugin.detect(discInfo)) {
logger.debug('registry:plugin-matched', { id: plugin.id, discType: discInfo?.discType });
return plugin;
}
} catch (error) {
logger.warn('registry:detect-error', {
id: plugin.id,
error: error?.message || String(error)
});
}
}
logger.warn('registry:no-plugin-matched', { discType: discInfo?.discType, filesystem: discInfo?.filesystem });
return null;
}
/**
* Gibt ein Plugin anhand seiner ID zurück.
*
* @param {string} id
* @returns {SourcePlugin|null}
*/
getPlugin(id) {
return this._plugins.get(id) ?? null;
}
/**
* Gibt alle registrierten Plugins zurück (Reihenfolge: Registrierungsreihenfolge).
*
* @returns {SourcePlugin[]}
*/
getAllPlugins() {
return [...this._plugins.values()];
}
/**
* Aggregiert die Settings-Schemata aller registrierten Plugins.
* Kann genutzt werden, um Plugin-Settings dynamisch in die DB einzutragen.
*
* @returns {Array<object>}
*/
getSettingsSchemas() {
const schemas = [];
for (const plugin of this._plugins.values()) {
try {
const pluginSchemas = plugin.getSettingsSchema();
if (Array.isArray(pluginSchemas) && pluginSchemas.length > 0) {
schemas.push(...pluginSchemas);
}
} catch (error) {
logger.warn('registry:schema-error', {
id: plugin.id,
error: error?.message || String(error)
});
}
}
return schemas;
}
/**
* Gibt die Anzahl der registrierten Plugins zurück.
* @returns {number}
*/
get size() {
return this._plugins.size;
}
}
// Modul-Level-Singleton — wird beim Start einmalig befüllt.
const registry = new PluginRegistry();
module.exports = { PluginRegistry, registry };
+540
View File
@@ -0,0 +1,540 @@
'use strict';
const path = require('path');
const { SourcePlugin } = require('./PluginBase');
const omdbService = require('../services/omdbService');
const { spawnTrackedProcess } = require('../services/processRunner');
const { parseMakeMkvProgress, parseHandBrakeProgress } = require('../utils/progressParsers');
const { ensureDir } = require('../utils/files');
/**
* Gemeinsame Basisklasse für Blu-ray und DVD Plugins.
*
* Implementiert die gemeinsame Pipeline-Logik für alle Video-Disc-Medien:
* analyze() → OMDB-Suche + Disc-Metadaten vorbereiten
* review() → HandBrake-Scan (liefert rohe Scandaten für den Orchestrator)
* rip() → makemkvcon backup/mkv
* encode() → HandBrakeCLI
*
* Subklassen müssen implementieren:
* get id() → 'bluray' | 'dvd'
* get name() → Anzeigename
* get mediaProfile() → 'bluray' | 'dvd'
* detect(discInfo) → boolean
*
* ctx.extra-Felder für rip():
* rawJobDir - Absoluter Pfad des RAW-Ausgabeordners
* deviceInfo - { path, index, mediaProfile } vom DiskDetectionService
* selectedTitleId - MakeMKV-Titel-ID (optional, nur für mkv-Modus)
* ripMode - 'backup' | 'mkv' (Default: aus Settings)
* backupOutputBase - Basisname für DVD-Backup (nur für backup+DVD)
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* ctx.extra-Felder für encode():
* inputPath - Absoluter Pfad zur gerippten Quelldatei/Verzeichnis
* outputPath - Absoluter Pfad des fertigen Ausgabefiles (inkl. Temp-Präfix)
* encodePlan - { handBrakeTitleId, trackSelection, userPreset } aus confirm-Review
* isCancelled - () => boolean
* onProcessHandle - (handle) => void [optional]
*
* ctx.extra-Felder für review():
* deviceInfo - Disc-Device (für Pre-Rip-Scan) ODER
* rawJobDir - RAW-Ordner (für Post-Rip-Scan)
* reviewMode - 'pre_rip' | 'rip' (Default: 'pre_rip')
*/
class VideoDiscPlugin extends SourcePlugin {
/**
* Gibt das Media-Profil zurück, das settingsService-Methoden verwenden.
* Muss von Subklassen implementiert werden.
* @returns {'bluray'|'dvd'}
*/
get mediaProfile() {
throw new Error(`${this.constructor.name}: mediaProfile nicht implementiert`);
}
/**
* Führt eine OMDB-Suche für den erkannten Disc-Titel durch.
* Gibt { detectedTitle, omdbCandidates } zurück.
*
* @param {string} devicePath
* @param {object} job - Vorbereiteter Job-Record (noch ohne Metadaten)
* @param {PluginContext} ctx
* @returns {Promise<{detectedTitle: string, omdbCandidates: Array}>}
*/
async analyze(devicePath, job, ctx) {
ctx.markExecution('analyze', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const discInfo = ctx.extra?.discInfo || {};
const detectedTitle = String(
discInfo.discLabel || discInfo.label || discInfo.model || job?.detected_title || 'Unknown Disc'
).trim();
ctx.logger.info(`${this.id}:analyze:start`, { devicePath, jobId: job?.id, detectedTitle });
const omdbCandidates = await omdbService.search(detectedTitle).catch((error) => {
ctx.logger.warn(`${this.id}:analyze:omdb-failed`, {
jobId: job?.id,
error: error?.message || String(error)
});
return [];
});
ctx.logger.info(`${this.id}:analyze:done`, {
jobId: job?.id,
detectedTitle,
omdbCandidates: omdbCandidates.length
});
return { detectedTitle, omdbCandidates };
}
/**
* Führt einen HandBrake-Scan durch und gibt die rohen Scandaten zurück.
* Der Orchestrator übernimmt die komplexe Auswertung (buildDiscScanReview).
*
* ctx.extra.reviewMode:
* 'pre_rip' → Scan direkt vom Laufwerk (deviceInfo)
* 'rip' → Scan aus dem RAW-Ordner (rawJobDir)
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<{scanLines: string[], runInfo: object}|null>}
*/
async review(job, ctx) {
ctx.markExecution('review', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const reviewMode = String(ctx.extra?.reviewMode || 'pre_rip').trim().toLowerCase();
const deviceInfo = ctx.extra?.deviceInfo || null;
const rawJobDir = ctx.extra?.rawJobDir || null;
ctx.logger.info(`${this.id}:review:start`, {
jobId: job?.id,
reviewMode,
deviceInfo: deviceInfo?.path || null,
rawJobDir: rawJobDir || null
});
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
let scanConfig;
if (reviewMode === 'rip' && rawJobDir) {
// Post-Rip scan: scan from the ripped RAW folder
scanConfig = await ctx.settings.buildHandBrakeScanConfigForInput(rawJobDir, {
mediaProfile: this.mediaProfile,
settingsMap: settings
});
} else {
// Pre-Rip scan: scan directly from disc device
scanConfig = await ctx.settings.buildHandBrakeScanConfig(deviceInfo, {
mediaProfile: this.mediaProfile,
settingsMap: settings
});
}
ctx.logger.info(`${this.id}:review:scan-command`, {
jobId: job?.id,
cmd: scanConfig.cmd,
args: scanConfig.args
});
const scanLines = [];
const scanAnalysisLines = [];
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
const reviewStage = String(ctx.extra?.reviewStage || 'MEDIAINFO_CHECK').trim().toUpperCase() || 'MEDIAINFO_CHECK';
const reviewSource = String(ctx.extra?.reviewSource || 'HANDBRAKE_SCAN').trim().toUpperCase() || 'HANDBRAKE_SCAN';
const reviewSilent = Boolean(ctx.extra?.reviewSilent);
let runInfo;
if (runCommandFn) {
try {
runInfo = await runCommandFn({
jobId: job?.id,
stage: reviewStage,
source: reviewSource,
cmd: scanConfig.cmd,
args: scanConfig.args,
collectLines: scanLines,
// Keep JSON parsing stable by collecting only stdout in scanLines.
// Stderr is still mirrored into scanAnalysisLines for heuristics.
collectStderrLines: false,
onStdoutLine: (line) => scanAnalysisLines.push(line),
onStderrLine: (line) => scanAnalysisLines.push(line),
silent: reviewSilent
});
} catch (cmdError) {
// runCommand collected stdout lines via callbacks before throwing.
// Recover the runInfo from the error so the caller can still parse
// whatever output HandBrakeCLI produced (e.g. exit code 2 with valid JSON).
runInfo = cmdError?.runInfo || {
status: 'ERROR',
exitCode: typeof cmdError?.code === 'number' ? cmdError.code : 1,
errorMessage: cmdError?.message || String(cmdError)
};
}
} else {
runInfo = await _runCommandCaptured({
cmd: scanConfig.cmd,
args: scanConfig.args,
onStdoutLine: (line) => {
scanLines.push(line);
scanAnalysisLines.push(line);
},
onStderrLine: (line) => scanAnalysisLines.push(line),
context: { jobId: job?.id, source: `${this.id}Plugin.review` }
});
}
ctx.logger.info(`${this.id}:review:scan-done`, {
jobId: job?.id,
exitCode: runInfo.exitCode,
lineCount: scanAnalysisLines.length
});
// Return raw scan data — the Orchestrator (or legacy pipelineService)
// processes this with buildDiscScanReview() / parseMediainfoJsonOutput().
return {
scanLines,
scanAnalysisLines,
runInfo,
reviewMode,
mediaProfile: this.mediaProfile,
sourceArg: scanConfig.sourceArg || null
};
}
/**
* Rippt die Disc mit makemkvcon in den rawJobDir.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} runInfo vom makemkvcon-Prozess
*/
async rip(job, ctx) {
ctx.markExecution('rip', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const {
rawJobDir,
deviceInfo,
selectedTitleId = null,
backupOutputBase = null,
disableMinLengthFilter = false,
sourceArgOverride = null,
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!rawJobDir) {
throw new Error(`${this.constructor.name}.rip(): ctx.extra.rawJobDir fehlt`);
}
if (!deviceInfo) {
throw new Error(`${this.constructor.name}.rip(): ctx.extra.deviceInfo fehlt`);
}
ensureDir(rawJobDir);
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
const ripConfig = await ctx.settings.buildMakeMKVRipConfig(rawJobDir, deviceInfo, {
selectedTitleId,
mediaProfile: this.mediaProfile,
settingsMap: settings,
backupOutputBase,
disableMinLengthFilter,
sourceArgOverride
});
const ripMode = String(ripConfig.ripMode || settings.makemkv_rip_mode || 'mkv')
.trim().toLowerCase() === 'backup' ? 'backup' : 'mkv';
ctx.logger.info(`${this.id}:rip:start`, {
jobId: job?.id,
cmd: ripConfig.cmd,
args: ripConfig.args,
ripMode,
rawJobDir,
selectedTitleId,
disableMinLengthFilter,
sourceArgOverride: sourceArgOverride || null
});
ctx.emitProgress(0, ripMode === 'backup'
? `${this.name}: Backup läuft …`
: `${this.name}: Ripping läuft …`
);
// Pipeline-integrierter Pfad: ctx.extra.runCommand delegiert an this.runCommand()
// des PipelineService und liefert das volle runInfo (inkl. highlights, stdout-Log,
// Prozess-Tracking, Cancellation). Fallback: _spawnAndWait für Standalone/Tests.
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
let runInfo;
if (runCommandFn) {
runInfo = await runCommandFn({
jobId: job?.id,
stage: 'RIPPING',
source: 'MAKEMKV_RIP',
cmd: ripConfig.cmd,
args: ripConfig.args,
parser: parseMakeMkvProgress
});
} else {
runInfo = await _spawnAndWait({
cmd: ripConfig.cmd,
args: ripConfig.args,
cwd: rawJobDir,
jobId: job?.id,
progressParser: parseMakeMkvProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: `${this.id}Plugin.rip`, stage: 'RIPPING' }
});
}
ctx.logger.info(`${this.id}:rip:done`, {
jobId: job?.id,
rawJobDir,
exitCode: runInfo?.exitCode ?? runInfo?.code ?? null
});
ctx.extra.ripRunInfo = runInfo;
return runInfo;
}
/**
* Encodiert die gerippten Dateien mit HandBrakeCLI.
*
* @param {object} job
* @param {PluginContext} ctx
* @returns {Promise<object>} runInfo vom HandBrake-Prozess
*/
async encode(job, ctx) {
ctx.markExecution('encode', {
pluginId: this.id,
pluginName: this.name,
pluginFile: 'VideoDiscPlugin.js'
});
const {
inputPath,
outputPath,
encodePlan = {},
isCancelled,
onProcessHandle
} = ctx.extra || {};
if (!inputPath) {
throw new Error(`${this.constructor.name}.encode(): ctx.extra.inputPath fehlt`);
}
if (!outputPath) {
throw new Error(`${this.constructor.name}.encode(): ctx.extra.outputPath fehlt`);
}
ensureDir(path.dirname(outputPath));
const settings = await ctx.settings.getEffectiveSettingsMap(this.mediaProfile);
const handBrakeConfig = await ctx.settings.buildHandBrakeConfig(inputPath, outputPath, {
trackSelection: encodePlan.trackSelection || null,
titleId: encodePlan.handBrakeTitleId || null,
mediaProfile: this.mediaProfile,
settingsMap: settings,
userPreset: encodePlan.userPreset || null
});
ctx.logger.info(`${this.id}:encode:start`, {
jobId: job?.id,
cmd: handBrakeConfig.cmd,
args: handBrakeConfig.args,
titleId: encodePlan.handBrakeTitleId || null
});
ctx.emitProgress(0, `${this.name}: Encoding läuft …`);
const runCommandFn = typeof ctx.extra?.runCommand === 'function' ? ctx.extra.runCommand : null;
const encodeSource = String(ctx.extra?.encodeSource || 'HANDBRAKE').trim().toUpperCase() || 'HANDBRAKE';
const encodeStage = String(ctx.extra?.encodeStage || 'ENCODING').trim().toUpperCase() || 'ENCODING';
const encodeParser = typeof ctx.extra?.progressParser === 'function'
? ctx.extra.progressParser
: parseHandBrakeProgress;
let runInfo;
if (runCommandFn) {
runInfo = await runCommandFn({
jobId: job?.id,
stage: encodeStage,
source: encodeSource,
cmd: handBrakeConfig.cmd,
args: handBrakeConfig.args,
parser: encodeParser
});
} else {
runInfo = await _spawnAndWait({
cmd: handBrakeConfig.cmd,
args: handBrakeConfig.args,
jobId: job?.id,
progressParser: parseHandBrakeProgress,
onProgress: (pct, statusText) => ctx.emitProgress(Math.round(pct), statusText || `${this.name}: Encoding ${pct}%`),
isCancelled,
onProcessHandle,
context: { jobId: job?.id, source: `${this.id}Plugin.encode`, stage: 'ENCODING' }
});
}
ctx.logger.info(`${this.id}:encode:done`, {
jobId: job?.id,
outputPath,
exitCode: runInfo.exitCode
});
ctx.extra.encodeRunInfo = runInfo;
return runInfo;
}
/**
* Plugin-spezifische Settings (gemeinsam für BluRay + DVD, via this.mediaProfile).
*/
getSettingsSchema() {
const profile = this.mediaProfile;
const label = profile === 'bluray' ? 'Blu-ray' : 'DVD';
const orderBase = profile === 'bluray' ? 200 : 220;
return [
{
key: `handbrake_preset_${profile}`,
category: 'Tools',
label: `HandBrake Preset (${label})`,
type: 'string',
required: 0,
description: `Preset Name für -Z (${label}). Leer = kein Preset, nur CLI-Parameter werden verwendet.`,
default_value: null,
options_json: '[]',
validation_json: '{}',
order_index: orderBase
},
{
key: `output_extension_${profile}`,
category: 'Tools',
label: `Ausgabe-Format (${label})`,
type: 'select',
required: 1,
description: `Dateiendung des encodierten ${label}-Outputs.`,
default_value: 'mkv',
options_json: '["mkv","mp4"]',
validation_json: '{}',
order_index: orderBase + 1
},
{
key: `output_template_${profile}`,
category: 'Pfade',
label: `Output Template (${label})`,
type: 'string',
required: 1,
description: `Template für relative ${label}-Ausgabepfade ohne Dateiendung. Platzhalter: {title}, {year}. Unterordner über "/".`,
default_value: '{title} ({year})/{title} ({year})',
options_json: '[]',
validation_json: '{"minLength":1}',
order_index: 700 + (profile === 'bluray' ? 0 : 5)
}
];
}
}
// ── Hilfsfunktionen (privat) ─────────────────────────────────────────────────
function _assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
throw error;
}
}
async function _runCommandCaptured({ cmd, args, onStdoutLine, onStderrLine, context }) {
const lines = [];
const handle = spawnTrackedProcess({
cmd,
args,
onStdoutLine: (line) => {
lines.push(line);
if (typeof onStdoutLine === 'function') {
onStdoutLine(line);
}
},
onStderrLine: (line) => {
lines.push(line);
if (typeof onStderrLine === 'function') {
onStderrLine(line);
}
},
context: context || {}
});
try {
await handle.promise;
return { exitCode: 0, lines };
} catch (error) {
return {
exitCode: typeof error?.code === 'number' ? error.code : 1,
lines,
error: error?.message || String(error)
};
}
}
async function _spawnAndWait({ cmd, args, cwd, jobId, progressParser, onProgress, isCancelled, onProcessHandle, context }) {
_assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.statusText || null);
}
}
},
onStderrLine: (line) => {
if (progressParser && typeof onProgress === 'function') {
const progress = progressParser(line);
if (progress?.percent != null) {
onProgress(progress.percent, progress.statusText || null);
}
}
},
context: context || { jobId }
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
const cancelError = new Error('Job wurde vom Benutzer abgebrochen.');
cancelError.statusCode = 409;
throw cancelError;
}
throw error;
}
}
module.exports = { VideoDiscPlugin };
+410
View File
@@ -0,0 +1,410 @@
'use strict';
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 converterScanService = require('../services/converterScanService');
const historyService = require('../services/historyService');
const logger = require('../services/logger').child('CONVERTER_ROUTE');
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
function resolveTarget(rawDir, input) {
const rel = converterScanService.normalizeRelPath(input != null ? String(input) : '');
if (rel === null) return { error: 'Ungültiger Pfad' };
const absolute = path.join(rawDir, rel || '.');
return { rel: rel || '', absolute };
}
const router = express.Router();
const converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads')
});
// ── Scan ──────────────────────────────────────────────────────────────────
/**
* GET /api/converter/tree
* Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB).
*/
router.get(
'/tree',
asyncHandler(async (req, res) => {
logger.debug('get:tree');
const result = await converterScanService.getTree();
res.json(result);
})
);
/**
* GET /api/converter/browse?parent=relPath
* File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht).
*/
router.get(
'/browse',
asyncHandler(async (req, res) => {
const parent = req.query.parent ? String(req.query.parent).trim() : null;
logger.debug('get:browse', { parent });
const entries = await converterScanService.getEntries(parent);
const rawDir = await converterScanService.getRawDir();
res.json({ entries, rawDir });
})
);
/**
* POST /api/converter/scan
* Manuellen Scan des converter_raw_dir auslösen.
*/
router.post(
'/scan',
asyncHandler(async (req, res) => {
logger.info('post:scan');
const result = await converterScanService.scan();
res.json({ result });
})
);
// ── Jobs erstellen ────────────────────────────────────────────────────────
/**
* POST /api/converter/create-jobs
* Body: { entries: [{ relPath, converterMediaType }] }
* Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl).
*/
router.post(
'/create-jobs',
asyncHandler(async (req, res) => {
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
if (entries.length === 0) {
const error = new Error('Keine Einträge ausgewählt.');
error.statusCode = 400;
throw error;
}
logger.info('post:create-jobs', { count: entries.length });
const jobs = [];
for (const entry of entries) {
const relPath = String(entry?.relPath || '').trim();
if (!relPath) continue;
const job = await pipelineService.createFileJob({
kind: 'converter_entry',
relPath,
options: {
converterMediaType: entry?.converterMediaType || null
}
});
jobs.push(job);
}
res.json({ jobs });
})
);
/**
* POST /api/converter/upload
* Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen).
* Gibt { folders: [{folderRelPath, fileCount}] } zurück.
*/
router.post(
'/upload',
converterUpload.array('files', 50),
asyncHandler(async (req, res) => {
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
const error = new Error('Keine Dateien hochgeladen.');
error.statusCode = 400;
throw error;
}
const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null;
logger.info('post:upload', {
fileCount: files.length,
folderName,
files: files.map((f) => ({ name: f.originalname, size: f.size }))
});
const result = await pipelineService.uploadConverterFiles(files, { folderName });
res.json(result);
})
);
/**
* POST /api/converter/jobs/from-selection
* Body: { relPaths: string[], audioMode: 'individual'|'shared' }
* Erstellt Jobs aus im File-Explorer ausgewählten Dateien.
*/
router.post(
'/jobs/from-selection',
asyncHandler(async (req, res) => {
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
const audioMode = String(req.body?.audioMode || 'individual');
if (relPaths.length === 0) {
const error = new Error('Keine Dateien ausgewählt.');
error.statusCode = 400;
throw error;
}
logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode });
const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode);
res.json({ jobs });
})
);
/**
* POST /api/converter/jobs/:jobId/assign-files
* Body: { relPaths: string[] }
* Fügt ausgewählte Dateien einem bestehenden (nicht gestarteten) Converter-Job hinzu.
*/
router.post(
'/jobs/:jobId/assign-files',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
if (!Number.isFinite(jobId) || jobId <= 0) {
return res.status(400).json({ detail: 'Ungültige jobId.' });
}
if (relPaths.length === 0) {
return res.status(400).json({ detail: 'Keine Dateien übergeben.' });
}
logger.info('post:jobs:assign-files', { jobId, count: relPaths.length });
const result = await pipelineService.assignConverterFilesToJob(jobId, relPaths);
res.json(result);
})
);
/**
* POST /api/converter/jobs/:jobId/remove-file
* Body: { relPath: string }
* Entfernt eine Datei aus einem bestehenden Converter-Job.
*/
router.post(
'/jobs/:jobId/remove-file',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const relPath = String(req.body?.relPath || '').trim();
if (!Number.isFinite(jobId) || jobId <= 0) {
return res.status(400).json({ detail: 'Ungültige jobId.' });
}
if (!relPath) {
return res.status(400).json({ detail: 'relPath fehlt.' });
}
logger.info('post:jobs:remove-file', { jobId, relPath });
const result = await pipelineService.removeConverterFileFromJob(jobId, relPath);
res.json(result);
})
);
/**
* POST /api/converter/jobs/:jobId/remove-input
* Body: { inputPath: string }
* Entfernt eine Datei aus einem Converter-Job anhand des absoluten Input-Pfads.
*/
router.post(
'/jobs/:jobId/remove-input',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const inputPath = String(req.body?.inputPath || '').trim();
if (!Number.isFinite(jobId) || jobId <= 0) {
return res.status(400).json({ detail: 'Ungültige jobId.' });
}
if (!inputPath) {
return res.status(400).json({ detail: 'inputPath fehlt.' });
}
logger.info('post:jobs:remove-input', { jobId, inputPath });
const result = await pipelineService.removeConverterInputFromJob(jobId, inputPath);
res.json(result);
})
);
/**
* POST /api/converter/jobs/:jobId/config
* Body: partial config draft (outputFormat, presets, metadata, tracks, MusicBrainz-UI-Stand)
* Speichert den Draft für READY_TO_START Jobs persistent im encode_plan_json.
*/
router.post(
'/jobs/:jobId/config',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
if (!Number.isFinite(jobId) || jobId <= 0) {
return res.status(400).json({ detail: 'Ungültige jobId.' });
}
logger.debug('post:jobs:config', { jobId });
const result = await pipelineService.updateConverterJobConfig(jobId, req.body || {});
res.json(result);
})
);
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
/**
* DELETE /api/converter/files
* Body: { relPath }
* Datei oder Ordner löschen (fs.rmSync, ohne DB).
*/
router.delete(
'/files',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' });
const target = resolveTarget(rawDir, relPath);
if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' });
logger.info('delete:files', { relPath });
fs.rmSync(target.absolute, { recursive: true, force: true });
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/rename
* Body: { relPath, newName }
* Umbenennen (fs.renameSync, ohne DB).
*/
router.post(
'/files/rename',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
const newName = String(req.body?.newName || '').trim();
if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' });
if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
const source = resolveTarget(rawDir, relPath);
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
const parentAbs = path.dirname(source.absolute);
const destAbs = path.join(parentAbs, newName);
logger.info('post:files:rename', { relPath, newName });
fs.renameSync(source.absolute, destAbs);
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/move
* Body: { relPath, targetParentRelPath }
* Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root.
*/
router.post(
'/files/move',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' });
const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : '';
const source = resolveTarget(rawDir, relPath);
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
const targetParent = resolveTarget(rawDir, targetParentRelPath);
if (targetParent.error) return res.status(400).json({ detail: targetParent.error });
const name = path.basename(source.absolute);
const destAbs = path.join(targetParent.absolute, name);
logger.info('post:files:move', { relPath, targetParentRelPath });
fs.renameSync(source.absolute, destAbs);
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/folder
* Body: { parentRelPath, name }
* Neuen Ordner anlegen (fs.mkdirSync, ohne DB).
*/
router.post(
'/files/folder',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : '';
const name = String(req.body?.name || '').trim();
if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
const parent = resolveTarget(rawDir, parentRelPath);
if (parent.error) return res.status(400).json({ detail: parent.error });
logger.info('post:files:folder', { parentRelPath, name });
fs.mkdirSync(path.join(parent.absolute, name), { recursive: true });
res.json({ ok: true });
})
);
// ── Job-Status ────────────────────────────────────────────────────────────
/**
* GET /api/converter/jobs
* Alle Converter-Jobs zurückgeben.
*/
router.get(
'/jobs',
asyncHandler(async (req, res) => {
const jobs = await pipelineService.getConverterJobs();
res.json({ jobs });
})
);
/**
* GET /api/converter/jobs/:jobId
* Einzelnen Converter-Job abrufen.
*/
router.get(
'/jobs/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const job = await historyService.getJobById(jobId);
if (!job) {
const error = new Error(`Job ${jobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
res.json({ job });
})
);
/**
* POST /api/converter/jobs/:jobId/start
* Job mit finaler Konfiguration starten.
* Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions }
*/
router.post(
'/jobs/:jobId/start',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const config = req.body || {};
logger.info('post:jobs:start', {
jobId,
converterMediaType: config.converterMediaType,
outputFormat: config.outputFormat
});
const result = await pipelineService.startConverterJob(jobId, config);
res.json({ result });
})
);
/**
* POST /api/converter/jobs/:jobId/cancel
* Job abbrechen.
*/
router.post(
'/jobs/:jobId/cancel',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:jobs:cancel', { jobId });
const result = await pipelineService.cancel(jobId);
res.json({ result });
})
);
/**
* DELETE /api/converter/jobs/:jobId
* Job aus der DB löschen.
*/
router.delete(
'/jobs/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('delete:jobs', { jobId });
await historyService.deleteJob(jobId, 'none', { includeRelated: false });
await converterScanService.clearAssignmentsForJob(jobId);
res.json({ ok: true });
})
);
module.exports = router;
+101
View File
@@ -0,0 +1,101 @@
const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const cronService = require('../services/cronService');
const wsService = require('../services/websocketService');
const logger = require('../services/logger').child('CRON_ROUTE');
const router = express.Router();
// GET /api/crons alle Cronjobs auflisten
router.get(
'/',
asyncHandler(async (req, res) => {
logger.debug('get:crons', { reqId: req.reqId });
const jobs = await cronService.listJobs();
res.json({ jobs });
})
);
// POST /api/crons/validate-expression Cron-Ausdruck validieren
router.post(
'/validate-expression',
asyncHandler(async (req, res) => {
const expr = String(req.body?.cronExpression || '').trim();
const validation = cronService.validateExpression(expr);
const nextRunAt = validation.valid ? cronService.getNextRunTime(expr) : null;
res.json({ ...validation, nextRunAt });
})
);
// POST /api/crons neuen Cronjob anlegen
router.post(
'/',
asyncHandler(async (req, res) => {
const payload = req.body || {};
logger.info('post:crons:create', { reqId: req.reqId, name: payload?.name });
const job = await cronService.createJob(payload);
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'created', id: job.id });
res.status(201).json({ job });
})
);
// GET /api/crons/:id einzelnen Cronjob abrufen
router.get(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
logger.debug('get:crons:one', { reqId: req.reqId, cronJobId: id });
const job = await cronService.getJobById(id);
res.json({ job });
})
);
// PUT /api/crons/:id Cronjob aktualisieren
router.put(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const payload = req.body || {};
logger.info('put:crons:update', { reqId: req.reqId, cronJobId: id });
const job = await cronService.updateJob(id, payload);
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'updated', id: job.id });
res.json({ job });
})
);
// DELETE /api/crons/:id Cronjob löschen
router.delete(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
logger.info('delete:crons', { reqId: req.reqId, cronJobId: id });
const removed = await cronService.deleteJob(id);
wsService.broadcast('CRON_JOBS_UPDATED', { action: 'deleted', id: removed.id });
res.json({ removed });
})
);
// GET /api/crons/:id/logs Ausführungs-Logs eines Cronjobs
router.get(
'/:id/logs',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const limit = Math.min(Number(req.query?.limit) || 20, 100);
logger.debug('get:crons:logs', { reqId: req.reqId, cronJobId: id, limit });
const logs = await cronService.getJobLogs(id, limit);
res.json({ logs });
})
);
// POST /api/crons/:id/run Cronjob manuell auslösen
router.post(
'/:id/run',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
logger.info('post:crons:run', { reqId: req.reqId, cronJobId: id });
const result = await cronService.triggerJobManually(id);
res.json(result);
})
);
module.exports = router;
+71
View File
@@ -0,0 +1,71 @@
const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const downloadService = require('../services/downloadService');
const logger = require('../services/logger').child('DOWNLOAD_ROUTE');
const router = express.Router();
router.get(
'/',
asyncHandler(async (req, res) => {
logger.debug('get:downloads', { reqId: req.reqId });
const items = await downloadService.listItems();
res.json({
items,
summary: downloadService.getSummary()
});
})
);
router.get(
'/summary',
asyncHandler(async (req, res) => {
await downloadService.init();
res.json({ summary: downloadService.getSummary() });
})
);
router.post(
'/history/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const target = String(req.body?.target || 'raw').trim();
const outputPath = String(req.body?.outputPath || '').trim() || null;
logger.info('post:downloads:history', {
reqId: req.reqId,
jobId,
target,
outputPath
});
const result = await downloadService.enqueueHistoryJob(jobId, target, { outputPath });
res.status(result.created ? 201 : 200).json({
...result,
summary: downloadService.getSummary()
});
})
);
router.get(
'/:id/file',
asyncHandler(async (req, res) => {
const descriptor = await downloadService.getDownloadDescriptor(req.params.id);
res.download(descriptor.path, descriptor.archiveName);
})
);
router.delete(
'/:id',
asyncHandler(async (req, res) => {
logger.info('delete:downloads:item', {
reqId: req.reqId,
id: req.params.id
});
const result = await downloadService.deleteItem(req.params.id);
res.json({
...result,
summary: downloadService.getSummary()
});
})
);
module.exports = router;
+376
View File
@@ -0,0 +1,376 @@
const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const historyService = require('../services/historyService');
const pipelineService = require('../services/pipelineService');
const logger = require('../services/logger').child('HISTORY_ROUTE');
const router = express.Router();
function parseSelectedJobIds(value, options = {}) {
const { hasExplicitValue = true } = options;
if (!hasExplicitValue) {
return null;
}
const sourceValues = Array.isArray(value)
? value
: String(value || '')
.split(',');
return sourceValues
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
}
router.get(
'/',
asyncHandler(async (req, res) => {
const parsedLimit = Number(req.query.limit);
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.trunc(parsedLimit)
: null;
const statuses = String(req.query.statuses || '')
.split(',')
.map((value) => String(value || '').trim())
.filter(Boolean);
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
const includeChildren = ['1', 'true', 'yes'].includes(String(req.query.includeChildren || '').toLowerCase());
logger.info('get:jobs', {
reqId: req.reqId,
status: req.query.status,
statuses: statuses.length > 0 ? statuses : null,
search: req.query.search,
limit,
lite,
includeChildren
});
const jobs = await historyService.getJobs({
status: req.query.status,
statuses,
search: req.query.search,
limit,
includeFsChecks: !lite,
includeChildren
});
res.json({ jobs });
})
);
router.get(
'/orphan-raw',
asyncHandler(async (req, res) => {
logger.info('get:orphan-raw', { reqId: req.reqId });
const result = await historyService.getOrphanRawFolders();
res.json(result);
})
);
router.post(
'/orphan-raw/import',
asyncHandler(async (req, res) => {
const rawPath = String(req.body?.rawPath || '').trim();
logger.info('post:orphan-raw:import', { reqId: req.reqId, rawPath });
const importedJob = await historyService.importOrphanRawFolder(rawPath);
const importedJobId = Number(importedJob?.id || 0);
let activation = null;
let activationError = null;
if (Number.isFinite(importedJobId) && importedJobId > 0) {
try {
activation = await pipelineService.analyzeRawImportJob(importedJobId, {
rawPath: importedJob?.raw_path || rawPath
});
} catch (error) {
activationError = error?.message || String(error);
logger.warn('post:orphan-raw:import:activation-failed', {
reqId: req.reqId,
jobId: importedJobId,
rawPath,
error: activationError
});
}
}
const refreshedJob = importedJobId > 0
? await historyService.getJobById(importedJobId)
: null;
res.json({
job: refreshedJob || importedJob,
activation,
...(activationError ? { activationError } : {})
});
})
);
router.post(
'/:id/omdb/assign',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const payload = req.body || {};
logger.info('post:job:omdb:assign', {
reqId: req.reqId,
id,
imdbId: payload?.imdbId || null,
hasTitle: Boolean(payload?.title),
hasYear: Boolean(payload?.year)
});
const job = await historyService.assignOmdbMetadata(id, payload);
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => {
logger.warn('post:job:omdb:assign:rename-failed', { id, error: err.message });
});
res.json({ job });
})
);
router.post(
'/:id/cd/assign',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const payload = req.body || {};
logger.info('post:job:cd:assign', {
reqId: req.reqId,
id,
mbId: payload?.mbId || null,
hasTitle: Boolean(payload?.title),
hasArtist: Boolean(payload?.artist),
trackCount: Array.isArray(payload?.tracks) ? payload.tracks.length : 0
});
const job = await historyService.assignCdMetadata(id, payload);
// Rename raw/output folders to reflect new metadata (best-effort, non-blocking)
pipelineService.renameJobFolders(id).catch((err) => {
logger.warn('post:job:cd:assign:rename-failed', { id, error: err.message });
});
res.json({ job });
})
);
router.post(
'/:id/error/ack',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
logger.info('post:job:error:ack', { reqId: req.reqId, id });
const job = await historyService.acknowledgeJobError(id);
res.json({ job });
})
);
router.post(
'/:id/nfo/generate',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
logger.info('post:job:nfo:generate', { reqId: req.reqId, id });
const result = await historyService.generateJobNfo(id, {
mode: 'manual',
requireSettingDisabled: true,
failIfExists: true,
failIfOutputMissing: true
});
const job = await historyService.getJobWithLogs(id, { includeFsChecks: true });
res.json({ result, job });
})
);
router.post(
'/:id/delete-files',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'both');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
});
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
? req.body.selectedRawPaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
? req.body.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
logger.warn('post:delete-files', {
reqId: req.reqId,
id,
target,
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const result = await historyService.deleteJobFiles(id, target, {
includeRelated,
selectedJobIds,
selectedRawPaths,
selectedMoviePaths
});
res.json(result);
})
);
router.get(
'/:id/delete-preview',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const includeRelated = ['1', 'true', 'yes'].includes(String(req.query.includeRelated || '1').toLowerCase());
const selectedJobIds = parseSelectedJobIds(req.query.selectedJobIds, {
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.query || {}, 'selectedJobIds')
});
logger.info('get:delete-preview', {
reqId: req.reqId,
id,
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0
});
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
res.json({ preview });
})
);
router.post(
'/:id/delete',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.body?.target || 'none');
const includeRelated = ['1', 'true', 'yes'].includes(String(req.body?.includeRelated || 'false').toLowerCase());
const selectedJobIds = parseSelectedJobIds(req.body?.selectedJobIds, {
hasExplicitValue: Object.prototype.hasOwnProperty.call(req.body || {}, 'selectedJobIds')
});
const requestedResetDriveState = ['1', 'true', 'yes'].includes(String(req.body?.resetDriveState || 'false').toLowerCase());
const preserveRawForImportJobs = ['1', 'true', 'yes'].includes(String(req.body?.preserveRawForImportJobs || 'false').toLowerCase());
const requestedKeepDetectedDevice = req.body?.keepDetectedDevice !== undefined
? ['1', 'true', 'yes'].includes(String(req.body?.keepDetectedDevice || 'false').toLowerCase())
: !requestedResetDriveState;
const selectedRawPaths = Array.isArray(req.body?.selectedRawPaths)
? req.body.selectedRawPaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
const selectedMoviePaths = Array.isArray(req.body?.selectedMoviePaths)
? req.body.selectedMoviePaths
.map((item) => String(item || '').trim())
.filter(Boolean)
: null;
logger.warn('post:delete-job', {
reqId: req.reqId,
id,
target,
includeRelated,
selectedJobIdCount: selectedJobIds ? selectedJobIds.length : 0,
requestedResetDriveState,
preserveRawForImportJobs,
requestedKeepDetectedDevice,
selectedRawPathCount: selectedRawPaths ? selectedRawPaths.length : 0,
selectedMoviePathCount: selectedMoviePaths ? selectedMoviePaths.length : 0
});
const preview = await historyService.getJobDeletePreview(id, { includeRelated, selectedJobIds });
const containsOrphanRawImportJob = Boolean(
preview?.flags?.containsOrphanRawImportJob
|| (Array.isArray(preview?.relatedJobs) && preview.relatedJobs.some(
(row) => Boolean(row?.selected) && Boolean(row?.orphanRawImport)
))
);
const resetDriveState = containsOrphanRawImportJob
? false
: requestedResetDriveState;
const keepDetectedDevice = containsOrphanRawImportJob
? true
: requestedKeepDetectedDevice;
if (containsOrphanRawImportJob && requestedResetDriveState) {
logger.info('post:delete-job:orphan-drive-reset-skipped', {
reqId: req.reqId,
id
});
}
const relatedDevicePaths = Array.isArray(preview?.relatedJobs)
? preview.relatedJobs
.filter((row) => Boolean(row?.selected))
.map((row) => String(row?.discDevice || '').trim())
.filter(Boolean)
: [];
const result = await historyService.deleteJob(id, target, {
includeRelated,
selectedJobIds,
selectedRawPaths,
selectedMoviePaths,
preserveRawForImportJobs
});
await pipelineService.onJobsDeleted(result?.deletedJobIds || [id], {
resetDriveState,
devicePaths: relatedDevicePaths
}).catch((error) => {
logger.warn('post:delete-job:cleanup-failed', { id, error: error?.message || String(error) });
});
const uiReset = await pipelineService.resetFrontendState('history_delete', {
keepDetectedDevice
});
res.json({
...result,
uiReset,
safeguards: {
containsOrphanRawImportJob,
resetDriveStateApplied: resetDriveState,
keepDetectedDeviceApplied: keepDetectedDevice
}
});
})
);
router.get(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const includeLiveLog = ['1', 'true', 'yes'].includes(String(req.query.includeLiveLog || '').toLowerCase());
const includeLogs = ['1', 'true', 'yes'].includes(String(req.query.includeLogs || '').toLowerCase());
const includeAllLogs = ['1', 'true', 'yes'].includes(String(req.query.includeAllLogs || '').toLowerCase());
const lite = ['1', 'true', 'yes'].includes(String(req.query.lite || '').toLowerCase());
const parsedTail = Number(req.query.logTailLines);
const logTailLines = Number.isFinite(parsedTail) && parsedTail > 0
? Math.trunc(parsedTail)
: null;
const includeFsChecks = !(lite || includeLiveLog);
logger.info('get:job-detail', {
reqId: req.reqId,
id,
includeLiveLog,
includeLogs,
includeAllLogs,
logTailLines,
lite,
includeFsChecks
});
const job = await historyService.getJobWithLogs(id, {
includeLiveLog,
includeLogs,
includeAllLogs,
logTailLines,
includeFsChecks
});
if (!job) {
const error = new Error('Job nicht gefunden.');
error.statusCode = 404;
throw error;
}
res.json({ job });
})
);
module.exports = router;
+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;
+69
View File
@@ -0,0 +1,69 @@
const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const runtimeActivityService = require('../services/runtimeActivityService');
const logger = require('../services/logger').child('RUNTIME_ROUTE');
const router = express.Router();
router.get(
'/activities',
asyncHandler(async (req, res) => {
logger.debug('get:runtime:activities', { reqId: req.reqId });
const snapshot = runtimeActivityService.getSnapshot();
res.json(snapshot);
})
);
router.post(
'/activities/:id/cancel',
asyncHandler(async (req, res) => {
const activityId = Number(req.params.id);
const reason = String(req.body?.reason || '').trim() || null;
logger.info('post:runtime:activities:cancel', { reqId: req.reqId, activityId, reason });
const action = await runtimeActivityService.requestCancel(activityId, { reason });
if (!action?.ok) {
const error = new Error(action?.message || 'Abbrechen fehlgeschlagen.');
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
throw error;
}
res.json({
ok: true,
action: action.result || null,
snapshot: runtimeActivityService.getSnapshot()
});
})
);
router.post(
'/activities/:id/next-step',
asyncHandler(async (req, res) => {
const activityId = Number(req.params.id);
logger.info('post:runtime:activities:next-step', { reqId: req.reqId, activityId });
const action = await runtimeActivityService.requestNextStep(activityId, {});
if (!action?.ok) {
const error = new Error(action?.message || 'Nächster Schritt fehlgeschlagen.');
error.statusCode = action?.code === 'NOT_FOUND' ? 404 : 409;
throw error;
}
res.json({
ok: true,
action: action.result || null,
snapshot: runtimeActivityService.getSnapshot()
});
})
);
router.post(
'/activities/clear-recent',
asyncHandler(async (req, res) => {
logger.info('post:runtime:activities:clear-recent', { reqId: req.reqId });
const result = runtimeActivityService.clearRecent();
res.json({
ok: true,
removed: Number(result?.removed || 0),
snapshot: result?.snapshot || runtimeActivityService.getSnapshot()
});
})
);
module.exports = router;
+594
View File
@@ -0,0 +1,594 @@
const express = require('express');
const asyncHandler = require('../middleware/asyncHandler');
const settingsService = require('../services/settingsService');
const scriptService = require('../services/scriptService');
const scriptChainService = require('../services/scriptChainService');
const notificationService = require('../services/notificationService');
const pipelineService = require('../services/pipelineService');
const wsService = require('../services/websocketService');
const hardwareMonitorService = require('../services/hardwareMonitorService');
const userPresetService = require('../services/userPresetService');
const activationBytesService = require('../services/activationBytesService');
const diskDetectionService = require('../services/diskDetectionService');
const coverArtRecoveryService = require('../services/coverArtRecoveryService');
const { fetchCurrentBetaKey } = require('../services/makemkvKeyService');
const logger = require('../services/logger').child('SETTINGS_ROUTE');
const { getDb } = require('../db/database');
const router = express.Router();
function isSensitiveSettingKey(key) {
const normalized = String(key || '').trim().toLowerCase();
if (!normalized) {
return false;
}
return /(token|password|secret|api_key|registration_key|pushover_user|subscriber_pin)/i.test(normalized);
}
router.get(
'/',
asyncHandler(async (req, res) => {
logger.debug('get:settings', { reqId: req.reqId });
const categories = await settingsService.getCategorizedSettings();
res.json({ categories });
})
);
router.get(
'/effective-paths',
asyncHandler(async (req, res) => {
logger.debug('get:settings:effective-paths', { reqId: req.reqId });
const paths = await settingsService.getEffectivePaths();
res.json(paths);
})
);
router.get(
'/handbrake-presets',
asyncHandler(async (req, res) => {
logger.debug('get:settings:handbrake-presets', { reqId: req.reqId });
const presets = await settingsService.getHandBrakePresetOptions();
res.json(presets);
})
);
router.get(
'/makemkv/beta-key',
asyncHandler(async (req, res) => {
logger.debug('get:settings:makemkv:beta-key', { reqId: req.reqId });
const result = await fetchCurrentBetaKey();
res.json({
betaKey: result.key,
sourceUrl: result.sourceUrl
});
})
);
router.post(
'/makemkv/beta-key/apply',
asyncHandler(async (req, res) => {
logger.info('post:settings:makemkv:beta-key:apply', { reqId: req.reqId });
const force = req.body?.force === true;
const currentSettings = await settingsService.getSettingsMap({ forceRefresh: true });
const existingKey = String(currentSettings?.makemkv_registration_key || '').trim();
const betaKeyResult = await fetchCurrentBetaKey();
if (!force && existingKey && existingKey !== betaKeyResult.key) {
const syncResult = await settingsService.syncMakeMKVRegistrationKeyFromSettings({
settingsMap: currentSettings
});
res.json({
applied: false,
preservedExistingKey: true,
reason: 'existing_key',
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl,
settingsFilePath: syncResult?.path || null
});
return;
}
const updated = await settingsService.setSettingValue('makemkv_registration_key', betaKeyResult.key);
wsService.broadcast('SETTINGS_UPDATED', updated);
res.json({
applied: true,
preservedExistingKey: false,
setting: updated,
betaKey: betaKeyResult.key,
sourceUrl: betaKeyResult.sourceUrl
});
})
);
router.get(
'/scripts',
asyncHandler(async (req, res) => {
logger.debug('get:settings:scripts', { reqId: req.reqId });
const scripts = await scriptService.listScripts();
res.json({ scripts });
})
);
router.post(
'/scripts',
asyncHandler(async (req, res) => {
const payload = req.body || {};
logger.info('post:settings:scripts:create', {
reqId: req.reqId,
name: String(payload?.name || '').trim() || null,
scriptBodyLength: String(payload?.scriptBody || '').length
});
const script = await scriptService.createScript(payload);
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'created', id: script.id });
res.status(201).json({ script });
})
);
router.post(
'/scripts/reorder',
asyncHandler(async (req, res) => {
const orderedScriptIds = Array.isArray(req.body?.orderedScriptIds) ? req.body.orderedScriptIds : [];
logger.info('post:settings:scripts:reorder', {
reqId: req.reqId,
count: orderedScriptIds.length
});
const scripts = await scriptService.reorderScripts(orderedScriptIds);
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'reordered', count: scripts.length });
res.json({ scripts });
})
);
router.put(
'/scripts/:id',
asyncHandler(async (req, res) => {
const scriptId = Number(req.params.id);
const payload = req.body || {};
logger.info('put:settings:scripts:update', {
reqId: req.reqId,
scriptId,
name: String(payload?.name || '').trim() || null,
scriptBodyLength: String(payload?.scriptBody || '').length
});
const script = await scriptService.updateScript(scriptId, payload);
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'updated', id: script.id });
res.json({ script });
})
);
router.delete(
'/scripts/:id',
asyncHandler(async (req, res) => {
const scriptId = Number(req.params.id);
logger.info('delete:settings:scripts', {
reqId: req.reqId,
scriptId
});
const removed = await scriptService.deleteScript(scriptId);
wsService.broadcast('SETTINGS_SCRIPTS_UPDATED', { action: 'deleted', id: removed.id });
res.json({ removed });
})
);
router.post(
'/scripts/:id/test',
asyncHandler(async (req, res) => {
const scriptId = Number(req.params.id);
logger.info('post:settings:scripts:test', {
reqId: req.reqId,
scriptId
});
const result = await scriptService.testScript(scriptId);
res.json({ result });
})
);
router.post(
'/script-chains/:id/test',
asyncHandler(async (req, res) => {
const chainId = Number(req.params.id);
logger.info('post:settings:script-chains:test', { reqId: req.reqId, chainId });
const result = await scriptChainService.executeChain(chainId, { source: 'settings_test', mode: 'test' });
res.json({ result });
})
);
router.get(
'/script-chains',
asyncHandler(async (req, res) => {
logger.debug('get:settings:script-chains', { reqId: req.reqId });
const chains = await scriptChainService.listChains();
res.json({ chains });
})
);
router.post(
'/script-chains',
asyncHandler(async (req, res) => {
const payload = req.body || {};
logger.info('post:settings:script-chains:create', { reqId: req.reqId, name: payload?.name });
const chain = await scriptChainService.createChain(payload);
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'created', id: chain.id });
res.status(201).json({ chain });
})
);
router.post(
'/script-chains/reorder',
asyncHandler(async (req, res) => {
const orderedChainIds = Array.isArray(req.body?.orderedChainIds) ? req.body.orderedChainIds : [];
logger.info('post:settings:script-chains:reorder', {
reqId: req.reqId,
count: orderedChainIds.length
});
const chains = await scriptChainService.reorderChains(orderedChainIds);
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'reordered', count: chains.length });
res.json({ chains });
})
);
router.get(
'/script-chains/:id',
asyncHandler(async (req, res) => {
const chainId = Number(req.params.id);
logger.debug('get:settings:script-chains:one', { reqId: req.reqId, chainId });
const chain = await scriptChainService.getChainById(chainId);
res.json({ chain });
})
);
router.put(
'/script-chains/:id',
asyncHandler(async (req, res) => {
const chainId = Number(req.params.id);
const payload = req.body || {};
logger.info('put:settings:script-chains:update', { reqId: req.reqId, chainId, name: payload?.name });
const chain = await scriptChainService.updateChain(chainId, payload);
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'updated', id: chain.id });
res.json({ chain });
})
);
router.delete(
'/script-chains/:id',
asyncHandler(async (req, res) => {
const chainId = Number(req.params.id);
logger.info('delete:settings:script-chains', { reqId: req.reqId, chainId });
const removed = await scriptChainService.deleteChain(chainId);
wsService.broadcast('SETTINGS_SCRIPT_CHAINS_UPDATED', { action: 'deleted', id: removed.id });
res.json({ removed });
})
);
router.post(
'/coverart/recover',
asyncHandler(async (req, res) => {
logger.info('post:settings:coverart:recover', { reqId: req.reqId });
const result = await coverArtRecoveryService.runNow({
trigger: 'manual',
force: true,
logFailures: true
});
res.json({
result,
scheduler: coverArtRecoveryService.getStatus()
});
})
);
router.put(
'/:key',
asyncHandler(async (req, res) => {
const { key } = req.params;
const { value } = req.body;
logger.info('put:setting', {
reqId: req.reqId,
key,
value: isSensitiveSettingKey(key) ? '[redacted]' : value
});
const updated = await settingsService.setSettingValue(key, value);
let reviewRefresh = null;
try {
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave([key]);
if (reviewRefresh?.triggered) {
logger.info('put:setting:review-refresh-started', {
reqId: req.reqId,
key,
jobId: reviewRefresh.jobId
});
}
} catch (error) {
logger.warn('put:setting:review-refresh-failed', {
reqId: req.reqId,
key,
error: {
name: error?.name,
message: error?.message
}
});
reviewRefresh = {
triggered: false,
reason: 'refresh_error',
message: error?.message || 'unknown'
};
}
try {
await hardwareMonitorService.handleSettingsChanged([key]);
} catch (error) {
logger.warn('put:setting:hardware-monitor-refresh-failed', {
reqId: req.reqId,
key,
error: {
name: error?.name,
message: error?.message
}
});
}
try {
await coverArtRecoveryService.handleSettingsChanged([key]);
} catch (error) {
logger.warn('put:setting:coverart-scheduler-refresh-failed', {
reqId: req.reqId,
key,
error: {
name: error?.name,
message: error?.message
}
});
}
wsService.broadcast('SETTINGS_UPDATED', updated);
res.json({ setting: updated, reviewRefresh });
})
);
router.put(
'/',
asyncHandler(async (req, res) => {
const { settings } = req.body || {};
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
const error = new Error('settings fehlt oder ist ungültig.');
error.statusCode = 400;
throw error;
}
logger.info('put:settings:bulk', { reqId: req.reqId, count: Object.keys(settings).length });
const changes = await settingsService.setSettingsBulk(settings);
let reviewRefresh = null;
try {
reviewRefresh = await pipelineService.refreshEncodeReviewAfterSettingsSave(changes.map((item) => item.key));
if (reviewRefresh?.triggered) {
logger.info('put:settings:bulk:review-refresh-started', {
reqId: req.reqId,
jobId: reviewRefresh.jobId,
relevantKeys: reviewRefresh.relevantKeys
});
}
} catch (error) {
logger.warn('put:settings:bulk:review-refresh-failed', {
reqId: req.reqId,
error: {
name: error?.name,
message: error?.message
}
});
reviewRefresh = {
triggered: false,
reason: 'refresh_error',
message: error?.message || 'unknown'
};
}
try {
await hardwareMonitorService.handleSettingsChanged(changes.map((item) => item.key));
} catch (error) {
logger.warn('put:settings:bulk:hardware-monitor-refresh-failed', {
reqId: req.reqId,
error: {
name: error?.name,
message: error?.message
}
});
}
try {
await coverArtRecoveryService.handleSettingsChanged(changes.map((item) => item.key));
} catch (error) {
logger.warn('put:settings:bulk:coverart-scheduler-refresh-failed', {
reqId: req.reqId,
error: {
name: error?.name,
message: error?.message
}
});
}
wsService.broadcast('SETTINGS_BULK_UPDATED', { count: changes.length, keys: changes.map((item) => item.key) });
res.json({ changes, reviewRefresh });
})
);
// ── User Presets ──────────────────────────────────────────────────────────────
router.get(
'/user-presets',
asyncHandler(async (req, res) => {
const mediaType = req.query.media_type || null;
logger.debug('get:user-presets', { reqId: req.reqId, mediaType });
const presets = await userPresetService.listPresets(mediaType);
res.json({ presets });
})
);
router.post(
'/user-presets',
asyncHandler(async (req, res) => {
const payload = req.body || {};
logger.info('post:user-presets:create', { reqId: req.reqId, name: payload?.name });
const preset = await userPresetService.createPreset(payload);
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'created', id: preset.id });
res.status(201).json({ preset });
})
);
router.put(
'/user-presets/:id',
asyncHandler(async (req, res) => {
const presetId = Number(req.params.id);
const payload = req.body || {};
logger.info('put:user-presets:update', { reqId: req.reqId, presetId });
const preset = await userPresetService.updatePreset(presetId, payload);
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'updated', id: preset.id });
res.json({ preset });
})
);
router.delete(
'/user-presets/:id',
asyncHandler(async (req, res) => {
const presetId = Number(req.params.id);
logger.info('delete:user-presets', { reqId: req.reqId, presetId });
const removed = await userPresetService.deletePreset(presetId);
wsService.broadcast('USER_PRESETS_UPDATED', { action: 'deleted', id: removed.id });
res.json({ removed });
})
);
router.post(
'/pushover/test',
asyncHandler(async (req, res) => {
const title = req.body?.title;
const message = req.body?.message;
logger.info('post:pushover:test', {
reqId: req.reqId,
hasTitle: Boolean(title),
hasMessage: Boolean(message)
});
const result = await notificationService.sendTest({ title, message });
res.json({ result });
})
);
router.get(
'/activation-bytes',
asyncHandler(async (req, res) => {
logger.debug('get:settings:activation-bytes', { reqId: req.reqId });
const entries = await activationBytesService.listCachedEntries();
res.json({ entries });
})
);
router.post(
'/activation-bytes',
asyncHandler(async (req, res) => {
const { checksum, activationBytes } = req.body || {};
if (!checksum || !activationBytes) {
const error = new Error('checksum und activationBytes sind erforderlich');
error.statusCode = 400;
throw error;
}
logger.debug('post:settings:activation-bytes', { reqId: req.reqId, checksum });
const saved = await activationBytesService.saveActivationBytes(checksum, activationBytes);
res.json({ success: true, checksum, activationBytes: saved });
})
);
// ── Optical drive scan ────────────────────────────────────────────────────────
router.get(
'/drives',
asyncHandler(async (req, res) => {
logger.debug('get:settings:drives', { reqId: req.reqId });
const devices = await diskDetectionService.getBlockDeviceInfo();
const drives = devices
.filter((d) => d.type === 'rom')
.map((d) => {
const name = String(d.name || '');
const devicePath = d.path || (name ? `/dev/${name}` : null);
const resolvedIndex = Number(d.makemkvIndex);
const discIndex = Number.isFinite(resolvedIndex) && resolvedIndex >= 0
? Math.trunc(resolvedIndex)
: null;
return {
path: devicePath,
name,
model: d.model || null,
discIndex,
discArg: discIndex != null ? `disc:${discIndex}` : null
};
})
.filter((d) => d.path);
res.json({ drives });
})
);
router.post(
'/drives/force-unlock',
asyncHandler(async (req, res) => {
const { devicePath, all } = req.body || {};
const settings = await settingsService.getSettingsMap();
const expertMode = ['1', 'true', 'yes', 'on'].includes(String(settings?.ui_expert_mode || '').toLowerCase());
if (!expertMode) {
const error = new Error('Expertenmodus erforderlich.');
error.statusCode = 403;
throw error;
}
const devices = await diskDetectionService.getBlockDeviceInfo();
const drives = devices
.filter((d) => d.type === 'rom')
.map((d) => {
const name = String(d.name || '');
return d.path || (name ? `/dev/${name}` : null);
})
.filter(Boolean);
const activeLockPaths = (diskDetectionService.getActiveLocks?.() || [])
.map((entry) => String(entry?.path || '').trim())
.filter(Boolean);
const targetPaths = all
? Array.from(new Set([...drives, ...activeLockPaths]))
: [String(devicePath || '').trim()].filter(Boolean);
if (targetPaths.length === 0) {
const error = new Error('Kein Laufwerk angegeben.');
error.statusCode = 400;
throw error;
}
const results = [];
for (const target of targetPaths) {
const result = await pipelineService.forceUnlockDrive(target, { reason: 'settings_force_unlock' });
results.push(result);
}
res.json({ results });
})
);
// User preferences (UI state, persisted per-installation)
router.get(
'/prefs/:key',
asyncHandler(async (req, res) => {
const { key } = req.params;
const db = await getDb();
const row = await db.get('SELECT value FROM user_prefs WHERE key = ?', [key]);
res.json({ key, value: row ? row.value : null });
})
);
router.put(
'/prefs/:key',
asyncHandler(async (req, res) => {
const { key } = req.params;
const { value } = req.body || {};
const db = await getDb();
await db.run(
`INSERT INTO user_prefs (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
[key, value ?? null]
);
res.json({ key, value: value ?? null });
})
);
module.exports = router;
@@ -0,0 +1,79 @@
const fs = require('fs');
const crypto = require('crypto');
const { getDb } = require('../db/database');
const logger = require('./logger').child('ActivationBytes');
const FIXED_KEY = Buffer.from([0x77, 0x21, 0x4d, 0x4b, 0x19, 0x6a, 0x87, 0xcd, 0x52, 0x00, 0x45, 0xfd, 0x20, 0xa5, 0x1d, 0x67]);
const AAX_CHECKSUM_OFFSET = 653;
const AAX_CHECKSUM_LENGTH = 20;
function sha1(data) {
return crypto.createHash('sha1').update(data).digest();
}
function verifyActivationBytes(activationBytesHex, expectedChecksumHex) {
const bytes = Buffer.from(activationBytesHex, 'hex');
const ik = sha1(Buffer.concat([FIXED_KEY, bytes]));
const iv = sha1(Buffer.concat([FIXED_KEY, ik, bytes]));
const checksum = sha1(Buffer.concat([ik.subarray(0, 16), iv.subarray(0, 16)]));
return checksum.toString('hex') === expectedChecksumHex;
}
function readAaxChecksum(filePath) {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(AAX_CHECKSUM_LENGTH);
const bytesRead = fs.readSync(fd, buf, 0, AAX_CHECKSUM_LENGTH, AAX_CHECKSUM_OFFSET);
if (bytesRead !== AAX_CHECKSUM_LENGTH) {
throw new Error(`Konnte Checksum nicht lesen (nur ${bytesRead} Bytes)`);
}
return buf.toString('hex');
} finally {
fs.closeSync(fd);
}
}
async function lookupCached(checksum) {
const db = await getDb();
const row = await db.get('SELECT activation_bytes FROM aax_activation_bytes WHERE checksum = ?', checksum);
return row ? row.activation_bytes : null;
}
async function saveActivationBytes(checksum, activationBytesHex) {
const normalized = String(activationBytesHex || '').trim().toLowerCase();
if (!/^[0-9a-f]{8}$/.test(normalized)) {
throw new Error('Activation Bytes müssen genau 8 Hex-Zeichen (4 Bytes) sein');
}
if (!verifyActivationBytes(normalized, checksum)) {
throw new Error('Activation Bytes passen nicht zur Checksum bitte nochmals prüfen');
}
const db = await getDb();
await db.run(
'INSERT OR REPLACE INTO aax_activation_bytes (checksum, activation_bytes) VALUES (?, ?)',
checksum,
normalized
);
logger.info({ checksum, activationBytes: normalized }, 'Activation Bytes manuell gespeichert');
return normalized;
}
async function resolveActivationBytes(filePath) {
const checksum = readAaxChecksum(filePath);
logger.info({ checksum }, 'AAX Checksum gelesen');
const cached = await lookupCached(checksum);
if (cached) {
logger.info({ checksum }, 'Activation Bytes aus lokalem Cache');
return { checksum, activationBytes: cached };
}
logger.info({ checksum }, 'Keine Activation Bytes im Cache manuelle Eingabe erforderlich');
return { checksum, activationBytes: null };
}
async function listCachedEntries() {
const db = await getDb();
return db.all('SELECT checksum, activation_bytes, created_at FROM aax_activation_bytes ORDER BY created_at DESC');
}
module.exports = { resolveActivationBytes, readAaxChecksum, saveActivationBytes, verifyActivationBytes, listCachedEntries };
+819
View File
@@ -0,0 +1,819 @@
const path = require('path');
const { sanitizeFileName } = require('../utils/files');
const SUPPORTED_INPUT_EXTENSIONS = new Set(['.aax']);
const SUPPORTED_OUTPUT_FORMATS = new Set(['m4b', 'mp3', 'flac']);
const DEFAULT_AUDIOBOOK_RAW_TEMPLATE = '{author} - {title} ({year})';
const DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})';
const DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE = '{author}/{author} - {title} ({year})/{chapterNr} {chapterTitle}';
const AUDIOBOOK_FORMAT_DEFAULTS = {
m4b: {},
flac: {
flacCompression: 5
},
mp3: {
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4
}
};
function normalizeText(value) {
return String(value || '')
.normalize('NFC')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function parseOptionalYear(value) {
const text = normalizeText(value);
if (!text) {
return null;
}
const match = text.match(/\b(19|20)\d{2}\b/);
if (!match) {
return null;
}
return Number(match[0]);
}
function parseOptionalNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseTimebaseToSeconds(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
if (/^\d+\/\d+$/u.test(raw)) {
const [num, den] = raw.split('/').map(Number);
if (Number.isFinite(num) && Number.isFinite(den) && den !== 0) {
return num / den;
}
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function secondsToMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}
return Math.max(0, Math.round(parsed * 1000));
}
function ticksToMs(value, timebase) {
const ticks = Number(value);
const factor = parseTimebaseToSeconds(timebase);
if (!Number.isFinite(ticks) || ticks < 0 || !Number.isFinite(factor) || factor <= 0) {
return null;
}
return Math.max(0, Math.round(ticks * factor * 1000));
}
function normalizeOutputFormat(value) {
const format = String(value || '').trim().toLowerCase();
return SUPPORTED_OUTPUT_FORMATS.has(format) ? format : 'mp3';
}
function clonePlainObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
}
function clampInteger(value, min, max, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(parsed)));
}
function getDefaultFormatOptions(format) {
const normalizedFormat = normalizeOutputFormat(format);
return clonePlainObject(AUDIOBOOK_FORMAT_DEFAULTS[normalizedFormat]);
}
function normalizeFormatOptions(format, formatOptions = {}) {
const normalizedFormat = normalizeOutputFormat(format);
const source = clonePlainObject(formatOptions);
const defaults = getDefaultFormatOptions(normalizedFormat);
if (normalizedFormat === 'flac') {
return {
flacCompression: clampInteger(source.flacCompression, 0, 8, defaults.flacCompression)
};
}
if (normalizedFormat === 'mp3') {
const mp3Mode = String(source.mp3Mode || defaults.mp3Mode || 'cbr').trim().toLowerCase() === 'vbr'
? 'vbr'
: 'cbr';
const allowedBitrates = new Set([128, 160, 192, 256, 320]);
const normalizedBitrate = clampInteger(source.mp3Bitrate, 96, 320, defaults.mp3Bitrate);
return {
mp3Mode,
mp3Bitrate: allowedBitrates.has(normalizedBitrate) ? normalizedBitrate : defaults.mp3Bitrate,
mp3Quality: clampInteger(source.mp3Quality, 0, 9, defaults.mp3Quality)
};
}
return {};
}
function normalizeInputExtension(filePath) {
return path.extname(String(filePath || '')).trim().toLowerCase();
}
function isSupportedInputFile(filePath) {
return SUPPORTED_INPUT_EXTENSIONS.has(normalizeInputExtension(filePath));
}
function normalizeTagMap(tags = null) {
const source = tags && typeof tags === 'object' ? tags : {};
const result = {};
for (const [key, value] of Object.entries(source)) {
const normalizedKey = String(key || '').trim().toLowerCase();
if (!normalizedKey) {
continue;
}
const normalizedValue = normalizeText(value);
if (!normalizedValue) {
continue;
}
result[normalizedKey] = normalizedValue;
}
return result;
}
function pickTag(tags, keys = []) {
const normalized = normalizeTagMap(tags);
for (const key of keys) {
const value = normalized[String(key || '').trim().toLowerCase()];
if (value) {
return value;
}
}
return null;
}
function sanitizeTemplateValue(value, fallback = '') {
const normalized = normalizeText(value);
if (!normalized) {
return fallback;
}
return sanitizeFileName(normalized);
}
function normalizeChapterTitle(value, index) {
const normalized = normalizeText(value);
return normalized || `Kapitel ${index}`;
}
function buildChapterList(probe = null) {
const chapters = Array.isArray(probe?.chapters) ? probe.chapters : [];
return chapters.map((chapter, index) => {
const chapterIndex = index + 1;
const tags = normalizeTagMap(chapter?.tags);
const startSeconds = parseOptionalNumber(chapter?.start_time);
const endSeconds = parseOptionalNumber(chapter?.end_time);
const startMs = secondsToMs(startSeconds) ?? ticksToMs(chapter?.start, chapter?.time_base) ?? 0;
const endMs = secondsToMs(endSeconds) ?? ticksToMs(chapter?.end, chapter?.time_base) ?? 0;
const title = normalizeChapterTitle(tags.title || tags.chapter, chapterIndex);
return {
index: chapterIndex,
title,
startSeconds: Number((startMs / 1000).toFixed(3)),
endSeconds: Number((endMs / 1000).toFixed(3)),
startMs,
endMs,
timeBase: String(chapter?.time_base || '').trim() || null
};
});
}
function normalizeChapterList(chapters = [], options = {}) {
const source = Array.isArray(chapters) ? chapters : [];
const durationMs = Number(options?.durationMs || 0);
const fallbackTitle = normalizeText(options?.fallbackTitle || '');
const createFallback = options?.createFallback === true;
const normalized = source.map((chapter, index) => {
const chapterIndex = Number(chapter?.index);
const safeIndex = Number.isFinite(chapterIndex) && chapterIndex > 0
? Math.trunc(chapterIndex)
: index + 1;
const rawStartMs = parseOptionalNumber(chapter?.startMs)
?? secondsToMs(chapter?.startSeconds)
?? ticksToMs(chapter?.start, chapter?.timeBase || chapter?.time_base)
?? 0;
const rawEndMs = parseOptionalNumber(chapter?.endMs)
?? secondsToMs(chapter?.endSeconds)
?? ticksToMs(chapter?.end, chapter?.timeBase || chapter?.time_base)
?? 0;
return {
index: safeIndex,
title: normalizeChapterTitle(chapter?.title, safeIndex),
startMs: Math.max(0, rawStartMs),
endMs: Math.max(0, rawEndMs)
};
});
const repaired = normalized.map((chapter, index) => {
const nextStartMs = normalized[index + 1]?.startMs ?? null;
let endMs = chapter.endMs;
if (!(endMs > chapter.startMs)) {
if (Number.isFinite(nextStartMs) && nextStartMs > chapter.startMs) {
endMs = nextStartMs;
} else if (durationMs > chapter.startMs) {
endMs = durationMs;
} else {
endMs = chapter.startMs;
}
}
return {
...chapter,
endMs,
startSeconds: Number((chapter.startMs / 1000).toFixed(3)),
endSeconds: Number((endMs / 1000).toFixed(3)),
durationMs: Math.max(0, endMs - chapter.startMs)
};
}).filter((chapter) => chapter.endMs > chapter.startMs || normalized.length === 1);
if (repaired.length > 0) {
return repaired;
}
if (createFallback && durationMs > 0) {
return [{
index: 1,
title: fallbackTitle || 'Kapitel 1',
startMs: 0,
endMs: durationMs,
startSeconds: 0,
endSeconds: Number((durationMs / 1000).toFixed(3)),
durationMs
}];
}
return [];
}
function looksLikeDescription(value) {
const normalized = normalizeText(value);
if (!normalized) {
return false;
}
return normalized.length >= 120 || /[.!?]\s/u.test(normalized);
}
function detectCoverStream(probe = null) {
const streams = Array.isArray(probe?.streams) ? probe.streams : [];
for (const stream of streams) {
const codecType = String(stream?.codec_type || '').trim().toLowerCase();
const codecName = String(stream?.codec_name || '').trim().toLowerCase();
const dispositionAttachedPic = Number(stream?.disposition?.attached_pic || 0) === 1;
const mimetype = String(stream?.tags?.mimetype || '').trim().toLowerCase();
const looksLikeImageStream = codecType === 'video'
&& (dispositionAttachedPic || mimetype.startsWith('image/') || ['jpeg', 'jpg', 'png', 'mjpeg'].includes(codecName));
if (!looksLikeImageStream) {
continue;
}
const streamIndex = Number(stream?.index);
return {
streamIndex: Number.isFinite(streamIndex) ? Math.trunc(streamIndex) : 0,
codecName: codecName || null,
mimetype: mimetype || null,
attachedPic: dispositionAttachedPic
};
}
return null;
}
function parseProbeOutput(rawOutput) {
if (!rawOutput) {
return null;
}
try {
return JSON.parse(rawOutput);
} catch (_error) {
return null;
}
}
function buildMetadataFromProbe(probe = null, originalName = null) {
const format = probe?.format && typeof probe.format === 'object' ? probe.format : {};
const tags = normalizeTagMap(format.tags);
const originalBaseName = path.basename(String(originalName || ''), path.extname(String(originalName || '')));
const fallbackTitle = normalizeText(originalBaseName) || 'Audiobook';
const title = pickTag(tags, ['title', 'album']) || fallbackTitle;
const author = pickTag(tags, ['author', 'artist', 'writer', 'album_artist', 'composer']) || 'Unknown Author';
const description = pickTag(tags, [
'description',
'synopsis',
'summary',
'long_description',
'longdescription',
'publisher_summary',
'publishersummary',
'comment'
]) || null;
let narrator = pickTag(tags, ['narrator', 'performer', 'album_artist']) || null;
if (narrator && (narrator === author || narrator === description || looksLikeDescription(narrator))) {
narrator = null;
}
const series = pickTag(tags, ['series', 'grouping', 'series_title', 'show']) || null;
const part = pickTag(tags, ['part', 'part_number', 'disc', 'discnumber', 'volume']) || null;
const year = parseOptionalYear(pickTag(tags, ['date', 'year', 'creation_time']));
const durationSeconds = Number(format.duration || 0);
const durationMs = Number.isFinite(durationSeconds) && durationSeconds > 0
? Math.round(durationSeconds * 1000)
: 0;
const chapters = normalizeChapterList(buildChapterList(probe), {
durationMs,
fallbackTitle: title,
createFallback: false
});
const cover = detectCoverStream(probe);
return {
title,
author,
narrator,
description,
series,
part,
year,
album: title,
artist: author,
durationMs,
chapters,
cover,
hasEmbeddedCover: Boolean(cover),
tags
};
}
function normalizeTemplateTokenKey(rawKey) {
const key = String(rawKey || '').trim().toLowerCase();
if (!key) {
return '';
}
if (key === 'artist') {
return 'author';
}
if (key === 'chapternr' || key === 'chapternumberpadded' || key === 'chapternopadded') {
return 'chapterNr';
}
if (key === 'chapterno' || key === 'chapternumber' || key === 'chapternum') {
return 'chapterNo';
}
if (key === 'chaptertitle') {
return 'chapterTitle';
}
return key;
}
function cleanupRenderedTemplate(value) {
return String(value || '')
.replace(/\(\s*\)/g, '')
.replace(/\[\s*]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function renderTemplate(template, values) {
const source = String(template || DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE).trim()
|| DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
const rawValue = values?.[normalizedKey];
if (rawValue === undefined || rawValue === null || rawValue === '') {
return '';
}
return String(rawValue);
});
return cleanupRenderedTemplate(rendered);
}
function buildTemplateValues(metadata = {}, format = null, chapter = null) {
const chapterIndex = Number(chapter?.index || chapter?.chapterNo || 0);
const safeChapterIndex = Number.isFinite(chapterIndex) && chapterIndex > 0 ? Math.trunc(chapterIndex) : 1;
const author = sanitizeTemplateValue(metadata.author || metadata.artist || 'Unknown Author', 'Unknown Author');
const title = sanitizeTemplateValue(metadata.title || metadata.album || 'Unknown Audiobook', 'Unknown Audiobook');
const narrator = sanitizeTemplateValue(metadata.narrator || '');
const series = sanitizeTemplateValue(metadata.series || '');
const part = sanitizeTemplateValue(metadata.part || '');
const chapterTitle = sanitizeTemplateValue(chapter?.title || `Kapitel ${safeChapterIndex}`, `Kapitel ${safeChapterIndex}`);
const year = metadata.year ? String(metadata.year) : '';
return {
author,
title,
narrator,
series,
part,
year,
format: format ? String(format).trim().toLowerCase() : '',
chapterNr: String(safeChapterIndex).padStart(2, '0'),
chapterNo: String(safeChapterIndex),
chapterTitle
};
}
function splitRenderedPath(value) {
return String(value || '')
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '')
.split('/')
.map((segment) => sanitizeFileName(segment))
.filter(Boolean);
}
function resolveTemplatePathParts(template, values, fallbackBaseName) {
const rendered = renderTemplate(template, values);
const parts = splitRenderedPath(rendered);
if (parts.length === 0) {
return {
folderParts: [],
baseName: sanitizeFileName(fallbackBaseName || 'untitled')
};
}
return {
folderParts: parts.slice(0, -1),
baseName: parts[parts.length - 1]
};
}
function buildRawStoragePaths(metadata, jobId, rawBaseDir, rawTemplate = DEFAULT_AUDIOBOOK_RAW_TEMPLATE, inputFileName = 'input.aax') {
const ext = normalizeInputExtension(inputFileName) || '.aax';
const values = buildTemplateValues(metadata);
const fallbackBaseName = path.basename(String(inputFileName || 'input.aax'), ext);
const { folderParts, baseName } = resolveTemplatePathParts(rawTemplate, values, fallbackBaseName);
const rawDirName = `${baseName} - RAW - job-${jobId}`;
const rawDir = path.join(String(rawBaseDir || ''), ...folderParts, rawDirName);
const rawFilePath = path.join(rawDir, `${baseName}${ext}`);
return {
rawDir,
rawFilePath,
rawFileName: `${baseName}${ext}`,
rawDirName
};
}
function buildOutputPath(metadata, movieBaseDir, outputTemplate = DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE, outputFormat = 'mp3') {
const normalizedFormat = normalizeOutputFormat(outputFormat);
const values = buildTemplateValues(metadata, normalizedFormat);
const fallbackBaseName = values.title || 'audiobook';
const { folderParts, baseName } = resolveTemplatePathParts(outputTemplate, values, fallbackBaseName);
return path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
}
function findCommonDirectory(paths = []) {
const segmentsList = (Array.isArray(paths) ? paths : [])
.map((entry) => String(entry || '').trim())
.filter(Boolean)
.map((entry) => path.resolve(entry).split(path.sep).filter((segment, index, list) => !(index === 0 && list[0] === '')));
if (segmentsList.length === 0) {
return null;
}
const common = [...segmentsList[0]];
for (let index = 1; index < segmentsList.length; index += 1) {
const next = segmentsList[index];
let matchLength = 0;
while (matchLength < common.length && matchLength < next.length && common[matchLength] === next[matchLength]) {
matchLength += 1;
}
common.length = matchLength;
if (common.length === 0) {
break;
}
}
if (common.length === 0) {
return null;
}
return path.join(path.sep, ...common);
}
function buildChapterOutputPlan(
metadata,
chapters,
movieBaseDir,
chapterTemplate = DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
outputFormat = 'mp3'
) {
const normalizedFormat = normalizeOutputFormat(outputFormat);
const normalizedChapters = normalizeChapterList(chapters, {
durationMs: metadata?.durationMs,
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
createFallback: true
});
const outputFiles = normalizedChapters.map((chapter, index) => {
const values = buildTemplateValues(metadata, normalizedFormat, chapter);
const fallbackBaseName = `${values.chapterNr} ${values.chapterTitle}`.trim() || `Kapitel ${index + 1}`;
const { folderParts, baseName } = resolveTemplatePathParts(chapterTemplate, values, fallbackBaseName);
const outputPath = path.join(String(movieBaseDir || ''), ...folderParts, `${baseName}.${normalizedFormat}`);
return {
chapter,
outputPath
};
});
const outputDir = findCommonDirectory(outputFiles.map((entry) => path.dirname(entry.outputPath)))
|| String(movieBaseDir || '').trim()
|| '.';
return {
outputDir,
outputFiles,
chapters: normalizedChapters,
format: normalizedFormat
};
}
function buildProbeCommand(ffprobeCommand, inputPath) {
const cmd = String(ffprobeCommand || 'ffprobe').trim() || 'ffprobe';
return {
cmd,
args: [
'-v', 'quiet',
'-print_format', 'json',
'-show_format',
'-show_streams',
'-show_chapters',
inputPath
]
};
}
function pushMetadataArg(args, key, value) {
const normalizedKey = String(key || '').trim();
const normalizedValue = normalizeText(value);
if (!normalizedKey || !normalizedValue) {
return;
}
args.push('-metadata', `${normalizedKey}=${normalizedValue}`);
}
function buildMetadataArgs(metadata = {}, options = {}) {
const source = metadata && typeof metadata === 'object' ? metadata : {};
const titleOverride = normalizeText(options?.title || '');
const albumOverride = normalizeText(options?.album || '');
const trackNo = Number(options?.trackNo || 0);
const trackTotal = Number(options?.trackTotal || 0);
const args = [];
const bookTitle = normalizeText(source.title || source.album || '');
const author = normalizeText(source.author || source.artist || '');
pushMetadataArg(args, 'title', titleOverride || bookTitle);
pushMetadataArg(args, 'album', albumOverride || bookTitle);
pushMetadataArg(args, 'artist', author);
pushMetadataArg(args, 'album_artist', author);
pushMetadataArg(args, 'author', author);
pushMetadataArg(args, 'narrator', source.narrator);
pushMetadataArg(args, 'performer', source.narrator);
pushMetadataArg(args, 'grouping', source.series);
pushMetadataArg(args, 'series', source.series);
pushMetadataArg(args, 'disc', source.part);
pushMetadataArg(args, 'description', source.description);
pushMetadataArg(args, 'comment', source.description);
if (source.year) {
pushMetadataArg(args, 'date', String(source.year));
pushMetadataArg(args, 'year', String(source.year));
}
if (Number.isFinite(trackNo) && trackNo > 0) {
const formattedTrack = Number.isFinite(trackTotal) && trackTotal > 0
? `${Math.trunc(trackNo)}/${Math.trunc(trackTotal)}`
: String(Math.trunc(trackNo));
pushMetadataArg(args, 'track', formattedTrack);
}
return args;
}
function buildCodecArgs(format, normalizedOptions) {
if (format === 'm4b') {
return ['-c:a', 'copy'];
}
if (format === 'flac') {
return ['-codec:a', 'flac', '-compression_level', String(normalizedOptions.flacCompression)];
}
if (normalizedOptions.mp3Mode === 'vbr') {
return ['-codec:a', 'libmp3lame', '-q:a', String(normalizedOptions.mp3Quality)];
}
return ['-codec:a', 'libmp3lame', '-b:a', `${normalizedOptions.mp3Bitrate}k`];
}
function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat = 'mp3', formatOptions = {}, options = {}) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
const extra = options && typeof options === 'object' ? options : {};
const commonArgs = [
'-y',
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
'-i', inputPath
];
if (extra.chapterMetadataPath) {
commonArgs.push('-f', 'ffmetadata', '-i', extra.chapterMetadataPath);
}
commonArgs.push(
'-map', '0:a:0?',
'-map_metadata', '0',
'-map_chapters', extra.chapterMetadataPath ? '1' : '0',
'-vn',
'-sn',
'-dn'
);
const metadataArgs = buildMetadataArgs(extra.metadata, extra.metadataOptions);
const codecArgs = buildCodecArgs(format, normalizedOptions);
return {
cmd,
args: [...commonArgs, ...codecArgs, ...metadataArgs, outputPath],
metadataArgs,
formatOptions: normalizedOptions
};
}
function formatSecondsArg(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return '0';
}
return parsed.toFixed(3).replace(/\.?0+$/u, '');
}
function buildChapterEncodeCommand(
ffmpegCommand,
inputPath,
outputPath,
outputFormat = 'mp3',
formatOptions = {},
metadata = {},
chapter = {},
chapterTotal = 1,
options = {}
) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const format = normalizeOutputFormat(outputFormat);
const normalizedOptions = normalizeFormatOptions(format, formatOptions);
const extra = options && typeof options === 'object' ? options : {};
const safeChapter = normalizeChapterList([chapter], {
durationMs: metadata?.durationMs,
fallbackTitle: metadata?.title || 'Kapitel',
createFallback: true
})[0];
const durationSeconds = Number(((safeChapter?.durationMs || 0) / 1000).toFixed(3));
const metadataArgs = buildMetadataArgs(metadata, {
title: safeChapter?.title,
album: metadata?.title || metadata?.album || null,
trackNo: safeChapter?.index || 1,
trackTotal: chapterTotal
});
const codecArgs = buildCodecArgs(format, normalizedOptions);
return {
cmd,
args: [
'-y',
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
'-i', inputPath,
'-ss', formatSecondsArg(safeChapter?.startSeconds),
'-t', formatSecondsArg(durationSeconds),
'-map', '0:a:0?',
'-map_metadata', '-1',
'-map_chapters', '-1',
'-vn',
'-sn',
'-dn',
...codecArgs,
...metadataArgs,
outputPath
],
metadataArgs,
formatOptions: normalizedOptions
};
}
function escapeFfmetadataValue(value) {
return String(value == null ? '' : value)
.replace(/\\/g, '\\\\')
.replace(/=/g, '\\=')
.replace(/;/g, '\\;')
.replace(/#/g, '\\#')
.replace(/\r?\n/g, ' ');
}
function buildChapterMetadataContent(chapters = [], metadata = {}) {
const normalizedChapters = normalizeChapterList(chapters, {
durationMs: metadata?.durationMs,
fallbackTitle: metadata?.title || metadata?.album || 'Audiobook',
createFallback: true
});
const chapterBlocks = normalizedChapters.map((chapter) => {
const startMs = Math.max(0, Math.round(chapter.startMs || 0));
const endMs = Math.max(startMs, Math.round(chapter.endMs || startMs));
return [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${startMs}`,
`END=${endMs}`,
`title=${escapeFfmetadataValue(chapter.title || `Kapitel ${chapter.index || 1}`)}`
].join('\n');
}).join('\n\n');
return `;FFMETADATA1\n\n${chapterBlocks}`;
}
function buildCoverExtractionCommand(ffmpegCommand, inputPath, outputPath, cover = null) {
const cmd = String(ffmpegCommand || 'ffmpeg').trim() || 'ffmpeg';
const streamIndex = Number(cover?.streamIndex);
const streamSpecifier = Number.isFinite(streamIndex) && streamIndex >= 0
? `0:${Math.trunc(streamIndex)}`
: '0:v:0';
return {
cmd,
args: [
'-y',
'-i', inputPath,
'-map', streamSpecifier,
'-an',
'-sn',
'-dn',
'-frames:v', '1',
'-c:v', 'mjpeg',
'-q:v', '2',
outputPath
]
};
}
function parseFfmpegTimestampToMs(rawValue) {
const value = String(rawValue || '').trim();
const match = value.match(/^(\d+):(\d{2}):(\d{2})(?:\.(\d+))?$/);
if (!match) {
return null;
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const fraction = match[4] ? Number(`0.${match[4]}`) : 0;
if (!Number.isFinite(hours) || !Number.isFinite(minutes) || !Number.isFinite(seconds)) {
return null;
}
return Math.round((((hours * 60) + minutes) * 60 + seconds + fraction) * 1000);
}
function buildProgressParser(totalDurationMs) {
const durationMs = Number(totalDurationMs || 0);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
return null;
}
return (line) => {
const match = String(line || '').match(/time=(\d+:\d{2}:\d{2}(?:\.\d+)?)/i);
if (!match) {
return null;
}
const currentMs = parseFfmpegTimestampToMs(match[1]);
if (!Number.isFinite(currentMs)) {
return null;
}
const percent = Math.max(0, Math.min(100, Number(((currentMs / durationMs) * 100).toFixed(2))));
return {
percent,
eta: null
};
};
}
module.exports = {
SUPPORTED_INPUT_EXTENSIONS,
SUPPORTED_OUTPUT_FORMATS,
DEFAULT_AUDIOBOOK_RAW_TEMPLATE,
DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE,
DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE,
AUDIOBOOK_FORMAT_DEFAULTS,
normalizeOutputFormat,
getDefaultFormatOptions,
normalizeFormatOptions,
isSupportedInputFile,
buildMetadataFromProbe,
normalizeChapterList,
buildRawStoragePaths,
buildOutputPath,
buildChapterOutputPlan,
buildProbeCommand,
parseProbeOutput,
buildEncodeCommand,
buildChapterEncodeCommand,
buildChapterMetadataContent,
buildCoverExtractionCommand,
buildProgressParser
};
+196
View File
@@ -0,0 +1,196 @@
const fs = require('fs');
const logger = require('./logger').child('AUDNEX');
const AUDNEX_BASE_URL = 'https://api.audnex.us';
const AUDNEX_TIMEOUT_MS = 10000;
const ASIN_PATTERN = /B0[0-9A-Z]{8}/u;
function normalizeAsin(value) {
const raw = String(value || '').trim().toUpperCase();
return ASIN_PATTERN.test(raw) ? raw : null;
}
async function extractAsinFromAaxFile(filePath) {
const sourcePath = String(filePath || '').trim();
if (!sourcePath) {
return null;
}
return new Promise((resolve, reject) => {
let printableWindow = '';
let settled = false;
const stream = fs.createReadStream(sourcePath, { highWaterMark: 64 * 1024 });
const finish = (value) => {
if (settled) {
return;
}
settled = true;
resolve(value);
};
stream.on('data', (chunk) => {
if (settled) {
return;
}
for (const byte of chunk) {
if (byte >= 32 && byte <= 126) {
printableWindow = `${printableWindow}${String.fromCharCode(byte)}`.slice(-48);
const match = printableWindow.match(/B0[0-9A-Z]{8}/u);
if (match?.[0]) {
const asin = normalizeAsin(match[0]);
if (asin) {
logger.info('asin:detected', { filePath: sourcePath, asin });
stream.destroy();
finish(asin);
return;
}
}
} else {
printableWindow = '';
}
}
});
stream.on('error', (error) => {
if (settled) {
return;
}
settled = true;
reject(error);
});
stream.on('close', () => {
if (!settled) {
finish(null);
}
});
});
}
async function audnexFetch(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), AUDNEX_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
clearTimeout(timer);
if (!response.ok) {
throw new Error(`Audnex Anfrage fehlgeschlagen (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timer);
throw error;
}
}
function extractChapterArray(payload) {
if (Array.isArray(payload)) {
return payload;
}
const candidates = [
payload?.chapters,
payload?.data?.chapters,
payload?.content?.chapters,
payload?.results?.chapters
];
return candidates.find((entry) => Array.isArray(entry)) || [];
}
function normalizeAudnexChapter(entry, index) {
const startOffsetMs = Number(
entry?.startOffsetMs
?? entry?.startMs
?? entry?.offsetMs
?? 0
);
const lengthMs = Number(
entry?.lengthMs
?? entry?.durationMs
?? entry?.length
?? 0
);
const title = String(entry?.title || entry?.chapterTitle || `Kapitel ${index + 1}`).trim() || `Kapitel ${index + 1}`;
const safeStartMs = Number.isFinite(startOffsetMs) && startOffsetMs >= 0 ? Math.round(startOffsetMs) : 0;
const safeLengthMs = Number.isFinite(lengthMs) && lengthMs > 0 ? Math.round(lengthMs) : 0;
return {
index: index + 1,
title,
startMs: safeStartMs,
endMs: safeStartMs + safeLengthMs,
startSeconds: Math.round(safeStartMs / 1000),
endSeconds: Math.round((safeStartMs + safeLengthMs) / 1000)
};
}
function normalizeNameList(items) {
if (!Array.isArray(items) || items.length === 0) {
return null;
}
const names = items
.map((item) => String(item?.name || '').trim())
.filter(Boolean);
return names.length > 0 ? names.join(', ') : null;
}
async function fetchBookByAsin(asin, region = 'de') {
const normalizedAsin = normalizeAsin(asin);
if (!normalizedAsin) {
return null;
}
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}`);
url.searchParams.set('region', String(region || 'de').trim() || 'de');
logger.info('book:fetch:start', { asin: normalizedAsin, url: url.toString() });
const payload = await audnexFetch(url.toString());
if (!payload || typeof payload !== 'object') {
return null;
}
const narrator = normalizeNameList(payload.narrators);
const author = normalizeNameList(payload.authors);
const title = String(payload.title || '').trim() || null;
const series = String(payload.seriesName || payload.series || '').trim() || null;
const part = String(payload.seriesPart || payload.part || '').trim() || null;
const description = String(payload.summary || payload.description || '').trim() || null;
const year = payload.releaseDate
? String(payload.releaseDate).slice(0, 4)
: null;
logger.info('book:fetch:done', { asin: normalizedAsin, narrator, author, title });
return { narrator, author, title, series, part, description, year };
}
async function fetchChaptersByAsin(asin, region = 'de') {
const normalizedAsin = normalizeAsin(asin);
if (!normalizedAsin) {
return [];
}
const url = new URL(`${AUDNEX_BASE_URL}/books/${normalizedAsin}/chapters`);
url.searchParams.set('region', String(region || 'de').trim() || 'de');
logger.info('chapters:fetch:start', { asin: normalizedAsin, url: url.toString() });
const payload = await audnexFetch(url.toString());
const chapters = extractChapterArray(payload)
.map((entry, index) => normalizeAudnexChapter(entry, index))
.filter((chapter) => chapter.endMs > chapter.startMs && chapter.title);
logger.info('chapters:fetch:done', { asin: normalizedAsin, count: chapters.length });
return chapters;
}
module.exports = {
extractAsinFromAaxFile,
fetchBookByAsin,
fetchChaptersByAsin
};
+784
View File
@@ -0,0 +1,784 @@
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const logger = require('./logger').child('CD_RIP');
const { spawnTrackedProcess } = require('./processRunner');
const { parseCdParanoiaProgress } = require('../utils/progressParsers');
const { ensureDir, transliterateForFilename } = require('../utils/files');
const { errorToMeta } = require('../utils/errorMeta');
const execFileAsync = promisify(execFile);
const SUPPORTED_FORMATS = new Set(['wav', 'flac', 'mp3', 'opus', 'ogg']);
const DEFAULT_CD_OUTPUT_TEMPLATE = '{artist} - {album} ({year})/{trackNr} {artist} - {title}';
/**
* Parse cdparanoia -Q output to extract track information.
* Supports both bracket styles shown by different builds:
* track 1: 0 (00:00.00) 24218 (05:22.43)
* track 1: 0 [00:00.00] 24218 [05:22.43]
*/
function parseToc(tocOutput) {
const lines = String(tocOutput || '').split(/\r?\n/);
const tracks = [];
const tocTimePattern = /[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/g;
const tocTablePattern =
/^\s*(\d+)\.?\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]\s+(\d+)\s+[\(\[]\s*\d+:\d+(?:[.,:]\d+)?\s*[\)\]]/i;
for (const line of lines) {
const trackMatch = line.match(/^\s*track\s+(\d+)\s*:\s*(.+)$/i);
if (trackMatch) {
const position = Number(trackMatch[1]);
const payloadWithoutTimes = String(trackMatch[2] || '')
.replace(tocTimePattern, ' ');
const sectorValues = payloadWithoutTimes.match(/\d+/g) || [];
if (sectorValues.length < 2) {
continue;
}
const startSector = Number(sectorValues[0]);
const lengthSector = Number(sectorValues[1]);
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
continue;
}
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
continue;
}
// duration in seconds: sectors / 75
const durationSec = Math.round(lengthSector / 75);
tracks.push({
position,
startSector,
lengthSector,
durationSec,
durationMs: durationSec * 1000
});
continue;
}
// Alternative cdparanoia -Q table style:
// 1. 16503 [03:40.03] 0 [00:00.00] no no 2
// ^ length sectors ^ start sector
const tableMatch = line.match(tocTablePattern);
if (!tableMatch) {
continue;
}
const position = Number(tableMatch[1]);
const lengthSector = Number(tableMatch[2]);
const startSector = Number(tableMatch[3]);
if (!Number.isFinite(position) || !Number.isFinite(startSector) || !Number.isFinite(lengthSector)) {
continue;
}
if (position <= 0 || startSector < 0 || lengthSector <= 0) {
continue;
}
const durationSec = Math.round(lengthSector / 75);
tracks.push({
position,
startSector,
lengthSector,
durationSec,
durationMs: durationSec * 1000
});
}
return tracks;
}
async function readToc(devicePath, cmd) {
const cdparanoia = String(cmd || 'cdparanoia').trim() || 'cdparanoia';
logger.info('toc:read', { devicePath, cmd: cdparanoia });
try {
// Depending on distro/build, TOC can appear on stderr and/or stdout.
const { stdout, stderr } = await execFileAsync(cdparanoia, ['-Q', '-d', devicePath], {
timeout: 15000
});
const tracks = parseToc(`${stderr || ''}\n${stdout || ''}`);
logger.info('toc:done', { devicePath, trackCount: tracks.length });
return tracks;
} catch (error) {
// cdparanoia -Q may exit non-zero even when TOC is readable.
const stderr = String(error?.stderr || '');
const stdout = String(error?.stdout || '');
const tracks = parseToc(`${stderr}\n${stdout}`);
if (tracks.length > 0) {
logger.info('toc:done-from-error-streams', { devicePath, trackCount: tracks.length });
return tracks;
}
logger.warn('toc:failed', { devicePath, error: errorToMeta(error) });
return [];
}
}
function buildOutputFilename(track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate);
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
return `${relativeBasePath}.${ext}`;
}
function sanitizePathSegment(value, fallback = 'unknown') {
const raw = transliterateForFilename(String(value == null ? '' : value))
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/[♥❤♡❥❣❦❧]/gu, ' ')
.replace(/\p{C}+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!raw || raw === '.' || raw === '..') {
return fallback;
}
return raw.slice(0, 180);
}
function normalizeTemplateTokenKey(rawKey) {
const key = String(rawKey || '').trim().toLowerCase();
if (!key) {
return '';
}
if (key === 'tracknr' || key === 'tracknumberpadded' || key === 'tracknopadded') {
return 'trackNr';
}
if (key === 'tracknumber' || key === 'trackno' || key === 'tracknum' || key === 'track') {
return 'trackNo';
}
if (key === 'trackartist' || key === 'track_artist') {
return 'trackArtist';
}
if (key === 'albumartist') {
return 'albumArtist';
}
if (key === 'interpret') {
return 'artist';
}
return key;
}
function cleanupRenderedTemplate(value) {
return String(value || '')
.replace(/\(\s*\)/g, '')
.replace(/\[\s*]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function renderOutputTemplate(template, values) {
const source = String(template || DEFAULT_CD_OUTPUT_TEMPLATE).trim() || DEFAULT_CD_OUTPUT_TEMPLATE;
const rendered = source.replace(/\$\{([^}]+)\}|\{([^{}]+)\}/g, (_, keyA, keyB) => {
const normalizedKey = normalizeTemplateTokenKey(keyA || keyB);
const rawValue = values[normalizedKey];
if (rawValue === undefined || rawValue === null) {
return '';
}
return String(rawValue);
});
return cleanupRenderedTemplate(rendered);
}
function buildTemplateValues(track, meta, format = null) {
const trackNo = Number(track?.position) > 0 ? Math.trunc(Number(track.position)) : 1;
const trackTitle = sanitizePathSegment(track?.title || `Track ${trackNo}`, `Track ${trackNo}`);
const albumArtist = sanitizePathSegment(meta?.artist || 'Unknown Artist', 'Unknown Artist');
const trackArtist = sanitizePathSegment(track?.artist || meta?.artist || 'Unknown Artist', 'Unknown Artist');
const album = sanitizePathSegment(meta?.title || meta?.album || 'Unknown Album', 'Unknown Album');
const year = meta?.year == null ? '' : sanitizePathSegment(String(meta.year), '');
return {
artist: albumArtist,
albumArtist,
trackArtist,
album,
year,
title: trackTitle,
trackNr: String(trackNo).padStart(2, '0'),
trackNo: String(trackNo),
format: format ? String(format).trim().toLowerCase() : ''
};
}
function buildTrackRelativeBasePath(track, meta, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE, format = null) {
const values = buildTemplateValues(track, meta, format);
const rendered = renderOutputTemplate(outputTemplate, values)
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+|\/+$/g, '');
const parts = rendered
.split('/')
.map((part) => sanitizePathSegment(part, 'unknown'))
.filter(Boolean);
if (parts.length === 0) {
return `${String(track?.position || 1).padStart(2, '0')} Track ${String(track?.position || 1).padStart(2, '0')}`;
}
return path.join(...parts);
}
function buildOutputDir(meta, baseDir, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const sampleTrack = {
position: 1,
title: 'Track 1'
};
const relativeBasePath = buildTrackRelativeBasePath(sampleTrack, meta, outputTemplate);
const relativeDir = path.dirname(relativeBasePath);
if (!relativeDir || relativeDir === '.' || relativeDir === path.sep) {
return baseDir;
}
return path.join(baseDir, relativeDir);
}
function splitPathSegments(value) {
return String(value || '')
.replace(/\\/g, '/')
.split('/')
.map((segment) => segment.trim())
.filter(Boolean);
}
function outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir) {
const outputSegments = splitPathSegments(outputBaseDir);
const relativeSegments = splitPathSegments(relativeDir);
if (relativeSegments.length === 0 || outputSegments.length < relativeSegments.length) {
return false;
}
const offset = outputSegments.length - relativeSegments.length;
for (let i = 0; i < relativeSegments.length; i++) {
const outputPart = outputSegments[offset + i];
const relativePart = relativeSegments[i];
if (outputPart === relativePart) {
continue;
}
// Numbered conflict folders end with "_X" (e.g. ".../Album (2007)_2").
// Treat this as containing the same relative directory to avoid nesting:
// Album (2007)_2/Album (2007)/...
const isLastRelativeSegment = i === relativeSegments.length - 1;
const matchesNumberedSuffix = isLastRelativeSegment
&& outputPart.startsWith(`${relativePart}_`)
&& /^\d+$/.test(outputPart.slice(relativePart.length + 1));
if (!matchesNumberedSuffix) {
return false;
}
}
return true;
}
function stripLeadingRelativeDir(relativeFilePath, relativeDir) {
const fileSegments = splitPathSegments(relativeFilePath);
const dirSegments = splitPathSegments(relativeDir);
if (dirSegments.length === 0 || fileSegments.length <= dirSegments.length) {
return relativeFilePath;
}
for (let i = 0; i < dirSegments.length; i++) {
if (fileSegments[i] !== dirSegments[i]) {
return relativeFilePath;
}
}
return path.join(...fileSegments.slice(dirSegments.length));
}
function buildOutputFilePath(outputBaseDir, track, meta, format, outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE) {
const relativeBasePath = buildTrackRelativeBasePath(track, meta, outputTemplate, format);
const ext = String(format === 'wav' ? 'wav' : format).trim().toLowerCase() || 'wav';
const relativeDir = path.dirname(relativeBasePath);
let relativeFilePath = `${relativeBasePath}.${ext}`;
if (relativeDir && relativeDir !== '.' && relativeDir !== path.sep) {
if (outputDirAlreadyContainsRelativeDir(outputBaseDir, relativeDir)) {
relativeFilePath = stripLeadingRelativeDir(relativeFilePath, relativeDir);
}
}
const outFile = path.join(outputBaseDir, relativeFilePath);
return {
outFile,
relativeFilePath,
outFilename: path.basename(relativeFilePath)
};
}
function buildCancelledError() {
const error = new Error('Job wurde vom Benutzer abgebrochen.');
error.statusCode = 409;
return error;
}
function assertNotCancelled(isCancelled) {
if (typeof isCancelled === 'function' && isCancelled()) {
throw buildCancelledError();
}
}
function normalizeExitCode(error) {
const code = Number(error?.code);
if (Number.isFinite(code)) {
return Math.trunc(code);
}
return 1;
}
function quoteShellArg(value) {
const text = String(value == null ? '' : value);
if (!text) {
return "''";
}
if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(text)) {
return text;
}
return `'${text.replace(/'/g, "'\\''")}'`;
}
function formatCommandLine(cmd, args = []) {
const normalizedArgs = Array.isArray(args) ? args : [];
return [quoteShellArg(cmd), ...normalizedArgs.map((arg) => quoteShellArg(arg))].join(' ');
}
function copyFilePreservingRaw(sourcePath, targetPath) {
const rawSource = String(sourcePath || '').trim();
const rawTarget = String(targetPath || '').trim();
if (!rawSource || !rawTarget) {
return;
}
const source = path.resolve(rawSource);
const target = path.resolve(rawTarget);
if (source === target) {
return;
}
fs.copyFileSync(source, target);
}
async function runProcessTracked({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
context,
onProcessHandle,
isCancelled
}) {
assertNotCancelled(isCancelled);
const handle = spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
context
});
if (typeof onProcessHandle === 'function') {
onProcessHandle(handle);
}
if (typeof isCancelled === 'function' && isCancelled()) {
handle.cancel();
}
try {
return await handle.promise;
} catch (error) {
if (typeof isCancelled === 'function' && isCancelled()) {
throw buildCancelledError();
}
throw error;
}
}
/**
* Returns sorted plain WAV filenames (not .cdda.wav) in rawWavDir.
* Used as fallback when track01.cdda.wav files are absent (e.g. orphan imports with artist-title WAV files).
*/
function getSortedPlainWavFiles(rawWavDir) {
try {
return fs.readdirSync(rawWavDir)
.filter((f) => /\.wav$/i.test(f) && !/\.cdda\.wav$/i.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
} catch (_) {
return [];
}
}
/**
* Resolves the WAV file path for a given track position.
* Primary: track01.cdda.wav (cdparanoia format)
* Fallback: Nth sorted plain WAV file (for orphan imports with artist-title filenames)
*/
function resolveTrackWavFile(rawWavDir, position, sortedPlainWavFiles) {
const cddaPath = path.join(rawWavDir, `track${String(position).padStart(2, '0')}.cdda.wav`);
if (fs.existsSync(cddaPath)) return cddaPath;
if (Array.isArray(sortedPlainWavFiles) && sortedPlainWavFiles.length >= position && position >= 1) {
const fallback = path.join(rawWavDir, sortedPlainWavFiles[position - 1]);
if (fs.existsSync(fallback)) return fallback;
}
return cddaPath;
}
/**
* Rip and encode a CD.
*
* @param {object} options
* @param {string} options.jobId - Job ID for logging
* @param {string} options.devicePath - e.g. /dev/sr0
* @param {string} options.cdparanoiaCmd - path/cmd for cdparanoia
* @param {string} options.rawWavDir - temp dir for WAV files
* @param {string} options.outputDir - final output dir
* @param {string} options.format - wav|flac|mp3|opus|ogg
* @param {object} options.formatOptions - encoder-specific options
* @param {number[]} options.selectedTracks - track positions to rip (empty = all)
* @param {object[]} options.tracks - TOC track list [{position, durationMs, title}]
* @param {object} options.meta - album metadata {title, artist, year}
* @param {string} options.outputTemplate - template for relative output path without extension
* @param {boolean} options.skipEncode - true => rip only (no encode)
* @param {Function} options.onProgress - ({phase, trackIndex, trackTotal, percent, track}) => void
* @param {Function} options.onLog - (level, msg) => void
* @param {Function} options.onProcessHandle- called with spawned process handle for cancellation integration
* @param {Function} options.isCancelled - returns true when user requested cancellation
* @param {object} options.context - passed to spawnTrackedProcess
*/
async function ripAndEncode(options) {
const {
jobId,
devicePath,
cdparanoiaCmd = 'cdparanoia',
rawWavDir,
outputDir,
format = 'flac',
formatOptions = {},
selectedTracks = [],
tracks = [],
meta = {},
outputTemplate = DEFAULT_CD_OUTPUT_TEMPLATE,
onProgress,
onLog,
onProcessHandle,
isCancelled,
context,
skipRip = false,
skipEncode = false
} = options;
if (!skipEncode && !SUPPORTED_FORMATS.has(format)) {
throw new Error(`Unbekanntes Ausgabeformat: ${format}`);
}
const tracksToRip = selectedTracks.length > 0
? tracks.filter((t) => selectedTracks.includes(t.position))
: tracks;
if (tracksToRip.length === 0) {
throw new Error('Keine Tracks zum Rippen ausgewählt.');
}
await ensureDir(rawWavDir);
if (!skipEncode) {
await ensureDir(outputDir);
}
logger.info('rip:start', {
jobId,
devicePath,
format,
trackCount: tracksToRip.length
});
const log = (level, msg) => {
logger[level] && logger[level](msg, { jobId });
onLog && onLog(level, msg);
};
const ripPercentSpan = skipEncode ? 100 : 50;
const encodePercentStart = ripPercentSpan;
const encodePercentSpan = Math.max(0, 100 - encodePercentStart);
// ── Phase 1: Rip each selected track to WAV ──────────────────────────────
const _sortedPlainWavFiles = skipRip ? getSortedPlainWavFiles(rawWavDir) : null;
if (skipRip) {
// Encode-only: WAV-Dateien müssen bereits vorhanden sein
for (const track of tracksToRip) {
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
if (!fs.existsSync(wavFile)) {
throw new Error(`Encode-only: WAV-Datei fehlt für Track ${track.position} (${wavFile})`);
}
}
} else {
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = path.join(rawWavDir, `track${String(track.position).padStart(2, '0')}.cdda.wav`);
const ripArgs = ['-d', devicePath, String(track.position), wavFile];
onProgress && onProgress({
phase: 'rip',
trackEvent: 'start',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 0,
percent: (i / tracksToRip.length) * ripPercentSpan
});
log('info', `Rippe Track ${track.position} von ${tracksToRip.length}`);
log('info', `Promptkette [Rip ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(cdparanoiaCmd, ripArgs)}`);
try {
await runProcessTracked({
cmd: cdparanoiaCmd,
args: ripArgs,
cwd: rawWavDir,
onStderrLine(line) {
const parsed = parseCdParanoiaProgress(line);
if (parsed && parsed.percent !== null) {
const overallPercent = ((i + parsed.percent / 100) / tracksToRip.length) * ripPercentSpan;
onProgress && onProgress({
phase: 'rip',
trackEvent: 'progress',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: parsed.percent,
percent: overallPercent
});
}
},
context,
onProcessHandle,
isCancelled
});
} catch (error) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
throw new Error(
`cdparanoia fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
}
onProgress && onProgress({
phase: 'rip',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: ((i + 1) / tracksToRip.length) * ripPercentSpan
});
log('info', `Track ${track.position} gerippt.`);
}
} // end if (!skipRip)
if (skipEncode) {
return {
outputDir: null,
format: 'raw',
trackCount: tracksToRip.length,
encodeResults: []
};
}
// ── Phase 2: Encode WAVs to target format ─────────────────────────────────
const encodeResults = [];
if (format === 'wav') {
// Keep RAW WAVs in place and copy them to the final output structure.
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
const { outFile } = buildOutputFilePath(outputDir, track, meta, 'wav', outputTemplate);
onProgress && onProgress({
phase: 'encode',
trackEvent: 'start',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 0,
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
});
ensureDir(path.dirname(outFile));
log('info', `Promptkette [Copy ${i + 1}/${tracksToRip.length}]: cp ${quoteShellArg(wavFile)} ${quoteShellArg(outFile)}`);
copyFilePreservingRaw(wavFile, outFile);
onProgress && onProgress({
phase: 'encode',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
});
log('info', `WAV für Track ${track.position} gespeichert.`);
encodeResults.push({ position: track.position, title: track.title || '', outputFile: path.basename(outFile), success: true });
}
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
}
for (let i = 0; i < tracksToRip.length; i++) {
assertNotCancelled(isCancelled);
const track = tracksToRip[i];
const wavFile = resolveTrackWavFile(rawWavDir, track.position, _sortedPlainWavFiles);
if (!fs.existsSync(wavFile)) {
throw new Error(`WAV-Datei nicht gefunden für Track ${track.position}: ${wavFile}`);
}
const { outFilename, outFile } = buildOutputFilePath(outputDir, track, meta, format, outputTemplate);
ensureDir(path.dirname(outFile));
onProgress && onProgress({
phase: 'encode',
trackEvent: 'start',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 0,
percent: encodePercentStart + ((i / tracksToRip.length) * encodePercentSpan)
});
log('info', `Encodiere Track ${track.position}${outFilename}`);
const encodeArgs = buildEncodeArgs(format, formatOptions, track, meta, wavFile, outFile);
log('info', `Promptkette [Encode ${i + 1}/${tracksToRip.length}]: ${formatCommandLine(encodeArgs.cmd, encodeArgs.args)}`);
try {
await runProcessTracked({
cmd: encodeArgs.cmd,
args: encodeArgs.args,
cwd: rawWavDir,
onStdoutLine() {},
onStderrLine() {},
context,
onProcessHandle,
isCancelled
});
} catch (error) {
if (String(error?.message || '').toLowerCase().includes('abgebrochen')) {
throw error;
}
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: false });
throw new Error(
`${encodeArgs.cmd} fehlgeschlagen für Track ${track.position} (Exit ${normalizeExitCode(error)})`
);
}
// Safety net: some encoders (e.g. older flac without --no-delete-input-file) may remove
// the source WAV. Restore it from the encoded output so the RAW folder stays filled.
if (!fs.existsSync(wavFile) && fs.existsSync(outFile)) {
try {
fs.copyFileSync(outFile, wavFile);
log('info', `Track ${track.position}: WAV-Quelldatei vom Encoder gelöscht aus Output wiederhergestellt.`);
} catch (restoreErr) {
log('warn', `Track ${track.position}: WAV-Wiederherstellung fehlgeschlagen: ${restoreErr.message}`);
}
}
onProgress && onProgress({
phase: 'encode',
trackEvent: 'complete',
trackIndex: i + 1,
trackTotal: tracksToRip.length,
trackPosition: track.position,
trackPercent: 100,
percent: encodePercentStart + ((i + 1) / tracksToRip.length) * encodePercentSpan
});
log('info', `Track ${track.position} encodiert.`);
encodeResults.push({ position: track.position, title: track.title || '', outputFile: outFilename, success: true });
}
return { outputDir, format, trackCount: tracksToRip.length, encodeResults };
}
function buildEncodeArgs(format, opts, track, meta, wavFile, outFile) {
const artist = track?.artist || meta?.artist || '';
const album = meta?.title || '';
const year = meta?.year ? String(meta.year) : '';
const trackTitle = track.title || `Track ${track.position}`;
const trackNum = String(track.position);
if (format === 'flac') {
const level = Number(opts.flacCompression ?? 5);
const clampedLevel = Math.max(0, Math.min(8, level));
return {
cmd: 'flac',
args: [
`--compression-level-${clampedLevel}`,
'--no-delete-input-file', // flac deletes input WAV by default; keep RAW folder filled
'--tag', `TITLE=${trackTitle}`,
'--tag', `ARTIST=${artist}`,
'--tag', `ALBUM=${album}`,
'--tag', `DATE=${year}`,
'--tag', `TRACKNUMBER=${trackNum}`,
wavFile,
'-o', outFile
]
};
}
if (format === 'mp3') {
const mode = String(opts.mp3Mode || 'cbr').trim().toLowerCase();
const args = ['--id3v2-only', '--noreplaygain'];
if (mode === 'vbr') {
const quality = Math.max(0, Math.min(9, Number(opts.mp3Quality ?? 4)));
args.push('-V', String(quality));
} else {
const bitrate = Number(opts.mp3Bitrate ?? 192);
args.push('-b', String(bitrate));
}
args.push(
'--tt', trackTitle,
'--ta', artist,
'--tl', album,
'--ty', year,
'--tn', trackNum,
wavFile,
outFile
);
return { cmd: 'lame', args };
}
if (format === 'opus') {
const bitrate = Math.max(32, Math.min(512, Number(opts.opusBitrate ?? 160)));
const complexity = Math.max(0, Math.min(10, Number(opts.opusComplexity ?? 10)));
return {
cmd: 'opusenc',
args: [
'--bitrate', String(bitrate),
'--comp', String(complexity),
'--title', trackTitle,
'--artist', artist,
'--album', album,
'--date', year,
'--tracknumber', trackNum,
wavFile,
outFile
]
};
}
if (format === 'ogg') {
const quality = Math.max(-1, Math.min(10, Number(opts.oggQuality ?? 6)));
return {
cmd: 'oggenc',
args: [
'-q', String(quality),
'-t', trackTitle,
'-a', artist,
'-l', album,
'-d', year,
'-N', trackNum,
'-o', outFile,
wavFile
]
};
}
throw new Error(`Unbekanntes Format: ${format}`);
}
module.exports = {
parseToc,
readToc,
ripAndEncode,
buildOutputDir,
buildOutputFilename,
DEFAULT_CD_OUTPUT_TEMPLATE,
SUPPORTED_FORMATS
};
@@ -0,0 +1,697 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { getDb } = require('../db/database');
const settingsService = require('./settingsService');
const wsService = require('./websocketService');
const logger = require('./logger').child('CONVERTER_SCAN');
const {
defaultConverterRawDir
} = require('../config');
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;
let _pollingInterval = 300;
function detectMediaType(fileName) {
const ext = path.extname(String(fileName || '')).slice(1).toLowerCase();
if (ISO_EXTENSIONS.has(ext)) return 'iso';
if (VIDEO_EXTENSIONS.has(ext)) return 'video';
if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
return null;
}
function detectFormat(fileName) {
return path.extname(String(fileName || '')).slice(1).toLowerCase() || null;
}
function getConfiguredExtensions(settings) {
const raw = String(settings?.converter_scan_extensions || '').trim();
if (!raw) {
return new Set(SUPPORTED_SCAN_EXTENSIONS);
}
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) {
try {
return fs.statSync(fullPath).size;
} catch (_err) {
return null;
}
}
const SCAN_MAX_DEPTH = 4;
/**
* Rekursiv alle Dateien und direkten Unterordner im rawDir scannen.
* Gibt ein flaches Array von { relPath, entryType, fileSize, detectedMediaType, detectedFormat } zurück.
* Maximale Tiefe: SCAN_MAX_DEPTH (verhindert unendliche Rekursion bei geschachtelten Ordnern).
*/
function scanDirectory(rawDir, allowedExtensions, parentRelPath = '', depth = 0) {
const entries = [];
if (depth >= SCAN_MAX_DEPTH) {
return entries;
}
let dirEntries;
try {
dirEntries = fs.readdirSync(rawDir, { withFileTypes: true });
} catch (_err) {
return entries;
}
for (const dirent of dirEntries) {
const relPath = parentRelPath ? `${parentRelPath}/${dirent.name}` : dirent.name;
const fullPath = path.join(rawDir, relPath);
if (dirent.isDirectory()) {
entries.push({
relPath,
entryType: 'directory',
fileSize: null,
detectedMediaType: null,
detectedFormat: null
});
// Rekursiv in Unterordner (mit Tiefenbegrenzung)
const subEntries = scanDirectory(rawDir, allowedExtensions, relPath, depth + 1);
entries.push(...subEntries);
} else if (dirent.isFile()) {
const ext = path.extname(dirent.name).slice(1).toLowerCase();
if (!allowedExtensions.has(ext)) {
continue;
}
entries.push({
relPath,
entryType: 'file',
fileSize: getFileSize(fullPath),
detectedMediaType: detectMediaType(dirent.name),
detectedFormat: detectFormat(dirent.name)
});
}
}
return entries;
}
/**
* Scan-Ergebnisse in die DB schreiben (INSERT OR REPLACE) und
* nicht mehr vorhandene Einträge ohne Job entfernen.
*/
async function persistScanResults(rawDir, entries) {
const db = await getDb();
if (entries.length > 0) {
const stmt = await db.prepare(`
INSERT INTO converter_scan_entries (rel_path, entry_type, file_size, detected_media_type, detected_format, last_seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(rel_path) DO UPDATE SET
entry_type = excluded.entry_type,
file_size = excluded.file_size,
detected_media_type = excluded.detected_media_type,
detected_format = excluded.detected_format,
last_seen_at = excluded.last_seen_at
`);
for (const entry of entries) {
await stmt.run(
entry.relPath,
entry.entryType,
entry.fileSize,
entry.detectedMediaType,
entry.detectedFormat
);
}
await stmt.finalize();
}
// Einträge ohne zugewiesenen Job entfernen, wenn Datei nicht mehr vorhanden
const existingEntries = await db.all(
`SELECT rel_path FROM converter_scan_entries WHERE job_id IS NULL`
);
const currentRelPaths = new Set(entries.map((e) => e.relPath));
for (const row of existingEntries) {
if (!currentRelPaths.has(row.rel_path)) {
const fullPath = path.join(rawDir, row.rel_path);
if (!fs.existsSync(fullPath)) {
await db.run(
`DELETE FROM converter_scan_entries WHERE rel_path = ? AND job_id IS NULL`,
[row.rel_path]
);
}
}
}
}
/**
* Hauptmethode: rawDir scannen, DB aktualisieren, WebSocket-Event senden.
*/
async function scan() {
const settings = await settingsService.getSettingsMap();
const rawDir = String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
if (!rawDir) {
logger.warn('converter:scan:no-raw-dir');
return { rawDir: null, entryCount: 0 };
}
if (!fs.existsSync(rawDir)) {
logger.warn('converter:scan:dir-missing', { rawDir });
return { rawDir, entryCount: 0 };
}
const allowedExtensions = getConfiguredExtensions(settings);
logger.info('converter:scan:start', { rawDir, allowedExtensions: [...allowedExtensions] });
const entries = scanDirectory(rawDir, allowedExtensions);
await persistScanResults(rawDir, entries);
logger.info('converter:scan:done', { rawDir, entryCount: entries.length });
wsService.broadcast('CONVERTER_SCAN_UPDATE', { entryCount: entries.length });
return { rawDir, entryCount: entries.length };
}
/**
* Alle Einträge für den File-Explorer zurückgeben.
* Optionaler parentRelPath filtert auf Kinder eines bestimmten Verzeichnisses.
*/
async function getEntries(parentRelPath = null) {
const db = await getDb();
let rows;
if (!parentRelPath) {
// Root-Ebene: nur Einträge ohne '/' im rel_path
rows = await db.all(`
SELECT e.*, j.status AS job_status, j.title AS job_title
FROM converter_scan_entries e
LEFT JOIN jobs j ON j.id = e.job_id
ORDER BY e.entry_type DESC, e.rel_path ASC
`);
// Nur direkte Kinder (kein '/' im rel_path)
rows = rows.filter((r) => !String(r.rel_path).includes('/'));
} else {
const prefix = parentRelPath.endsWith('/') ? parentRelPath : `${parentRelPath}/`;
rows = await db.all(`
SELECT e.*, j.status AS job_status, j.title AS job_title
FROM converter_scan_entries e
LEFT JOIN jobs j ON j.id = e.job_id
ORDER BY e.entry_type DESC, e.rel_path ASC
`);
// Direkte Kinder des angegebenen Verzeichnisses
rows = rows.filter((r) => {
const rel = String(r.rel_path);
if (!rel.startsWith(prefix)) return false;
const remainder = rel.slice(prefix.length);
return !remainder.includes('/');
});
}
return rows.map((r) => ({
id: r.id,
relPath: r.rel_path,
entryType: r.entry_type,
fileSize: r.file_size,
detectedMediaType: r.detected_media_type,
detectedFormat: r.detected_format,
jobId: r.job_id,
jobStatus: r.job_status || null,
jobTitle: r.job_title || null,
lastSeenAt: r.last_seen_at
}));
}
async function getEntryById(id) {
const db = await getDb();
const row = await db.get(
`SELECT * FROM converter_scan_entries WHERE id = ?`,
[Number(id)]
);
return row || null;
}
async function getEntryByRelPath(relPath) {
const db = await getDb();
const row = await db.get(
`SELECT * FROM converter_scan_entries WHERE rel_path = ?`,
[relPath]
);
return row || null;
}
async function setEntryJobAssignment(relPath, jobId) {
const normalizedRelPath = normalizeRelPath(relPath);
if (normalizedRelPath === null || normalizedRelPath === '') {
throw makeError('Ungültiger relPath für Job-Zuweisung.', 400);
}
const normalizedJobId = Number(jobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
throw makeError('Ungültige jobId für Job-Zuweisung.', 400);
}
const db = await getDb();
const existing = await db.get(
`SELECT entry_type, file_size, detected_media_type, detected_format
FROM converter_scan_entries
WHERE rel_path = ?`,
[normalizedRelPath]
);
let entryType = String(existing?.entry_type || 'file').trim() || 'file';
let fileSize = existing?.file_size ?? null;
let detectedMediaType = existing?.detected_media_type ?? null;
let detectedFormat = existing?.detected_format ?? null;
const rawDir = await getRawDir();
const absPath = rawDir ? path.join(rawDir, normalizedRelPath) : null;
if (absPath && fs.existsSync(absPath)) {
try {
const stat = fs.statSync(absPath);
entryType = stat.isDirectory() ? 'directory' : 'file';
fileSize = stat.isFile() ? stat.size : null;
if (stat.isFile()) {
const fileName = path.basename(normalizedRelPath);
detectedMediaType = detectMediaType(fileName);
detectedFormat = detectFormat(fileName);
}
} catch (_err) {
// Keep existing metadata values if stat/read fails.
}
}
await db.run(
`
INSERT INTO converter_scan_entries (
rel_path,
entry_type,
file_size,
detected_media_type,
detected_format,
job_id,
last_seen_at
)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(rel_path) DO UPDATE SET
entry_type = excluded.entry_type,
file_size = excluded.file_size,
detected_media_type = excluded.detected_media_type,
detected_format = excluded.detected_format,
job_id = excluded.job_id,
last_seen_at = excluded.last_seen_at
`,
[
normalizedRelPath,
entryType,
fileSize,
detectedMediaType,
detectedFormat,
Math.trunc(normalizedJobId)
]
);
}
async function clearEntryJobAssignment(relPath, expectedJobId = null) {
const normalizedRelPath = normalizeRelPath(relPath);
if (normalizedRelPath === null || normalizedRelPath === '') {
throw makeError('Ungültiger relPath für Job-Entfernung.', 400);
}
const db = await getDb();
const normalizedExpected = Number(expectedJobId);
if (Number.isFinite(normalizedExpected) && normalizedExpected > 0) {
await db.run(
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ? AND job_id = ?`,
[normalizedRelPath, Math.trunc(normalizedExpected)]
);
return;
}
await db.run(
`UPDATE converter_scan_entries SET job_id = NULL WHERE rel_path = ?`,
[normalizedRelPath]
);
}
async function clearAssignmentsForJob(jobId) {
const normalizedJobId = Number(jobId);
if (!Number.isFinite(normalizedJobId) || normalizedJobId <= 0) {
throw makeError('Ungültige jobId für Job-Entfernung.', 400);
}
const db = await getDb();
await db.run(
`UPDATE converter_scan_entries SET job_id = NULL WHERE job_id = ?`,
[Math.trunc(normalizedJobId)]
);
}
async function assignEntriesToJob(relPaths, jobId) {
const normalizedPaths = Array.isArray(relPaths) ? relPaths : [];
for (const relPath of normalizedPaths) {
await setEntryJobAssignment(relPath, jobId);
}
}
async function markEntryAsJob(relPath, jobId) {
await setEntryJobAssignment(relPath, jobId);
}
async function getRawDir() {
const settings = await settingsService.getSettingsMap();
return String(settings?.converter_raw_dir || defaultConverterRawDir || '').trim();
}
/**
* Polling-Loop starten.
*/
async function startPolling() {
const settings = await settingsService.getSettingsMap();
_pollingEnabled = String(settings?.converter_polling_enabled || 'false').toLowerCase() === 'true';
_pollingInterval = Math.max(30, Number(settings?.converter_polling_interval || 300)) * 1000;
stopPolling();
if (!_pollingEnabled) {
logger.info('converter:polling:disabled');
return;
}
logger.info('converter:polling:start', { intervalMs: _pollingInterval });
const tick = async () => {
try {
await scan();
} catch (error) {
logger.error('converter:polling:error', { error: error?.message });
}
if (_pollingEnabled) {
_pollingTimer = setTimeout(tick, _pollingInterval);
}
};
_pollingTimer = setTimeout(tick, _pollingInterval);
}
function stopPolling() {
if (_pollingTimer) {
clearTimeout(_pollingTimer);
_pollingTimer = null;
}
}
async function restartPolling() {
await startPolling();
}
// ── Datei-Operationen (Löschen, Umbenennen, Verschieben, Ordner erstellen) ──
/**
* Relativen Pfad normalisieren und auf Path-Traversal prüfen.
* Gibt null zurück wenn der Pfad ungültig ist.
*/
function normalizeRelPath(input) {
if (input === null || input === undefined) return '';
const raw = String(input).replace(/\\/g, '/').trim();
if (!raw || raw === '.') return '';
if (raw.startsWith('/')) return null;
const normalized = path.posix.normalize(raw);
if (normalized.startsWith('..')) return null;
if (normalized === '.') return '';
return normalized;
}
function makeError(msg, code) {
const err = new Error(msg);
err.statusCode = code;
return err;
}
/**
* Datei oder Ordner löschen. Aktualisiert DB-Einträge.
*/
async function deleteEntry(relPath) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const rel = normalizeRelPath(relPath);
if (rel === null || rel === '') throw makeError('Ungültiger oder leerer Pfad (Root kann nicht gelöscht werden).', 400);
const absPath = path.join(rawDir, rel);
// Traversal-Schutz
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
const db = await getDb();
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht gelöscht werden.', 409);
if (!fs.existsSync(absPath)) throw makeError(`Pfad existiert nicht: ${rel}`, 404);
fs.rmSync(absPath, { recursive: true, force: true });
await db.run(
`DELETE FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
[rel, `${rel}/%`]
);
return { deleted: rel };
}
/**
* Datei oder Ordner umbenennen. Aktualisiert DB-Einträge.
*/
async function renameEntry(relPath, newName) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const rel = normalizeRelPath(relPath);
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
const safeName = String(newName || '').trim();
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
throw makeError('Ungültiger Name.', 400);
}
const parentRel = path.posix.dirname(rel);
const newRel = (parentRel === '.' || parentRel === '') ? safeName : `${parentRel}/${safeName}`;
const absOld = path.join(rawDir, rel);
const absNew = path.join(rawDir, newRel);
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
if (!absNew.startsWith(rawDir + path.sep)) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
const db = await getDb();
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht umbenannt werden.', 409);
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
fs.renameSync(absOld, absNew);
const rows = await db.all(
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
[rel, `${rel}/%`]
);
for (const row of rows) {
const updatedRel = newRel + row.rel_path.slice(rel.length);
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
}
return { oldRelPath: rel, newRelPath: newRel };
}
/**
* Datei oder Ordner in ein anderes Verzeichnis verschieben. Aktualisiert DB-Einträge.
*/
async function moveEntry(relPath, targetParentRelPath) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const rel = normalizeRelPath(relPath);
if (rel === null || rel === '') throw makeError('Ungültiger Quellpfad.', 400);
const targetParentRel = normalizeRelPath(targetParentRelPath != null ? targetParentRelPath : '');
if (targetParentRel === null) throw makeError('Ungültiger Zielpfad.', 400);
const name = path.posix.basename(rel);
const newRel = targetParentRel === '' ? name : `${targetParentRel}/${name}`;
if (rel === newRel) throw makeError('Quelle und Ziel sind identisch.', 400);
if (newRel.startsWith(`${rel}/`)) throw makeError('Kann nicht in eigenen Unterordner verschoben werden.', 400);
const absOld = path.join(rawDir, rel);
const absNew = path.join(rawDir, newRel);
if (!absOld.startsWith(rawDir + path.sep)) throw makeError('Quellpfad außerhalb des RAW-Verzeichnisses.', 400);
if (!absNew.startsWith(rawDir + path.sep) && absNew !== rawDir) throw makeError('Zielpfad außerhalb des RAW-Verzeichnisses.', 400);
const db = await getDb();
const entry = await db.get(`SELECT job_id FROM converter_scan_entries WHERE rel_path = ?`, [rel]);
if (entry?.job_id) throw makeError('Eintrag ist einem Job zugewiesen und kann nicht verschoben werden.', 409);
if (!fs.existsSync(absOld)) throw makeError('Quelle nicht gefunden.', 404);
if (fs.existsSync(absNew)) throw makeError('Ziel existiert bereits.', 409);
fs.renameSync(absOld, absNew);
const rows = await db.all(
`SELECT rel_path FROM converter_scan_entries WHERE rel_path = ? OR rel_path LIKE ?`,
[rel, `${rel}/%`]
);
for (const row of rows) {
const updatedRel = newRel + row.rel_path.slice(rel.length);
await db.run(`UPDATE converter_scan_entries SET rel_path = ? WHERE rel_path = ?`, [updatedRel, row.rel_path]);
}
return { oldRelPath: rel, newRelPath: newRel, targetParentRelPath: targetParentRel };
}
/**
* Neuen Ordner erstellen (kein DB-Eintrag — erscheint beim nächsten Scan).
*/
async function createFolder(parentRelPath, name) {
const rawDir = await getRawDir();
if (!rawDir) throw makeError('Kein RAW-Verzeichnis konfiguriert.', 400);
const parentRel = normalizeRelPath(parentRelPath != null ? parentRelPath : '');
if (parentRel === null) throw makeError('Ungültiger übergeordneter Pfad.', 400);
const safeName = String(name || '').trim();
if (!safeName || safeName.includes('/') || safeName.includes('\\') || safeName === '.' || safeName === '..') {
throw makeError('Ungültiger Ordnername.', 400);
}
const newRel = parentRel === '' ? safeName : `${parentRel}/${safeName}`;
const absPath = path.join(rawDir, newRel);
if (!absPath.startsWith(rawDir + path.sep)) throw makeError('Pfad außerhalb des RAW-Verzeichnisses.', 400);
if (fs.existsSync(absPath)) throw makeError('Ordner existiert bereits.', 409);
fs.mkdirSync(absPath, { recursive: true });
return { relPath: newRel };
}
// ── Reines FS-Baum-Listing (keine DB) ─────────────────────────────────────
const TREE_MAX_DEPTH = 8;
function buildRawTree(rawDir, relPath, depth, assignments = new Map()) {
if (depth >= TREE_MAX_DEPTH) return [];
const absDir = relPath ? path.join(rawDir, relPath) : rawDir;
let dirents;
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch (_) {
return [];
}
dirents.sort((a, b) => {
const ad = a.isDirectory() ? 0 : 1;
const bd = b.isDirectory() ? 0 : 1;
if (ad !== bd) return ad - bd;
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
const nodes = [];
for (const dirent of dirents) {
// Versteckte Einträge überspringen
if (dirent.name.startsWith('.')) continue;
const childRel = relPath ? `${relPath}/${dirent.name}` : dirent.name;
if (dirent.isDirectory()) {
const children = buildRawTree(rawDir, childRel, depth + 1, assignments);
const size = children.reduce((s, c) => s + (c.size || 0), 0);
nodes.push({ name: dirent.name, type: 'folder', path: childRel, size, children });
} else if (dirent.isFile()) {
let size = 0;
try { size = fs.statSync(path.join(rawDir, childRel)).size; } catch (_) {}
const assignment = assignments.get(childRel) || null;
nodes.push({
name: dirent.name,
type: 'file',
path: childRel,
size,
detectedMediaType: detectMediaType(dirent.name),
detectedFormat: detectFormat(dirent.name),
jobId: assignment?.jobId || null,
jobTitle: assignment?.jobTitle || null,
jobStatus: assignment?.jobStatus || null
});
}
}
return nodes;
}
async function getTree() {
const rawDir = await getRawDir();
if (!rawDir || !fs.existsSync(rawDir)) {
return { rawDir: rawDir || null, tree: null };
}
const db = await getDb();
const rows = await db.all(`
SELECT
e.rel_path,
e.job_id,
j.title AS job_title,
j.detected_title AS job_detected_title,
j.status AS job_status
FROM converter_scan_entries e
LEFT JOIN jobs j ON j.id = e.job_id
WHERE e.job_id IS NOT NULL
`);
const assignments = new Map();
for (const row of rows) {
const rel = String(row?.rel_path || '').trim();
const jobId = Number(row?.job_id);
if (!rel || !Number.isFinite(jobId) || jobId <= 0) continue;
assignments.set(rel, {
jobId: Math.trunc(jobId),
jobTitle: String(row?.job_title || row?.job_detected_title || '').trim() || null,
jobStatus: String(row?.job_status || '').trim() || null
});
}
const children = buildRawTree(rawDir, '', 0, assignments);
const size = children.reduce((s, c) => s + (c.size || 0), 0);
return {
rawDir,
tree: { name: path.basename(rawDir) || 'raw', type: 'folder', path: '', size, children }
};
}
module.exports = {
scan,
getEntries,
getEntryById,
getEntryByRelPath,
markEntryAsJob,
setEntryJobAssignment,
clearEntryJobAssignment,
clearAssignmentsForJob,
assignEntriesToJob,
getRawDir,
normalizeRelPath,
getTree,
startPolling,
stopPolling,
restartPolling,
detectMediaType,
detectFormat,
deleteEntry,
renameEntry,
moveEntry,
createFolder
};
@@ -0,0 +1,393 @@
const { getDb } = require('../db/database');
const logger = require('./logger').child('COVERART_RECOVERY');
const settingsService = require('./settingsService');
const historyService = require('./historyService');
const thumbnailService = require('./thumbnailService');
const COVERART_RECOVERY_ENABLED_KEY = 'coverart_recovery_enabled';
const COVERART_RECOVERY_INTERVAL_HOURS_KEY = 'coverart_recovery_interval_hours';
const DEFAULT_INTERVAL_HOURS = 6;
const MIN_INTERVAL_HOURS = 1;
const MAX_INTERVAL_HOURS = 168;
const RUNNING_JOB_STATUSES = new Set([
'ANALYZING',
'RIPPING',
'MEDIAINFO_CHECK',
'ENCODING',
'CD_ANALYZING',
'CD_RIPPING',
'CD_ENCODING'
]);
function parseJsonSafe(raw, fallback = null) {
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw);
} catch (_error) {
return fallback;
}
}
function toBoolean(value, fallback = false) {
if (value === null || value === undefined) {
return fallback;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) {
return fallback;
}
return ['1', 'true', 'yes', 'on'].includes(normalized);
}
function normalizeIntervalHours(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return DEFAULT_INTERVAL_HOURS;
}
return Math.max(MIN_INTERVAL_HOURS, Math.min(MAX_INTERVAL_HOURS, Math.trunc(parsed)));
}
function normalizeExternalUrl(value) {
const normalized = String(value || '').trim();
if (!normalized) {
return null;
}
if (!/^https?:\/\//i.test(normalized)) {
return null;
}
return normalized;
}
function deriveCoverArtArchiveUrl(mbId) {
const normalized = String(mbId || '').trim();
if (!normalized) {
return null;
}
return `https://coverartarchive.org/release/${encodeURIComponent(normalized)}/front-250`;
}
function isLikelyMusicBrainzId(value) {
const normalized = String(value || '').trim();
if (!normalized) {
return false;
}
if (/^tt\d{6,12}$/i.test(normalized)) {
return false;
}
return /^[a-z0-9-]{8,}$/i.test(normalized);
}
function collectCoverCandidates(row) {
const candidates = [];
const seen = new Set();
const push = (url, source) => {
const normalized = normalizeExternalUrl(url);
if (!normalized) {
return;
}
const dedupeKey = normalized.toLowerCase();
if (seen.has(dedupeKey)) {
return;
}
seen.add(dedupeKey);
candidates.push({
url: normalized,
source: String(source || '').trim() || null
});
};
push(row?.poster_url, 'job.poster_url');
const makemkvInfo = parseJsonSafe(row?.makemkv_info_json, {});
const selectedMetadata = makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {};
push(selectedMetadata?.coverUrl, 'selectedMetadata.coverUrl');
push(selectedMetadata?.poster, 'selectedMetadata.poster');
push(selectedMetadata?.posterUrl, 'selectedMetadata.posterUrl');
const mbId = String(
selectedMetadata?.mbId
|| selectedMetadata?.musicBrainzId
|| selectedMetadata?.musicbrainzId
|| selectedMetadata?.musicbrainz_id
|| selectedMetadata?.music_brainz_id
|| selectedMetadata?.musicbrainz
|| selectedMetadata?.mbid
|| row?.imdb_id
|| ''
).trim();
if (isLikelyMusicBrainzId(mbId)) {
push(deriveCoverArtArchiveUrl(mbId), 'musicbrainz.coverartarchive');
}
const omdbInfo = parseJsonSafe(row?.omdb_json, {});
const omdbPoster = String(omdbInfo?.Poster || '').trim();
if (omdbPoster && omdbPoster.toUpperCase() !== 'N/A') {
push(omdbPoster, 'omdb_json.Poster');
}
return candidates;
}
class CoverArtRecoveryService {
constructor() {
this.timer = null;
this.inFlight = null;
this.nextRunAt = null;
this.schedulerEnabled = false;
this.intervalHours = DEFAULT_INTERVAL_HOURS;
this.lastRunSummary = null;
}
getStatus() {
return {
enabled: this.schedulerEnabled,
intervalHours: this.intervalHours,
nextRunAt: this.nextRunAt,
running: Boolean(this.inFlight),
lastRunSummary: this.lastRunSummary
};
}
stop() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.nextRunAt = null;
}
async init() {
await this.refreshSchedule({ runStartupCheck: true });
}
async handleSettingsChanged(changedKeys = []) {
const normalizedKeys = Array.isArray(changedKeys)
? changedKeys.map((key) => String(key || '').trim().toLowerCase()).filter(Boolean)
: [];
if (
normalizedKeys.length > 0
&& !normalizedKeys.includes(COVERART_RECOVERY_ENABLED_KEY)
&& !normalizedKeys.includes(COVERART_RECOVERY_INTERVAL_HOURS_KEY)
) {
return this.getStatus();
}
return this.refreshSchedule({ runStartupCheck: false });
}
async refreshSchedule(options = {}) {
const runStartupCheck = options?.runStartupCheck !== false;
this.stop();
const settings = await settingsService.getSettingsMap();
this.schedulerEnabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
this.intervalHours = normalizeIntervalHours(settings?.[COVERART_RECOVERY_INTERVAL_HOURS_KEY]);
logger.info('scheduler:refresh', {
enabled: this.schedulerEnabled,
intervalHours: this.intervalHours,
runStartupCheck
});
if (this.schedulerEnabled && runStartupCheck) {
this.runNow({ trigger: 'startup' }).catch((error) => {
logger.warn('scheduler:startup-run-failed', {
error: error?.message || String(error)
});
});
}
if (this.schedulerEnabled) {
this.scheduleNextAutoRun();
}
return this.getStatus();
}
scheduleNextAutoRun() {
this.stop();
if (!this.schedulerEnabled) {
return;
}
const delayMs = this.intervalHours * 60 * 60 * 1000;
this.nextRunAt = new Date(Date.now() + delayMs).toISOString();
this.timer = setTimeout(() => {
this.runNow({ trigger: 'auto' })
.catch((error) => {
logger.warn('scheduler:auto-run-failed', {
error: error?.message || String(error)
});
})
.finally(() => {
this.scheduleNextAutoRun();
});
}, delayMs);
}
async runNow(options = {}) {
if (this.inFlight) {
return this.inFlight;
}
let promise = null;
promise = this._runNowInternal(options)
.finally(() => {
if (this.inFlight === promise) {
this.inFlight = null;
}
});
this.inFlight = promise;
return promise;
}
async _runNowInternal(options = {}) {
const trigger = String(options?.trigger || 'manual').trim().toLowerCase() || 'manual';
const force = Boolean(options?.force);
const logFailures = options?.logFailures !== false;
const startedMs = Date.now();
const startedAt = new Date().toISOString();
const settings = await settingsService.getSettingsMap();
const enabled = toBoolean(settings?.[COVERART_RECOVERY_ENABLED_KEY], true);
if (!enabled && !force) {
const skipped = {
trigger,
startedAt,
finishedAt: new Date().toISOString(),
durationMs: 0,
skipped: true,
reason: 'disabled'
};
this.lastRunSummary = skipped;
return skipped;
}
const db = await getDb();
const rows = await db.all(
`
SELECT
id,
title,
detected_title,
status,
poster_url,
imdb_id,
makemkv_info_json,
omdb_json
FROM jobs
ORDER BY COALESCE(updated_at, created_at) DESC, id DESC
`
);
const summary = {
trigger,
startedAt,
finishedAt: null,
durationMs: 0,
scannedJobs: Array.isArray(rows) ? rows.length : 0,
runningSkipped: 0,
alreadyLocal: 0,
noCandidate: 0,
recovered: 0,
failed: 0,
failedJobs: []
};
for (const row of rows || []) {
const jobId = Number(row?.id || 0);
if (!jobId) {
continue;
}
const status = String(row?.status || '').trim().toUpperCase();
if (RUNNING_JOB_STATUSES.has(status)) {
summary.runningSkipped += 1;
continue;
}
const currentPosterUrl = String(row?.poster_url || '').trim();
if (
currentPosterUrl
&& thumbnailService.isLocalUrl(currentPosterUrl)
&& thumbnailService.localThumbnailUrlExists(currentPosterUrl)
) {
summary.alreadyLocal += 1;
continue;
}
const candidates = collectCoverCandidates(row);
if (candidates.length === 0) {
summary.noCandidate += 1;
continue;
}
let recovered = false;
let lastError = null;
for (const candidate of candidates) {
const result = await historyService.cacheAndPromoteExternalPoster(jobId, candidate.url, {
source: 'Coverart',
logFailures: false
});
if (result?.ok && result?.localUrl) {
recovered = true;
summary.recovered += 1;
await historyService.appendLog(
jobId,
'SYSTEM',
`Coverart nachgeladen (${trigger}): ${candidate.url}${candidate?.source ? ` [${candidate.source}]` : ''}`
);
break;
}
lastError = String(result?.error || result?.reason || 'download_failed');
}
if (!recovered) {
summary.failed += 1;
const failedInfo = {
jobId,
title: String(row?.title || row?.detected_title || `Job #${jobId}`),
attemptedUrls: candidates.map((item) => item.url),
error: lastError
};
summary.failedJobs.push(failedInfo);
if (logFailures && trigger !== 'auto') {
await historyService.appendLog(
jobId,
'SYSTEM',
`Coverart-Download fehlgeschlagen (${trigger}). Versuchte Links: ${failedInfo.attemptedUrls.join(', ')}${lastError ? ` | Fehler: ${lastError}` : ''}`
);
}
}
}
const finishedAt = new Date().toISOString();
const durationMs = Math.max(0, Date.now() - startedMs);
const result = {
...summary,
finishedAt,
durationMs
};
this.lastRunSummary = result;
logger.info('recovery:done', {
trigger: result.trigger,
scannedJobs: result.scannedJobs,
recovered: result.recovered,
failed: result.failed,
alreadyLocal: result.alreadyLocal,
noCandidate: result.noCandidate,
runningSkipped: result.runningSkipped,
durationMs: result.durationMs
});
return result;
}
}
module.exports = new CoverArtRecoveryService();
+662
View File
@@ -0,0 +1,662 @@
/**
* cronService.js
* Verwaltet und führt Cronjobs aus (Skripte oder Skriptketten).
* Kein externer Package nötig eigener Cron-Expression-Parser.
*/
const { getDb } = require('../db/database');
const logger = require('./logger').child('CRON');
const notificationService = require('./notificationService');
const settingsService = require('./settingsService');
const wsService = require('./websocketService');
const runtimeActivityService = require('./runtimeActivityService');
const { spawnTrackedProcess } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
const MAX_OUTPUT_CHARS = 100000;
// Maximale Log-Einträge pro Cron-Job (ältere werden gelöscht)
const MAX_LOGS_PER_JOB = 50;
// ─── Cron-Expression-Parser ────────────────────────────────────────────────
// Parst ein einzelnes Cron-Feld (z.B. "* /5", "1,3,5", "1-5", "*") und gibt
// alle erlaubten Werte als Set zurück.
function parseCronField(field, min, max) {
const values = new Set();
for (const part of field.split(',')) {
const trimmed = part.trim();
if (trimmed === '*') {
for (let i = min; i <= max; i++) values.add(i);
} else if (trimmed.startsWith('*/')) {
const step = parseInt(trimmed.slice(2), 10);
if (!Number.isFinite(step) || step < 1) throw new Error(`Ungültiges Step: ${trimmed}`);
for (let i = min; i <= max; i += step) values.add(i);
} else if (trimmed.includes('-')) {
const [startStr, endStr] = trimmed.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Ungültiger Bereich: ${trimmed}`);
for (let i = Math.max(min, start); i <= Math.min(max, end); i++) values.add(i);
} else {
const num = parseInt(trimmed, 10);
if (!Number.isFinite(num) || num < min || num > max) throw new Error(`Ungültiger Wert: ${trimmed}`);
values.add(num);
}
}
return values;
}
/**
* Validiert eine Cron-Expression (5 Felder: minute hour day month weekday).
* Gibt { valid: true } oder { valid: false, error: string } zurück.
*/
function validateCronExpression(expr) {
try {
const parts = String(expr || '').trim().split(/\s+/);
if (parts.length !== 5) {
return { valid: false, error: 'Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).' };
}
parseCronField(parts[0], 0, 59); // minute
parseCronField(parts[1], 0, 23); // hour
parseCronField(parts[2], 1, 31); // day of month
parseCronField(parts[3], 1, 12); // month
parseCronField(parts[4], 0, 7); // weekday (0 und 7 = Sonntag)
return { valid: true };
} catch (error) {
return { valid: false, error: error.message };
}
}
/**
* Berechnet den nächsten Ausführungszeitpunkt nach einem Datum.
* Gibt ein Date-Objekt zurück oder null bei Fehler.
*/
function getNextRunTime(expr, fromDate = new Date()) {
try {
const parts = String(expr || '').trim().split(/\s+/);
if (parts.length !== 5) return null;
const minutes = parseCronField(parts[0], 0, 59);
const hours = parseCronField(parts[1], 0, 23);
const days = parseCronField(parts[2], 1, 31);
const months = parseCronField(parts[3], 1, 12);
const weekdays = parseCronField(parts[4], 0, 7);
// Normalisiere Wochentag: 7 → 0 (beide = Sonntag)
if (weekdays.has(7)) weekdays.add(0);
// Suche ab der nächsten Minute
const candidate = new Date(fromDate);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
// Maximal 2 Jahre in die Zukunft suchen
const limit = new Date(fromDate);
limit.setFullYear(limit.getFullYear() + 2);
while (candidate < limit) {
const month = candidate.getMonth() + 1; // 1-12
const day = candidate.getDate();
const hour = candidate.getHours();
const minute = candidate.getMinutes();
const weekday = candidate.getDay(); // 0 = Sonntag
if (!months.has(month)) {
candidate.setMonth(candidate.getMonth() + 1, 1);
candidate.setHours(0, 0, 0, 0);
continue;
}
if (!days.has(day) || !weekdays.has(weekday)) {
candidate.setDate(candidate.getDate() + 1);
candidate.setHours(0, 0, 0, 0);
continue;
}
if (!hours.has(hour)) {
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
continue;
}
if (!minutes.has(minute)) {
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
continue;
}
return candidate;
}
return null;
} catch (_error) {
return null;
}
}
// ─── DB-Helpers ────────────────────────────────────────────────────────────
function mapJobRow(row) {
if (!row) return null;
return {
id: Number(row.id),
name: String(row.name || ''),
cronExpression: String(row.cron_expression || ''),
sourceType: String(row.source_type || ''),
sourceId: Number(row.source_id),
sourceName: row.source_name != null ? String(row.source_name) : null,
enabled: Boolean(row.enabled),
pushoverEnabled: Boolean(row.pushover_enabled),
lastRunAt: row.last_run_at || null,
lastRunStatus: row.last_run_status || null,
nextRunAt: row.next_run_at || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function mapLogRow(row) {
if (!row) return null;
return {
id: Number(row.id),
cronJobId: Number(row.cron_job_id),
startedAt: row.started_at,
finishedAt: row.finished_at || null,
status: String(row.status || ''),
output: row.output || null,
errorMessage: row.error_message || null
};
}
async function fetchJobWithSource(db, id) {
return db.get(
`
SELECT
c.*,
CASE c.source_type
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
ELSE NULL
END AS source_name
FROM cron_jobs c
WHERE c.id = ?
LIMIT 1
`,
[id]
);
}
async function fetchAllJobsWithSource(db) {
return db.all(
`
SELECT
c.*,
CASE c.source_type
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
ELSE NULL
END AS source_name
FROM cron_jobs c
ORDER BY c.id ASC
`
);
}
// ─── Ausführungslogik ──────────────────────────────────────────────────────
async function runCronJob(job) {
const db = await getDb();
const startedAt = new Date().toISOString();
const cronActivityId = runtimeActivityService.startActivity('cron', {
name: job?.name || `Cron #${job?.id || '?'}`,
source: 'cron',
cronJobId: job?.id || null,
currentStep: 'Starte Cronjob'
});
logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId });
// Log-Eintrag anlegen (status = 'running')
const insertResult = await db.run(
`INSERT INTO cron_run_logs (cron_job_id, started_at, status) VALUES (?, ?, 'running')`,
[job.id, startedAt]
);
const logId = insertResult.lastID;
// Job als laufend markieren
await db.run(
`UPDATE cron_jobs SET last_run_at = ?, last_run_status = 'running', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[startedAt, job.id]
);
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: 'running', lastRunAt: startedAt });
let output = '';
let errorMessage = null;
let success = false;
try {
if (job.sourceType === 'script') {
const scriptService = require('./scriptService');
const script = await scriptService.getScriptById(job.sourceId);
runtimeActivityService.updateActivity(cronActivityId, {
currentStepType: 'script',
currentStep: `Skript: ${script.name}`,
currentScriptName: script.name,
scriptId: script.id
});
const scriptActivityId = runtimeActivityService.startActivity('script', {
name: script.name,
source: 'cron',
scriptId: script.id,
cronJobId: job.id,
parentActivityId: cronActivityId,
currentStep: `Cronjob: ${job.name}`
});
let prepared = null;
try {
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
const processHandle = spawnTrackedProcess({
cmd: prepared.cmd,
args: prepared.args,
context: { source: 'cron', cronJobId: job.id, scriptId: script.id },
onStdoutLine: (line) => {
const next = stdout.length <= MAX_OUTPUT_CHARS
? `${stdout}${line}\n`
: stdout;
stdout = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
stdoutTruncated = stdoutTruncated || next.length > MAX_OUTPUT_CHARS;
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
},
onStderrLine: (line) => {
const next = stderr.length <= MAX_OUTPUT_CHARS
? `${stderr}${line}\n`
: stderr;
stderr = next.length > MAX_OUTPUT_CHARS ? next.slice(-MAX_OUTPUT_CHARS) : next;
stderrTruncated = stderrTruncated || next.length > MAX_OUTPUT_CHARS;
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
}
});
let exitCode = 0;
try {
const result = await processHandle.promise;
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
} catch (error) {
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
if (exitCode === null) {
throw error;
}
}
output = [stdout, stderr].filter(Boolean).join('\n');
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
success = exitCode === 0;
if (!success) errorMessage = `Exit-Code ${exitCode}`;
runtimeActivityService.completeActivity(scriptActivityId, {
status: success ? 'success' : 'error',
success,
outcome: success ? 'success' : 'error',
exitCode,
message: success ? null : errorMessage,
output: output || null,
stdout: stdout || null,
stderr: stderr || null,
stdoutTruncated,
stderrTruncated,
errorMessage: success ? null : (errorMessage || null)
});
} catch (error) {
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'error',
message: error?.message || 'Skriptfehler',
errorMessage: error?.message || 'Skriptfehler'
});
throw error;
} finally {
if (prepared?.cleanup) {
await prepared.cleanup();
}
}
} else if (job.sourceType === 'chain') {
const scriptChainService = require('./scriptChainService');
const logLines = [];
runtimeActivityService.updateActivity(cronActivityId, {
currentStepType: 'chain',
currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`,
currentScriptName: null,
chainId: job.sourceId
});
const result = await scriptChainService.executeChain(
job.sourceId,
{
source: 'cron',
cronJobId: job.id,
runtimeParentActivityId: cronActivityId,
onRuntimeStep: (payload = {}) => {
const currentScriptName = payload?.stepType === 'script'
? (payload?.scriptName || payload?.currentScriptName || null)
: null;
runtimeActivityService.updateActivity(cronActivityId, {
currentStepType: payload?.stepType || 'chain',
currentStep: payload?.currentStep || null,
currentScriptName,
scriptId: payload?.scriptId || null
});
}
},
{
appendLog: async (_source, line) => {
logLines.push(line);
}
}
);
output = logLines.join('\n');
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
success = result && typeof result === 'object'
? !(Boolean(result.aborted) || Number(result.failed || 0) > 0)
: Boolean(result);
if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.';
} else {
throw new Error(`Unbekannter source_type: ${job.sourceType}`);
}
} catch (error) {
success = false;
errorMessage = error.message || String(error);
logger.error('cron:run:error', { cronJobId: job.id, error: errorToMeta(error) });
}
const finishedAt = new Date().toISOString();
const status = success ? 'success' : 'error';
const nextRunAt = getNextRunTime(job.cronExpression)?.toISOString() || null;
// Log-Eintrag abschließen
await db.run(
`UPDATE cron_run_logs SET finished_at = ?, status = ?, output = ?, error_message = ? WHERE id = ?`,
[finishedAt, status, output || null, errorMessage, logId]
);
// Job-Status aktualisieren
await db.run(
`UPDATE cron_jobs SET last_run_status = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[status, nextRunAt, job.id]
);
// Alte Logs trimmen
await db.run(
`
DELETE FROM cron_run_logs
WHERE cron_job_id = ?
AND id NOT IN (
SELECT id FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?
)
`,
[job.id, job.id, MAX_LOGS_PER_JOB]
);
logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) });
runtimeActivityService.completeActivity(cronActivityId, {
status,
success,
outcome: success ? 'success' : 'error',
finishedAt,
currentStep: null,
currentScriptName: null,
message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'),
output: output || null,
errorMessage: success ? null : (errorMessage || null)
});
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt });
// Pushover-Benachrichtigung (nur wenn am Cron aktiviert UND global aktiviert)
if (job.pushoverEnabled) {
try {
const settings = await settingsService.getSettingsMap();
const eventKey = success ? 'cron_success' : 'cron_error';
const title = `Ripster Cron: ${job.name}`;
const message = success
? `Cronjob "${job.name}" erfolgreich ausgeführt.`
: `Cronjob "${job.name}" fehlgeschlagen: ${errorMessage || 'Unbekannter Fehler'}`;
await notificationService.notifyWithSettings(settings, eventKey, { title, message });
} catch (notifyError) {
logger.warn('cron:run:notify-failed', { cronJobId: job.id, error: errorToMeta(notifyError) });
}
}
return { success, status, output, errorMessage, finishedAt, nextRunAt };
}
// ─── Scheduler ─────────────────────────────────────────────────────────────
class CronService {
constructor() {
this._timer = null;
this._running = new Set(); // IDs aktuell laufender Jobs
}
async init() {
logger.info('cron:scheduler:init');
// Beim Start next_run_at für alle enabled Jobs neu berechnen
await this._recalcNextRuns();
this._scheduleNextTick();
}
stop() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
logger.info('cron:scheduler:stopped');
}
_scheduleNextTick() {
// Auf den Beginn der nächsten vollen Minute warten
const now = new Date();
const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500;
this._timer = setTimeout(() => this._tick(), msUntilNextMinute);
}
async _tick() {
try {
await this._checkAndRunDueJobs();
} catch (error) {
logger.error('cron:scheduler:tick-error', { error: errorToMeta(error) });
}
this._scheduleNextTick();
}
async _recalcNextRuns() {
const db = await getDb();
const jobs = await db.all(`SELECT id, cron_expression FROM cron_jobs WHERE enabled = 1`);
for (const job of jobs) {
const nextRunAt = getNextRunTime(job.cron_expression)?.toISOString() || null;
await db.run(`UPDATE cron_jobs SET next_run_at = ? WHERE id = ?`, [nextRunAt, job.id]);
}
}
async _checkAndRunDueJobs() {
const db = await getDb();
const now = new Date();
const nowIso = now.toISOString();
// Jobs, deren next_run_at <= jetzt ist und die nicht gerade laufen
const dueJobs = await db.all(
`SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`,
[nowIso]
);
for (const jobRow of dueJobs) {
const id = Number(jobRow.id);
if (this._running.has(id)) {
logger.warn('cron:scheduler:skip-still-running', { cronJobId: id });
continue;
}
const job = mapJobRow(jobRow);
this._running.add(id);
// Asynchron ausführen, damit der Scheduler nicht blockiert
runCronJob(job)
.catch((error) => {
logger.error('cron:run:unhandled-error', { cronJobId: id, error: errorToMeta(error) });
})
.finally(() => {
this._running.delete(id);
});
}
}
// ─── Public API ──────────────────────────────────────────────────────────
async listJobs() {
const db = await getDb();
const rows = await fetchAllJobsWithSource(db);
return rows.map(mapJobRow);
}
async getJobById(id) {
const db = await getDb();
const row = await fetchJobWithSource(db, id);
if (!row) {
const error = new Error(`Cronjob #${id} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
return mapJobRow(row);
}
async createJob(payload) {
const { name, cronExpression, sourceType, sourceId, enabled = true, pushoverEnabled = true } = payload || {};
const trimmedName = String(name || '').trim();
const trimmedExpr = String(cronExpression || '').trim();
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
const validation = validateCronExpression(trimmedExpr);
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
if (!['script', 'chain'].includes(sourceType)) {
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
}
const normalizedSourceId = Number(sourceId);
if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
}
const nextRunAt = getNextRunTime(trimmedExpr)?.toISOString() || null;
const db = await getDb();
const result = await db.run(
`
INSERT INTO cron_jobs (name, cron_expression, source_type, source_id, enabled, pushover_enabled, next_run_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
[trimmedName, trimmedExpr, sourceType, normalizedSourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt]
);
logger.info('cron:create', { cronJobId: result.lastID, name: trimmedName, cronExpression: trimmedExpr });
return this.getJobById(result.lastID);
}
async updateJob(id, payload) {
const db = await getDb();
const existing = await this.getJobById(id);
const trimmedName = Object.prototype.hasOwnProperty.call(payload, 'name')
? String(payload.name || '').trim()
: existing.name;
const trimmedExpr = Object.prototype.hasOwnProperty.call(payload, 'cronExpression')
? String(payload.cronExpression || '').trim()
: existing.cronExpression;
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
const validation = validateCronExpression(trimmedExpr);
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
const sourceType = Object.prototype.hasOwnProperty.call(payload, 'sourceType') ? payload.sourceType : existing.sourceType;
const sourceId = Object.prototype.hasOwnProperty.call(payload, 'sourceId') ? Number(payload.sourceId) : existing.sourceId;
const enabled = Object.prototype.hasOwnProperty.call(payload, 'enabled') ? Boolean(payload.enabled) : existing.enabled;
const pushoverEnabled = Object.prototype.hasOwnProperty.call(payload, 'pushoverEnabled') ? Boolean(payload.pushoverEnabled) : existing.pushoverEnabled;
if (!['script', 'chain'].includes(sourceType)) {
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
}
if (!Number.isFinite(sourceId) || sourceId <= 0) {
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
}
const nextRunAt = enabled ? (getNextRunTime(trimmedExpr)?.toISOString() || null) : null;
await db.run(
`
UPDATE cron_jobs
SET name = ?, cron_expression = ?, source_type = ?, source_id = ?,
enabled = ?, pushover_enabled = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
[trimmedName, trimmedExpr, sourceType, sourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt, id]
);
logger.info('cron:update', { cronJobId: id });
return this.getJobById(id);
}
async deleteJob(id) {
const db = await getDb();
const job = await this.getJobById(id);
await db.run(`DELETE FROM cron_jobs WHERE id = ?`, [id]);
logger.info('cron:delete', { cronJobId: id });
return job;
}
async getJobLogs(id, limit = 20) {
await this.getJobById(id); // Existenz prüfen
const db = await getDb();
const rows = await db.all(
`SELECT * FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?`,
[id, Math.min(Number(limit) || 20, 100)]
);
return rows.map(mapLogRow);
}
async triggerJobManually(id) {
const job = await this.getJobById(id);
if (this._running.has(id)) {
throw Object.assign(new Error('Cronjob läuft bereits.'), { statusCode: 409 });
}
this._running.add(id);
logger.info('cron:manual-trigger', { cronJobId: id });
// Asynchron starten
runCronJob(job)
.catch((error) => {
logger.error('cron:manual-trigger:error', { cronJobId: id, error: errorToMeta(error) });
})
.finally(() => {
this._running.delete(id);
});
return { triggered: true, cronJobId: id };
}
validateExpression(expr) {
return validateCronExpression(expr);
}
getNextRunTime(expr) {
const next = getNextRunTime(expr);
return next ? next.toISOString() : null;
}
}
module.exports = new CronService();
File diff suppressed because it is too large Load Diff
+572
View File
@@ -0,0 +1,572 @@
const fs = require('fs');
const path = require('path');
const { randomUUID } = require('crypto');
const { spawnSync } = require('child_process');
const archiver = require('archiver');
const settingsService = require('./settingsService');
const historyService = require('./historyService');
const wsService = require('./websocketService');
const logger = require('./logger').child('DOWNLOADS');
function safeJsonParse(raw, fallback = null) {
if (!raw) {
return fallback;
}
try {
return JSON.parse(raw);
} catch (_error) {
return fallback;
}
}
function normalizeDownloadId(value) {
const raw = String(value || '').trim();
return raw || null;
}
function normalizeStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (['queued', 'processing', 'ready', 'failed'].includes(raw)) {
return raw;
}
return 'failed';
}
function normalizeTarget(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'raw') {
return 'raw';
}
if (raw === 'output') {
return 'output';
}
return 'output';
}
function normalizeDateString(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
function normalizeNumber(value, fallback = null) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function compareCreatedDesc(a, b) {
const left = String(a?.createdAt || '');
const right = String(b?.createdAt || '');
return right.localeCompare(left) || String(b?.id || '').localeCompare(String(a?.id || ''));
}
function applyOwnerToPath(targetPath, ownerSpec) {
const spec = String(ownerSpec || '').trim();
if (!targetPath || !spec) {
return;
}
try {
const result = spawnSync('chown', [spec, targetPath], { timeout: 15000 });
if (result.status !== 0) {
logger.warn('download:chown:failed', {
targetPath,
spec,
stderr: String(result.stderr || '').trim() || null
});
}
} catch (error) {
logger.warn('download:chown:error', {
targetPath,
spec,
error: error?.message || String(error)
});
}
}
class DownloadService {
constructor() {
this.items = new Map();
this.activeTasks = new Map();
this.initPromise = null;
}
async init() {
if (!this.initPromise) {
this.initPromise = this._init();
}
return this.initPromise;
}
async _init() {
const settings = await settingsService.getEffectiveSettingsMap(null);
const downloadDir = String(settings?.download_dir || '').trim();
const owner = String(settings?.download_dir_owner || '').trim() || null;
await fs.promises.mkdir(downloadDir, { recursive: true });
applyOwnerToPath(downloadDir, owner);
let entries = [];
try {
entries = await fs.promises.readdir(downloadDir, { withFileTypes: true });
} catch (error) {
logger.warn('download:init:readdir-failed', {
downloadDir,
error: error?.message || String(error)
});
entries = [];
}
const nowIso = new Date().toISOString();
const pendingResumeIds = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) {
continue;
}
const metaPath = path.join(downloadDir, entry.name);
const parsed = safeJsonParse(await fs.promises.readFile(metaPath, 'utf-8').catch(() => null), null);
if (!parsed || typeof parsed !== 'object') {
continue;
}
const item = this._normalizeLoadedItem(parsed, downloadDir);
if (!item) {
continue;
}
let changed = false;
if (item.status === 'queued' || item.status === 'processing') {
const archiveExists = await this._pathExists(item.archivePath);
if (archiveExists) {
const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null);
item.status = 'ready';
item.errorMessage = null;
item.finishedAt = item.finishedAt || nowIso;
item.sizeBytes = Number.isFinite(Number(archiveStat?.size))
? archiveStat.size
: item.sizeBytes;
changed = true;
logger.warn('download:init:recovered-ready-archive', {
id: item.id,
archiveName: item.archiveName
});
} else {
item.status = 'queued';
item.startedAt = null;
item.finishedAt = null;
item.errorMessage = null;
item.sizeBytes = null;
changed = true;
pendingResumeIds.push(item.id);
await this._safeUnlink(item.partialPath);
logger.warn('download:init:requeue-interrupted-job', {
id: item.id,
archiveName: item.archiveName
});
}
} else if (item.status === 'ready') {
const exists = await this._pathExists(item.archivePath);
if (!exists) {
item.status = 'failed';
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
item.finishedAt = nowIso;
item.sizeBytes = null;
changed = true;
}
}
this.items.set(item.id, item);
if (changed) {
await this._persistItem(item);
}
}
if (pendingResumeIds.length > 0) {
logger.warn('download:init:resume-pending-jobs', {
count: pendingResumeIds.length
});
setImmediate(() => {
for (const id of pendingResumeIds) {
void this._startArchiveJob(id);
}
});
}
}
_normalizeLoadedItem(rawItem, fallbackDir) {
const id = normalizeDownloadId(rawItem?.id);
if (!id) {
return null;
}
const downloadDir = String(rawItem?.downloadDir || fallbackDir || '').trim();
if (!downloadDir) {
return null;
}
return {
id,
kind: String(rawItem?.kind || 'history').trim() || 'history',
jobId: normalizeNumber(rawItem?.jobId, null),
target: normalizeTarget(rawItem?.target),
label: String(rawItem?.label || (rawItem?.target === 'raw' ? 'RAW' : 'Encode')).trim() || 'Download',
displayTitle: String(rawItem?.displayTitle || '').trim() || null,
sourcePath: String(rawItem?.sourcePath || '').trim() || null,
sourceType: String(rawItem?.sourceType || '').trim() === 'file' ? 'file' : 'directory',
sourceMtimeMs: normalizeNumber(rawItem?.sourceMtimeMs, null),
sourceModifiedAt: normalizeDateString(rawItem?.sourceModifiedAt),
entryName: String(rawItem?.entryName || '').trim() || null,
archiveName: String(rawItem?.archiveName || `${id}.zip`).trim() || `${id}.zip`,
downloadDir,
archivePath: String(rawItem?.archivePath || path.join(downloadDir, `${id}.zip`)).trim(),
partialPath: String(rawItem?.partialPath || path.join(downloadDir, `${id}.partial.zip`)).trim(),
metaPath: String(rawItem?.metaPath || path.join(downloadDir, `${id}.json`)).trim(),
ownerSpec: String(rawItem?.ownerSpec || '').trim() || null,
status: normalizeStatus(rawItem?.status),
createdAt: normalizeDateString(rawItem?.createdAt) || new Date().toISOString(),
startedAt: normalizeDateString(rawItem?.startedAt),
finishedAt: normalizeDateString(rawItem?.finishedAt),
errorMessage: String(rawItem?.errorMessage || '').trim() || null,
sizeBytes: normalizeNumber(rawItem?.sizeBytes, null)
};
}
_serializeItem(item) {
return {
id: item.id,
kind: item.kind,
jobId: item.jobId,
target: item.target,
label: item.label,
displayTitle: item.displayTitle,
sourcePath: item.sourcePath,
sourceType: item.sourceType,
archiveName: item.archiveName,
downloadDir: item.downloadDir,
status: item.status,
createdAt: item.createdAt,
startedAt: item.startedAt,
finishedAt: item.finishedAt,
errorMessage: item.errorMessage,
sizeBytes: item.sizeBytes,
downloadUrl: item.status === 'ready' ? `/api/downloads/${encodeURIComponent(item.id)}/file` : null
};
}
getSummary() {
const items = Array.from(this.items.values());
const queuedCount = items.filter((item) => item.status === 'queued').length;
const processingCount = items.filter((item) => item.status === 'processing').length;
const readyCount = items.filter((item) => item.status === 'ready').length;
const failedCount = items.filter((item) => item.status === 'failed').length;
return {
totalCount: items.length,
queuedCount,
processingCount,
activeCount: queuedCount + processingCount,
readyCount,
failedCount
};
}
_broadcastUpdate(reason, item = null) {
wsService.broadcast('DOWNLOADS_UPDATED', {
reason: String(reason || 'updated').trim() || 'updated',
summary: this.getSummary(),
item: item ? this._serializeItem(item) : null
});
}
async listItems() {
await this.init();
return Array.from(this.items.values())
.sort(compareCreatedDesc)
.map((item) => this._serializeItem(item));
}
async getItem(id) {
await this.init();
const normalizedId = normalizeDownloadId(id);
if (!normalizedId || !this.items.has(normalizedId)) {
const error = new Error('Download nicht gefunden.');
error.statusCode = 404;
throw error;
}
return this.items.get(normalizedId);
}
async enqueueHistoryJob(jobId, target, options = {}) {
await this.init();
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options);
const settings = await settingsService.getEffectiveSettingsMap(null);
const downloadDir = String(settings?.download_dir || '').trim();
const ownerSpec = String(settings?.download_dir_owner || '').trim() || null;
await fs.promises.mkdir(downloadDir, { recursive: true });
applyOwnerToPath(downloadDir, ownerSpec);
const reusable = await this._findReusableHistoryItem(descriptor, downloadDir);
if (reusable) {
return {
item: this._serializeItem(reusable),
reused: true,
created: false
};
}
const id = randomUUID();
const nowIso = new Date().toISOString();
const item = {
id,
kind: 'history',
jobId: descriptor.jobId,
target: descriptor.target,
label: descriptor.target === 'raw' ? 'RAW' : 'Encode',
displayTitle: descriptor.displayTitle,
sourcePath: descriptor.sourcePath,
sourceType: descriptor.sourceType,
sourceMtimeMs: descriptor.sourceMtimeMs,
sourceModifiedAt: descriptor.sourceModifiedAt,
entryName: descriptor.entryName,
archiveName: descriptor.archiveName,
downloadDir,
archivePath: path.join(downloadDir, `${id}.zip`),
partialPath: path.join(downloadDir, `${id}.partial.zip`),
metaPath: path.join(downloadDir, `${id}.json`),
ownerSpec,
status: 'queued',
createdAt: nowIso,
startedAt: null,
finishedAt: null,
errorMessage: null,
sizeBytes: null
};
this.items.set(id, item);
await this._persistItem(item);
this._broadcastUpdate('queued', item);
setImmediate(() => {
void this._startArchiveJob(id);
});
return {
item: this._serializeItem(item),
reused: false,
created: true
};
}
async _findReusableHistoryItem(descriptor, downloadDir) {
for (const item of this.items.values()) {
if (item.kind !== 'history') {
continue;
}
if (item.jobId !== descriptor.jobId || item.target !== descriptor.target) {
continue;
}
if (item.sourcePath !== descriptor.sourcePath || item.sourceMtimeMs !== descriptor.sourceMtimeMs) {
continue;
}
if (item.downloadDir !== downloadDir) {
continue;
}
if (!['queued', 'processing', 'ready'].includes(item.status)) {
continue;
}
if (item.status === 'ready' && !(await this._pathExists(item.archivePath))) {
item.status = 'failed';
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
item.finishedAt = new Date().toISOString();
item.sizeBytes = null;
await this._persistItem(item);
this._broadcastUpdate('failed', item);
continue;
}
return item;
}
return null;
}
async _startArchiveJob(id) {
const item = this.items.get(id);
if (!item) {
return;
}
if (this.activeTasks.has(id)) {
return this.activeTasks.get(id);
}
const promise = this._runArchiveJob(item)
.catch((error) => {
logger.warn('download:job:failed', {
id,
archiveName: item.archiveName,
error: error?.message || String(error)
});
})
.finally(() => {
this.activeTasks.delete(id);
});
this.activeTasks.set(id, promise);
return promise;
}
async _runArchiveJob(item) {
item.status = 'processing';
item.startedAt = new Date().toISOString();
item.finishedAt = null;
item.errorMessage = null;
item.sizeBytes = null;
await this._safeUnlink(item.partialPath);
await this._persistItem(item);
this._broadcastUpdate('processing', item);
await fs.promises.mkdir(item.downloadDir, { recursive: true });
applyOwnerToPath(item.downloadDir, item.ownerSpec);
await new Promise((resolve, reject) => {
let settled = false;
const output = fs.createWriteStream(item.partialPath);
const archive = archiver('zip', { zlib: { level: 9 } });
const finishError = (error) => {
if (settled) {
return;
}
settled = true;
output.destroy();
reject(error);
};
output.on('close', () => {
if (settled) {
return;
}
settled = true;
resolve();
});
output.on('error', finishError);
archive.on('warning', finishError);
archive.on('error', finishError);
archive.pipe(output);
if (item.sourceType === 'directory') {
archive.directory(item.sourcePath, item.entryName);
} else {
archive.file(item.sourcePath, { name: item.entryName });
}
try {
const finalizeResult = archive.finalize();
if (finalizeResult && typeof finalizeResult.catch === 'function') {
finalizeResult.catch(finishError);
}
} catch (error) {
finishError(error);
}
}).catch(async (error) => {
await this._safeUnlink(item.partialPath);
item.status = 'failed';
item.finishedAt = new Date().toISOString();
item.errorMessage = error?.message || 'ZIP-Erstellung fehlgeschlagen.';
item.sizeBytes = null;
await this._persistItem(item);
this._broadcastUpdate('failed', item);
throw error;
});
await fs.promises.rename(item.partialPath, item.archivePath);
applyOwnerToPath(item.archivePath, item.ownerSpec);
const stat = await fs.promises.stat(item.archivePath);
item.status = 'ready';
item.finishedAt = new Date().toISOString();
item.errorMessage = null;
item.sizeBytes = stat.size;
await this._persistItem(item);
this._broadcastUpdate('ready', item);
}
async getDownloadDescriptor(id) {
const item = await this.getItem(id);
if (item.status !== 'ready') {
const error = new Error('ZIP-Datei ist noch nicht fertig.');
error.statusCode = 409;
throw error;
}
const exists = await this._pathExists(item.archivePath);
if (!exists) {
item.status = 'failed';
item.finishedAt = new Date().toISOString();
item.errorMessage = 'ZIP-Datei wurde nicht gefunden.';
item.sizeBytes = null;
await this._persistItem(item);
this._broadcastUpdate('failed', item);
const error = new Error('ZIP-Datei wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return {
path: item.archivePath,
archiveName: item.archiveName
};
}
async deleteItem(id) {
const item = await this.getItem(id);
if (item.status === 'queued' || item.status === 'processing' || this.activeTasks.has(item.id)) {
const error = new Error('Laufende ZIP-Jobs können nicht gelöscht werden.');
error.statusCode = 409;
throw error;
}
await this._safeUnlink(item.archivePath);
await this._safeUnlink(item.partialPath);
await this._safeUnlink(item.metaPath);
this.items.delete(item.id);
this._broadcastUpdate('deleted', item);
return {
deleted: true,
id: item.id
};
}
async _persistItem(item) {
const next = {
...item,
metaPath: item.metaPath,
archivePath: item.archivePath,
partialPath: item.partialPath
};
const tmpMetaPath = `${item.metaPath}.tmp`;
await fs.promises.writeFile(tmpMetaPath, JSON.stringify(next, null, 2), 'utf-8');
await fs.promises.rename(tmpMetaPath, item.metaPath);
applyOwnerToPath(item.metaPath, item.ownerSpec);
}
async _safeUnlink(targetPath) {
if (!targetPath) {
return;
}
try {
await fs.promises.rm(targetPath, { force: true });
} catch (_error) {
// ignore cleanup errors
}
}
async _pathExists(targetPath) {
if (!targetPath) {
return false;
}
try {
await fs.promises.access(targetPath, fs.constants.F_OK);
return true;
} catch (_error) {
return false;
}
}
}
module.exports = new DownloadService();
@@ -0,0 +1,528 @@
'use strict';
const TITLE_KIND = Object.freeze({
EPISODE_CANDIDATE: 'episode_candidate',
PLAY_ALL: 'play_all',
EXTRA: 'extra',
DUPLICATE: 'duplicate',
SHORT: 'short',
UNKNOWN: 'unknown'
});
const DEFAULTS = Object.freeze({
minEpisodeMinutes: 18,
maxEpisodeMinutes: 75,
minChapterCount: 3,
shortTitleMinutes: 5
});
function nowIso() {
return new Date().toISOString();
}
function parseDurationToSeconds(rawValue) {
const text = String(rawValue || '').trim();
const match = text.match(/^(\d{1,2}):(\d{2}):(\d{2})$/);
if (!match) {
return 0;
}
return (Number(match[1]) * 3600) + (Number(match[2]) * 60) + Number(match[3]);
}
function roundToStep(value, step) {
const num = Number(value);
if (!Number.isFinite(num) || step <= 0) {
return 0;
}
return Math.round(num / step) * step;
}
function median(values = []) {
const nums = (Array.isArray(values) ? values : [])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.sort((left, right) => left - right);
if (nums.length === 0) {
return 0;
}
const middle = Math.floor(nums.length / 2);
if (nums.length % 2 === 1) {
return nums[middle];
}
return (nums[middle - 1] + nums[middle]) / 2;
}
function normalizeLanguageCode(rawCode, fallbackLabel = null) {
const raw = String(rawCode || '').trim().toLowerCase();
if (raw && raw.length === 3) {
return raw;
}
const label = String(fallbackLabel || '').trim().toLowerCase();
if (label.startsWith('de')) return 'deu';
if (label.startsWith('en')) return 'eng';
if (label.startsWith('fr')) return 'fra';
if (label.startsWith('es')) return 'spa';
if (label.startsWith('nl')) return 'nld';
return raw || 'und';
}
function normalizeSeriesLookupTitle(rawValue) {
return String(rawValue || '')
.replace(/[_./]+/g, ' ')
.replace(/\bs\d{1,2}\s*d\d{1,2}\b/gi, ' ')
.replace(/\bs(?:taffel|eason)?\s*\d{1,2}\s*(?:disc|dvd|cd|d)\s*\d{1,2}\b/gi, ' ')
.replace(/\b(?:disc|dvd|cd)\s*\d+\b/gi, ' ')
.replace(/\b(?:s(?:taffel|eason)?|season)\s*\d+\b/gi, ' ')
.replace(/\b\d{1,2}x\b/gi, ' ')
.replace(/\b(?:complete|collection|boxset)\b/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function deriveSeriesLookupHint(inputs = []) {
const normalizedInputs = (Array.isArray(inputs) ? inputs : [inputs])
.map((entry) => {
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
return {
value: String(entry.value || '').trim(),
source: String(entry.source || '').trim() || 'unknown'
};
}
return {
value: String(entry || '').trim(),
source: 'unknown'
};
})
.filter((entry) => entry.value);
for (const entry of normalizedInputs) {
const compactValue = entry.value.replace(/[_./]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!compactValue) {
continue;
}
const compactSeasonDiscMatch = compactValue.match(
/(?:^|\s)s(?:taffel|eason)?\s*0?(\d{1,2})\s*(?:d|disc|dvd|cd)\s*0?(\d{1,2})(?=\s|$)/i
);
const seasonMatch = compactSeasonDiscMatch
|| compactValue.match(/(?:^|\s)(?:s(?:taffel|eason)?|season)\s*(\d{1,2})(?=\s|$)/i)
|| compactValue.match(/(?:^|\s)s\s*0?(\d{1,2})(?=\s|$)/i)
|| compactValue.match(/(?:^|\s)(\d{1,2})x(?=\s|$)/i);
if (!seasonMatch) {
continue;
}
const seasonNumber = Number(seasonMatch[1] || 0);
if (!Number.isFinite(seasonNumber) || seasonNumber <= 0) {
continue;
}
const discMatch = compactSeasonDiscMatch
|| compactValue.match(/(?:^|\s)(?:disc|dvd|cd|d)\s*0?(\d{1,2})(?=\s|$)/i);
const compactDiscToken = compactSeasonDiscMatch ? compactSeasonDiscMatch[2] : null;
const discNumber = discMatch
? Number(compactDiscToken || discMatch[1] || 0) || null
: null;
const baseTitle = normalizeSeriesLookupTitle(compactValue);
if (!baseTitle) {
continue;
}
return {
query: baseTitle,
seriesTitle: baseTitle,
seasonNumber,
discNumber,
source: entry.source,
sourceLabel: entry.value,
confidence: discNumber ? 'high' : 'medium'
};
}
return null;
}
function normalizeTitleRecord(title = {}) {
const durationSeconds = Number(title.durationSeconds || 0);
const chapterCount = Number(title.chapterCount || 0);
const roundedDurationBucket = roundToStep(durationSeconds, 30);
const audioLanguages = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
.filter(Boolean)
.sort();
const subtitleLanguages = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
.map((track) => normalizeLanguageCode(track?.languageCode, track?.languageLabel))
.filter(Boolean)
.sort();
return {
index: Number(title.index || 0),
durationSeconds,
durationLabel: String(title.durationLabel || '').trim() || null,
chapterCount,
chapterDurationsMs: Array.isArray(title.chapterDurationsMs) ? title.chapterDurationsMs : [],
aspectRatio: String(title.aspectRatio || '').trim() || null,
audioTracks: Array.isArray(title.audioTracks) ? title.audioTracks : [],
subtitleTracks: Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [],
flags: Array.isArray(title.flags) ? title.flags : [],
roundedDurationBucket,
signature: JSON.stringify({
duration: roundedDurationBucket,
chapterCount,
audioLanguages,
subtitleLanguages
})
};
}
function buildBestEpisodeCluster(titles = [], options = {}) {
const minEpisodeSeconds = Math.max(60, Math.round(Number(options.minEpisodeMinutes || DEFAULTS.minEpisodeMinutes) * 60));
const maxEpisodeSeconds = Math.max(minEpisodeSeconds, Math.round(Number(options.maxEpisodeMinutes || DEFAULTS.maxEpisodeMinutes) * 60));
const minChapterCount = Math.max(1, Number(options.minChapterCount || DEFAULTS.minChapterCount));
const candidates = titles.filter((title) =>
title.durationSeconds >= minEpisodeSeconds
&& title.durationSeconds <= maxEpisodeSeconds
&& title.chapterCount >= minChapterCount
);
if (candidates.length === 0) {
return {
titles: [],
medianDurationSeconds: 0,
medianChapterCount: 0
};
}
let bestCluster = [];
for (const anchor of candidates) {
const durationTolerance = Math.max(90, Math.round(anchor.durationSeconds * 0.12));
const cluster = candidates.filter((candidate) => {
const durationGap = Math.abs(candidate.durationSeconds - anchor.durationSeconds);
const chapterGap = Math.abs(candidate.chapterCount - anchor.chapterCount);
return durationGap <= durationTolerance && chapterGap <= 2;
});
if (cluster.length > bestCluster.length) {
bestCluster = cluster;
continue;
}
if (cluster.length === bestCluster.length) {
const clusterMedian = median(cluster.map((item) => item.durationSeconds));
const bestMedian = median(bestCluster.map((item) => item.durationSeconds));
if (clusterMedian > bestMedian) {
bestCluster = cluster;
}
}
}
return {
titles: bestCluster,
medianDurationSeconds: median(bestCluster.map((item) => item.durationSeconds)),
medianChapterCount: median(bestCluster.map((item) => item.chapterCount))
};
}
function classifyTitles(titles = [], cluster = {}, options = {}) {
const shortTitleSeconds = Math.max(60, Math.round(Number(options.shortTitleMinutes || DEFAULTS.shortTitleMinutes) * 60));
const clusterTitleIds = new Set((cluster.titles || []).map((item) => Number(item.index)));
const duplicateFirstBySignature = new Map();
for (const title of titles) {
if (!duplicateFirstBySignature.has(title.signature)) {
duplicateFirstBySignature.set(title.signature, title.index);
}
}
const playAllCandidate = (() => {
if (!Array.isArray(cluster.titles) || cluster.titles.length < 2 || !cluster.medianDurationSeconds) {
return null;
}
const minPlayAllSeconds = cluster.medianDurationSeconds * Math.max(2, cluster.titles.length - 1);
return titles
.filter((title) =>
!clusterTitleIds.has(Number(title.index))
&& title.durationSeconds >= minPlayAllSeconds * 0.75
&& title.chapterCount >= Math.max(6, Math.round(cluster.medianChapterCount * cluster.titles.length * 0.6))
)
.sort((left, right) => right.durationSeconds - left.durationSeconds)[0] || null;
})();
return titles.map((title) => {
const isFirstWithSignature = duplicateFirstBySignature.get(title.signature) === title.index;
const isShort = title.durationSeconds > 0 && title.durationSeconds <= shortTitleSeconds;
const isEpisodeCandidate = clusterTitleIds.has(Number(title.index));
const isPlayAll = playAllCandidate && Number(playAllCandidate.index) === Number(title.index);
const kind = isPlayAll
? TITLE_KIND.PLAY_ALL
: isEpisodeCandidate
? TITLE_KIND.EPISODE_CANDIDATE
: !isFirstWithSignature
? TITLE_KIND.DUPLICATE
: isShort
? TITLE_KIND.SHORT
: (title.durationSeconds > 0 ? TITLE_KIND.EXTRA : TITLE_KIND.UNKNOWN);
let confidence = 0.2;
if (kind === TITLE_KIND.EPISODE_CANDIDATE) confidence = 0.8;
if (kind === TITLE_KIND.PLAY_ALL) confidence = 0.95;
if (kind === TITLE_KIND.DUPLICATE) confidence = 0.85;
if (kind === TITLE_KIND.SHORT) confidence = 0.9;
if (kind === TITLE_KIND.EXTRA) confidence = 0.55;
return {
...title,
kind,
confidence,
duplicateOfTitleIndex: kind === TITLE_KIND.DUPLICATE
? duplicateFirstBySignature.get(title.signature)
: null
};
});
}
function buildDiscSignature(parsedScan = {}) {
return JSON.stringify({
discTitle: String(parsedScan.discTitle || '').trim() || null,
discSerial: String(parsedScan.discSerial || '').trim() || null,
titleCount: Number(parsedScan.titleCount || 0),
durations: (Array.isArray(parsedScan.titles) ? parsedScan.titles : [])
.map((title) => ({
index: Number(title.index || 0),
durationBucket: roundToStep(title.durationSeconds || 0, 30),
chapterCount: Number(title.chapterCount || 0)
}))
});
}
function summarizeSeriesLikelihood(classifiedTitles = [], cluster = {}) {
const episodeCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length;
const playAllCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length;
const duplicateCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length;
const extrasCount = classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length;
const reasons = [];
let confidence = 'low';
let seriesLike = false;
if (episodeCount >= 3) {
seriesLike = true;
confidence = playAllCount > 0 ? 'high' : 'medium';
reasons.push(`${episodeCount} ähnlich lange Episodenkandidaten erkannt.`);
} else if (episodeCount === 2) {
seriesLike = true;
confidence = 'medium';
reasons.push('Mindestens zwei ähnlich lange Episodenkandidaten erkannt.');
}
if (playAllCount > 0) {
reasons.push('Ein langer Play-All-Titel wurde erkannt.');
}
if (duplicateCount > 0) {
reasons.push(`${duplicateCount} alternative/duplizierte Titel gefunden.`);
}
if (cluster.medianDurationSeconds > 0) {
reasons.push(`Typische Episodenlänge ca. ${Math.round(cluster.medianDurationSeconds / 60)} Minuten.`);
}
if (extrasCount > 0) {
reasons.push(`${extrasCount} Titel als Extras oder Sonderfälle klassifiziert.`);
}
return {
seriesLike,
confidence,
reasons
};
}
function parseHandBrakeScanText(rawOutput) {
const lines = String(rawOutput || '').split(/\r?\n/);
const parsed = {
source: 'handbrake_scan_text',
generatedAt: nowIso(),
discTitle: null,
discSerial: null,
titleCount: 0,
rawLineCount: lines.length,
titles: []
};
let currentTitle = null;
const ensureCurrentTitle = (index) => {
const normalizedIndex = Number(index);
if (!Number.isFinite(normalizedIndex) || normalizedIndex <= 0) {
return null;
}
if (currentTitle && Number(currentTitle.index) === normalizedIndex) {
return currentTitle;
}
const existing = parsed.titles.find((title) => Number(title.index) === normalizedIndex);
if (existing) {
currentTitle = existing;
return currentTitle;
}
currentTitle = {
index: normalizedIndex,
durationLabel: null,
durationSeconds: 0,
chapterCount: 0,
chapterDurationsMs: [],
audioTracks: [],
subtitleTracks: [],
aspectRatio: null,
flags: []
};
parsed.titles.push(currentTitle);
return currentTitle;
};
for (const line of lines) {
let match = line.match(/libdvdnav:\s+DVD Title:\s+(.+)\s*$/i);
if (match) {
parsed.discTitle = String(match[1] || '').trim() || null;
continue;
}
match = line.match(/libdvdnav:\s+DVD Serial Number:\s+(.+)\s*$/i);
if (match) {
parsed.discSerial = String(match[1] || '').trim() || null;
continue;
}
match = line.match(/scan:\s+DVD has\s+(\d+)\s+title/i);
if (match) {
parsed.titleCount = Number(match[1] || 0);
continue;
}
match = line.match(/scan:\s+(?:BD|Blu-?ray)\s+has\s+(\d+)\s+title/i);
if (match) {
parsed.titleCount = Number(match[1] || 0);
continue;
}
match = line.match(/scan:\s+scanning title\s+(\d+)/i);
if (match) {
ensureCurrentTitle(match[1]);
continue;
}
match = line.match(/^\s*\+\s+title\s+(\d+):/i);
if (match) {
ensureCurrentTitle(match[1]);
continue;
}
match = line.match(/scan:\s+duration is\s+(\d{1,2}:\d{2}:\d{2})/i);
if (match && currentTitle) {
currentTitle.durationLabel = match[1];
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
continue;
}
match = line.match(/^\s*\+\s+duration:\s+(\d{1,2}:\d{2}:\d{2})/i);
if (match && currentTitle) {
currentTitle.durationLabel = match[1];
currentTitle.durationSeconds = parseDurationToSeconds(match[1]);
continue;
}
match = line.match(/scan:\s+title\s+(\d+)\s+has\s+(\d+)\s+chapters/i);
if (match) {
const title = ensureCurrentTitle(match[1]);
if (title) {
title.chapterCount = Number(match[2] || 0);
}
continue;
}
match = line.match(/^\s*\+\s+chapters:\s+(\d+)/i);
if (match && currentTitle) {
currentTitle.chapterCount = Number(match[1] || 0);
continue;
}
match = line.match(/scan:\s+chap\s+\d+,\s+(\d+)\s+ms/i);
if (match && currentTitle) {
currentTitle.chapterDurationsMs.push(Number(match[1] || 0));
continue;
}
match = line.match(/scan:\s+id=0x[0-9a-f]+,\s+lang=(.+?)\s+\([^)]+\),\s+3cc=([a-z]{3})/i);
if (match && currentTitle) {
const track = {
languageLabel: String(match[1] || '').trim() || null,
languageCode: normalizeLanguageCode(match[2], match[1])
};
if (/\[VOBSUB\]/i.test(line)) {
currentTitle.subtitleTracks.push(track);
} else {
currentTitle.audioTracks.push(track);
}
continue;
}
match = line.match(/scan:\s+aspect\s+=\s+(.+)$/i);
if (match && currentTitle) {
currentTitle.aspectRatio = String(match[1] || '').trim() || null;
continue;
}
match = line.match(/scan:\s+ignoring title \(too short\)/i);
if (match && currentTitle) {
currentTitle.flags.push('ignored_too_short');
}
}
parsed.titles.sort((left, right) => Number(left.index || 0) - Number(right.index || 0));
if (!parsed.titleCount) {
parsed.titleCount = parsed.titles.length;
}
return parsed;
}
function analyzeParsedScan(parsedScan = {}, options = {}) {
const titles = (Array.isArray(parsedScan.titles) ? parsedScan.titles : []).map(normalizeTitleRecord);
const cluster = buildBestEpisodeCluster(titles, options);
const classifiedTitles = classifyTitles(titles, cluster, options);
const summary = summarizeSeriesLikelihood(classifiedTitles, cluster);
return {
source: parsedScan.source || 'handbrake_scan_text',
generatedAt: nowIso(),
discTitle: parsedScan.discTitle || null,
discSerial: parsedScan.discSerial || null,
titleCount: Number(parsedScan.titleCount || titles.length || 0),
discSignature: buildDiscSignature({
discTitle: parsedScan.discTitle,
discSerial: parsedScan.discSerial,
titleCount: parsedScan.titleCount,
titles
}),
summary: {
...summary,
titleCount: classifiedTitles.length,
episodeCandidateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EPISODE_CANDIDATE).length,
playAllCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.PLAY_ALL).length,
duplicateCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.DUPLICATE).length,
extraCount: classifiedTitles.filter((title) => title.kind === TITLE_KIND.EXTRA).length,
typicalEpisodeMinutes: cluster.medianDurationSeconds
? Number((cluster.medianDurationSeconds / 60).toFixed(2))
: 0
},
titles: classifiedTitles
};
}
function analyzeHandBrakeScan(rawOutput, options = {}) {
const parsed = parseHandBrakeScanText(rawOutput);
const analysis = analyzeParsedScan(parsed, options);
return {
parsed,
analysis
};
}
module.exports = {
TITLE_KIND,
parseHandBrakeScanText,
analyzeParsedScan,
analyzeHandBrakeScan,
deriveSeriesLookupHint
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46
View File
@@ -0,0 +1,46 @@
const path = require('path');
const { logDir: fallbackLogDir } = require('../config');
function normalizeDir(value) {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(raw);
}
function getFallbackLogRootDir() {
return path.resolve(fallbackLogDir);
}
function resolveLogRootDir(value) {
return normalizeDir(value) || getFallbackLogRootDir();
}
let runtimeLogRootDir = getFallbackLogRootDir();
function setLogRootDir(value) {
runtimeLogRootDir = resolveLogRootDir(value);
return runtimeLogRootDir;
}
function getLogRootDir() {
return runtimeLogRootDir || getFallbackLogRootDir();
}
function getBackendLogDir() {
return path.join(getLogRootDir(), 'backend');
}
function getJobLogDir() {
return getLogRootDir();
}
module.exports = {
getFallbackLogRootDir,
resolveLogRootDir,
setLogRootDir,
getLogRootDir,
getBackendLogDir,
getJobLogDir
};
+238
View File
@@ -0,0 +1,238 @@
const fs = require('fs');
const path = require('path');
const { logLevel } = require('../config');
const { getBackendLogDir, getFallbackLogRootDir } = require('./logPathService');
const LEVELS = {
debug: 10,
info: 20,
warn: 30,
error: 40
};
const ACTIVE_LEVEL = LEVELS[String(logLevel || 'info').toLowerCase()] || LEVELS.info;
let consoleConfig = {
enabled: true,
levels: {
debug: true,
info: true,
warn: true,
error: true
},
http: true
};
function normalizeBoolean(value, fallback = true) {
if (value === null || value === undefined) {
return fallback;
}
return Boolean(value);
}
function ensureLogDir(logDirPath) {
try {
fs.mkdirSync(logDirPath, { recursive: true });
return true;
} catch (_error) {
return false;
}
}
function resolveWritableBackendLogDir() {
const preferred = getBackendLogDir();
if (ensureLogDir(preferred)) {
return preferred;
}
const fallback = path.join(getFallbackLogRootDir(), 'backend');
if (fallback !== preferred && ensureLogDir(fallback)) {
return fallback;
}
return null;
}
function getDailyFileName() {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `backend-${y}-${m}-${day}.log`;
}
function safeJson(value) {
try {
return JSON.stringify(value);
} catch (error) {
return JSON.stringify({ serializationError: error.message });
}
}
function truncateString(value, maxLen = 3000) {
const str = String(value);
if (str.length <= maxLen) {
return str;
}
return `${str.slice(0, maxLen)}...[truncated ${str.length - maxLen} chars]`;
}
function sanitizeMeta(meta) {
if (!meta || typeof meta !== 'object') {
return meta;
}
const out = Array.isArray(meta) ? [] : {};
for (const [key, val] of Object.entries(meta)) {
if (val instanceof Error) {
out[key] = {
name: val.name,
message: val.message,
stack: val.stack
};
continue;
}
if (typeof val === 'string') {
out[key] = truncateString(val, 5000);
continue;
}
out[key] = val;
}
return out;
}
function writeLine(line) {
const backendLogDir = resolveWritableBackendLogDir();
if (!backendLogDir) {
return;
}
const daily = path.join(backendLogDir, getDailyFileName());
const latest = path.join(backendLogDir, 'backend-latest.log');
fs.appendFile(daily, `${line}\n`, (_error) => null);
fs.appendFile(latest, `${line}\n`, (_error) => null);
}
function emit(level, scope, message, meta = null) {
const normLevel = String(level || 'info').toLowerCase();
const lvl = LEVELS[normLevel] || LEVELS.info;
if (lvl < ACTIVE_LEVEL) {
return;
}
const timestamp = new Date().toISOString();
const payload = {
timestamp,
level: normLevel,
scope,
message,
meta: sanitizeMeta(meta)
};
const line = safeJson(payload);
writeLine(line);
if (!shouldEmitToConsole(normLevel, scope)) {
return;
}
const print = `[${timestamp}] [${normLevel.toUpperCase()}] [${scope}] ${message}`;
if (normLevel === 'error') {
console.error(print, payload.meta ? payload.meta : '');
} else if (normLevel === 'warn') {
console.warn(print, payload.meta ? payload.meta : '');
} else {
console.log(print, payload.meta ? payload.meta : '');
}
}
function shouldEmitToConsole(level, scope) {
if (!consoleConfig.enabled) {
return false;
}
const normalizedLevel = String(level || 'info').toLowerCase();
if (consoleConfig.levels[normalizedLevel] === false) {
return false;
}
const normalizedScope = String(scope || '').trim().toUpperCase();
if (normalizedScope === 'HTTP' && !consoleConfig.http) {
return false;
}
return true;
}
function child(scope) {
return {
debug(message, meta) {
emit('debug', scope, message, meta);
},
info(message, meta) {
emit('info', scope, message, meta);
},
warn(message, meta) {
emit('warn', scope, message, meta);
},
error(message, meta) {
emit('error', scope, message, meta);
}
};
}
function setConsoleOutputEnabled(enabled) {
consoleConfig.enabled = Boolean(enabled);
return consoleConfig.enabled;
}
function isConsoleOutputEnabled() {
return consoleConfig.enabled;
}
function configureConsoleOutput(options = {}) {
const opts = options && typeof options === 'object' ? options : {};
if (Object.prototype.hasOwnProperty.call(opts, 'enabled')) {
consoleConfig.enabled = normalizeBoolean(opts.enabled, true);
}
if (opts.levels && typeof opts.levels === 'object') {
const sourceLevels = opts.levels;
for (const level of Object.keys(LEVELS)) {
if (Object.prototype.hasOwnProperty.call(sourceLevels, level)) {
consoleConfig.levels[level] = normalizeBoolean(sourceLevels[level], true);
}
}
}
if (Object.prototype.hasOwnProperty.call(opts, 'http')) {
consoleConfig.http = normalizeBoolean(opts.http, true);
}
return getConsoleOutputConfig();
}
function getConsoleOutputConfig() {
return {
enabled: Boolean(consoleConfig.enabled),
levels: {
debug: Boolean(consoleConfig.levels.debug),
info: Boolean(consoleConfig.levels.info),
warn: Boolean(consoleConfig.levels.warn),
error: Boolean(consoleConfig.levels.error)
},
http: Boolean(consoleConfig.http)
};
}
module.exports = {
child,
emit,
setConsoleOutputEnabled,
isConsoleOutputEnabled,
configureConsoleOutput,
getConsoleOutputConfig
};
+215
View File
@@ -0,0 +1,215 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
const logger = require('./logger').child('MAKEMKV_KEY');
const MAKEMKV_BETA_KEY_API_URL = 'https://cable.ayra.ch/makemkv/api.php?json';
const MAKEMKV_BETA_KEY_API_USER_AGENT = process.env.MAKEMKV_BETA_KEY_API_USER_AGENT
|| 'Ripster/1.0 (MakeMKV beta key sync)';
const MAKEMKV_BETA_KEY_API_FROM = process.env.MAKEMKV_BETA_KEY_API_FROM
|| 'admin@example.invalid';
const MAKEMKV_BETA_KEY_API_TIMEOUT_MS = Math.max(
1000,
Number(process.env.MAKEMKV_BETA_KEY_API_TIMEOUT_MS || 15000)
);
function normalizeRegistrationKey(rawValue) {
return String(rawValue || '').trim();
}
function getMakeMKVConfigDir(homeDir = os.homedir()) {
return path.join(String(homeDir || '').trim() || os.homedir(), '.MakeMKV');
}
function getMakeMKVSettingsFilePath(homeDir = os.homedir()) {
return path.join(getMakeMKVConfigDir(homeDir), 'settings.conf');
}
function escapeKeyForSettingsFile(value) {
return String(value || '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
function buildUpdatedSettingsContent(currentContent, registrationKey) {
const existingLines = String(currentContent || '')
.split(/\r?\n/)
.filter((line) => !/^\s*app_Key\s*=/.test(line));
const normalizedKey = normalizeRegistrationKey(registrationKey);
while (existingLines.length > 0 && existingLines[existingLines.length - 1] === '') {
existingLines.pop();
}
if (normalizedKey) {
existingLines.push(`app_Key = "${escapeKeyForSettingsFile(normalizedKey)}"`);
}
return existingLines.length > 0 ? `${existingLines.join('\n')}\n` : '';
}
async function syncRegistrationKeyToConfig(rawValue, options = {}) {
const normalizedKey = normalizeRegistrationKey(rawValue);
const homeDir = String(options?.homeDir || '').trim() || os.homedir();
const configDir = getMakeMKVConfigDir(homeDir);
const settingsFilePath = getMakeMKVSettingsFilePath(homeDir);
const fileExists = fs.existsSync(settingsFilePath);
const currentContent = fileExists
? await fs.promises.readFile(settingsFilePath, 'utf8')
: '';
const nextContent = buildUpdatedSettingsContent(currentContent, normalizedKey);
if (!normalizedKey && !fileExists) {
return {
changed: false,
path: settingsFilePath,
hasKey: false
};
}
await fs.promises.mkdir(configDir, { recursive: true });
if (nextContent) {
await fs.promises.writeFile(settingsFilePath, nextContent, 'utf8');
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
logger.info('settings-conf:key-synced', {
path: settingsFilePath,
hasKey: true
});
} else {
await fs.promises.writeFile(settingsFilePath, '', 'utf8');
await fs.promises.chmod(settingsFilePath, 0o600).catch(() => {});
logger.info('settings-conf:key-cleared', {
path: settingsFilePath,
hasKey: false
});
}
return {
changed: currentContent !== nextContent,
path: settingsFilePath,
hasKey: Boolean(normalizedKey)
};
}
async function fetchCurrentBetaKey() {
const errors = [];
try {
return await fetchCurrentBetaKeyViaCurl();
} catch (error) {
errors.push(error?.message || String(error));
logger.warn('beta-key:curl-failed', {
error: error?.message || String(error)
});
}
try {
return await fetchCurrentBetaKeyViaFetch();
} catch (error) {
errors.push(error?.message || String(error));
logger.warn('beta-key:fetch-failed', {
error: error?.message || String(error)
});
}
const error = new Error(`Betakey konnte nicht geladen werden. Details: ${errors.join(' | ')}`);
error.statusCode = 502;
throw error;
}
function parseBetaKeyResponse(rawBody) {
let payload = null;
try {
payload = JSON.parse(String(rawBody || '{}'));
} catch (_error) {
const error = new Error('Betakey-Antwort ist kein gültiges JSON.');
error.statusCode = 502;
throw error;
}
const betaKey = String(payload?.key || '').trim();
if (!/^[A-Za-z0-9][A-Za-z0-9-]{15,}$/.test(betaKey)) {
const error = new Error('Betakey-Antwort ist ungültig.');
error.statusCode = 502;
throw error;
}
return {
key: betaKey,
sourceUrl: MAKEMKV_BETA_KEY_API_URL
};
}
function fetchCurrentBetaKeyViaCurl() {
return new Promise((resolve, reject) => {
execFile(
'curl',
[
'-fsSL',
'--max-time', String(Math.ceil(MAKEMKV_BETA_KEY_API_TIMEOUT_MS / 1000)),
'-A', MAKEMKV_BETA_KEY_API_USER_AGENT,
'-H', `From: ${MAKEMKV_BETA_KEY_API_FROM}`,
'-H', 'Accept: text/plain, text/html;q=0.9, */*;q=0.8',
'-H', 'Accept-Language: de,en-US;q=0.7,en;q=0.3',
'-H', 'Cache-Control: no-cache',
'-H', 'Pragma: no-cache',
MAKEMKV_BETA_KEY_API_URL
],
{
timeout: MAKEMKV_BETA_KEY_API_TIMEOUT_MS,
maxBuffer: 1024 * 1024
},
(error, stdout, stderr) => {
if (error) {
const resolvedError = new Error(
`curl fehlgeschlagen (${error.code || 'unknown'}): ${String(stderr || error.message || '').trim() || 'keine Details'}`
);
resolvedError.statusCode = 502;
reject(resolvedError);
return;
}
try {
resolve(parseBetaKeyResponse(stdout));
} catch (parseError) {
reject(parseError);
}
}
);
});
}
async function fetchCurrentBetaKeyViaFetch() {
const response = await fetch(MAKEMKV_BETA_KEY_API_URL, {
headers: {
'User-Agent': MAKEMKV_BETA_KEY_API_USER_AGENT,
'From': MAKEMKV_BETA_KEY_API_FROM,
'Accept': 'text/plain, text/html;q=0.9, */*;q=0.8',
'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
signal: AbortSignal.timeout(MAKEMKV_BETA_KEY_API_TIMEOUT_MS)
});
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.statusCode = 502;
throw error;
}
const rawBody = await response.text();
return parseBetaKeyResponse(rawBody);
}
module.exports = {
MAKEMKV_BETA_KEY_API_URL,
fetchCurrentBetaKey,
getMakeMKVConfigDir,
getMakeMKVSettingsFilePath,
normalizeRegistrationKey,
syncRegistrationKeyToConfig
};
+169
View File
@@ -0,0 +1,169 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('MUSICBRAINZ');
const MB_BASE = 'https://musicbrainz.org/ws/2';
const MB_USER_AGENT = 'Ripster/1.0 (https://github.com/ripster)';
const MB_TIMEOUT_MS = 10000;
async function mbFetch(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), MB_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': MB_USER_AGENT
},
signal: controller.signal
});
clearTimeout(timer);
if (!response.ok) {
throw new Error(`MusicBrainz Anfrage fehlgeschlagen (${response.status})`);
}
return response.json();
} catch (error) {
clearTimeout(timer);
throw error;
}
}
function normalizeRelease(release) {
if (!release) {
return null;
}
const artistCredit = Array.isArray(release['artist-credit'])
? release['artist-credit'].map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ')
: null;
const date = String(release.date || '').trim();
const yearMatch = date.match(/\b(\d{4})\b/);
const year = yearMatch ? Number(yearMatch[1]) : null;
const media = Array.isArray(release.media) ? release.media : [];
const normalizedTracks = media.flatMap((medium, mediumIdx) => {
const mediumTracks = Array.isArray(medium.tracks) ? medium.tracks : [];
return mediumTracks.map((track, trackIdx) => {
const rawPosition = String(track.position || track.number || '').trim();
const parsedPosition = Number.parseInt(rawPosition, 10);
const fallbackPosition = mediumIdx * 100 + trackIdx + 1;
const position = Number.isFinite(parsedPosition) && parsedPosition > 0
? parsedPosition
: fallbackPosition;
return {
position,
number: String(track.number || track.position || ''),
title: String(track.title || ''),
durationMs: Number(track.length || 0) || null,
rawTrackArtistCredit: Array.isArray(track['artist-credit']) ? track['artist-credit'] : [],
rawRecordingArtistCredit: Array.isArray(track?.recording?.['artist-credit']) ? track.recording['artist-credit'] : []
};
});
}).map((track) => {
const trackArtistCredit = Array.isArray(track?.rawTrackArtistCredit)
? track.rawTrackArtistCredit
: [];
const recordingArtistCredit = Array.isArray(track?.rawRecordingArtistCredit)
? track.rawRecordingArtistCredit
: [];
const artistFromTrack = trackArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
const artistFromRecording = recordingArtistCredit.map((ac) => ac?.artist?.name || ac?.name || '').filter(Boolean).join(', ');
return {
position: track.position,
number: track.number,
title: track.title,
durationMs: track.durationMs,
artist: artistFromTrack || artistFromRecording || artistCredit || null
};
});
// Always generate the CAA URL when an id is present; the browser/onError
// handles 404s for releases that have no front cover.
const coverArtUrl = release.id
? `https://coverartarchive.org/release/${release.id}/front-250`
: null;
return {
mbId: String(release.id || ''),
title: String(release.title || ''),
artist: artistCredit || null,
year,
date,
country: String(release.country || '').trim() || null,
label: Array.isArray(release['label-info'])
? release['label-info'].map((li) => li?.label?.name).filter(Boolean).join(', ') || null
: null,
coverArtUrl,
tracks: normalizedTracks
};
}
class MusicBrainzService {
async isEnabled() {
const settings = await settingsService.getSettingsMap();
return settings.musicbrainz_enabled !== 'false';
}
async searchByTitle(query) {
const q = String(query || '').trim();
if (!q) {
return [];
}
const enabled = await this.isEnabled();
if (!enabled) {
return [];
}
logger.info('search:start', { query: q });
const url = new URL(`${MB_BASE}/release`);
url.searchParams.set('query', q);
url.searchParams.set('fmt', 'json');
url.searchParams.set('limit', '10');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
const releases = Array.isArray(data.releases) ? data.releases : [];
const results = releases.map(normalizeRelease).filter(Boolean);
logger.info('search:done', { query: q, count: results.length });
return results;
} catch (error) {
logger.warn('search:failed', { query: q, error: String(error?.message || error) });
return [];
}
}
async searchByDiscLabel(discLabel) {
return this.searchByTitle(discLabel);
}
async getReleaseById(mbId) {
const id = String(mbId || '').trim();
if (!id) {
return null;
}
const enabled = await this.isEnabled();
if (!enabled) {
return null;
}
logger.info('getById:start', { mbId: id });
const url = new URL(`${MB_BASE}/release/${id}`);
url.searchParams.set('fmt', 'json');
url.searchParams.set('inc', 'artist-credits+labels+recordings+media');
try {
const data = await mbFetch(url.toString());
const result = normalizeRelease(data);
logger.info('getById:done', { mbId: id, title: result?.title });
return result;
} catch (error) {
logger.warn('getById:failed', { mbId: id, error: String(error?.message || error) });
return null;
}
}
}
module.exports = new MusicBrainzService();
+165
View File
@@ -0,0 +1,165 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('PUSHOVER');
const { toBoolean } = require('../utils/validators');
const { errorToMeta } = require('../utils/errorMeta');
const PUSHOVER_API_URL = 'https://api.pushover.net/1/messages.json';
const EVENT_TOGGLE_KEYS = {
metadata_ready: 'pushover_notify_metadata_ready',
rip_started: 'pushover_notify_rip_started',
encoding_started: 'pushover_notify_encoding_started',
job_finished: 'pushover_notify_job_finished',
job_error: 'pushover_notify_job_error',
job_cancelled: 'pushover_notify_job_cancelled',
reencode_started: 'pushover_notify_reencode_started',
reencode_finished: 'pushover_notify_reencode_finished'
};
function truncate(value, maxLen = 1024) {
const text = String(value || '').trim();
if (text.length <= maxLen) {
return text;
}
return `${text.slice(0, maxLen - 20)}...[truncated]`;
}
function normalizePriority(raw) {
const n = Number(raw);
if (Number.isNaN(n)) {
return 0;
}
if (n < -2) {
return -2;
}
if (n > 2) {
return 2;
}
return Math.round(n);
}
class NotificationService {
async notify(eventKey, payload = {}) {
const settings = await settingsService.getSettingsMap();
return this.notifyWithSettings(settings, eventKey, payload);
}
async sendTest({ title, message } = {}) {
return this.notify('test', {
title: title || 'Ripster Test',
message: message || 'PushOver Testnachricht von Ripster.'
});
}
async notifyWithSettings(settings, eventKey, payload = {}) {
const enabled = toBoolean(settings.pushover_enabled);
if (!enabled) {
logger.debug('notify:skip:disabled', { eventKey });
return { sent: false, reason: 'disabled', eventKey };
}
const toggleKey = EVENT_TOGGLE_KEYS[eventKey];
if (toggleKey && !toBoolean(settings[toggleKey])) {
logger.debug('notify:skip:event-disabled', { eventKey, toggleKey });
return { sent: false, reason: 'event-disabled', eventKey };
}
const token = String(settings.pushover_token || '').trim();
const user = String(settings.pushover_user || '').trim();
if (!token || !user) {
logger.warn('notify:skip:missing-credentials', {
eventKey,
hasToken: Boolean(token),
hasUser: Boolean(user)
});
return { sent: false, reason: 'missing-credentials', eventKey };
}
const prefix = String(settings.pushover_title_prefix || 'Ripster').trim();
const title = truncate(payload.title || `${prefix} - ${eventKey}`, 120);
const message = truncate(payload.message || eventKey, 1024);
const priority = normalizePriority(
payload.priority !== undefined ? payload.priority : settings.pushover_priority
);
const timeoutMs = Math.max(1000, Number(settings.pushover_timeout_ms || 7000));
const form = new URLSearchParams();
form.set('token', token);
form.set('user', user);
form.set('title', title);
form.set('message', message);
form.set('priority', String(priority));
const device = String(settings.pushover_device || '').trim();
if (device) {
form.set('device', device);
}
if (payload.url) {
form.set('url', String(payload.url));
}
if (payload.urlTitle) {
form.set('url_title', String(payload.urlTitle));
}
if (payload.sound) {
form.set('sound', String(payload.sound));
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(PUSHOVER_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: form.toString(),
signal: controller.signal
});
const rawText = await response.text();
let data = null;
try {
data = rawText ? JSON.parse(rawText) : null;
} catch (error) {
data = null;
}
if (!response.ok) {
const messageText = data?.errors?.join(', ') || data?.error || rawText || `HTTP ${response.status}`;
const error = new Error(`PushOver HTTP ${response.status}: ${messageText}`);
error.statusCode = response.status;
throw error;
}
if (data && data.status !== 1) {
const messageText = data.errors?.join(', ') || data.error || 'Unbekannte PushOver Antwort.';
throw new Error(`PushOver Fehler: ${messageText}`);
}
logger.info('notify:sent', {
eventKey,
title,
priority,
requestId: data?.request || null
});
return {
sent: true,
eventKey,
requestId: data?.request || null
};
} catch (error) {
logger.error('notify:failed', {
eventKey,
title,
error: errorToMeta(error)
});
throw error;
} finally {
clearTimeout(timeout);
}
}
}
module.exports = new NotificationService();
+225
View File
@@ -0,0 +1,225 @@
const settingsService = require('./settingsService');
const logger = require('./logger').child('OMDB');
const OMDB_BASE_URL = 'https://www.omdbapi.com/';
const OMDB_TIMEOUT_MS = 10000;
const OMDB_MAX_ATTEMPTS = 2;
const OMDB_RETRY_BASE_DELAY_MS = 300;
function normalizeOmdbTimeoutMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return OMDB_TIMEOUT_MS;
}
return Math.max(1000, Math.trunc(parsed));
}
function isRetryableOmdbError(error, aborted = false) {
if (aborted) {
return true;
}
const code = String(error?.code || '').trim().toUpperCase();
if (code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNABORTED') {
return true;
}
const causeCode = String(error?.cause?.code || '').trim().toUpperCase();
if (causeCode === 'UND_ERR_CONNECT_TIMEOUT' || causeCode === 'UND_ERR_HEADERS_TIMEOUT' || causeCode === 'UND_ERR_SOCKET') {
return true;
}
const message = String(error?.message || '').toLowerCase();
return message.includes('timed out') || message.includes('timeout');
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
class OmdbService {
mapSearchResults(data) {
if (!data || typeof data !== 'object') {
return [];
}
if (data.Response === 'False' || !Array.isArray(data.Search)) {
return [];
}
return data.Search.map((item) => ({
title: item.Title,
year: item.Year,
imdbId: item.imdbID,
type: item.Type,
poster: item.Poster
}));
}
async requestJson(url, meta = {}, options = {}) {
const timeoutMs = normalizeOmdbTimeoutMs(options?.timeoutMs);
const maxAttempts = Math.max(1, Math.trunc(Number(options?.maxAttempts || OMDB_MAX_ATTEMPTS)));
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
logger.warn('request:http-failed', {
...meta,
status: response.status,
attempt,
maxAttempts
});
return null;
}
return await response.json();
} catch (error) {
const aborted = error?.name === 'AbortError';
const retryable = isRetryableOmdbError(error, aborted);
const willRetry = retryable && attempt < maxAttempts;
logger[willRetry ? 'info' : 'warn']('request:failed', {
...meta,
timeoutMs,
aborted,
retryable,
attempt,
maxAttempts,
willRetry,
message: error?.message || String(error)
});
if (willRetry) {
await sleep(OMDB_RETRY_BASE_DELAY_MS * attempt);
continue;
}
return null;
} finally {
clearTimeout(timer);
}
}
return null;
}
async search(query) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery) {
return [];
}
logger.info('search:start', { query: normalizedQuery });
// Allow direct IMDb-ID lookups in the search field (useful for History remapping).
const imdbLike = normalizedQuery.toLowerCase();
if (/^tt\d{6,12}$/.test(imdbLike)) {
const byId = await this.fetchByImdbId(imdbLike);
return byId
? [{
title: byId.title,
year: byId.year != null ? String(byId.year) : null,
imdbId: byId.imdbId,
type: byId.type,
poster: byId.poster
}]
: [];
}
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return [];
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const configuredType = String(settings.omdb_default_type || 'movie').trim().toLowerCase() || 'movie';
const requestSearch = async (type = null) => {
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('s', normalizedQuery);
if (type) {
url.searchParams.set('type', type);
}
const data = await this.requestJson(
url,
{ query: normalizedQuery, action: 'search', type: type || null },
{ timeoutMs }
);
return {
data,
results: this.mapSearchResults(data)
};
};
let { data, results } = await requestSearch(configuredType);
if (results.length === 0 && configuredType) {
logger.info('search:fallback-no-type', {
query: normalizedQuery,
configuredType,
response: data?.Response || null,
error: data?.Error || null
});
({ data, results } = await requestSearch(null));
}
if (results.length === 0) {
logger.warn('search:no-results', {
query: normalizedQuery,
response: data?.Response || null,
error: data?.Error || null,
configuredType
});
return [];
}
logger.info('search:done', { query: normalizedQuery, count: results.length, configuredType });
return results;
}
async fetchByImdbId(imdbId) {
const normalizedId = String(imdbId || '').trim().toLowerCase();
if (!/^tt\d{6,12}$/.test(normalizedId)) {
return null;
}
logger.info('fetchByImdbId:start', { imdbId: normalizedId });
const settings = await settingsService.getSettingsMap();
const apiKey = settings.omdb_api_key;
if (!apiKey) {
return null;
}
const timeoutMs = normalizeOmdbTimeoutMs(settings.omdb_timeout_ms);
const url = new URL(OMDB_BASE_URL);
url.searchParams.set('apikey', apiKey);
url.searchParams.set('i', normalizedId);
url.searchParams.set('plot', 'full');
const data = await this.requestJson(url, { imdbId: normalizedId, action: 'fetchByImdbId' }, { timeoutMs });
if (!data || typeof data !== 'object') {
return null;
}
if (data.Response === 'False') {
logger.warn('fetchByImdbId:not-found', { imdbId: normalizedId, error: data.Error });
return null;
}
const yearMatch = String(data.Year || '').match(/\b(19|20)\d{2}\b/);
const year = yearMatch ? Number(yearMatch[0]) : null;
const poster = data.Poster && data.Poster !== 'N/A' ? data.Poster : null;
const result = {
title: data.Title || null,
year: Number.isFinite(year) ? year : null,
imdbId: String(data.imdbID || normalizedId),
type: data.Type || null,
poster,
raw: data
};
logger.info('fetchByImdbId:done', { imdbId: result.imdbId, title: result.title });
return result;
}
}
module.exports = new OmdbService();
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
const { spawn } = require('child_process');
const logger = require('./logger').child('PROCESS');
const { errorToMeta } = require('../utils/errorMeta');
function streamLines(stream, onLine) {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString();
const parts = buffer.split(/\r\n|\n|\r/);
buffer = parts.pop() ?? '';
for (const line of parts) {
if (line.length > 0) {
onLine(line);
}
}
});
stream.on('end', () => {
if (buffer.length > 0) {
onLine(buffer);
}
});
}
function spawnTrackedProcess({
cmd,
args,
cwd,
onStdoutLine,
onStderrLine,
onStart,
context = {}
}) {
logger.info('spawn:start', { cmd, args, cwd, context });
const child = spawn(cmd, args, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true
});
if (onStart) {
onStart(child);
}
if (child.stdout && onStdoutLine) {
streamLines(child.stdout, onStdoutLine);
}
if (child.stderr && onStderrLine) {
streamLines(child.stderr, onStderrLine);
}
const promise = new Promise((resolve, reject) => {
child.on('error', (error) => {
logger.error('spawn:error', { cmd, args, context, error: errorToMeta(error) });
reject(error);
});
child.on('close', (code, signal) => {
logger.info('spawn:close', { cmd, args, code, signal, context });
if (code === 0) {
resolve({ code, signal });
} else {
const error = new Error(`Prozess ${cmd} beendet mit Code ${code ?? 'null'} (Signal ${signal ?? 'none'}).`);
error.code = code;
error.signal = signal;
reject(error);
}
});
});
let cancelCalled = false;
const killProcessTree = (signal) => {
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try {
process.kill(-pid, signal);
return true;
} catch (_error) {
// fallback below
}
}
try {
child.kill(signal);
return true;
} catch (_error) {
return false;
}
};
const cancel = () => {
if (cancelCalled) {
return;
}
cancelCalled = true;
logger.warn('spawn:cancel:requested', { cmd, args, context, pid: child.pid });
// Instant cancel by user request.
killProcessTree('SIGKILL');
};
return {
child,
promise,
cancel
};
}
module.exports = {
spawnTrackedProcess,
streamLines
};
@@ -0,0 +1,347 @@
const wsService = require('./websocketService');
const MAX_RECENT_ACTIVITIES = 120;
const MAX_ACTIVITY_OUTPUT_CHARS = 12000;
const MAX_ACTIVITY_TEXT_CHARS = 2000;
const OUTPUT_BROADCAST_THROTTLE_MS = 180;
function nowIso() {
return new Date().toISOString();
}
function normalizeNumber(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS } = {}) {
if (value === null || value === undefined) {
return null;
}
let text = String(value);
if (trim) {
text = text.trim();
}
if (!text) {
return null;
}
if (text.length > maxChars) {
if (trim) {
const suffix = ' ...[gekürzt]';
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
} else {
const prefix = '...[gekürzt]\n';
text = `${prefix}${text.slice(-Math.max(0, maxChars - prefix.length))}`;
}
}
return text;
}
function normalizeOutputChunk(value) {
if (value === null || value === undefined) {
return '';
}
const normalized = String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
if (!normalized) {
return '';
}
return normalized.endsWith('\n') ? normalized : `${normalized}\n`;
}
function appendOutputTail(currentValue, chunk, maxChars = MAX_ACTIVITY_OUTPUT_CHARS) {
const normalizedChunk = normalizeOutputChunk(chunk);
const currentText = currentValue == null ? '' : String(currentValue);
if (!normalizedChunk) {
return {
value: currentText || null,
truncated: false
};
}
const combined = `${currentText}${normalizedChunk}`;
if (combined.length <= maxChars) {
return {
value: combined,
truncated: false
};
}
return {
value: combined.slice(-maxChars),
truncated: true
};
}
function sanitizeActivity(input = {}) {
const source = input && typeof input === 'object' ? input : {};
const normalizedOutcome = normalizeText(source.outcome, { trim: true, maxChars: 40 });
return {
id: normalizeNumber(source.id),
type: String(source.type || '').trim().toLowerCase() || 'task',
name: String(source.name || '').trim() || null,
status: String(source.status || '').trim().toLowerCase() || 'running',
source: String(source.source || '').trim() || null,
message: String(source.message || '').trim() || null,
currentStep: String(source.currentStep || '').trim() || null,
currentStepType: String(source.currentStepType || '').trim() || null,
currentScriptName: String(source.currentScriptName || '').trim() || null,
stepIndex: normalizeNumber(source.stepIndex),
stepTotal: normalizeNumber(source.stepTotal),
parentActivityId: normalizeNumber(source.parentActivityId),
jobId: normalizeNumber(source.jobId),
cronJobId: normalizeNumber(source.cronJobId),
chainId: normalizeNumber(source.chainId),
scriptId: normalizeNumber(source.scriptId),
canCancel: Boolean(source.canCancel),
canNextStep: Boolean(source.canNextStep),
outcome: normalizedOutcome ? String(normalizedOutcome).toLowerCase() : null,
errorMessage: normalizeText(source.errorMessage, { trim: true, maxChars: MAX_ACTIVITY_TEXT_CHARS }),
output: normalizeText(source.output, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
stdout: normalizeText(source.stdout, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
stderr: normalizeText(source.stderr, { trim: false, maxChars: MAX_ACTIVITY_OUTPUT_CHARS }),
outputTruncated: Boolean(source.outputTruncated),
stdoutTruncated: Boolean(source.stdoutTruncated),
stderrTruncated: Boolean(source.stderrTruncated),
startedAt: source.startedAt || nowIso(),
finishedAt: source.finishedAt || null,
durationMs: Number.isFinite(Number(source.durationMs)) ? Number(source.durationMs) : null,
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
success: source.success === null || source.success === undefined ? null : Boolean(source.success)
};
}
class RuntimeActivityService {
constructor() {
this.nextId = 1;
this.active = new Map();
this.recent = [];
this.controls = new Map();
this.outputBroadcastTimer = null;
}
buildSnapshot() {
const active = Array.from(this.active.values())
.sort((a, b) => String(b.startedAt || '').localeCompare(String(a.startedAt || '')));
const recent = [...this.recent]
.sort((a, b) => String(b.finishedAt || b.startedAt || '').localeCompare(String(a.finishedAt || a.startedAt || '')));
return {
active,
recent,
updatedAt: nowIso()
};
}
broadcastSnapshot() {
if (this.outputBroadcastTimer) {
clearTimeout(this.outputBroadcastTimer);
this.outputBroadcastTimer = null;
}
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
}
scheduleOutputBroadcast() {
if (this.outputBroadcastTimer) {
return;
}
this.outputBroadcastTimer = setTimeout(() => {
this.outputBroadcastTimer = null;
wsService.broadcast('RUNTIME_ACTIVITY_CHANGED', this.buildSnapshot());
}, OUTPUT_BROADCAST_THROTTLE_MS);
}
startActivity(type, payload = {}) {
const id = this.nextId;
this.nextId += 1;
const activity = sanitizeActivity({
...payload,
id,
type,
status: 'running',
outcome: 'running',
startedAt: payload?.startedAt || nowIso(),
finishedAt: null,
durationMs: null,
canCancel: Boolean(payload?.canCancel),
canNextStep: Boolean(payload?.canNextStep)
});
this.active.set(id, activity);
this.broadcastSnapshot();
return id;
}
updateActivity(activityId, patch = {}) {
const id = normalizeNumber(activityId);
if (!id || !this.active.has(id)) {
return null;
}
const current = this.active.get(id);
const next = sanitizeActivity({
...current,
...patch,
id: current.id,
type: current.type,
status: current.status === 'running' ? (patch?.status || current.status) : current.status,
startedAt: current.startedAt
});
this.active.set(id, next);
this.broadcastSnapshot();
return next;
}
appendActivityOutput(activityId, patch = {}) {
const id = normalizeNumber(activityId);
if (!id || !this.active.has(id)) {
return null;
}
const current = this.active.get(id);
const nextOutput = appendOutputTail(current.output, patch?.output, MAX_ACTIVITY_OUTPUT_CHARS);
const nextStdout = appendOutputTail(current.stdout, patch?.stdout, MAX_ACTIVITY_OUTPUT_CHARS);
const nextStderr = appendOutputTail(current.stderr, patch?.stderr, MAX_ACTIVITY_OUTPUT_CHARS);
const next = sanitizeActivity({
...current,
...patch,
id: current.id,
type: current.type,
status: current.status,
startedAt: current.startedAt,
output: nextOutput.value,
stdout: nextStdout.value,
stderr: nextStderr.value,
outputTruncated: Boolean(current.outputTruncated || patch?.outputTruncated || nextOutput.truncated),
stdoutTruncated: Boolean(current.stdoutTruncated || patch?.stdoutTruncated || nextStdout.truncated),
stderrTruncated: Boolean(current.stderrTruncated || patch?.stderrTruncated || nextStderr.truncated)
});
this.active.set(id, next);
this.scheduleOutputBroadcast();
return next;
}
completeActivity(activityId, payload = {}) {
const id = normalizeNumber(activityId);
if (!id || !this.active.has(id)) {
return null;
}
const current = this.active.get(id);
const finishedAt = payload?.finishedAt || nowIso();
const startedAtDate = new Date(current.startedAt);
const finishedAtDate = new Date(finishedAt);
const durationMs = Number.isFinite(startedAtDate.getTime()) && Number.isFinite(finishedAtDate.getTime())
? Math.max(0, finishedAtDate.getTime() - startedAtDate.getTime())
: null;
const status = String(payload?.status || '').trim().toLowerCase() || (payload?.success === false ? 'error' : 'success');
let outcome = String(payload?.outcome || '').trim().toLowerCase();
if (!outcome) {
if (Boolean(payload?.cancelled)) {
outcome = 'cancelled';
} else if (Boolean(payload?.skipped)) {
outcome = 'skipped';
} else {
outcome = status === 'success' ? 'success' : 'error';
}
}
const finalized = sanitizeActivity({
...current,
...payload,
id: current.id,
type: current.type,
status,
outcome,
canCancel: false,
canNextStep: false,
finishedAt,
durationMs
});
this.active.delete(id);
this.controls.delete(id);
this.recent.unshift(finalized);
if (this.recent.length > MAX_RECENT_ACTIVITIES) {
this.recent = this.recent.slice(0, MAX_RECENT_ACTIVITIES);
}
this.broadcastSnapshot();
return finalized;
}
getSnapshot() {
return this.buildSnapshot();
}
clearRecent() {
const removed = this.recent.length;
if (removed === 0) {
return { removed: 0, snapshot: this.buildSnapshot() };
}
this.recent = [];
this.broadcastSnapshot();
return {
removed,
snapshot: this.buildSnapshot()
};
}
setControls(activityId, handlers = {}) {
const id = normalizeNumber(activityId);
if (!id || !this.active.has(id)) {
return null;
}
const safeHandlers = {
cancel: typeof handlers?.cancel === 'function' ? handlers.cancel : null,
nextStep: typeof handlers?.nextStep === 'function' ? handlers.nextStep : null
};
this.controls.set(id, safeHandlers);
return this.updateActivity(id, {
canCancel: Boolean(safeHandlers.cancel),
canNextStep: Boolean(safeHandlers.nextStep)
});
}
async invokeControl(activityId, control, payload = {}) {
const id = normalizeNumber(activityId);
if (!id || !this.active.has(id)) {
return {
ok: false,
code: 'NOT_FOUND',
message: 'Aktivität nicht gefunden oder bereits abgeschlossen.'
};
}
const handlers = this.controls.get(id) || {};
const key = control === 'nextStep' ? 'nextStep' : 'cancel';
const fn = handlers[key];
if (typeof fn !== 'function') {
return {
ok: false,
code: 'UNSUPPORTED',
message: key === 'nextStep'
? 'Nächster-Schritt ist für diese Aktivität nicht verfügbar.'
: 'Abbrechen ist für diese Aktivität nicht verfügbar.'
};
}
try {
const result = await fn(payload);
return {
ok: true,
code: 'OK',
result: result && typeof result === 'object' ? result : null
};
} catch (error) {
return {
ok: false,
code: 'FAILED',
message: error?.message || 'Aktion fehlgeschlagen.'
};
}
}
async requestCancel(activityId, payload = {}) {
return this.invokeControl(activityId, 'cancel', payload);
}
async requestNextStep(activityId, payload = {}) {
return this.invokeControl(activityId, 'nextStep', payload);
}
}
module.exports = new RuntimeActivityService();
+927
View File
@@ -0,0 +1,927 @@
const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPT_CHAINS');
const runtimeActivityService = require('./runtimeActivityService');
const { spawnTrackedProcess } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const CHAIN_NAME_MAX_LENGTH = 120;
const STEP_TYPE_SCRIPT = 'script';
const STEP_TYPE_WAIT = 'wait';
const VALID_STEP_TYPES = new Set([STEP_TYPE_SCRIPT, STEP_TYPE_WAIT]);
function normalizeChainId(rawValue) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return Math.trunc(value);
}
function createValidationError(message, details = null) {
const error = new Error(message);
error.statusCode = 400;
if (details) {
error.details = details;
}
return error;
}
function mapChainRow(row, steps = []) {
if (!row) {
return null;
}
return {
id: Number(row.id),
name: String(row.name || ''),
orderIndex: Number(row.order_index || 0),
steps: steps.map(mapStepRow),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function mapStepRow(row) {
if (!row) {
return null;
}
return {
id: Number(row.id),
position: Number(row.position),
stepType: String(row.step_type || ''),
scriptId: row.script_id != null ? Number(row.script_id) : null,
scriptName: row.script_name != null ? String(row.script_name) : null,
waitSeconds: row.wait_seconds != null ? Number(row.wait_seconds) : null
};
}
function terminateChildProcess(child, { immediate = false } = {}) {
if (!child) {
return;
}
const signal = immediate ? 'SIGKILL' : 'SIGTERM';
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try {
// For detached children this targets the full process group.
process.kill(-pid, signal);
return;
} catch (_error) {
// Fall through to direct child signal.
}
}
try {
child.kill(signal);
} catch (_error) {
return;
}
}
function appendTailText(currentValue, nextChunk, maxChars = 12000) {
const chunk = String(nextChunk || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
if (!chunk) {
return {
value: currentValue || '',
truncated: false
};
}
const normalizedChunk = chunk.endsWith('\n') ? chunk : `${chunk}\n`;
const combined = `${String(currentValue || '')}${normalizedChunk}`;
if (combined.length <= maxChars) {
return {
value: combined,
truncated: false
};
}
return {
value: combined.slice(-maxChars),
truncated: true
};
}
function validateSteps(rawSteps) {
const steps = Array.isArray(rawSteps) ? rawSteps : [];
const errors = [];
const normalized = [];
for (let i = 0; i < steps.length; i++) {
const step = steps[i] && typeof steps[i] === 'object' ? steps[i] : {};
const stepType = String(step.stepType || step.step_type || '').trim();
if (!VALID_STEP_TYPES.has(stepType)) {
errors.push({ field: `steps[${i}].stepType`, message: `Ungültiger Schritt-Typ: '${stepType}'. Erlaubt: script, wait.` });
continue;
}
if (stepType === STEP_TYPE_SCRIPT) {
const scriptId = Number(step.scriptId ?? step.script_id);
if (!Number.isFinite(scriptId) || scriptId <= 0) {
errors.push({ field: `steps[${i}].scriptId`, message: 'scriptId fehlt oder ist ungültig.' });
continue;
}
normalized.push({ stepType, scriptId: Math.trunc(scriptId), waitSeconds: null });
} else if (stepType === STEP_TYPE_WAIT) {
const waitSeconds = Number(step.waitSeconds ?? step.wait_seconds);
if (!Number.isFinite(waitSeconds) || waitSeconds < 1 || waitSeconds > 3600) {
errors.push({ field: `steps[${i}].waitSeconds`, message: 'waitSeconds muss zwischen 1 und 3600 liegen.' });
continue;
}
normalized.push({ stepType, scriptId: null, waitSeconds: Math.round(waitSeconds) });
}
}
if (errors.length > 0) {
throw createValidationError('Ungültige Schritte in der Skriptkette.', errors);
}
return normalized;
}
async function getStepsForChain(db, chainId) {
return db.all(
`
SELECT
s.id,
s.chain_id,
s.position,
s.step_type,
s.script_id,
s.wait_seconds,
sc.name AS script_name
FROM script_chain_steps s
LEFT JOIN scripts sc ON sc.id = s.script_id
WHERE s.chain_id = ?
ORDER BY s.position ASC, s.id ASC
`,
[chainId]
);
}
class ScriptChainService {
async listChains() {
const db = await getDb();
const rows = await db.all(
`
SELECT id, name, order_index, created_at, updated_at
FROM script_chains
ORDER BY order_index ASC, id ASC
`
);
if (rows.length === 0) {
return [];
}
const chainIds = rows.map((row) => Number(row.id));
const placeholders = chainIds.map(() => '?').join(', ');
const stepRows = await db.all(
`
SELECT
s.id,
s.chain_id,
s.position,
s.step_type,
s.script_id,
s.wait_seconds,
sc.name AS script_name
FROM script_chain_steps s
LEFT JOIN scripts sc ON sc.id = s.script_id
WHERE s.chain_id IN (${placeholders})
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
`,
chainIds
);
const stepsByChain = new Map();
for (const step of stepRows) {
const cid = Number(step.chain_id);
if (!stepsByChain.has(cid)) {
stepsByChain.set(cid, []);
}
stepsByChain.get(cid).push(step);
}
return rows.map((row) => mapChainRow(row, stepsByChain.get(Number(row.id)) || []));
}
async getChainById(chainId) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const db = await getDb();
const row = await db.get(
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id = ?`,
[normalizedId]
);
if (!row) {
const error = new Error(`Skriptkette #${normalizedId} wurde nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const steps = await getStepsForChain(db, normalizedId);
return mapChainRow(row, steps);
}
async getChainsByIds(rawIds = []) {
const ids = Array.isArray(rawIds)
? rawIds.map(normalizeChainId).filter(Boolean)
: [];
if (ids.length === 0) {
return [];
}
const db = await getDb();
const placeholders = ids.map(() => '?').join(', ');
const rows = await db.all(
`SELECT id, name, order_index, created_at, updated_at FROM script_chains WHERE id IN (${placeholders})`,
ids
);
const stepRows = await db.all(
`
SELECT
s.id, s.chain_id, s.position, s.step_type, s.script_id, s.wait_seconds,
sc.name AS script_name
FROM script_chain_steps s
LEFT JOIN scripts sc ON sc.id = s.script_id
WHERE s.chain_id IN (${placeholders})
ORDER BY s.chain_id ASC, s.position ASC, s.id ASC
`,
ids
);
const stepsByChain = new Map();
for (const step of stepRows) {
const cid = Number(step.chain_id);
if (!stepsByChain.has(cid)) {
stepsByChain.set(cid, []);
}
stepsByChain.get(cid).push(step);
}
const byId = new Map(rows.map((row) => [
Number(row.id),
mapChainRow(row, stepsByChain.get(Number(row.id)) || [])
]));
return ids.map((id) => byId.get(id)).filter(Boolean);
}
async createChain(payload = {}) {
const body = payload && typeof payload === 'object' ? payload : {};
const name = String(body.name || '').trim();
if (!name) {
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
}
if (name.length > CHAIN_NAME_MAX_LENGTH) {
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
}
const steps = validateSteps(body.steps);
const db = await getDb();
try {
const nextOrderIndex = await this._getNextOrderIndex(db);
const result = await db.run(
`
INSERT INTO script_chains (name, order_index, created_at, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[name, nextOrderIndex]
);
const chainId = result.lastID;
await this._saveSteps(db, chainId, steps);
return this.getChainById(chainId);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
}
throw error;
}
}
async updateChain(chainId, payload = {}) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const body = payload && typeof payload === 'object' ? payload : {};
const name = String(body.name || '').trim();
if (!name) {
throw createValidationError('Skriptkettenname darf nicht leer sein.', [{ field: 'name', message: 'Name darf nicht leer sein.' }]);
}
if (name.length > CHAIN_NAME_MAX_LENGTH) {
throw createValidationError('Skriptkettenname zu lang.', [{ field: 'name', message: `Maximal ${CHAIN_NAME_MAX_LENGTH} Zeichen.` }]);
}
const steps = validateSteps(body.steps);
await this.getChainById(normalizedId);
const db = await getDb();
try {
await db.run(
`UPDATE script_chains SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
[name, normalizedId]
);
await db.run(`DELETE FROM script_chain_steps WHERE chain_id = ?`, [normalizedId]);
await this._saveSteps(db, normalizedId, steps);
return this.getChainById(normalizedId);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
throw createValidationError(`Skriptkettenname "${name}" existiert bereits.`, [{ field: 'name', message: 'Name muss eindeutig sein.' }]);
}
throw error;
}
}
async deleteChain(chainId) {
const normalizedId = normalizeChainId(chainId);
if (!normalizedId) {
throw createValidationError('Ungültige chainId.');
}
const existing = await this.getChainById(normalizedId);
const db = await getDb();
await db.run(`DELETE FROM script_chains WHERE id = ?`, [normalizedId]);
return existing;
}
async reorderChains(orderedIds = []) {
const providedIds = Array.isArray(orderedIds)
? orderedIds.map(normalizeChainId).filter(Boolean)
: [];
const db = await getDb();
const rows = await db.all(
`
SELECT id
FROM script_chains
ORDER BY order_index ASC, id ASC
`
);
if (rows.length === 0) {
return [];
}
const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0);
const existingSet = new Set(existingIds);
const used = new Set();
const nextOrder = [];
for (const id of providedIds) {
if (!existingSet.has(id) || used.has(id)) {
continue;
}
used.add(id);
nextOrder.push(id);
}
for (const id of existingIds) {
if (used.has(id)) {
continue;
}
used.add(id);
nextOrder.push(id);
}
await db.exec('BEGIN');
try {
for (let i = 0; i < nextOrder.length; i += 1) {
await db.run(
`
UPDATE script_chains
SET order_index = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
[i + 1, nextOrder[i]]
);
}
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
return this.listChains();
}
async _getNextOrderIndex(db) {
const row = await db.get(
`
SELECT COALESCE(MAX(order_index), 0) AS max_order_index
FROM script_chains
`
);
const maxOrder = Number(row?.max_order_index || 0);
if (!Number.isFinite(maxOrder) || maxOrder < 0) {
return 1;
}
return Math.trunc(maxOrder) + 1;
}
async _saveSteps(db, chainId, steps) {
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
await db.run(
`
INSERT INTO script_chain_steps (chain_id, position, step_type, script_id, wait_seconds, created_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`,
[chainId, i + 1, step.stepType, step.scriptId ?? null, step.waitSeconds ?? null]
);
}
}
async executeChain(chainId, context = {}, { appendLog = null } = {}) {
const chain = await this.getChainById(chainId);
logger.info('chain:execute:start', { chainId, chainName: chain.name, steps: chain.steps.length });
const totalSteps = chain.steps.length;
const activityId = runtimeActivityService.startActivity('chain', {
name: chain.name,
source: context?.source || 'chain',
chainId: chain.id,
jobId: context?.jobId || null,
cronJobId: context?.cronJobId || null,
parentActivityId: context?.runtimeParentActivityId || null,
currentStep: totalSteps > 0 ? `Schritt 1/${totalSteps}` : 'Keine Schritte'
});
const controlState = {
cancelRequested: false,
cancelReason: null,
currentStepType: null,
activeWaitResolve: null,
activeChild: null,
activeChildTermination: null
};
const emitRuntimeStep = (payload = {}) => {
if (typeof context?.onRuntimeStep !== 'function') {
return;
}
try {
context.onRuntimeStep({
chainId: chain.id,
chainName: chain.name,
...payload
});
} catch (_error) {
// ignore runtime callback errors
}
};
const requestCancel = async (payload = {}) => {
if (controlState.cancelRequested) {
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
}
controlState.cancelRequested = true;
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
runtimeActivityService.updateActivity(activityId, {
message: 'Abbruch angefordert',
currentStep: controlState.currentStepType ? `Abbruch läuft (${controlState.currentStepType})` : 'Abbruch angefordert'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Abbruch angefordert.`);
} catch (_error) {
// ignore appendLog failures for control actions
}
}
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('cancel');
} else if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'cancel';
terminateChildProcess(controlState.activeChild, { immediate: true });
}
return { accepted: true, message: 'Abbruch angefordert.' };
};
const requestNextStep = async () => {
if (controlState.cancelRequested) {
return { accepted: false, message: 'Kette wird bereits abgebrochen.' };
}
if (controlState.currentStepType === STEP_TYPE_WAIT && typeof controlState.activeWaitResolve === 'function') {
controlState.activeWaitResolve('skip');
runtimeActivityService.updateActivity(activityId, {
message: 'Nächster Schritt angefordert (Wait übersprungen)'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Wait-Schritt manuell übersprungen.`);
} catch (_error) {
// ignore appendLog failures for control actions
}
}
return { accepted: true, message: 'Wait-Schritt übersprungen.' };
}
if (controlState.currentStepType === STEP_TYPE_SCRIPT && controlState.activeChild) {
controlState.activeChildTermination = 'skip';
terminateChildProcess(controlState.activeChild, { immediate: true });
runtimeActivityService.updateActivity(activityId, {
message: 'Nächster Schritt angefordert (aktuelles Skript wird übersprungen)'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript-Schritt manuell übersprungen.`);
} catch (_error) {
// ignore appendLog failures for control actions
}
}
return { accepted: true, message: 'Skript-Schritt wird übersprungen.' };
}
return { accepted: false, message: 'Kein aktiver Schritt zum Überspringen.' };
};
runtimeActivityService.setControls(activityId, {
cancel: requestCancel,
nextStep: requestNextStep
});
const results = [];
let completionPayload = null;
let abortedByUser = false;
try {
for (let index = 0; index < chain.steps.length; index += 1) {
if (controlState.cancelRequested) {
abortedByUser = true;
break;
}
const step = chain.steps[index];
const stepIndex = index + 1;
if (step.stepType === STEP_TYPE_WAIT) {
const seconds = Math.max(1, Number(step.waitSeconds || 1));
const waitLabel = `Warte ${seconds} Sekunde(n)`;
controlState.currentStepType = STEP_TYPE_WAIT;
runtimeActivityService.updateActivity(activityId, {
currentStepType: 'wait',
currentStep: waitLabel,
currentScriptName: null,
stepIndex,
stepTotal: totalSteps
});
emitRuntimeStep({
stepType: 'wait',
stepIndex,
stepTotal: totalSteps,
currentStep: waitLabel
});
logger.info('chain:step:wait', { chainId, seconds });
if (typeof appendLog === 'function') {
await appendLog('SYSTEM', `Kette "${chain.name}" - Warte ${seconds} Sekunde(n)...`);
}
const waitOutcome = await new Promise((resolve) => {
const timer = setTimeout(() => {
controlState.activeWaitResolve = null;
resolve('done');
}, seconds * 1000);
controlState.activeWaitResolve = (mode = 'done') => {
clearTimeout(timer);
controlState.activeWaitResolve = null;
resolve(mode);
};
});
controlState.currentStepType = null;
if (waitOutcome === 'skip') {
results.push({ stepType: 'wait', waitSeconds: seconds, success: true, skipped: true, reason: 'skipped_by_user' });
continue;
}
if (waitOutcome === 'cancel' || controlState.cancelRequested) {
abortedByUser = true;
results.push({ stepType: 'wait', waitSeconds: seconds, success: false, aborted: true, reason: 'cancelled_by_user' });
break;
}
results.push({ stepType: 'wait', waitSeconds: seconds, success: true });
} else if (step.stepType === STEP_TYPE_SCRIPT) {
if (!step.scriptId) {
logger.warn('chain:step:script-missing', { chainId, stepId: step.id });
results.push({ stepType: 'script', scriptId: null, success: false, skipped: true, reason: 'scriptId fehlt' });
continue;
}
const scriptService = require('./scriptService');
let script;
try {
script = await scriptService.getScriptById(step.scriptId);
} catch (error) {
logger.warn('chain:step:script-not-found', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, skipped: true, reason: 'Skript nicht gefunden' });
continue;
}
controlState.currentStepType = STEP_TYPE_SCRIPT;
runtimeActivityService.updateActivity(activityId, {
currentStepType: 'script',
currentStep: `Skript: ${script.name}`,
currentScriptName: script.name,
stepIndex,
stepTotal: totalSteps,
scriptId: script.id
});
emitRuntimeStep({
stepType: 'script',
stepIndex,
stepTotal: totalSteps,
scriptId: script.id,
scriptName: script.name,
currentScriptName: script.name,
currentStep: `Skript: ${script.name}`
});
if (typeof appendLog === 'function') {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript: ${script.name}`);
}
const scriptActivityId = runtimeActivityService.startActivity('script', {
name: script.name,
source: context?.source || 'chain',
scriptId: script.id,
chainId: chain.id,
jobId: context?.jobId || null,
cronJobId: context?.cronJobId || null,
parentActivityId: activityId,
currentStep: `Kette: ${chain.name}`
});
let prepared = null;
try {
prepared = await scriptService.createExecutableScriptFile(script, {
...context,
scriptId: script.id,
scriptName: script.name,
source: context?.source || 'chain'
});
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
const processHandle = spawnTrackedProcess({
cmd: prepared.cmd,
args: prepared.args,
context: { source: context?.source || 'chain', chainId: chain.id, scriptId: script.id },
onStart: (child) => {
controlState.activeChild = child;
controlState.activeChildTermination = null;
},
onStdoutLine: (line) => {
const next = appendTailText(stdout, line);
stdout = next.value;
stdoutTruncated = stdoutTruncated || next.truncated;
runtimeActivityService.appendActivityOutput(scriptActivityId, { stdout: line });
},
onStderrLine: (line) => {
const next = appendTailText(stderr, line);
stderr = next.value;
stderrTruncated = stderrTruncated || next.truncated;
runtimeActivityService.appendActivityOutput(scriptActivityId, { stderr: line });
}
});
let runError = null;
let exitCode = 0;
let signal = null;
try {
const result = await processHandle.promise;
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
signal = result?.signal || null;
} catch (error) {
runError = error;
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
signal = error?.signal || null;
}
const termination = controlState.activeChildTermination;
controlState.activeChild = null;
controlState.activeChildTermination = null;
if (runError && exitCode === null && !termination) {
throw runError;
}
const run = {
code: exitCode,
signal,
stdout,
stderr,
stdoutTruncated,
stderrTruncated,
termination
};
controlState.currentStepType = null;
if (run.termination === 'skip') {
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'success',
success: true,
outcome: 'skipped',
skipped: true,
currentStep: null,
message: 'Schritt übersprungen',
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
stdout: run.stdout || null,
stderr: run.stderr || null,
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated)
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" übersprungen.`);
} catch (_error) {
// ignore appendLog failures on skip path
}
}
results.push({
stepType: 'script',
scriptId: script.id,
scriptName: script.name,
success: true,
skipped: true,
reason: 'skipped_by_user'
});
continue;
}
if (run.termination === 'cancel' || controlState.cancelRequested) {
abortedByUser = true;
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'cancelled',
cancelled: true,
currentStep: null,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
stdout: run.stdout || null,
stderr: run.stderr || null,
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
} catch (_error) {
// ignore appendLog failures on cancel path
}
}
results.push({
stepType: 'script',
scriptId: script.id,
scriptName: script.name,
success: false,
aborted: true,
reason: 'cancelled_by_user'
});
break;
}
const success = run.code === 0;
runtimeActivityService.completeActivity(scriptActivityId, {
status: success ? 'success' : 'error',
success,
outcome: success ? 'success' : 'error',
exitCode: run.code,
currentStep: null,
message: success ? null : `Fehler (Exit ${run.code})`,
output: success ? null : [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null,
stderr: success ? null : (run.stderr || null),
stdout: success ? null : (run.stdout || null),
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated),
errorMessage: success ? null : `Fehler (Exit ${run.code})`
});
logger.info('chain:step:script-done', { chainId, scriptId: script.id, exitCode: run.code, success });
if (typeof appendLog === 'function') {
await appendLog(
success ? 'SYSTEM' : 'ERROR',
`Kette "${chain.name}" - Skript "${script.name}": ${success ? 'OK' : `Fehler (Exit ${run.code})`}`
);
}
results.push({ stepType: 'script', scriptId: script.id, scriptName: script.name, success, exitCode: run.code, stdout: run.stdout || '', stderr: run.stderr || '' });
if (!success) {
logger.warn('chain:step:script-failed', { chainId, scriptId: script.id, exitCode: run.code });
break;
}
} catch (error) {
controlState.currentStepType = null;
if (controlState.cancelRequested) {
abortedByUser = true;
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'cancelled',
cancelled: true,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
});
if (typeof appendLog === 'function') {
try {
await appendLog('SYSTEM', `Kette "${chain.name}" - Skript "${script.name}" abgebrochen.`);
} catch (_error) {
// ignore appendLog failures on cancel path
}
}
results.push({
stepType: 'script',
scriptId: script.id,
scriptName: script.name,
success: false,
aborted: true,
reason: 'cancelled_by_user'
});
break;
}
runtimeActivityService.completeActivity(scriptActivityId, {
status: 'error',
success: false,
outcome: 'error',
message: error?.message || 'unknown',
errorMessage: error?.message || 'unknown'
});
logger.error('chain:step:script-error', { chainId, scriptId: step.scriptId, error: errorToMeta(error) });
if (typeof appendLog === 'function') {
await appendLog('ERROR', `Kette "${chain.name}" - Skript-Fehler: ${error.message}`);
}
results.push({ stepType: 'script', scriptId: step.scriptId, success: false, error: error.message });
break;
} finally {
controlState.activeChild = null;
controlState.activeChildTermination = null;
if (prepared?.cleanup) {
await prepared.cleanup();
}
}
}
}
const succeeded = results.filter((r) => r.success).length;
const skipped = results.filter((r) => r.skipped).length;
const failed = results.filter((r) => !r.success && !r.skipped && !r.aborted).length;
logger.info('chain:execute:done', { chainId, steps: results.length, succeeded, failed, skipped, abortedByUser });
if (abortedByUser) {
completionPayload = {
status: 'error',
success: false,
outcome: 'cancelled',
cancelled: true,
currentStep: null,
currentScriptName: null,
message: controlState.cancelReason || 'Von Benutzer abgebrochen',
errorMessage: controlState.cancelReason || 'Von Benutzer abgebrochen'
};
emitRuntimeStep({
finished: true,
success: false,
aborted: true,
failed,
succeeded
});
return {
chainId,
chainName: chain.name,
steps: results.length,
succeeded,
failed,
skipped,
aborted: true,
abortedByUser: true,
results
};
}
completionPayload = {
status: failed > 0 ? 'error' : 'success',
success: failed === 0,
outcome: failed > 0 ? 'error' : (skipped > 0 ? 'skipped' : 'success'),
skipped: skipped > 0,
currentStep: null,
currentScriptName: null,
message: failed > 0
? `${failed} Schritt(e) fehlgeschlagen`
: (skipped > 0
? `${succeeded} Schritt(e) erfolgreich, ${skipped} übersprungen`
: `${succeeded} Schritt(e) erfolgreich`)
};
emitRuntimeStep({
finished: true,
success: failed === 0,
failed,
succeeded
});
return {
chainId,
chainName: chain.name,
steps: results.length,
succeeded,
failed,
skipped,
aborted: failed > 0,
results
};
} catch (error) {
completionPayload = {
status: 'error',
success: false,
outcome: 'error',
message: error?.message || 'unknown',
errorMessage: error?.message || 'unknown',
currentStep: null
};
throw error;
} finally {
runtimeActivityService.completeActivity(activityId, completionPayload || {
status: 'error',
success: false,
outcome: 'error',
message: 'Kette unerwartet beendet',
errorMessage: 'Kette unerwartet beendet',
currentStep: null
});
}
}
}
module.exports = new ScriptChainService();
+688
View File
@@ -0,0 +1,688 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { getDb } = require('../db/database');
const logger = require('./logger').child('SCRIPTS');
const settingsService = require('./settingsService');
const runtimeActivityService = require('./runtimeActivityService');
const { streamLines } = require('./processRunner');
const { errorToMeta } = require('../utils/errorMeta');
const SCRIPT_NAME_MAX_LENGTH = 120;
const SCRIPT_BODY_MAX_LENGTH = 200000;
const SCRIPT_TEST_TIMEOUT_SETTING_KEY = 'script_test_timeout_ms';
const DEFAULT_SCRIPT_TEST_TIMEOUT_MS = 0;
const SCRIPT_TEST_TIMEOUT_MS = (() => {
const parsed = Number(process.env.RIPSTER_SCRIPT_TEST_TIMEOUT_MS);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.trunc(parsed));
}
return DEFAULT_SCRIPT_TEST_TIMEOUT_MS;
})();
const SCRIPT_OUTPUT_MAX_CHARS = 150000;
function normalizeScriptTestTimeoutMs(rawValue, fallbackMs = SCRIPT_TEST_TIMEOUT_MS) {
const parsed = Number(rawValue);
if (Number.isFinite(parsed)) {
return Math.max(0, Math.trunc(parsed));
}
if (fallbackMs === null || fallbackMs === undefined) {
return null;
}
const parsedFallback = Number(fallbackMs);
if (Number.isFinite(parsedFallback)) {
return Math.max(0, Math.trunc(parsedFallback));
}
return 0;
}
function normalizeScriptId(rawValue) {
const value = Number(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return Math.trunc(value);
}
function normalizeScriptIdList(rawList) {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set();
const output = [];
for (const item of list) {
const normalized = normalizeScriptId(item);
if (!normalized) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function normalizeScriptName(rawValue) {
return String(rawValue || '').trim();
}
function normalizeScriptBody(rawValue) {
return String(rawValue || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}
function createValidationError(message, details = null) {
const error = new Error(message);
error.statusCode = 400;
if (details) {
error.details = details;
}
return error;
}
function validateScriptPayload(payload, { partial = false } = {}) {
const body = payload && typeof payload === 'object' ? payload : {};
const hasName = Object.prototype.hasOwnProperty.call(body, 'name');
const hasScriptBody = Object.prototype.hasOwnProperty.call(body, 'scriptBody');
const normalized = {};
const errors = [];
if (!partial || hasName) {
const name = normalizeScriptName(body.name);
if (!name) {
errors.push({ field: 'name', message: 'Name darf nicht leer sein.' });
} else if (name.length > SCRIPT_NAME_MAX_LENGTH) {
errors.push({ field: 'name', message: `Name darf maximal ${SCRIPT_NAME_MAX_LENGTH} Zeichen enthalten.` });
} else {
normalized.name = name;
}
}
if (!partial || hasScriptBody) {
const scriptBody = normalizeScriptBody(body.scriptBody);
if (!scriptBody.trim()) {
errors.push({ field: 'scriptBody', message: 'Skript darf nicht leer sein.' });
} else if (scriptBody.length > SCRIPT_BODY_MAX_LENGTH) {
errors.push({ field: 'scriptBody', message: `Skript darf maximal ${SCRIPT_BODY_MAX_LENGTH} Zeichen enthalten.` });
} else {
normalized.scriptBody = scriptBody;
}
}
if (errors.length > 0) {
throw createValidationError('Skript ist ungültig.', errors);
}
return normalized;
}
function mapScriptRow(row) {
if (!row) {
return null;
}
return {
id: Number(row.id),
name: String(row.name || ''),
scriptBody: String(row.script_body || ''),
orderIndex: Number(row.order_index || 0),
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function quoteForBashSingle(value) {
return `'${String(value || '').replace(/'/g, `'\"'\"'`)}'`;
}
function buildScriptEnvironment(context = {}) {
const now = new Date().toISOString();
const entries = {
RIPSTER_SCRIPT_RUN_AT: now,
RIPSTER_JOB_ID: context?.jobId ?? '',
RIPSTER_JOB_TITLE: context?.jobTitle ?? '',
RIPSTER_MODE: context?.mode ?? '',
RIPSTER_INPUT_PATH: context?.inputPath ?? '',
RIPSTER_OUTPUT_PATH: context?.outputPath ?? '',
RIPSTER_RAW_PATH: context?.rawPath ?? '',
RIPSTER_SCRIPT_ID: context?.scriptId ?? '',
RIPSTER_SCRIPT_NAME: context?.scriptName ?? '',
RIPSTER_SCRIPT_SOURCE: context?.source ?? ''
};
const output = {};
for (const [key, value] of Object.entries(entries)) {
output[key] = String(value ?? '');
}
return output;
}
function buildScriptWrapper(scriptBody, context = {}) {
const envVars = buildScriptEnvironment(context);
const exportLines = Object.entries(envVars)
.map(([key, value]) => `export ${key}=${quoteForBashSingle(value)}`)
.join('\n');
// Wait for potential background jobs started by the script before returning.
return `${exportLines}\n\n${String(scriptBody || '')}\n\nwait\n`;
}
function appendWithCap(current, chunk, maxChars) {
const value = String(chunk || '');
if (!value) {
return { value: current, truncated: false };
}
const currentText = String(current || '');
if (currentText.length >= maxChars) {
return { value: currentText, truncated: true };
}
const available = maxChars - currentText.length;
if (value.length <= available) {
return { value: `${currentText}${value}`, truncated: false };
}
return {
value: `${currentText}${value.slice(0, available)}`,
truncated: true
};
}
function killChildProcessTree(child, signal = 'SIGTERM') {
if (!child) {
return false;
}
const pid = Number(child.pid);
if (Number.isFinite(pid) && pid > 0) {
try {
// If spawned as detached=true this targets the full process group.
process.kill(-pid, signal);
return true;
} catch (_error) {
// Fallback below.
}
}
try {
child.kill(signal);
return true;
} catch (_error) {
return false;
}
}
function runProcessCapture({
cmd,
args,
timeoutMs = SCRIPT_TEST_TIMEOUT_MS,
cwd = process.cwd(),
onChild = null,
onStdoutLine = null,
onStderrLine = null
}) {
return new Promise((resolve, reject) => {
const effectiveTimeoutMs = normalizeScriptTestTimeoutMs(timeoutMs, SCRIPT_TEST_TIMEOUT_MS);
const startedAt = Date.now();
let ended = false;
const child = spawn(cmd, args, {
cwd,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true
});
if (typeof onChild === 'function') {
try {
onChild(child);
} catch (_error) {
// ignore observer errors
}
}
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
let timedOut = false;
let timeout = null;
if (effectiveTimeoutMs > 0) {
timeout = setTimeout(() => {
timedOut = true;
killChildProcessTree(child, 'SIGTERM');
setTimeout(() => {
if (!ended) {
killChildProcessTree(child, 'SIGKILL');
}
}, 2000);
}, effectiveTimeoutMs);
}
const onData = (streamName, chunk) => {
if (streamName === 'stdout') {
const next = appendWithCap(stdout, chunk, SCRIPT_OUTPUT_MAX_CHARS);
stdout = next.value;
stdoutTruncated = stdoutTruncated || next.truncated;
} else {
const next = appendWithCap(stderr, chunk, SCRIPT_OUTPUT_MAX_CHARS);
stderr = next.value;
stderrTruncated = stderrTruncated || next.truncated;
}
};
child.stdout?.on('data', (chunk) => onData('stdout', chunk));
child.stderr?.on('data', (chunk) => onData('stderr', chunk));
if (child.stdout && typeof onStdoutLine === 'function') {
streamLines(child.stdout, onStdoutLine);
}
if (child.stderr && typeof onStderrLine === 'function') {
streamLines(child.stderr, onStderrLine);
}
child.on('error', (error) => {
ended = true;
if (timeout) {
clearTimeout(timeout);
}
reject(error);
});
child.on('close', (code, signal) => {
ended = true;
if (timeout) {
clearTimeout(timeout);
}
const endedAt = Date.now();
resolve({
code: Number.isFinite(Number(code)) ? Number(code) : null,
signal: signal || null,
durationMs: Math.max(0, endedAt - startedAt),
timedOut,
stdout,
stderr,
stdoutTruncated,
stderrTruncated
});
});
});
}
async function resolveScriptTestTimeoutMs(options = {}) {
const timeoutFromOptions = normalizeScriptTestTimeoutMs(options?.timeoutMs, null);
if (timeoutFromOptions !== null) {
return timeoutFromOptions;
}
try {
const settingsMap = await settingsService.getSettingsMap();
return normalizeScriptTestTimeoutMs(
settingsMap?.[SCRIPT_TEST_TIMEOUT_SETTING_KEY],
SCRIPT_TEST_TIMEOUT_MS
);
} catch (error) {
logger.warn('script:test-timeout:settings-read-failed', { error: errorToMeta(error) });
return SCRIPT_TEST_TIMEOUT_MS;
}
}
class ScriptService {
async listScripts() {
const db = await getDb();
const rows = await db.all(
`
SELECT id, name, script_body, order_index, created_at, updated_at
FROM scripts
ORDER BY order_index ASC, id ASC
`
);
return rows.map(mapScriptRow);
}
async getScriptById(scriptId) {
const normalizedId = normalizeScriptId(scriptId);
if (!normalizedId) {
throw createValidationError('Ungültige scriptId.');
}
const db = await getDb();
const row = await db.get(
`
SELECT id, name, script_body, order_index, created_at, updated_at
FROM scripts
WHERE id = ?
`,
[normalizedId]
);
if (!row) {
const error = new Error(`Skript #${normalizedId} wurde nicht gefunden.`);
error.statusCode = 404;
throw error;
}
return mapScriptRow(row);
}
async createScript(payload = {}) {
const normalized = validateScriptPayload(payload, { partial: false });
const db = await getDb();
try {
const nextOrderIndex = await this._getNextOrderIndex(db);
const result = await db.run(
`
INSERT INTO scripts (name, script_body, order_index, created_at, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`,
[normalized.name, normalized.scriptBody, nextOrderIndex]
);
return this.getScriptById(result.lastID);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
{ field: 'name', message: 'Name muss eindeutig sein.' }
]);
}
throw error;
}
}
async updateScript(scriptId, payload = {}) {
const normalizedId = normalizeScriptId(scriptId);
if (!normalizedId) {
throw createValidationError('Ungültige scriptId.');
}
const normalized = validateScriptPayload(payload, { partial: false });
const db = await getDb();
await this.getScriptById(normalizedId);
try {
await db.run(
`
UPDATE scripts
SET
name = ?,
script_body = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
[normalized.name, normalized.scriptBody, normalizedId]
);
} catch (error) {
if (String(error?.message || '').includes('UNIQUE constraint failed')) {
throw createValidationError(`Skriptname "${normalized.name}" existiert bereits.`, [
{ field: 'name', message: 'Name muss eindeutig sein.' }
]);
}
throw error;
}
return this.getScriptById(normalizedId);
}
async deleteScript(scriptId) {
const normalizedId = normalizeScriptId(scriptId);
if (!normalizedId) {
throw createValidationError('Ungültige scriptId.');
}
const db = await getDb();
const existing = await this.getScriptById(normalizedId);
await db.run('DELETE FROM scripts WHERE id = ?', [normalizedId]);
return existing;
}
async getScriptsByIds(rawIds = []) {
const ids = normalizeScriptIdList(rawIds);
if (ids.length === 0) {
return [];
}
const db = await getDb();
const placeholders = ids.map(() => '?').join(', ');
const rows = await db.all(
`
SELECT id, name, script_body, order_index, created_at, updated_at
FROM scripts
WHERE id IN (${placeholders})
`,
ids
);
const byId = new Map(rows.map((row) => [Number(row.id), mapScriptRow(row)]));
return ids.map((id) => byId.get(id)).filter(Boolean);
}
async resolveScriptsByIds(rawIds = [], options = {}) {
const ids = normalizeScriptIdList(rawIds);
if (ids.length === 0) {
return [];
}
const strict = options?.strict !== false;
const scripts = await this.getScriptsByIds(ids);
if (!strict) {
return scripts;
}
const foundIds = new Set(scripts.map((item) => Number(item.id)));
const missing = ids.filter((id) => !foundIds.has(Number(id)));
if (missing.length > 0) {
throw createValidationError(`Skript(e) nicht gefunden: ${missing.join(', ')}`, [
{ field: 'selectedPostEncodeScriptIds', message: `Nicht gefunden: ${missing.join(', ')}` }
]);
}
return scripts;
}
async reorderScripts(orderedIds = []) {
const db = await getDb();
const providedIds = normalizeScriptIdList(orderedIds);
const rows = await db.all(
`
SELECT id
FROM scripts
ORDER BY order_index ASC, id ASC
`
);
if (rows.length === 0) {
return [];
}
const existingIds = rows.map((row) => Number(row.id)).filter((id) => Number.isFinite(id) && id > 0);
const existingSet = new Set(existingIds);
const used = new Set();
const nextOrder = [];
for (const id of providedIds) {
if (!existingSet.has(id) || used.has(id)) {
continue;
}
used.add(id);
nextOrder.push(id);
}
for (const id of existingIds) {
if (used.has(id)) {
continue;
}
used.add(id);
nextOrder.push(id);
}
await db.exec('BEGIN');
try {
for (let i = 0; i < nextOrder.length; i += 1) {
await db.run(
`
UPDATE scripts
SET order_index = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
[i + 1, nextOrder[i]]
);
}
await db.exec('COMMIT');
} catch (error) {
await db.exec('ROLLBACK');
throw error;
}
return this.listScripts();
}
async _getNextOrderIndex(db) {
const row = await db.get(
`
SELECT COALESCE(MAX(order_index), 0) AS max_order_index
FROM scripts
`
);
const maxOrder = Number(row?.max_order_index || 0);
if (!Number.isFinite(maxOrder) || maxOrder < 0) {
return 1;
}
return Math.trunc(maxOrder) + 1;
}
async createExecutableScriptFile(script, context = {}) {
const name = String(script?.name || '').trim() || `script-${script?.id || 'unknown'}`;
const scriptBody = normalizeScriptBody(script?.scriptBody);
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ripster-script-'));
const scriptPath = path.join(tempDir, 'script.sh');
const wrapped = buildScriptWrapper(scriptBody, {
...context,
scriptId: script?.id ?? context?.scriptId ?? '',
scriptName: name,
source: context?.source || 'post_encode'
});
await fs.promises.writeFile(scriptPath, wrapped, {
encoding: 'utf-8',
mode: 0o700
});
const cleanup = async () => {
try {
await fs.promises.rm(tempDir, { recursive: true, force: true });
} catch (error) {
logger.warn('script:temp-cleanup-failed', {
scriptId: script?.id ?? null,
scriptName: name,
tempDir,
error: errorToMeta(error)
});
}
};
return {
tempDir,
scriptPath,
cmd: '/usr/bin/env',
args: ['bash', scriptPath],
argsForLog: ['bash', `<script:${name}>`],
cleanup
};
}
async testScript(scriptId, options = {}) {
const script = await this.getScriptById(scriptId);
const effectiveTimeoutMs = await resolveScriptTestTimeoutMs(options);
const prepared = await this.createExecutableScriptFile(script, {
source: 'settings_test',
mode: 'test'
});
const activityId = runtimeActivityService.startActivity('script', {
name: script.name,
source: 'settings_test',
scriptId: script.id,
currentStep: 'Skript-Test läuft'
});
const controlState = {
cancelRequested: false,
cancelReason: null,
child: null,
cancelSignalSent: false
};
runtimeActivityService.setControls(activityId, {
cancel: async (payload = {}) => {
if (controlState.cancelRequested) {
return { accepted: true, alreadyRequested: true, message: 'Abbruch bereits angefordert.' };
}
controlState.cancelRequested = true;
controlState.cancelReason = String(payload?.reason || '').trim() || 'Von Benutzer abgebrochen';
runtimeActivityService.updateActivity(activityId, {
message: 'Abbruch angefordert'
});
if (controlState.child) {
// User cancel should stop instantly.
controlState.cancelSignalSent = killChildProcessTree(controlState.child, 'SIGKILL') || controlState.cancelSignalSent;
}
return { accepted: true, message: 'Abbruch angefordert.' };
}
});
try {
const run = await runProcessCapture({
cmd: prepared.cmd,
args: prepared.args,
timeoutMs: effectiveTimeoutMs,
onChild: (child) => {
controlState.child = child;
},
onStdoutLine: (line) => {
runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
},
onStderrLine: (line) => {
runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
}
});
const exitCode = Number.isFinite(Number(run.code)) ? Number(run.code) : null;
const finishedSuccessfully = exitCode === 0 && !run.timedOut;
const cancelledByUser = Boolean(controlState.cancelRequested)
&& (Boolean(controlState.cancelSignalSent) || !finishedSuccessfully);
const success = finishedSuccessfully;
const message = cancelledByUser
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (run.timedOut
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
: (success ? 'Skript-Test abgeschlossen' : `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
const errorMessage = success
? null
: (cancelledByUser
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (run.timedOut
? `Skript-Test Timeout nach ${Math.round(effectiveTimeoutMs / 1000)}s`
: `Skript-Test fehlgeschlagen (Exit ${run.code ?? 'n/a'})`));
runtimeActivityService.completeActivity(activityId, {
status: success ? 'success' : 'error',
success,
outcome: cancelledByUser ? 'cancelled' : (success ? 'success' : 'error'),
cancelled: cancelledByUser,
exitCode,
stdout: run.stdout || null,
stderr: run.stderr || null,
stdoutTruncated: Boolean(run.stdoutTruncated),
stderrTruncated: Boolean(run.stderrTruncated),
errorMessage,
message
});
return {
scriptId: script.id,
scriptName: script.name,
success,
exitCode: run.code,
signal: run.signal,
timedOut: run.timedOut,
durationMs: run.durationMs,
stdout: run.stdout,
stderr: run.stderr,
stdoutTruncated: run.stdoutTruncated,
stderrTruncated: run.stderrTruncated
};
} catch (error) {
runtimeActivityService.completeActivity(activityId, {
status: 'error',
success: false,
outcome: controlState.cancelRequested ? 'cancelled' : 'error',
cancelled: Boolean(controlState.cancelRequested),
errorMessage: controlState.cancelRequested
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (error?.message || 'Skript-Test Fehler'),
message: controlState.cancelRequested
? (controlState.cancelReason || 'Von Benutzer abgebrochen')
: (error?.message || 'Skript-Test Fehler')
});
throw error;
} finally {
controlState.child = null;
await prepared.cleanup();
}
}
}
module.exports = new ScriptService();
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const logger = require('./logger').child('TEMP_CLEANUP');
const TMP_ROOT = os.tmpdir();
const SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const STALE_ENTRY_MIN_AGE_MS = 12 * 60 * 60 * 1000;
const TOP_LEVEL_PREFIXES = [
'ripster-merge-',
'ripster-script-',
'ripster-export-'
];
const MANAGED_UPLOAD_DIRS = [
'ripster-converter-uploads',
'ripster-audiobook-uploads'
];
function getEntryAgeMs(stats) {
const referenceTime = Math.max(
Number(stats?.mtimeMs) || 0,
Number(stats?.ctimeMs) || 0,
Number(stats?.birthtimeMs) || 0
);
return Date.now() - referenceTime;
}
function isStale(stats, minAgeMs = STALE_ENTRY_MIN_AGE_MS) {
return getEntryAgeMs(stats) >= minAgeMs;
}
function removeEntry(targetPath, stats) {
const isDirectory = stats?.isDirectory?.() || false;
fs.rmSync(targetPath, {
recursive: isDirectory,
force: true
});
}
class TempCleanupService {
constructor() {
this.interval = null;
}
async init() {
await this.runSweep('startup');
if (!this.interval) {
this.interval = setInterval(() => {
this.runSweep('interval').catch((error) => {
logger.warn('temp:sweep:interval-failed', { error: error?.message || String(error) });
});
}, SWEEP_INTERVAL_MS);
this.interval.unref?.();
}
}
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
async runSweep(reason = 'manual') {
const deleted = [];
const skipped = [];
const failed = [];
let topLevelEntries = [];
try {
topLevelEntries = fs.readdirSync(TMP_ROOT, { withFileTypes: true });
} catch (error) {
logger.warn('temp:sweep:readdir-failed', {
reason,
tmpRoot: TMP_ROOT,
error: error?.message || String(error)
});
return { deleted, skipped, failed };
}
for (const entry of topLevelEntries) {
const entryName = String(entry?.name || '').trim();
if (!entryName || !TOP_LEVEL_PREFIXES.some((prefix) => entryName.startsWith(prefix))) {
continue;
}
const targetPath = path.join(TMP_ROOT, entryName);
try {
const stats = fs.lstatSync(targetPath);
if (!isStale(stats)) {
skipped.push({ path: targetPath, reason: 'fresh-top-level-entry' });
continue;
}
removeEntry(targetPath, stats);
deleted.push(targetPath);
} catch (error) {
failed.push({
path: targetPath,
error: error?.message || String(error)
});
}
}
for (const dirName of MANAGED_UPLOAD_DIRS) {
const uploadDir = path.join(TMP_ROOT, dirName);
if (!fs.existsSync(uploadDir)) {
continue;
}
let uploadEntries = [];
try {
uploadEntries = fs.readdirSync(uploadDir, { withFileTypes: true });
} catch (error) {
failed.push({
path: uploadDir,
error: error?.message || String(error)
});
continue;
}
for (const entry of uploadEntries) {
const targetPath = path.join(uploadDir, entry.name);
try {
const stats = fs.lstatSync(targetPath);
if (!isStale(stats)) {
skipped.push({ path: targetPath, reason: 'fresh-upload-entry' });
continue;
}
removeEntry(targetPath, stats);
deleted.push(targetPath);
} catch (error) {
failed.push({
path: targetPath,
error: error?.message || String(error)
});
}
}
}
logger.info('temp:sweep:completed', {
reason,
tmpRoot: TMP_ROOT,
deletedCount: deleted.length,
skippedCount: skipped.length,
failedCount: failed.length,
deleted: deleted.slice(0, 50),
failed: failed.slice(0, 20)
});
return { deleted, skipped, failed };
}
}
module.exports = new TempCleanupService();
+316
View File
@@ -0,0 +1,316 @@
'use strict';
const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const { dataDir } = require('../config');
const { getDb } = require('../db/database');
const logger = require('./logger').child('THUMBNAIL');
const THUMBNAILS_DIR = path.join(dataDir, 'thumbnails');
const CACHE_DIR = path.join(THUMBNAILS_DIR, 'cache');
const MAX_REDIRECTS = 5;
function ensureDirs() {
fs.mkdirSync(CACHE_DIR, { recursive: true });
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
}
function cacheFilePath(jobId) {
return path.join(CACHE_DIR, `job-${jobId}.jpg`);
}
function persistentFilePath(jobId) {
return path.join(THUMBNAILS_DIR, `job-${jobId}.jpg`);
}
function localUrl(jobId) {
return `/api/thumbnails/job-${jobId}.jpg`;
}
function isLocalUrl(url) {
return typeof url === 'string' && url.startsWith('/api/thumbnails/');
}
function resolveLocalThumbnailPath(url) {
const raw = String(url || '').trim();
const match = raw.match(/^\/api\/thumbnails\/job-(\d+)\.jpg$/i);
if (!match) {
return null;
}
const jobId = Number(match[1]);
if (!Number.isFinite(jobId) || jobId <= 0) {
return null;
}
return persistentFilePath(Math.trunc(jobId));
}
function localThumbnailUrlExists(url) {
const targetPath = resolveLocalThumbnailPath(url);
if (!targetPath) {
return false;
}
try {
return fs.existsSync(targetPath);
} catch (_error) {
return false;
}
}
function downloadImage(url, destPath, redirectsLeft = MAX_REDIRECTS) {
return new Promise((resolve, reject) => {
if (redirectsLeft <= 0) {
return reject(new Error('Zu viele Weiterleitungen beim Bild-Download'));
}
const proto = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(destPath);
const cleanup = () => {
try { file.destroy(); } catch (_) {}
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
};
proto.get(url, { timeout: 15000 }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
file.close(() => {
try { if (fs.existsSync(destPath)) fs.unlinkSync(destPath); } catch (_) {}
downloadImage(res.headers.location, destPath, redirectsLeft - 1).then(resolve).catch(reject);
});
return;
}
if (res.statusCode !== 200) {
res.resume();
cleanup();
return reject(new Error(`HTTP ${res.statusCode} beim Bild-Download`));
}
res.pipe(file);
file.on('finish', () => file.close(() => resolve()));
file.on('error', (err) => { cleanup(); reject(err); });
}).on('error', (err) => {
cleanup();
reject(err);
}).on('timeout', function () {
this.destroy();
cleanup();
reject(new Error('Timeout beim Bild-Download'));
});
});
}
/**
* Lädt das Bild einer extern-URL in den Cache herunter.
* Wird aufgerufen sobald poster_url bekannt ist (vor Rip-Start).
* @returns {Promise<string|null>} lokaler Pfad oder null
*/
async function cacheJobThumbnailDetailed(jobId, posterUrl) {
const normalizedPosterUrl = String(posterUrl || '').trim();
if (!normalizedPosterUrl || isLocalUrl(normalizedPosterUrl)) {
return {
ok: false,
jobId,
posterUrl: normalizedPosterUrl || null,
cachedPath: null,
error: 'Kein externer Poster-Link vorhanden.'
};
}
try {
ensureDirs();
const dest = cacheFilePath(jobId);
await downloadImage(normalizedPosterUrl, dest);
logger.info('thumbnail:cached', { jobId, posterUrl: normalizedPosterUrl, dest });
return {
ok: true,
jobId,
posterUrl: normalizedPosterUrl,
cachedPath: dest,
error: null
};
} catch (err) {
const message = String(err?.message || err || 'Bild-Download fehlgeschlagen');
logger.warn('thumbnail:cache:failed', { jobId, posterUrl: normalizedPosterUrl, error: message });
return {
ok: false,
jobId,
posterUrl: normalizedPosterUrl,
cachedPath: null,
error: message
};
}
}
async function cacheJobThumbnail(jobId, posterUrl) {
const result = await cacheJobThumbnailDetailed(jobId, posterUrl);
return result?.ok ? result.cachedPath : null;
}
/**
* Verschiebt das gecachte Bild in den persistenten Ordner.
* Gibt die lokale API-URL zurück, oder null wenn kein Bild vorhanden.
* Wird nach erfolgreichem Rip aufgerufen.
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
*/
function promoteJobThumbnail(jobId) {
try {
ensureDirs();
const src = cacheFilePath(jobId);
const dest = persistentFilePath(jobId);
if (fs.existsSync(src)) {
fs.renameSync(src, dest);
logger.info('thumbnail:promoted', { jobId, dest });
return localUrl(jobId);
}
// Falls kein Cache vorhanden, aber persistente Datei schon existiert
if (fs.existsSync(dest)) {
return localUrl(jobId);
}
logger.warn('thumbnail:promote:no-source', { jobId });
return null;
} catch (err) {
logger.warn('thumbnail:promote:failed', { jobId, error: err.message });
return null;
}
}
/**
* Gibt den Pfad zum persistenten Thumbnail-Ordner zurück (für Static-Serving).
*/
function getThumbnailsDir() {
return THUMBNAILS_DIR;
}
/**
* Kopiert das persistente Thumbnail von sourceJobId zu targetJobId.
* Wird bei Rip-Neustart genutzt, damit der neue Job ein eigenes Bild hat
* und nicht auf die Datei des alten Jobs angewiesen ist.
* @returns {string|null} neue lokale URL oder null
*/
function copyThumbnail(sourceJobId, targetJobId) {
try {
const src = persistentFilePath(sourceJobId);
if (!fs.existsSync(src)) return null;
ensureDirs();
const dest = persistentFilePath(targetJobId);
fs.copyFileSync(src, dest);
logger.info('thumbnail:copied', { sourceJobId, targetJobId });
return localUrl(targetJobId);
} catch (err) {
logger.warn('thumbnail:copy:failed', { sourceJobId, targetJobId, error: err.message });
return null;
}
}
/**
* Speichert ein lokal extrahiertes Bild als persistentes Job-Thumbnail.
* @returns {string|null} lokale URL (/api/thumbnails/job-{id}.jpg) oder null
*/
function storeLocalThumbnail(jobId, sourcePath) {
try {
const src = String(sourcePath || '').trim();
if (!src || !fs.existsSync(src)) {
return null;
}
ensureDirs();
const dest = persistentFilePath(jobId);
fs.copyFileSync(src, dest);
logger.info('thumbnail:stored-local', { jobId, sourcePath: src, dest });
return localUrl(jobId);
} catch (err) {
logger.warn('thumbnail:store-local:failed', { jobId, sourcePath, error: err.message });
return null;
}
}
/**
* Löscht Cache- und persistente Thumbnail-Datei eines Jobs.
* Wird beim Löschen eines Jobs aufgerufen.
*/
function deleteThumbnail(jobId) {
for (const filePath of [persistentFilePath(jobId), cacheFilePath(jobId)]) {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch (err) {
logger.warn('thumbnail:delete:failed', { jobId, filePath, error: err.message });
}
}
}
/**
* Migriert bestehende Jobs: lädt alle externen poster_url-Bilder herunter
* und speichert sie lokal. Läuft beim Start im Hintergrund, sequenziell
* mit kurzem Delay um externe Server nicht zu überlasten.
*/
async function migrateExistingThumbnails() {
try {
ensureDirs();
const db = await getDb();
// Alle abgeschlossenen Jobs mit externer poster_url, die noch kein lokales Bild haben
const jobs = await db.all(
`SELECT id, poster_url FROM jobs
WHERE rip_successful = 1
AND poster_url IS NOT NULL
AND poster_url != ''
AND poster_url NOT LIKE '/api/thumbnails/%'
ORDER BY id ASC`
);
if (!jobs.length) {
logger.info('thumbnail:migrate:nothing-to-do');
return;
}
logger.info('thumbnail:migrate:start', { count: jobs.length });
let succeeded = 0;
let failed = 0;
for (const job of jobs) {
// Persistente Datei bereits vorhanden? Dann nur DB aktualisieren.
const dest = persistentFilePath(job.id);
if (fs.existsSync(dest)) {
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
succeeded++;
continue;
}
try {
await downloadImage(job.poster_url, dest);
await db.run('UPDATE jobs SET poster_url = ? WHERE id = ?', [localUrl(job.id), job.id]);
logger.info('thumbnail:migrate:ok', { jobId: job.id });
succeeded++;
} catch (err) {
logger.warn('thumbnail:migrate:failed', { jobId: job.id, url: job.poster_url, error: err.message });
failed++;
}
// Kurze Pause zwischen Downloads (externe Server schonen)
await new Promise((r) => setTimeout(r, 300));
}
logger.info('thumbnail:migrate:done', { succeeded, failed, total: jobs.length });
} catch (err) {
logger.error('thumbnail:migrate:error', { error: err.message });
}
}
module.exports = {
cacheJobThumbnail,
cacheJobThumbnailDetailed,
promoteJobThumbnail,
copyThumbnail,
storeLocalThumbnail,
deleteThumbnail,
getThumbnailsDir,
migrateExistingThumbnails,
isLocalUrl,
resolveLocalThumbnailPath,
localThumbnailUrlExists
};
+488
View File
@@ -0,0 +1,488 @@
'use strict';
const settingsService = require('./settingsService');
const logger = require('./logger').child('TMDB');
const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
const TMDB_TIMEOUT_MS = 15000;
class TmdbService {
isAbortError(error) {
const name = String(error?.name || '').trim().toLowerCase();
const message = String(error?.message || '').trim().toLowerCase();
return name === 'aborterror' || message.includes('aborted');
}
classifyRequestError(error) {
if (this.isAbortError(error)) {
return 'timeout';
}
const statusCode = Number(error?.statusCode || 0) || null;
if (statusCode === 401 || statusCode === 403) {
return 'auth';
}
if (statusCode >= 500) {
return 'upstream';
}
if (statusCode >= 400) {
return 'request_failed';
}
return 'network';
}
readFailureCode(rows) {
const value = rows && typeof rows.tmdbFailureCode === 'string'
? rows.tmdbFailureCode
: '';
const normalized = String(value || '').trim().toLowerCase();
return normalized || null;
}
attachFailureCode(rows, failureCode = null) {
const output = Array.isArray(rows) ? rows : [];
const normalized = String(failureCode || '').trim().toLowerCase();
if (!normalized) {
return output;
}
try {
Object.defineProperty(output, 'tmdbFailureCode', {
value: normalized,
enumerable: false,
configurable: true
});
} catch (_error) {
output.tmdbFailureCode = normalized;
}
return output;
}
normalizeNameList(values = [], options = {}) {
const maxItems = Math.max(1, Number(options.maxItems || 10));
const source = Array.isArray(values) ? values : [];
const output = [];
const seen = new Set();
for (const item of source) {
const name = String(item?.name || item || '').trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(name);
if (output.length >= maxItems) {
break;
}
}
return output;
}
formatRuntimeLabel(value) {
if (Array.isArray(value)) {
const values = value
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
if (values.length === 0) {
return null;
}
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? `${min} min` : `${min}-${max} min`;
}
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return `${Math.trunc(numeric)} min`;
}
const text = String(value || '').trim();
return text || null;
}
async getConfig() {
const settings = await settingsService.getSettingsMap();
return {
readAccessToken: String(settings.tmdb_api_read_access_token || '').trim() || null,
language: String(settings.dvd_series_language || 'de-DE').trim() || 'de-DE'
};
}
async isConfigured() {
const config = await this.getConfig();
return Boolean(config.readAccessToken);
}
async resolveLanguage(explicitLanguage = null) {
const config = await this.getConfig();
return String(explicitLanguage || config.language || 'de-DE').trim() || 'de-DE';
}
async request(pathName, options = {}) {
const config = await this.getConfig();
if (!config.readAccessToken) {
return null;
}
const timeoutMs = Math.max(1000, Number(options.timeoutMs || TMDB_TIMEOUT_MS));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const normalizedPath = String(pathName || '').replace(/^\/+/, '');
const url = new URL(normalizedPath, `${TMDB_BASE_URL}/`);
if (options.query && typeof options.query === 'object') {
for (const [key, value] of Object.entries(options.query)) {
if (value === undefined || value === null || value === '') {
continue;
}
url.searchParams.set(key, String(value));
}
}
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'accept': 'application/json',
'Authorization': `Bearer ${config.readAccessToken}`,
'User-Agent': 'Ripster/1.0'
},
signal: controller.signal
});
if (!response.ok) {
const error = new Error(`TMDb request failed (${response.status})`);
error.statusCode = response.status;
error.url = url.toString();
throw error;
}
return response.json();
} finally {
clearTimeout(timer);
}
}
buildImageUrl(imagePath, size = 'w342') {
const normalizedPath = String(imagePath || '').trim();
if (!normalizedPath) {
return null;
}
return `${TMDB_IMAGE_BASE_URL}/${String(size || 'w342').trim()}/${normalizedPath.replace(/^\/+/, '')}`;
}
async searchSeries(query, options = {}) {
const normalizedQuery = String(query || '').trim();
if (!normalizedQuery || !(await this.isConfigured())) {
return [];
}
const language = await this.resolveLanguage(options.language);
let data = null;
try {
data = await this.request('/search/tv', {
query: {
query: normalizedQuery,
first_air_date_year: options.year || undefined,
language,
page: options.page || 1,
include_adult: false
}
});
} catch (error) {
const failureCode = this.classifyRequestError(error);
logger.warn('search:failed', {
query: normalizedQuery,
error: error?.message || String(error),
failureCode,
statusCode: Number(error?.statusCode || 0) || null
});
return this.attachFailureCode([], failureCode);
}
const rows = Array.isArray(data?.results) ? data.results : [];
const normalizedRows = rows
.map((row) => ({
id: Number(row?.id || 0) || null,
title: String(row?.name || row?.original_name || '').trim() || null,
originalTitle: String(row?.original_name || '').trim() || null,
year: Number(String(row?.first_air_date || '').slice(0, 4)) || null,
overview: String(row?.overview || '').trim() || null,
posterPath: String(row?.poster_path || '').trim() || null,
poster: this.buildImageUrl(row?.poster_path, 'w342'),
backdropPath: String(row?.backdrop_path || '').trim() || null,
backdrop: this.buildImageUrl(row?.backdrop_path, 'w780'),
originalLanguage: String(row?.original_language || '').trim() || null,
popularity: Number(row?.popularity || 0) || 0
}))
.filter((row) => row.id && row.title);
return this.attachFailureCode(normalizedRows, null);
}
async getSeriesDetails(seriesId, options = {}) {
const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
const appendToResponse = Array.isArray(options.appendToResponse)
? options.appendToResponse.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return this.request(`/tv/${Math.trunc(id)}`, {
query: {
language,
append_to_response: appendToResponse.length > 0 ? appendToResponse.join(',') : undefined
}
}).catch((error) => {
logger.warn('series:details:failed', {
seriesId: Math.trunc(id),
error: error?.message || String(error)
});
return null;
});
}
async getEpisodeGroups(seriesId) {
const id = Number(seriesId);
if (!Number.isFinite(id) || id <= 0 || !(await this.isConfigured())) {
return [];
}
const response = await this.request(`/tv/${Math.trunc(id)}/episode_groups`).catch((error) => {
logger.warn('series:episode-groups:failed', {
seriesId: Math.trunc(id),
error: error?.message || String(error)
});
return null;
});
return Array.isArray(response?.results) ? response.results : [];
}
async getEpisodeGroupDetails(groupId, options = {}) {
const normalizedId = String(groupId || '').trim();
if (!normalizedId || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/episode_group/${normalizedId}`, {
query: {
language
}
}).catch((error) => {
logger.warn('episode-group:details:failed', {
groupId: normalizedId,
error: error?.message || String(error)
});
return null;
});
}
async getSeasonDetails(seriesId, seasonNumber, options = {}) {
const id = Number(seriesId);
const season = Number(seasonNumber);
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}`, {
query: {
language
}
}).catch(() => null);
}
async getSeasonCredits(seriesId, seasonNumber, options = {}) {
const id = Number(seriesId);
const season = Number(seasonNumber);
if (!Number.isFinite(id) || id <= 0 || !Number.isFinite(season) || season < 0 || !(await this.isConfigured())) {
return null;
}
const language = await this.resolveLanguage(options.language);
return this.request(`/tv/${Math.trunc(id)}/season/${Math.trunc(season)}/credits`, {
query: {
language
}
}).catch((error) => {
logger.warn('season:credits:failed', {
seriesId: Math.trunc(id),
seasonNumber: Math.trunc(season),
error: error?.message || String(error)
});
return null;
});
}
buildSeasonSummary(seasonDetails = null) {
const details = seasonDetails && typeof seasonDetails === 'object' ? seasonDetails : {};
const episodes = Array.isArray(details.episodes) ? details.episodes : [];
return {
seasonNumber: Number(details.season_number || details.seasonNumber || 0) || null,
name: String(details.name || '').trim() || null,
overview: String(details.overview || '').trim() || null,
posterPath: String(details.poster_path || '').trim() || null,
poster: this.buildImageUrl(details.poster_path, 'w342'),
episodeCount: episodes.length,
episodes: episodes.map((episode) => ({
id: Number(episode?.id || 0) || null,
number: Number(episode?.episode_number || episode?.episodeNumber || 0) || null,
seasonNumber: Number(episode?.season_number || details.season_number || 0) || null,
name: String(episode?.name || '').trim() || null,
overview: String(episode?.overview || '').trim() || null,
runtime: Number(episode?.runtime || 0) || null,
airDate: String(episode?.air_date || '').trim() || null,
stillPath: String(episode?.still_path || '').trim() || null,
still: this.buildImageUrl(episode?.still_path, 'w300')
})).filter((episode) => episode.id && episode.number)
};
}
buildSeriesDetailsSummary(seriesDetails = null) {
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
const crew = Array.isArray(details?.credits?.crew) ? details.credits.crew : [];
const cast = Array.isArray(details?.credits?.cast) ? details.credits.cast : [];
const creators = Array.isArray(details?.created_by) ? details.created_by : [];
const genres = Array.isArray(details?.genres) ? details.genres : [];
const runtimeLabel = this.formatRuntimeLabel(details?.episode_run_time);
const directorNames = this.normalizeNameList(
crew.filter((member) => String(member?.job || '').trim().toLowerCase() === 'director'),
{ maxItems: 5 }
);
const creatorNames = this.normalizeNameList(creators, { maxItems: 5 });
const actorNames = this.normalizeNameList(cast, { maxItems: 10 });
const genreNames = this.normalizeNameList(genres, { maxItems: 10 });
const voteAverageRaw = Number(details?.vote_average || 0);
const voteAverage = Number.isFinite(voteAverageRaw) && voteAverageRaw > 0
? Number(voteAverageRaw.toFixed(1))
: null;
const voteCount = Number(details?.vote_count || 0);
const imdbId = String(details?.external_ids?.imdb_id || '').trim() || null;
return {
director: directorNames.length > 0
? directorNames.join(', ')
: (creatorNames.length > 0 ? creatorNames.join(', ') : null),
actors: actorNames.length > 0 ? actorNames.join(', ') : null,
runtime: runtimeLabel,
runtimeLabel,
genre: genreNames.length > 0 ? genreNames.join(', ') : null,
imdbRating: voteAverage !== null ? voteAverage.toFixed(1) : null,
voteAverage,
voteCount: Number.isFinite(voteCount) && voteCount > 0 ? Math.trunc(voteCount) : null,
rottenTomatoes: null,
imdbId,
tmdbId: Number(details?.id || 0) || null,
firstAirDate: String(details?.first_air_date || '').trim() || null
};
}
buildSeriesMetadataCandidate(series = {}, options = {}) {
const season = series?.season && typeof series.season === 'object'
? series.season
: null;
const seasonNumber = Number(options.seasonNumber || season?.seasonNumber || 0) || null;
const providerId = seasonNumber
? `tmdb:${series.id}:season:${seasonNumber}`
: `tmdb:${series.id}`;
return {
provider: 'tmdb',
providerId,
metadataKind: seasonNumber ? 'season' : 'series',
tmdbId: Number(series?.id || 0) || null,
title: String(series?.title || '').trim() || null,
originalTitle: String(series?.originalTitle || '').trim() || null,
year: Number(series?.year || 0) || null,
overview: String(series?.overview || '').trim() || null,
poster: series?.poster || this.buildImageUrl(series?.posterPath, 'w342'),
backdrop: series?.backdrop || this.buildImageUrl(series?.backdropPath, 'w780'),
seasonNumber,
seasonName: String(season?.name || '').trim() || null,
seasonOverview: String(season?.overview || '').trim() || null,
seasonPoster: season?.poster || this.buildImageUrl(season?.posterPath, 'w342'),
episodeCount: Number(season?.episodeCount || 0) || 0,
episodes: Array.isArray(season?.episodes) ? season.episodes : []
};
}
buildSeasonSummariesFromSeriesDetails(seriesDetails = null) {
const details = seriesDetails && typeof seriesDetails === 'object' ? seriesDetails : {};
const seasons = Array.isArray(details.seasons) ? details.seasons : [];
return seasons
.map((season) => ({
seasonNumber: Number(season?.season_number || season?.seasonNumber || 0) || null,
name: String(season?.name || '').trim() || null,
overview: String(season?.overview || '').trim() || null,
posterPath: String(season?.poster_path || '').trim() || null,
poster: this.buildImageUrl(season?.poster_path, 'w342'),
episodeCount: Number(season?.episode_count || season?.episodeCount || 0) || 0,
episodes: []
}))
.filter((season) => season.seasonNumber !== null && season.episodeCount > 0);
}
async searchSeriesWithSeasons(query, options = {}) {
const candidates = await this.searchSeries(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) {
return this.attachFailureCode([], failureCode);
}
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
const selectedCandidates = candidates.slice(0, limit);
const expanded = await Promise.all(selectedCandidates.map(async (candidate) => {
const details = await this.getSeriesDetails(candidate.id, options);
const seasons = this.buildSeasonSummariesFromSeriesDetails(details);
if (seasons.length === 0) {
return [this.buildSeriesMetadataCandidate(candidate)];
}
return seasons.map((season) => this.buildSeriesMetadataCandidate({
...candidate,
season
}, {
seasonNumber: season.seasonNumber
}));
}));
const normalized = expanded.flat().filter((item) => item?.tmdbId && item?.title);
return this.attachFailureCode(normalized, failureCode);
}
async searchSeriesWithSeason(query, seasonNumber, options = {}) {
const normalizedSeason = Number(seasonNumber);
if (!Number.isFinite(normalizedSeason) || normalizedSeason < 0) {
return [];
}
const candidates = await this.searchSeries(query, options);
const failureCode = this.readFailureCode(candidates);
if (candidates.length === 0) {
return this.attachFailureCode([], failureCode);
}
const limit = Math.max(1, Math.min(10, Number(options.limit || 5)));
const selectedCandidates = candidates.slice(0, limit);
const withSeasons = await Promise.all(selectedCandidates.map(async (candidate) => {
const seasonDetails = await this.getSeasonDetails(candidate.id, normalizedSeason, options);
if (!seasonDetails) {
return {
...candidate,
season: null
};
}
return {
...candidate,
season: this.buildSeasonSummary(seasonDetails)
};
}));
const normalized = withSeasons
.filter((candidate) => candidate.season && candidate.season.episodeCount > 0)
.map((candidate) => this.buildSeriesMetadataCandidate(candidate, {
seasonNumber: normalizedSeason
}));
return this.attachFailureCode(normalized, failureCode);
}
}
module.exports = new TmdbService();
+133
View File
@@ -0,0 +1,133 @@
const { getDb } = require('../db/database');
const logger = require('./logger').child('USER_PRESET');
const VALID_MEDIA_TYPES = new Set(['bluray', 'dvd', 'other', 'all']);
function normalizeMediaType(value) {
const v = String(value || '').trim().toLowerCase();
return VALID_MEDIA_TYPES.has(v) ? v : 'all';
}
function rowToPreset(row) {
if (!row) {
return null;
}
return {
id: row.id,
name: row.name,
mediaType: row.media_type,
handbrakePreset: row.handbrake_preset || null,
extraArgs: row.extra_args || null,
description: row.description || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
async function listPresets(mediaType = null) {
const db = await getDb();
let rows;
if (mediaType && VALID_MEDIA_TYPES.has(mediaType)) {
rows = await db.all(
`SELECT * FROM user_presets WHERE media_type = ? OR media_type = 'all' ORDER BY name ASC`,
[mediaType]
);
} else {
rows = await db.all(`SELECT * FROM user_presets ORDER BY media_type ASC, name ASC`);
}
return rows.map(rowToPreset);
}
async function getPresetById(id) {
const db = await getDb();
const row = await db.get(`SELECT * FROM user_presets WHERE id = ? LIMIT 1`, [id]);
return rowToPreset(row);
}
async function createPreset(payload) {
const name = String(payload?.name || '').trim();
if (!name) {
const error = new Error('Preset-Name darf nicht leer sein.');
error.statusCode = 400;
throw error;
}
const mediaType = normalizeMediaType(payload?.mediaType);
const handbrakePreset = String(payload?.handbrakePreset || '').trim() || null;
const extraArgs = String(payload?.extraArgs || '').trim() || null;
const description = String(payload?.description || '').trim() || null;
const db = await getDb();
const result = await db.run(
`INSERT INTO user_presets (name, media_type, handbrake_preset, extra_args, description)
VALUES (?, ?, ?, ?, ?)`,
[name, mediaType, handbrakePreset, extraArgs, description]
);
const preset = await getPresetById(result.lastID);
logger.info('create', { id: preset.id, name: preset.name, mediaType: preset.mediaType });
return preset;
}
async function updatePreset(id, payload) {
const db = await getDb();
const existing = await getPresetById(id);
if (!existing) {
const error = new Error(`Preset ${id} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
const name = payload?.name !== undefined ? String(payload.name || '').trim() : existing.name;
if (!name) {
const error = new Error('Preset-Name darf nicht leer sein.');
error.statusCode = 400;
throw error;
}
const mediaType = payload?.mediaType !== undefined
? normalizeMediaType(payload.mediaType)
: existing.mediaType;
const handbrakePreset = payload?.handbrakePreset !== undefined
? (String(payload.handbrakePreset || '').trim() || null)
: existing.handbrakePreset;
const extraArgs = payload?.extraArgs !== undefined
? (String(payload.extraArgs || '').trim() || null)
: existing.extraArgs;
const description = payload?.description !== undefined
? (String(payload.description || '').trim() || null)
: existing.description;
await db.run(
`UPDATE user_presets
SET name = ?, media_type = ?, handbrake_preset = ?, extra_args = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
[name, mediaType, handbrakePreset, extraArgs, description, id]
);
const updated = await getPresetById(id);
logger.info('update', { id: updated.id, name: updated.name });
return updated;
}
async function deletePreset(id) {
const db = await getDb();
const existing = await getPresetById(id);
if (!existing) {
const error = new Error(`Preset ${id} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
await db.run(`DELETE FROM user_presets WHERE id = ?`, [id]);
logger.info('delete', { id: existing.id, name: existing.name });
return existing;
}
module.exports = {
listPresets,
getPresetById,
createPreset,
updatePreset,
deletePreset
};
+121
View File
@@ -0,0 +1,121 @@
const { WebSocketServer } = require('ws');
const logger = require('./logger').child('WS');
class WebSocketService {
constructor() {
this.wss = null;
this.clients = new Set();
}
_removeClient(socket, logLevel = 'info', event = 'client:removed') {
if (!socket) {
return;
}
const deleted = this.clients.delete(socket);
if (!deleted) {
return;
}
logger[logLevel](event, { clients: this.clients.size });
}
init(httpServer) {
if (this.wss) {
return;
}
this.wss = new WebSocketServer({ server: httpServer, path: '/ws' });
this.wss.on('connection', (socket) => {
this.clients.add(socket);
logger.info('client:connected', { clients: this.clients.size });
try {
socket.send(
JSON.stringify({
type: 'WS_CONNECTED',
payload: { connectedAt: new Date().toISOString() }
})
);
} catch (error) {
logger.warn('client:connected:initial-send-failed', {
clients: this.clients.size,
error: error?.message || String(error)
});
this._removeClient(socket, 'warn', 'client:connected:removed-after-send-failure');
return;
}
socket.on('close', () => {
this._removeClient(socket, 'info', 'client:closed');
});
socket.on('error', (error) => {
logger.warn('client:error', {
clients: this.clients.size,
error: error?.message || String(error)
});
this._removeClient(socket, 'warn', 'client:error:removed');
});
});
}
broadcast(type, payload) {
if (!this.wss) {
return;
}
logger.debug('broadcast', {
type,
clients: this.clients.size,
payloadKeys: payload && typeof payload === 'object' ? Object.keys(payload) : []
});
const message = JSON.stringify({
type,
payload,
timestamp: new Date().toISOString()
});
for (const client of this.clients) {
if (!client || client.readyState !== client.OPEN) {
if (client && client.readyState === client.CLOSED) {
this._removeClient(client, 'info', 'client:pruned-closed');
}
continue;
}
try {
client.send(message, (error) => {
if (!error) {
return;
}
logger.warn('broadcast:send-failed', {
type,
clients: this.clients.size,
error: error?.message || String(error)
});
this._removeClient(client, 'warn', 'client:removed-after-send-failure');
try {
client.terminate();
} catch (_error) {
// noop
}
});
} catch (error) {
logger.warn('broadcast:send-threw', {
type,
clients: this.clients.size,
error: error?.message || String(error)
});
this._removeClient(client, 'warn', 'client:removed-after-send-throw');
try {
client.terminate();
} catch (_terminateError) {
// noop
}
}
}
}
}
module.exports = new WebSocketService();
+57
View File
@@ -0,0 +1,57 @@
function splitArgs(input) {
if (!input || typeof input !== 'string') {
return [];
}
const args = [];
let current = '';
let quote = null;
let escaping = false;
for (const ch of input) {
if (escaping) {
current += ch;
escaping = false;
continue;
}
if (ch === '\\') {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) {
quote = null;
} else {
current += ch;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current.length > 0) {
args.push(current);
current = '';
}
continue;
}
current += ch;
}
if (current.length > 0) {
args.push(current);
}
return args;
}
module.exports = {
splitArgs
};
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
function errorToMeta(error) {
if (!error) {
return {};
}
return {
name: error.name,
message: error.message,
stack: error.stack,
code: error.code,
signal: error.signal,
statusCode: error.statusCode
};
}
module.exports = {
errorToMeta
};
+146
View File
@@ -0,0 +1,146 @@
const fs = require('fs');
const path = require('path');
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function transliterateForFilename(input) {
return String(input || '')
// German — must come before NFD to get ae/oe/ue instead of just a/o/u
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
// Danish / Norwegian / Swedish
.replace(/å/g, 'a').replace(/Å/g, 'A')
.replace(/æ/g, 'ae').replace(/Æ/g, 'Ae')
.replace(/ø/g, 'o').replace(/Ø/g, 'O')
// Icelandic
.replace(/ð/g, 'd').replace(/Ð/g, 'D')
.replace(/þ/g, 'th').replace(/Þ/g, 'Th')
// NFD decomposition + strip combining diacritical marks (handles é→e, ñ→n, ç→c, etc.)
.normalize('NFD')
.replace(/\p{M}+/gu, '')
// Drop any remaining non-ASCII characters
.replace(/[^\x00-\x7F]/g, '');
}
function sanitizeFileName(input) {
return transliterateForFilename(String(input || 'untitled'))
.replace(/[^a-zA-Z0-9 ()[\]_+-]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function sanitizeFileNameWithExtension(input) {
const str = String(input || 'untitled');
const ext = path.extname(str);
const base = ext ? str.slice(0, -ext.length) : str;
const cleanBase = sanitizeFileName(base);
const cleanExt = ext.toLowerCase().replace(/[^a-z0-9.]/g, '');
return (cleanBase || 'untitled') + cleanExt;
}
function renderTemplate(template, values) {
const rawSource = String(template || '${title} (${year})');
const parseToken = (rawToken) => {
const token = String(rawToken || '').trim();
if (!token) {
return { format: '', key: '' };
}
const separatorIndex = token.indexOf(':');
if (separatorIndex <= 0) {
return { format: '', key: token };
}
return {
format: token.slice(0, separatorIndex).trim(),
key: token.slice(separatorIndex + 1).trim()
};
};
const applyFormat = (value, format) => {
const normalizedFormat = String(format || '').trim().toLowerCase();
if (!normalizedFormat) {
return String(value);
}
// {0:key} => number with leading zero (width 2), e.g. 2 -> 02
if (normalizedFormat === '0') {
const raw = String(value);
const match = raw.match(/^(-?)(\d+)$/);
if (match) {
const sign = match[1] || '';
const digits = String(match[2] || '');
return `${sign}${digits.padStart(2, '0')}`;
}
}
return String(value);
};
const resolveToken = (rawKey) => {
const { format, key } = parseToken(rawKey);
const val = values[key];
if (val === undefined || val === null || val === '') {
return 'unknown';
}
return applyFormat(val, format);
};
const source = (
/[{}]/.test(rawSource)
? rawSource
: rawSource.replace(/\b(trackNr|trackNumber|artist|album|title|year)\b/g, '{$1}')
);
// Support both ${key} and legacy {key} placeholders (+ recovered bare tokens).
return source
.replace(/\$\{([^}]+)\}/g, (_m, key) => resolveToken(key))
.replace(/\{([^{}$]+)\}/g, (_m, key) => resolveToken(key));
}
function findLargestMediaFile(dirPath, extensions = ['.mkv', '.mp4']) {
const files = findMediaFiles(dirPath, extensions);
if (files.length === 0) {
return null;
}
return files.reduce((largest, file) => (largest === null || file.size > largest.size ? file : largest), null);
}
function findMediaFiles(dirPath, extensions = ['.mkv', '.mp4']) {
const results = [];
function walk(current) {
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const abs = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(abs);
} else {
const ext = path.extname(entry.name).toLowerCase();
if (!extensions.includes(ext)) {
continue;
}
const stat = fs.statSync(abs);
results.push({
path: abs,
size: stat.size
});
}
}
}
walk(dirPath);
results.sort((a, b) => b.size - a.size || a.path.localeCompare(b.path));
return results;
}
module.exports = {
ensureDir,
transliterateForFilename,
sanitizeFileName,
sanitizeFileNameWithExtension,
renderTemplate,
findLargestMediaFile,
findMediaFiles
};
+742
View File
@@ -0,0 +1,742 @@
const LARGE_JUMP_THRESHOLD = 20;
const DEFAULT_DURATION_SIMILARITY_SECONDS = 90;
const RAW_MIRROR_DURATION_TOLERANCE_SECONDS = 2;
const RAW_MIRROR_SIZE_TOLERANCE_BYTES = 64 * 1024 * 1024;
function parseDurationSeconds(raw) {
const text = String(raw || '').trim();
if (!text) {
return 0;
}
const hms = text.match(/^(\d{1,2}):(\d{2}):(\d{2})(?:\.\d+)?$/);
if (hms) {
const h = Number(hms[1]);
const m = Number(hms[2]);
const s = Number(hms[3]);
return (h * 3600) + (m * 60) + s;
}
const hm = text.match(/^(\d{1,2}):(\d{2})(?:\.\d+)?$/);
if (hm) {
const m = Number(hm[1]);
const s = Number(hm[2]);
return (m * 60) + s;
}
const asNumber = Number(text);
if (Number.isFinite(asNumber) && asNumber > 0) {
return Math.round(asNumber);
}
return 0;
}
function formatDuration(seconds) {
const total = Number(seconds || 0);
if (!Number.isFinite(total) || total <= 0) {
return '-';
}
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function parseSizeBytes(raw) {
const text = String(raw || '').trim();
if (!text) {
return 0;
}
if (/^\d+$/.test(text)) {
const direct = Number(text);
return Number.isFinite(direct) ? Math.max(0, Math.round(direct)) : 0;
}
const match = text.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
if (!match) {
return 0;
}
const value = Number(match[1]);
if (!Number.isFinite(value)) {
return 0;
}
const unit = String(match[2] || '').toUpperCase();
const factorByUnit = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4
};
const factor = factorByUnit[unit] || 1;
return Math.max(0, Math.round(value * factor));
}
function normalizePlaylistId(raw) {
const value = String(raw || '').trim().toLowerCase();
if (!value) {
return null;
}
const match = value.match(/(\d{1,5})(?:\.mpls)?$/i);
if (!match) {
return null;
}
return String(match[1]).padStart(5, '0');
}
function toSegmentFile(segmentNumber) {
const value = Number(segmentNumber);
if (!Number.isFinite(value) || value < 0) {
return null;
}
return `${String(Math.trunc(value)).padStart(5, '0')}.m2ts`;
}
function parseSegmentNumbers(raw) {
const text = String(raw || '').trim();
if (!text) {
return [];
}
const matches = text.match(/\d{1,6}/g) || [];
return matches
.map((item) => Number(item))
.filter((value) => Number.isFinite(value) && value >= 0)
.map((value) => Math.trunc(value));
}
function extractPlaylistMapping(line) {
const raw = String(line || '');
// Robot message typically maps playlist to title id.
const msgMatch = raw.match(/MSG:3016.*,"(\d{5}\.mpls)","(\d+)"/i);
if (msgMatch) {
return {
playlistId: normalizePlaylistId(msgMatch[1]),
titleId: Number(msgMatch[2])
};
}
const textMatch = raw.match(/(?:file|datei)\s+(\d{5}\.mpls).*?(?:title\s*#|titel\s*#?\s*)(\d+)/i);
if (textMatch) {
return {
playlistId: normalizePlaylistId(textMatch[1]),
titleId: Number(textMatch[2])
};
}
return null;
}
function parseAnalyzeTitles(lines) {
const titleMap = new Map();
const ensureTitle = (titleId) => {
if (!titleMap.has(titleId)) {
titleMap.set(titleId, {
titleId,
playlistId: null,
playlistIdFromMap: null,
playlistIdFromField16: null,
playlistFile: null,
durationSeconds: 0,
durationLabel: null,
sizeBytes: 0,
sizeLabel: null,
chapters: 0,
segmentNumbers: [],
segmentFiles: [],
streams: {},
fields: {}
});
}
return titleMap.get(titleId);
};
for (const line of lines || []) {
const mapping = extractPlaylistMapping(line);
if (mapping && Number.isFinite(mapping.titleId) && mapping.titleId >= 0) {
const title = ensureTitle(mapping.titleId);
title.playlistIdFromMap = normalizePlaylistId(mapping.playlistId);
}
const sinfo = String(line || '').match(/^SINFO:(\d+),(\d+),(\d+),\d+,"([^"]*)"/i);
if (sinfo) {
const titleId = Number(sinfo[1]);
const streamIndex = Number(sinfo[2]);
const fieldId = Number(sinfo[3]);
const value = String(sinfo[4] || '').trim();
if (
Number.isFinite(titleId) && titleId >= 0
&& Number.isFinite(streamIndex) && streamIndex >= 0
&& Number.isFinite(fieldId)
) {
const title = ensureTitle(titleId);
const streamKey = String(Math.trunc(streamIndex));
if (!title.streams[streamKey]) {
title.streams[streamKey] = {
index: Math.trunc(streamIndex),
type: null,
language: null,
languageLabel: null,
format: null,
channels: null,
description: null
};
}
const stream = title.streams[streamKey];
if (fieldId === 1) {
const lowered = value.toLowerCase();
if (lowered.includes('audio')) {
stream.type = 'audio';
} else if (lowered.includes('subtitle') || lowered.includes('untertitel') || lowered.includes('text')) {
stream.type = 'subtitle';
}
} else if (fieldId === 3) {
stream.language = value ? value.toLowerCase() : null;
} else if (fieldId === 4) {
stream.languageLabel = value || null;
} else if (fieldId === 6 || fieldId === 7) {
if (!stream.format || fieldId === 6) {
stream.format = value || null;
}
} else if (fieldId === 14 || fieldId === 40) {
if (!stream.channels || fieldId === 40) {
stream.channels = value || null;
}
} else if (fieldId === 30) {
stream.description = value || null;
}
}
continue;
}
const tinfo = String(line || '').match(/^TINFO:(\d+),(\d+),\d+,"([^"]*)"/i);
if (!tinfo) {
continue;
}
const titleId = Number(tinfo[1]);
const fieldId = Number(tinfo[2]);
const value = String(tinfo[3] || '').trim();
if (!Number.isFinite(titleId) || titleId < 0) {
continue;
}
const title = ensureTitle(titleId);
title.fields[fieldId] = value;
if (fieldId === 16) {
const fromField = normalizePlaylistId(value);
if (fromField) {
title.playlistIdFromField16 = fromField;
}
continue;
}
if (fieldId === 26) {
const segmentNumbers = parseSegmentNumbers(value);
if (segmentNumbers.length > 0) {
title.segmentNumbers = segmentNumbers;
}
continue;
}
if (fieldId === 9) {
const seconds = parseDurationSeconds(value);
if (seconds > 0) {
title.durationSeconds = seconds;
title.durationLabel = formatDuration(seconds);
}
continue;
}
if (fieldId === 10 || fieldId === 11) {
const bytes = parseSizeBytes(value);
if (bytes > 0) {
title.sizeBytes = bytes;
title.sizeLabel = value;
}
continue;
}
if (fieldId === 8 || fieldId === 7) {
const chapters = Number(value);
if (Number.isFinite(chapters) && chapters >= 0) {
title.chapters = Math.trunc(chapters);
}
}
if (!title.durationSeconds && /\d+:\d{2}:\d{2}/.test(value)) {
const seconds = parseDurationSeconds(value);
if (seconds > 0) {
title.durationSeconds = seconds;
title.durationLabel = formatDuration(seconds);
}
}
if (!title.sizeBytes && /(kb|mb|gb|tb)\b/i.test(value)) {
const bytes = parseSizeBytes(value);
if (bytes > 0) {
title.sizeBytes = bytes;
title.sizeLabel = value;
}
}
}
return Array.from(titleMap.values())
.map((item) => {
const playlistId = normalizePlaylistId(item.playlistId);
const playlistIdFromMap = normalizePlaylistId(item.playlistIdFromMap);
const playlistIdFromField16 = normalizePlaylistId(item.playlistIdFromField16);
const field16Raw = String(item?.fields?.[16] || '').trim();
const hasField16 = field16Raw.length > 0;
const field16LooksPlaylist = /\.mpls$/i.test(field16Raw) || /^\d{1,5}$/i.test(field16Raw);
const field16LooksClip = /\.(?:m2ts|m2t|mts)$/i.test(field16Raw);
let resolvedPlaylistId = null;
// TINFO:16 is part of the final title block and is more reliable than MSG:3307
// lines, which can include pre-dedup title ids.
if (field16LooksPlaylist && playlistIdFromField16) {
resolvedPlaylistId = playlistIdFromField16;
} else if (!hasField16) {
resolvedPlaylistId = playlistIdFromField16 || playlistIdFromMap || playlistId;
} else if (!field16LooksClip && playlistIdFromField16) {
resolvedPlaylistId = playlistIdFromField16;
}
const segmentNumbers = Array.isArray(item.segmentNumbers) ? item.segmentNumbers : [];
const segmentFiles = segmentNumbers
.map((number) => toSegmentFile(number))
.filter(Boolean);
const streams = item?.streams && typeof item.streams === 'object' ? Object.values(item.streams) : [];
const sortedStreams = streams
.filter((stream) => Number.isFinite(Number(stream?.index)))
.sort((a, b) => Number(a.index) - Number(b.index));
const audioTracks = sortedStreams
.filter((stream) => String(stream?.type || '').toLowerCase() === 'audio')
.map((stream) => ({
id: Number(stream.index) + 1,
sourceTrackId: Number(stream.index) + 1,
language: stream.language || 'und',
languageLabel: stream.languageLabel || stream.language || 'und',
title: stream.description || null,
format: stream.format || null,
channels: stream.channels || null
}));
const subtitleTracks = sortedStreams
.filter((stream) => String(stream?.type || '').toLowerCase() === 'subtitle')
.map((stream) => ({
id: Number(stream.index) + 1,
sourceTrackId: Number(stream.index) + 1,
language: stream.language || 'und',
languageLabel: stream.languageLabel || stream.language || 'und',
title: stream.description || null,
format: stream.format || null,
channels: null
}));
const { streams: _omitStreams, ...restItem } = item;
return {
...restItem,
playlistId: resolvedPlaylistId,
playlistIdFromMap,
playlistIdFromField16,
playlistFile: resolvedPlaylistId ? `${resolvedPlaylistId}.mpls` : null,
durationLabel: item.durationLabel || formatDuration(item.durationSeconds),
audioTracks,
subtitleTracks,
audioTrackCount: audioTracks.length,
subtitleTrackCount: subtitleTracks.length,
segmentNumbers,
segmentFiles
};
})
.sort((a, b) => a.titleId - b.titleId);
}
function uniqueOrdered(values) {
const seen = new Set();
const output = [];
for (const value of values || []) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
output.push(String(value).trim());
}
return output;
}
function parseReportedTitleCount(lines) {
for (let index = (Array.isArray(lines) ? lines.length : 0) - 1; index >= 0; index -= 1) {
const line = String(lines[index] || '').trim();
const match = line.match(/^TCOUNT:(\d+)/i);
if (!match) {
continue;
}
const value = Number(match[1]);
if (Number.isFinite(value) && value >= 0) {
return Math.trunc(value);
}
}
return null;
}
function likelyRawMirrorOfPlaylist(rawTitle, playlistTitle) {
const rawDuration = Number(rawTitle?.durationSeconds || 0);
const playlistDuration = Number(playlistTitle?.durationSeconds || 0);
const rawSize = Number(rawTitle?.sizeBytes || 0);
const playlistSize = Number(playlistTitle?.sizeBytes || 0);
if (!Number.isFinite(rawDuration) || !Number.isFinite(playlistDuration) || rawDuration <= 0 || playlistDuration <= 0) {
return false;
}
if (Math.abs(rawDuration - playlistDuration) > RAW_MIRROR_DURATION_TOLERANCE_SECONDS) {
return false;
}
if (rawSize > 0 && playlistSize > 0) {
return Math.abs(rawSize - playlistSize) <= RAW_MIRROR_SIZE_TOLERANCE_BYTES;
}
return true;
}
function suppressRawMirrorCandidates(candidates) {
const rows = Array.isArray(candidates) ? candidates : [];
if (rows.length <= 1) {
return rows;
}
const playlistRows = rows.filter((item) => normalizePlaylistId(item?.playlistId));
if (playlistRows.length === 0) {
return rows;
}
return rows.filter((item) => {
if (normalizePlaylistId(item?.playlistId)) {
return true;
}
return !playlistRows.some((playlistRow) => likelyRawMirrorOfPlaylist(item, playlistRow));
});
}
function buildSimilarityGroups(candidates, durationSimilaritySeconds) {
const list = Array.isArray(candidates) ? [...candidates] : [];
const tolerance = Math.max(0, Math.round(Number(durationSimilaritySeconds || 0)));
const groups = [];
const used = new Set();
for (let i = 0; i < list.length; i += 1) {
if (used.has(i)) {
continue;
}
const base = list[i];
const currentGroup = [base];
used.add(i);
for (let j = i + 1; j < list.length; j += 1) {
if (used.has(j)) {
continue;
}
const candidate = list[j];
if (Math.abs(Number(candidate.durationSeconds || 0) - Number(base.durationSeconds || 0)) <= tolerance) {
currentGroup.push(candidate);
used.add(j);
}
}
if (currentGroup.length > 1) {
const sortedTitles = currentGroup
.slice()
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const referenceDuration = Number(sortedTitles[0]?.durationSeconds || 0);
groups.push({
durationSeconds: referenceDuration,
durationLabel: formatDuration(referenceDuration),
titles: sortedTitles
});
}
}
return groups.sort((a, b) =>
b.durationSeconds - a.durationSeconds || b.titles.length - a.titles.length
);
}
function computeSegmentMetrics(segmentNumbers) {
const numbers = Array.isArray(segmentNumbers)
? segmentNumbers.filter((value) => Number.isFinite(value)).map((value) => Math.trunc(value))
: [];
if (numbers.length === 0) {
return {
segmentCount: 0,
segmentNumbers: [],
directSequenceSteps: 0,
backwardJumps: 0,
largeJumps: 0,
alternatingJumps: 0,
alternatingPairs: 0,
alternatingRatio: 0,
sequenceCoherence: 0,
monotonicRatio: 0,
score: 0
};
}
let directSequenceSteps = 0;
let backwardJumps = 0;
let largeJumps = 0;
let alternatingJumps = 0;
let alternatingPairs = 0;
let prevDiff = null;
for (let i = 1; i < numbers.length; i += 1) {
const current = numbers[i - 1];
const next = numbers[i];
const diff = next - current;
if (next < current) {
backwardJumps += 1;
}
if (Math.abs(diff) > LARGE_JUMP_THRESHOLD) {
largeJumps += 1;
}
if (diff === 1) {
directSequenceSteps += 1;
}
if (prevDiff !== null) {
const largePair = Math.abs(prevDiff) > LARGE_JUMP_THRESHOLD && Math.abs(diff) > LARGE_JUMP_THRESHOLD;
if (largePair) {
alternatingPairs += 1;
const signChanged = (prevDiff < 0 && diff > 0) || (prevDiff > 0 && diff < 0);
if (signChanged) {
alternatingJumps += 1;
}
}
}
prevDiff = diff;
}
const transitions = Math.max(1, numbers.length - 1);
const sequenceCoherence = Number((directSequenceSteps / transitions).toFixed(4));
const alternatingRatio = alternatingPairs > 0
? Number((alternatingJumps / alternatingPairs).toFixed(4))
: 0;
const score = (directSequenceSteps * 2) - (backwardJumps * 3) - (largeJumps * 2);
return {
segmentCount: numbers.length,
segmentNumbers: numbers,
directSequenceSteps,
backwardJumps,
largeJumps,
alternatingJumps,
alternatingPairs,
alternatingRatio,
sequenceCoherence,
monotonicRatio: sequenceCoherence,
score
};
}
function buildEvaluationLabel(metrics) {
if (!metrics || metrics.segmentCount === 0) {
return 'Keine Segmentliste aus TINFO:26 verfügbar';
}
if (metrics.alternatingRatio >= 0.55 && metrics.alternatingPairs >= 3) {
return 'Fake-Struktur (alternierendes Sprungmuster)';
}
if (metrics.backwardJumps > 0 || metrics.largeJumps > 0) {
return 'Auffällige Segmentreihenfolge';
}
return 'wahrscheinlich korrekt (lineare Segmentfolge)';
}
function scoreCandidates(groupTitles) {
const titles = Array.isArray(groupTitles) ? groupTitles : [];
if (titles.length === 0) {
return [];
}
return titles
.map((title) => {
const metrics = computeSegmentMetrics(title.segmentNumbers);
const reasons = [
`sequence_steps=${metrics.directSequenceSteps}`,
`sequence_coherence=${metrics.sequenceCoherence.toFixed(3)}`,
`backward_jumps=${metrics.backwardJumps}`,
`large_jumps=${metrics.largeJumps}`,
`alternating_ratio=${metrics.alternatingRatio.toFixed(3)}`
];
return {
...title,
score: Number(metrics.score || 0),
reasons,
structuralMetrics: metrics,
evaluationLabel: buildEvaluationLabel(metrics)
};
})
.sort((a, b) =>
b.score - a.score
|| b.structuralMetrics.sequenceCoherence - a.structuralMetrics.sequenceCoherence
|| b.durationSeconds - a.durationSeconds
|| b.sizeBytes - a.sizeBytes
|| a.titleId - b.titleId
)
.map((item, index) => ({
...item,
recommended: index === 0
}));
}
function buildPlaylistSegmentMap(titles) {
const map = {};
for (const title of titles || []) {
const playlistId = normalizePlaylistId(title?.playlistId);
if (!playlistId || map[playlistId]) {
continue;
}
map[playlistId] = {
playlistId,
playlistFile: `${playlistId}.mpls`,
playlistPath: `BDMV/PLAYLIST/${playlistId}.mpls`,
segmentCommand: `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
segmentFiles: Array.isArray(title?.segmentFiles) ? title.segmentFiles : [],
segmentNumbers: Array.isArray(title?.segmentNumbers) ? title.segmentNumbers : [],
fileExists: null,
source: 'makemkv_tinfo_26'
};
}
return map;
}
function buildPlaylistToTitleIdMap(titles) {
const map = {};
for (const title of titles || []) {
const playlistId = normalizePlaylistId(title?.playlistId || title?.playlistFile || null);
const titleId = Number(title?.titleId);
if (!playlistId || !Number.isFinite(titleId) || titleId < 0) {
continue;
}
const normalizedTitleId = Math.trunc(titleId);
if (map[playlistId] === undefined) {
map[playlistId] = normalizedTitleId;
}
const playlistFile = `${playlistId}.mpls`;
if (map[playlistFile] === undefined) {
map[playlistFile] = normalizedTitleId;
}
}
return map;
}
function extractWarningLines(lines) {
return (Array.isArray(lines) ? lines : [])
.filter((line) => /warn|warning|error|fehler|decode|decoder|timeout|corrupt/i.test(String(line || '')))
.slice(0, 40)
.map((line) => String(line || '').slice(0, 260));
}
function extractPlaylistMismatchWarnings(titles) {
return (Array.isArray(titles) ? titles : [])
.filter((title) => title?.playlistIdFromMap && title?.playlistIdFromField16)
.filter((title) => String(title.playlistIdFromMap) !== String(title.playlistIdFromField16))
.slice(0, 25)
.map((title) =>
`Titel #${title.titleId}: MSG-Playlist=${title.playlistIdFromMap}.mpls, TINFO16=${title.playlistIdFromField16}.mpls (TINFO16 bevorzugt)`
);
}
function analyzePlaylistObfuscation(lines, minLengthMinutes = 60, options = {}) {
const parsedTitles = parseAnalyzeTitles(lines);
const reportedTitleCount = parseReportedTitleCount(lines);
const minSeconds = Math.max(0, Math.round(Number(minLengthMinutes || 0) * 60));
const durationSimilaritySeconds = Math.max(
0,
Math.round(Number(options.durationSimilaritySeconds || DEFAULT_DURATION_SIMILARITY_SECONDS))
);
const candidatesRaw = parsedTitles
.filter((item) => Number(item.durationSeconds || 0) >= minSeconds)
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const candidates = suppressRawMirrorCandidates(candidatesRaw)
.slice()
.sort((a, b) => b.durationSeconds - a.durationSeconds || b.sizeBytes - a.sizeBytes || a.titleId - b.titleId);
const playlistBackedCandidates = candidates
.filter((item) => normalizePlaylistId(item?.playlistId));
const candidatePlaylistsAll = uniqueOrdered(
playlistBackedCandidates.map((item) => item.playlistId).filter(Boolean)
);
const similarityGroups = buildSimilarityGroups(playlistBackedCandidates, durationSimilaritySeconds);
const obfuscationDetected = similarityGroups.length > 0;
const multipleCandidatesDetected = candidatePlaylistsAll.length > 1;
const manualDecisionRequired = multipleCandidatesDetected;
const decisionPool = manualDecisionRequired ? playlistBackedCandidates : [];
const evaluatedCandidates = decisionPool.length > 0 ? scoreCandidates(decisionPool) : [];
const recommendation = evaluatedCandidates[0] || null;
const candidatePlaylists = manualDecisionRequired ? candidatePlaylistsAll : [];
const playlistSegments = buildPlaylistSegmentMap(decisionPool);
const playlistToTitleId = buildPlaylistToTitleIdMap(parsedTitles);
return {
generatedAt: new Date().toISOString(),
reportedTitleCount,
minLengthMinutes: Number(minLengthMinutes || 0),
minLengthSeconds: minSeconds,
durationSimilaritySeconds,
titles: parsedTitles,
candidates,
duplicateDurationGroups: similarityGroups,
obfuscationDetected,
manualDecisionRequired,
manualDecisionReason: manualDecisionRequired
? (obfuscationDetected ? 'multiple_similar_candidates' : 'multiple_candidates_after_min_length')
: null,
candidatePlaylists,
candidatePlaylistFiles: candidatePlaylists.map((item) => `${item}.mpls`),
playlistToTitleId,
recommendation: recommendation
? {
titleId: recommendation.titleId,
playlistId: recommendation.playlistId,
score: Number(recommendation.score || 0),
reason: Array.isArray(recommendation.reasons) && recommendation.reasons.length > 0
? recommendation.reasons.join('; ')
: 'höchster Struktur-Score'
}
: null,
evaluatedCandidates,
playlistSegments,
structuralAnalysis: {
method: 'makemkv_tinfo_26',
sourceCommand: 'makemkvcon -r info disc:0 --robot',
analyzedPlaylists: Object.keys(playlistSegments).length
},
warningLines: [
...extractWarningLines(lines),
...(reportedTitleCount !== null && reportedTitleCount !== parsedTitles.length
? [`Titel-Anzahl abweichend: TCOUNT=${reportedTitleCount}, geparst=${parsedTitles.length}`]
: []),
...extractPlaylistMismatchWarnings(parsedTitles)
].slice(0, 60)
};
}
module.exports = {
normalizePlaylistId,
analyzePlaylistObfuscation
};
+126
View File
@@ -0,0 +1,126 @@
function clampPercent(value) {
if (Number.isNaN(value) || value === Infinity || value === -Infinity) {
return null;
}
return Math.max(0, Math.min(100, Number(value.toFixed(2))));
}
function parseGenericPercent(line) {
const match = line.match(/(\d{1,3}(?:\.\d+)?)\s?%/);
if (!match) {
return null;
}
return clampPercent(Number(match[1]));
}
function parseEta(line) {
const etaMatch = line.match(/ETA\s+([0-9:.hms-]+)/i);
if (!etaMatch) {
return null;
}
const value = etaMatch[1].trim();
if (!value || value.includes('--')) {
return null;
}
return value.replace(/[),.;]+$/, '');
}
function parseMakeMkvProgress(line) {
const prgv = line.match(/PRGV:(\d+),(\d+),(\d+)/);
if (prgv) {
// Format: PRGV:current,total,max (official makemkv docs)
// current = per-file progress, total = overall progress across all files
const total = Number(prgv[2]);
const max = Number(prgv[3]);
if (max > 0) {
return { percent: clampPercent((total / max) * 100), eta: null };
}
}
const percent = parseGenericPercent(line);
if (percent !== null) {
return { percent, eta: null };
}
return null;
}
function parseHandBrakeProgress(line) {
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
const match = normalized.match(/Encoding:\s*(?:task\s+\d+\s+of\s+\d+,\s*)?(\d+(?:\.\d+)?)\s?%/i);
if (match) {
return {
percent: clampPercent(Number(match[1])),
eta: parseEta(normalized)
};
}
return null;
}
function parseCdParanoiaProgress(line) {
// cdparanoia writes progress to stderr with \r overwrites.
// Formats seen in the wild:
// "Ripping track 1 of 12 progress: ( 34.21%)"
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])"
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
const progressMatch = normalized.match(/progress:\s*\(\s*(\d+(?:\.\d+)?)\s*%\s*\)/i);
if (progressMatch) {
const trackMatch = normalized.match(/track\s+(\d+)\s+of\s+(\d+)/i);
const currentTrack = trackMatch ? Number(trackMatch[1]) : null;
const totalTracks = trackMatch ? Number(trackMatch[2]) : null;
return {
percent: clampPercent(Number(progressMatch[1])),
currentTrack,
totalTracks,
eta: null
};
}
// "###: 14 [wrote ] (track 3 of 12 [ 0:12.33])" style no clear percent here
// Fall back to generic percent match
const percent = parseGenericPercent(normalized);
if (percent !== null) {
return { percent, currentTrack: null, totalTracks: null, eta: null };
}
return null;
}
function parseMkvmergeProgress(line) {
const normalized = String(line || '').replace(/\s+/g, ' ').trim();
if (!normalized) {
return null;
}
// Common mkvmerge output examples:
// "Progress: 42%"
// "#GUI#progress 42%"
const explicitMatch = normalized.match(/(?:^|\s)(?:progress:?|#GUI#progress)\s*(\d{1,3}(?:\.\d+)?)\s*%/i);
if (explicitMatch) {
return {
percent: clampPercent(Number(explicitMatch[1])),
eta: null
};
}
const percent = parseGenericPercent(normalized);
if (percent !== null) {
return { percent, eta: null };
}
return null;
}
module.exports = {
parseMakeMkvProgress,
parseHandBrakeProgress,
parseCdParanoiaProgress,
parseMkvmergeProgress
};
+112
View File
@@ -0,0 +1,112 @@
function parseJson(value, fallback = null) {
if (!value) {
return fallback;
}
try {
return JSON.parse(value);
} catch (error) {
return fallback;
}
}
function toBoolean(value) {
if (typeof value === 'boolean') {
return value;
}
if (value === 'true' || value === '1' || value === 1) {
return true;
}
if (value === 'false' || value === '0' || value === 0) {
return false;
}
return Boolean(value);
}
function normalizeValueByType(type, rawValue) {
if (rawValue === undefined || rawValue === null) {
return null;
}
switch (type) {
case 'number':
return Number(rawValue);
case 'boolean':
return toBoolean(rawValue);
case 'select':
case 'string':
case 'path':
default:
return String(rawValue);
}
}
function serializeValueByType(type, value) {
if (value === undefined || value === null) {
return null;
}
if (type === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
}
function validateSetting(schemaItem, value) {
const errors = [];
const normalized = normalizeValueByType(schemaItem.type, value);
if (schemaItem.required) {
const emptyString = typeof normalized === 'string' && normalized.trim().length === 0;
if (normalized === null || emptyString) {
errors.push('Wert ist erforderlich.');
}
}
if (schemaItem.type === 'number' && normalized !== null) {
if (Number.isNaN(normalized)) {
errors.push('Ungültige Zahl.');
} else {
const rules = parseJson(schemaItem.validation_json, {});
if (typeof rules.min === 'number' && normalized < rules.min) {
errors.push(`Wert muss >= ${rules.min} sein.`);
}
if (typeof rules.max === 'number' && normalized > rules.max) {
errors.push(`Wert muss <= ${rules.max} sein.`);
}
}
}
if (schemaItem.type === 'select' && normalized !== null) {
const options = parseJson(schemaItem.options_json, []);
const values = options.map((option) => option.value);
if (!values.includes(normalized)) {
errors.push('Ungültige Auswahl.');
}
}
if ((schemaItem.type === 'path' || schemaItem.type === 'string') && normalized !== null) {
const rules = parseJson(schemaItem.validation_json, {});
if (typeof rules.minLength === 'number' && normalized.length < rules.minLength) {
errors.push(`Wert muss mindestens ${rules.minLength} Zeichen haben.`);
}
}
return {
valid: errors.length === 0,
errors,
normalized
};
}
module.exports = {
parseJson,
normalizeValueByType,
serializeValueByType,
validateSetting,
toBoolean
};