Compare commits
6 Commits
9c0af285ea
...
3c694d06df
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c694d06df | |||
| cc20dc8120 | |||
| ca2bd76572 | |||
| 7d6c154909 | |||
| 52ef155c7c | |||
| a957dfea73 |
@@ -8,7 +8,8 @@
|
||||
"Bash(mkdocs build --strict)",
|
||||
"Read(//mnt/external/media/**)",
|
||||
"WebFetch(domain:www.makemkv.com)",
|
||||
"Bash(node --check backend/src/services/pipelineService.js)"
|
||||
"Bash(node --check backend/src/services/pipelineService.js)",
|
||||
"Bash(wc -l /home/michael/ripster/debug/backend/data/logs/backend/*.log)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +82,4 @@ Thumbs.db
|
||||
/scripts/
|
||||
/release.sh
|
||||
/Audible_Tool
|
||||
/AddOns
|
||||
@@ -9,3 +9,4 @@ LOG_LEVEL=debug
|
||||
DEFAULT_RAW_DIR=
|
||||
DEFAULT_MOVIE_DIR=
|
||||
DEFAULT_CD_DIR=
|
||||
DEFAULT_DOWNLOAD_DIR=
|
||||
|
||||
Generated
+964
-14
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-backend",
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "nodemon src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
|
||||
@@ -25,5 +25,6 @@ module.exports = {
|
||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||
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')
|
||||
defaultAudiobookDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_DIR, 'output', 'audiobooks'),
|
||||
defaultDownloadDir: resolveOutputPath(process.env.DEFAULT_DOWNLOAD_DIR, 'downloads')
|
||||
};
|
||||
|
||||
@@ -788,7 +788,8 @@ async function removeDeprecatedSettings(db) {
|
||||
'filename_template_bluray',
|
||||
'filename_template_dvd',
|
||||
'output_folder_template_bluray',
|
||||
'output_folder_template_dvd'
|
||||
'output_folder_template_dvd',
|
||||
'output_extension_audiobook'
|
||||
];
|
||||
for (const key of deprecatedKeys) {
|
||||
const schemaResult = await db.run('DELETE FROM settings_schema WHERE key = ?', [key]);
|
||||
@@ -906,11 +907,6 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('ffprobe_command', 'ffprobe')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3')`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
@@ -953,6 +949,18 @@ async function migrateSettingsSchemaMetadata(db) {
|
||||
VALUES ('movie_dir_audiobook_owner', 'Pfade', 'Eigentümer Audiobook Output-Ordner', 'string', 0, 'Eigentümer der encodierten Audiobook-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1155)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('movie_dir_audiobook_owner', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('download_dir', 'Pfade', 'Download ZIP-Ordner', 'path', 0, 'Zielordner für vorbereitete ZIP-Downloads. Leer = Standardpfad (data/downloads).', NULL, '[]', '{}', 118)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir', NULL)`);
|
||||
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('download_dir_owner', 'Pfade', 'Eigentümer Download ZIP-Ordner', 'string', 0, 'Eigentümer der vorbereiteten ZIP-Dateien im Format user:gruppe. Leer = Standardbenutzer des Dienstes.', NULL, '[]', '{}', 1185)`
|
||||
);
|
||||
await db.run(`INSERT OR IGNORE INTO settings_values (key, value) VALUES ('download_dir_owner', NULL)`);
|
||||
}
|
||||
|
||||
async function getDb() {
|
||||
|
||||
@@ -10,11 +10,13 @@ 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 wsService = require('./services/websocketService');
|
||||
const pipelineService = require('./services/pipelineService');
|
||||
const cronService = require('./services/cronService');
|
||||
const downloadService = require('./services/downloadService');
|
||||
const diskDetectionService = require('./services/diskDetectionService');
|
||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||
const logger = require('./services/logger').child('BOOT');
|
||||
@@ -26,6 +28,7 @@ async function start() {
|
||||
await initDatabase();
|
||||
await pipelineService.init();
|
||||
await cronService.init();
|
||||
await downloadService.init();
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: corsOrigin }));
|
||||
@@ -39,6 +42,7 @@ async function start() {
|
||||
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/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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();
|
||||
logger.info('post:downloads:history', {
|
||||
reqId: req.reqId,
|
||||
jobId,
|
||||
target
|
||||
});
|
||||
const result = await downloadService.enqueueHistoryJob(jobId, target);
|
||||
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;
|
||||
@@ -7,6 +7,8 @@ const pipelineService = require('../services/pipelineService');
|
||||
const diskDetectionService = require('../services/diskDetectionService');
|
||||
const hardwareMonitorService = require('../services/hardwareMonitorService');
|
||||
const logger = require('../services/logger').child('PIPELINE_ROUTE');
|
||||
const activationBytesService = require('../services/activationBytesService');
|
||||
const { getDb } = require('../db/database');
|
||||
|
||||
const router = express.Router();
|
||||
const audiobookUpload = multer({
|
||||
@@ -155,6 +157,25 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 logger = require('../services/logger').child('SETTINGS_ROUTE');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -375,4 +376,28 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
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 });
|
||||
})
|
||||
);
|
||||
|
||||
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 };
|
||||
@@ -618,6 +618,7 @@ function buildEncodeCommand(ffmpegCommand, inputPath, outputPath, outputFormat =
|
||||
const extra = options && typeof options === 'object' ? options : {};
|
||||
const commonArgs = [
|
||||
'-y',
|
||||
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
|
||||
'-i', inputPath
|
||||
];
|
||||
if (extra.chapterMetadataPath) {
|
||||
@@ -657,11 +658,13 @@ function buildChapterEncodeCommand(
|
||||
formatOptions = {},
|
||||
metadata = {},
|
||||
chapter = {},
|
||||
chapterTotal = 1
|
||||
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',
|
||||
@@ -679,6 +682,7 @@ function buildChapterEncodeCommand(
|
||||
cmd,
|
||||
args: [
|
||||
'-y',
|
||||
...(extra.activationBytes ? ['-activation_bytes', extra.activationBytes] : []),
|
||||
'-i', inputPath,
|
||||
'-ss', formatSecondsArg(safeChapter?.startSeconds),
|
||||
'-t', formatSecondsArg(durationSeconds),
|
||||
|
||||
@@ -10,6 +10,7 @@ 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)
|
||||
@@ -252,33 +253,57 @@ async function runCronJob(job) {
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const { spawn } = require('child_process');
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
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 = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
||||
output = [stdout, stderr].filter(Boolean).join('\n');
|
||||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||||
success = result.code === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${result.code}`;
|
||||
success = exitCode === 0;
|
||||
if (!success) errorMessage = `Exit-Code ${exitCode}`;
|
||||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: result.code,
|
||||
exitCode,
|
||||
message: success ? null : errorMessage,
|
||||
output: output || null,
|
||||
stdout: result.stdout || null,
|
||||
stderr: result.stderr || null,
|
||||
stdout: stdout || null,
|
||||
stderr: stderr || null,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
errorMessage: success ? null : (errorMessage || null)
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
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();
|
||||
|
||||
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') {
|
||||
item.status = 'failed';
|
||||
item.errorMessage = 'ZIP-Erstellung wurde durch einen Server-Neustart unterbrochen.';
|
||||
item.finishedAt = nowIso;
|
||||
changed = true;
|
||||
await this._safeUnlink(item.partialPath);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_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) {
|
||||
await this.init();
|
||||
const descriptor = await historyService.getJobArchiveDescriptor(jobId, target);
|
||||
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();
|
||||
@@ -764,6 +764,36 @@ function normalizeJobIdValue(value) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeArchiveTarget(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (raw === 'raw') {
|
||||
return 'raw';
|
||||
}
|
||||
if (raw === 'output' || raw === 'movie' || raw === 'encode') {
|
||||
return 'output';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeArchiveNamePart(value, fallback = 'job') {
|
||||
const normalized = String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\x00-\x7F]+/g, '');
|
||||
const safe = normalized
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^[_-]+|[_-]+$/g, '')
|
||||
.slice(0, 80);
|
||||
return safe || fallback;
|
||||
}
|
||||
|
||||
function buildJobArchiveName(job, target) {
|
||||
const jobId = normalizeJobIdValue(job?.id) || 'unknown';
|
||||
const titlePart = sanitizeArchiveNamePart(job?.title || job?.detected_title || '', 'job');
|
||||
const targetPart = target === 'raw' ? 'raw' : 'encode';
|
||||
return `job-${jobId}-${titlePart}-${targetPart}.zip`;
|
||||
}
|
||||
|
||||
function parseSourceJobIdFromPlan(encodePlanRaw) {
|
||||
const plan = parseInfoFromValue(encodePlanRaw, null);
|
||||
const sourceJobId = normalizeJobIdValue(plan?.sourceJobId);
|
||||
@@ -1427,6 +1457,79 @@ class HistoryService {
|
||||
};
|
||||
}
|
||||
|
||||
async getJobArchiveDescriptor(jobId, target) {
|
||||
const normalizedJobId = normalizeJobIdValue(jobId);
|
||||
if (!normalizedJobId) {
|
||||
const error = new Error('Ungültige Job-ID.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeArchiveTarget(target);
|
||||
if (!normalizedTarget) {
|
||||
const error = new Error('Ungültiges Download-Ziel. Erlaubt sind raw und output.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [job, settings] = await Promise.all([
|
||||
this.getJobById(normalizedJobId),
|
||||
settingsService.getSettingsMap()
|
||||
]);
|
||||
|
||||
if (!job) {
|
||||
const error = new Error('Job nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
|
||||
const sourcePath = normalizedTarget === 'raw'
|
||||
? resolvedPaths.effectiveRawPath
|
||||
: resolvedPaths.effectiveOutputPath;
|
||||
|
||||
if (!sourcePath) {
|
||||
const error = new Error(
|
||||
normalizedTarget === 'raw'
|
||||
? 'Kein RAW-Pfad für diesen Job vorhanden.'
|
||||
: 'Kein Output-Pfad für diesen Job vorhanden.'
|
||||
);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let sourceStat;
|
||||
try {
|
||||
sourceStat = await fs.promises.stat(sourcePath);
|
||||
} catch (_error) {
|
||||
const error = new Error(
|
||||
normalizedTarget === 'raw'
|
||||
? 'RAW-Pfad wurde nicht gefunden.'
|
||||
: 'Output-Pfad wurde nicht gefunden.'
|
||||
);
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!sourceStat.isDirectory() && !sourceStat.isFile()) {
|
||||
const error = new Error('Nur Dateien oder Verzeichnisse können als ZIP heruntergeladen werden.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
jobId: normalizedJobId,
|
||||
displayTitle: buildJobDisplayTitle(job),
|
||||
target: normalizedTarget,
|
||||
sourcePath,
|
||||
sourceType: sourceStat.isDirectory() ? 'directory' : 'file',
|
||||
sourceMtimeMs: Number(sourceStat.mtimeMs || 0),
|
||||
sourceModifiedAt: sourceStat.mtime ? sourceStat.mtime.toISOString() : null,
|
||||
entryName: path.basename(sourcePath) || (normalizedTarget === 'raw' ? 'raw' : 'output'),
|
||||
archiveName: buildJobArchiveName(job, normalizedTarget)
|
||||
};
|
||||
}
|
||||
|
||||
async getDatabaseRows(filters = {}) {
|
||||
const jobs = await this.getJobs(filters);
|
||||
return jobs.map((job) => ({
|
||||
|
||||
@@ -25,6 +25,7 @@ const { analyzePlaylistObfuscation, normalizePlaylistId } = require('../utils/pl
|
||||
const { errorToMeta } = require('../utils/errorMeta');
|
||||
const userPresetService = require('./userPresetService');
|
||||
const thumbnailService = require('./thumbnailService');
|
||||
const activationBytesService = require('./activationBytesService');
|
||||
|
||||
const RUNNING_STATES = new Set(['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING']);
|
||||
const REVIEW_REFRESH_SETTING_PREFIXES = [
|
||||
@@ -736,7 +737,7 @@ function buildAudiobookOutputConfig(settings, job, makemkvInfo = null, encodePla
|
||||
|| audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE
|
||||
).trim() || audiobookService.DEFAULT_AUDIOBOOK_CHAPTER_OUTPUT_TEMPLATE;
|
||||
const outputFormat = audiobookService.normalizeOutputFormat(
|
||||
encodePlan?.format || settings?.output_extension || 'mp3'
|
||||
encodePlan?.format || 'm4b'
|
||||
);
|
||||
const numericJobId = Number(fallbackJobId || job?.id || 0);
|
||||
const incompleteFolder = Number.isFinite(numericJobId) && numericJobId > 0
|
||||
@@ -798,6 +799,28 @@ function truncateLine(value, max = 180) {
|
||||
return `${raw.slice(0, max)}...`;
|
||||
}
|
||||
|
||||
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 extractProgressDetail(source, line) {
|
||||
const text = truncateLine(line, 220);
|
||||
if (!text) {
|
||||
@@ -3250,26 +3273,43 @@ function extractHandBrakeTrackSelectionFromPlan(encodePlan, inputPath = null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const audioTrackIds = normalizeTrackIdList(
|
||||
(Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedForEncode))
|
||||
.map((track) => track?.sourceTrackId ?? track?.id)
|
||||
);
|
||||
const subtitleTrackIds = normalizeTrackIdList(
|
||||
(Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedForEncode))
|
||||
.map((track) => track?.sourceTrackId ?? track?.id)
|
||||
);
|
||||
const selectedSubtitleTracks = (Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [])
|
||||
.filter((track) => Boolean(track?.selectedForEncode));
|
||||
const allAudioTracks = Array.isArray(encodeTitle.audioTracks) ? encodeTitle.audioTracks : [];
|
||||
const allSubtitleTracks = Array.isArray(encodeTitle.subtitleTracks) ? encodeTitle.subtitleTracks : [];
|
||||
|
||||
// manualTrackSelection stores validated track?.id values written by applyManualTrackSelectionToPlan.
|
||||
// Use it as the authoritative source for audio/subtitle IDs. Fall back to the selectedForEncode
|
||||
// flags on the track objects when the field is absent (e.g. auto-selected plans without user review).
|
||||
// Always resolve through track?.id – never sourceTrackId, which may hold MakeMKV stream IDs
|
||||
// (e.g. 189) that have no relation to HandBrake's 1-indexed track positions.
|
||||
const manualSelection = plan.manualTrackSelection && typeof plan.manualTrackSelection === 'object'
|
||||
? plan.manualTrackSelection
|
||||
: null;
|
||||
const manualTitleId = normalizeReviewTitleId(manualSelection?.titleId);
|
||||
const manualMatchesTitle = !manualTitleId || !encodeInputTitleId || manualTitleId === encodeInputTitleId;
|
||||
|
||||
const audioTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.audioTrackIds))
|
||||
? normalizeTrackIdList(manualSelection.audioTrackIds)
|
||||
: normalizeTrackIdList(allAudioTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id));
|
||||
|
||||
const subtitleTrackIds = (manualMatchesTitle && Array.isArray(manualSelection?.subtitleTrackIds))
|
||||
? normalizeTrackIdList(manualSelection.subtitleTrackIds)
|
||||
: normalizeTrackIdList(allSubtitleTracks.filter((t) => Boolean(t?.selectedForEncode)).map((t) => t?.id));
|
||||
|
||||
// Resolve burn/default/forced attributes from the actual track objects, matched by track?.id.
|
||||
const subtitleTrackIdSet = new Set(subtitleTrackIds.map(String));
|
||||
const selectedSubtitleTracks = allSubtitleTracks.filter((t) => {
|
||||
const tid = normalizeTrackIdList([t?.id])[0];
|
||||
return tid !== undefined && subtitleTrackIdSet.has(String(tid));
|
||||
});
|
||||
|
||||
const subtitleBurnTrackId = normalizeTrackIdList(
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.sourceTrackId ?? track?.id)
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.burnIn)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
const subtitleDefaultTrackId = normalizeTrackIdList(
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.sourceTrackId ?? track?.id)
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.defaultTrack)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
const subtitleForcedTrackId = normalizeTrackIdList(
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.forced)).map((track) => track?.sourceTrackId ?? track?.id)
|
||||
selectedSubtitleTracks.filter((track) => Boolean(track?.forced)).map((track) => track?.id)
|
||||
)[0] || null;
|
||||
const subtitleForcedOnly = selectedSubtitleTracks.some((track) => Boolean(track?.forcedOnly));
|
||||
|
||||
@@ -4836,42 +4876,59 @@ class PipelineService extends EventEmitter {
|
||||
let prepared = null;
|
||||
try {
|
||||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'queue', scriptId: script.id, scriptName: script.name });
|
||||
const { spawn } = require('child_process');
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(prepared.cmd, prepared.args, { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.length > 12000) {
|
||||
stdout = `${stdout.slice(0, 12000)}\n...[truncated]`;
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.length > 12000) {
|
||||
stderr = `${stderr.slice(0, 12000)}\n...[truncated]`;
|
||||
}
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
logger.info('queue:script:done', { scriptId: script.id, exitCode: code });
|
||||
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
||||
const success = Number(code) === 0;
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: Number.isFinite(Number(code)) ? Number(code) : null,
|
||||
message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${code})`,
|
||||
output: output || null,
|
||||
stdout: stdout || null,
|
||||
stderr: stderr || null,
|
||||
errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${code})`
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
const processHandle = spawnTrackedProcess({
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
context: { source: 'queue', scriptId: script.id },
|
||||
onStdoutLine: (line) => {
|
||||
const next = appendTailText(stdout, line);
|
||||
stdout = next.value;
|
||||
stdoutTruncated = stdoutTruncated || next.truncated;
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
const next = appendTailText(stderr, line);
|
||||
stderr = next.value;
|
||||
stderrTruncated = stderrTruncated || next.truncated;
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
let exitCode = 0;
|
||||
let runError = null;
|
||||
try {
|
||||
const result = await processHandle.promise;
|
||||
exitCode = Number.isFinite(Number(result?.code)) ? Number(result.code) : 0;
|
||||
} catch (error) {
|
||||
runError = error;
|
||||
exitCode = Number.isFinite(Number(error?.code)) ? Number(error.code) : null;
|
||||
if (exitCode === null) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('queue:script:done', { scriptId: script.id, exitCode });
|
||||
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
||||
const success = Number(exitCode) === 0;
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: success ? 'success' : 'error',
|
||||
success,
|
||||
outcome: success ? 'success' : 'error',
|
||||
exitCode: Number.isFinite(Number(exitCode)) ? Number(exitCode) : null,
|
||||
message: success ? 'Queue-Skript abgeschlossen' : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`,
|
||||
output: output || null,
|
||||
stdout: stdout || null,
|
||||
stderr: stderr || null,
|
||||
stdoutTruncated,
|
||||
stderrTruncated,
|
||||
errorMessage: success ? null : `Queue-Skript fehlgeschlagen (Exit ${exitCode ?? 'n/a'})`
|
||||
});
|
||||
if (runError && !success) {
|
||||
logger.warn('queue:script:exit-nonzero', { scriptId: script.id, exitCode });
|
||||
}
|
||||
} catch (err) {
|
||||
runtimeActivityService.completeActivity(activityId, {
|
||||
status: 'error',
|
||||
@@ -8348,7 +8405,13 @@ class PipelineService extends EventEmitter {
|
||||
source: 'PRE_ENCODE_SCRIPT',
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
argsForLog: prepared.argsForLog
|
||||
argsForLog: prepared.argsForLog,
|
||||
onStdoutLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
succeeded += 1;
|
||||
results.push({ scriptId: script.id, scriptName: script.name, status: 'SUCCESS', runInfo });
|
||||
@@ -8359,7 +8422,11 @@ class PipelineService extends EventEmitter {
|
||||
outcome: 'success',
|
||||
exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
|
||||
message: 'Pre-Encode Skript erfolgreich',
|
||||
output: runOutput || null
|
||||
output: runOutput || null,
|
||||
stdout: runInfo?.stdoutTail || null,
|
||||
stderr: runInfo?.stderrTail || null,
|
||||
stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
|
||||
stderrTruncated: Boolean(runInfo?.stderrTruncated)
|
||||
});
|
||||
await historyService.appendLog(jobId, 'SYSTEM', `Pre-Encode Skript erfolgreich: ${script.name}`);
|
||||
if (progressTracker?.onStepComplete) {
|
||||
@@ -8377,7 +8444,11 @@ class PipelineService extends EventEmitter {
|
||||
cancelled,
|
||||
message: error?.message || 'Pre-Encode Skriptfehler',
|
||||
errorMessage: error?.message || 'Pre-Encode Skriptfehler',
|
||||
output: runOutput || null
|
||||
output: runOutput || null,
|
||||
stdout: runInfo?.stdoutTail || null,
|
||||
stderr: runInfo?.stderrTail || null,
|
||||
stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
|
||||
stderrTruncated: Boolean(runInfo?.stderrTruncated)
|
||||
});
|
||||
failed += 1;
|
||||
aborted = true;
|
||||
@@ -8509,7 +8580,13 @@ class PipelineService extends EventEmitter {
|
||||
source: 'POST_ENCODE_SCRIPT',
|
||||
cmd: prepared.cmd,
|
||||
args: prepared.args,
|
||||
argsForLog: prepared.argsForLog
|
||||
argsForLog: prepared.argsForLog,
|
||||
onStdoutLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stdout: line });
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
runtimeActivityService.appendActivityOutput(activityId, { stderr: line });
|
||||
}
|
||||
});
|
||||
|
||||
succeeded += 1;
|
||||
@@ -8526,7 +8603,11 @@ class PipelineService extends EventEmitter {
|
||||
outcome: 'success',
|
||||
exitCode: Number.isFinite(Number(runInfo?.exitCode)) ? Number(runInfo.exitCode) : null,
|
||||
message: 'Post-Encode Skript erfolgreich',
|
||||
output: runOutput || null
|
||||
output: runOutput || null,
|
||||
stdout: runInfo?.stdoutTail || null,
|
||||
stderr: runInfo?.stderrTail || null,
|
||||
stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
|
||||
stderrTruncated: Boolean(runInfo?.stderrTruncated)
|
||||
});
|
||||
await historyService.appendLog(
|
||||
jobId,
|
||||
@@ -8548,7 +8629,11 @@ class PipelineService extends EventEmitter {
|
||||
cancelled,
|
||||
message: error?.message || 'Post-Encode Skriptfehler',
|
||||
errorMessage: error?.message || 'Post-Encode Skriptfehler',
|
||||
output: runOutput || null
|
||||
output: runOutput || null,
|
||||
stdout: runInfo?.stdoutTail || null,
|
||||
stderr: runInfo?.stderrTail || null,
|
||||
stdoutTruncated: Boolean(runInfo?.stdoutTruncated),
|
||||
stderrTruncated: Boolean(runInfo?.stderrTruncated)
|
||||
});
|
||||
failed += 1;
|
||||
aborted = true;
|
||||
@@ -10532,7 +10617,9 @@ class PipelineService extends EventEmitter {
|
||||
collectStdoutLines = true,
|
||||
collectStderrLines = true,
|
||||
argsForLog = null,
|
||||
silent = false
|
||||
silent = false,
|
||||
onStdoutLine = null,
|
||||
onStderrLine = null
|
||||
}) {
|
||||
const normalizedJobId = this.normalizeQueueJobId(jobId) || Number(jobId) || jobId;
|
||||
const loggableArgs = Array.isArray(argsForLog) ? argsForLog : args;
|
||||
@@ -10552,6 +10639,10 @@ class PipelineService extends EventEmitter {
|
||||
exitCode: null,
|
||||
stdoutLines: 0,
|
||||
stderrLines: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
lastProgress: 0,
|
||||
eta: null,
|
||||
lastDetail: null,
|
||||
@@ -10576,6 +10667,10 @@ class PipelineService extends EventEmitter {
|
||||
exitCode: null,
|
||||
stdoutLines: 0,
|
||||
stderrLines: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
lastProgress: 0,
|
||||
eta: null,
|
||||
lastDetail: null,
|
||||
@@ -10625,6 +10720,16 @@ class PipelineService extends EventEmitter {
|
||||
collectLines.push(line);
|
||||
}
|
||||
void historyService.appendProcessLog(jobId, source, line);
|
||||
const nextStdout = appendTailText(runInfo.stdoutTail, line);
|
||||
runInfo.stdoutTail = nextStdout.value;
|
||||
runInfo.stdoutTruncated = runInfo.stdoutTruncated || nextStdout.truncated;
|
||||
if (typeof onStdoutLine === 'function') {
|
||||
try {
|
||||
onStdoutLine(line);
|
||||
} catch (_error) {
|
||||
// ignore observer failures for live runtime mirroring
|
||||
}
|
||||
}
|
||||
applyLine(line, false);
|
||||
},
|
||||
onStderrLine: (line) => {
|
||||
@@ -10632,6 +10737,16 @@ class PipelineService extends EventEmitter {
|
||||
collectLines.push(line);
|
||||
}
|
||||
void historyService.appendProcessLog(jobId, `${source}_ERR`, line);
|
||||
const nextStderr = appendTailText(runInfo.stderrTail, line);
|
||||
runInfo.stderrTail = nextStderr.value;
|
||||
runInfo.stderrTruncated = runInfo.stderrTruncated || nextStderr.truncated;
|
||||
if (typeof onStderrLine === 'function') {
|
||||
try {
|
||||
onStderrLine(line);
|
||||
} catch (_error) {
|
||||
// ignore observer failures for live runtime mirroring
|
||||
}
|
||||
}
|
||||
applyLine(line, true);
|
||||
}
|
||||
});
|
||||
@@ -10882,7 +10997,7 @@ class PipelineService extends EventEmitter {
|
||||
settings?.output_template || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE
|
||||
).trim() || audiobookService.DEFAULT_AUDIOBOOK_OUTPUT_TEMPLATE;
|
||||
const outputFormat = audiobookService.normalizeOutputFormat(
|
||||
requestedFormat || settings?.output_extension || 'mp3'
|
||||
requestedFormat || 'm4b'
|
||||
);
|
||||
const formatOptions = audiobookService.getDefaultFormatOptions(outputFormat);
|
||||
const ffprobeCommand = String(settings?.ffprobe_command || 'ffprobe').trim() || 'ffprobe';
|
||||
@@ -10946,6 +11061,24 @@ class PipelineService extends EventEmitter {
|
||||
stagedRawFilePath
|
||||
});
|
||||
|
||||
// Activation Bytes: Cache prüfen und Checksum am Job speichern
|
||||
let aaxChecksum = null;
|
||||
let aaxNeedsActivationBytes = false;
|
||||
try {
|
||||
const abResult = await activationBytesService.resolveActivationBytes(stagedRawFilePath);
|
||||
aaxChecksum = abResult.checksum;
|
||||
await historyService.updateJob(job.id, { aax_checksum: aaxChecksum });
|
||||
if (abResult.activationBytes) {
|
||||
await historyService.appendLog(job.id, 'SYSTEM', `Activation Bytes im Cache gefunden: checksum=${abResult.checksum}`);
|
||||
logger.info('audiobook:upload:activation-bytes', { jobId: job.id, checksum: abResult.checksum, source: 'cache' });
|
||||
} else {
|
||||
aaxNeedsActivationBytes = true;
|
||||
logger.info('audiobook:upload:activation-bytes-needed', { jobId: job.id, checksum: abResult.checksum });
|
||||
}
|
||||
} catch (abError) {
|
||||
logger.warn('audiobook:upload:activation-bytes-failed', { jobId: job.id, error: errorToMeta(abError) });
|
||||
}
|
||||
|
||||
let detectedAsin = null;
|
||||
let audnexChapters = [];
|
||||
try {
|
||||
@@ -11082,7 +11215,8 @@ class PipelineService extends EventEmitter {
|
||||
jobId: job.id,
|
||||
started: false,
|
||||
queued: false,
|
||||
stage: 'READY_TO_START'
|
||||
stage: 'READY_TO_START',
|
||||
...(aaxNeedsActivationBytes ? { needsActivationBytes: true, checksum: aaxChecksum } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11159,7 +11293,7 @@ class PipelineService extends EventEmitter {
|
||||
}
|
||||
|
||||
const format = audiobookService.normalizeOutputFormat(
|
||||
config?.format || encodePlan?.format || 'mp3'
|
||||
config?.format || encodePlan?.format || 'm4b'
|
||||
);
|
||||
const formatOptions = audiobookService.normalizeFormatOptions(
|
||||
format,
|
||||
@@ -11379,6 +11513,22 @@ class PipelineService extends EventEmitter {
|
||||
|
||||
let temporaryChapterMetadataPath = null;
|
||||
|
||||
// Activation Bytes für AAX-Dateien aus Cache lesen
|
||||
let encodeActivationBytes = null;
|
||||
if (path.extname(inputPath).toLowerCase() === '.aax') {
|
||||
try {
|
||||
const abResult = await activationBytesService.resolveActivationBytes(inputPath);
|
||||
encodeActivationBytes = abResult.activationBytes || null;
|
||||
if (!encodeActivationBytes) {
|
||||
throw new Error('Activation Bytes nicht im Cache – bitte zuerst über den Upload-Dialog eintragen');
|
||||
}
|
||||
logger.info('audiobook:encode:activation-bytes', { jobId, checksum: abResult.checksum });
|
||||
} catch (abError) {
|
||||
logger.error('audiobook:encode:activation-bytes-failed', { jobId, error: errorToMeta(abError) });
|
||||
throw abError;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let ffmpegRunInfo = null;
|
||||
if (isSplitOutput) {
|
||||
@@ -11429,7 +11579,8 @@ class PipelineService extends EventEmitter {
|
||||
formatOptions,
|
||||
metadata,
|
||||
chapter,
|
||||
outputFiles.length
|
||||
outputFiles.length,
|
||||
{ activationBytes: encodeActivationBytes }
|
||||
);
|
||||
const baseParser = audiobookService.buildProgressParser(chapter?.durationMs || 0);
|
||||
const scaledParser = baseParser
|
||||
@@ -11503,7 +11654,8 @@ class PipelineService extends EventEmitter {
|
||||
formatOptions,
|
||||
{
|
||||
chapterMetadataPath: temporaryChapterMetadataPath,
|
||||
metadata
|
||||
metadata,
|
||||
activationBytes: encodeActivationBytes
|
||||
}
|
||||
);
|
||||
logger.info('audiobook:encode:command', { jobId, cmd: ffmpegConfig.cmd, args: ffmpegConfig.args });
|
||||
|
||||
@@ -109,5 +109,6 @@ function spawnTrackedProcess({
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
spawnTrackedProcess
|
||||
spawnTrackedProcess,
|
||||
streamLines
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ 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();
|
||||
@@ -28,12 +29,52 @@ function normalizeText(value, { trim = true, maxChars = MAX_ACTIVITY_TEXT_CHARS
|
||||
return null;
|
||||
}
|
||||
if (text.length > maxChars) {
|
||||
const suffix = trim ? ' ...[gekürzt]' : '\n...[gekürzt]';
|
||||
text = `${text.slice(0, Math.max(0, maxChars - suffix.length))}${suffix}`;
|
||||
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 });
|
||||
@@ -61,6 +102,7 @@ function sanitizeActivity(input = {}) {
|
||||
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(),
|
||||
@@ -77,6 +119,7 @@ class RuntimeActivityService {
|
||||
this.active = new Map();
|
||||
this.recent = [];
|
||||
this.controls = new Map();
|
||||
this.outputBroadcastTimer = null;
|
||||
}
|
||||
|
||||
buildSnapshot() {
|
||||
@@ -92,9 +135,23 @@ class RuntimeActivityService {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -134,6 +191,35 @@ class RuntimeActivityService {
|
||||
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)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { spawn } = require('child_process');
|
||||
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;
|
||||
@@ -76,6 +76,28 @@ function terminateChildProcess(child, { immediate = false } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
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 = [];
|
||||
@@ -615,29 +637,58 @@ class ScriptChainService {
|
||||
scriptName: script.name,
|
||||
source: context?.source || 'chain'
|
||||
});
|
||||
const run = await new Promise((resolve, reject) => {
|
||||
const child = spawn(prepared.cmd, prepared.args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true
|
||||
});
|
||||
controlState.activeChild = child;
|
||||
controlState.activeChildTermination = null;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||||
child.on('error', (error) => {
|
||||
controlState.activeChild = null;
|
||||
reject(error);
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
const termination = controlState.activeChildTermination;
|
||||
controlState.activeChild = null;
|
||||
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;
|
||||
resolve({ code, signal, stdout, stderr, termination });
|
||||
});
|
||||
},
|
||||
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') {
|
||||
@@ -648,7 +699,11 @@ class ScriptChainService {
|
||||
skipped: true,
|
||||
currentStep: null,
|
||||
message: 'Schritt übersprungen',
|
||||
output: [run.stdout || '', run.stderr || ''].filter(Boolean).join('\n').trim() || null
|
||||
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 {
|
||||
@@ -678,6 +733,10 @@ class ScriptChainService {
|
||||
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') {
|
||||
@@ -709,6 +768,8 @@ class ScriptChainService {
|
||||
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 });
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -206,7 +207,15 @@ function killChildProcessTree(child, signal = 'SIGTERM') {
|
||||
}
|
||||
}
|
||||
|
||||
function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd = process.cwd(), onChild = null }) {
|
||||
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();
|
||||
@@ -259,6 +268,13 @@ function runProcessCapture({ cmd, args, timeoutMs = SCRIPT_TEST_TIMEOUT_MS, cwd
|
||||
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) {
|
||||
@@ -597,6 +613,12 @@ class ScriptService {
|
||||
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;
|
||||
|
||||
@@ -18,7 +18,8 @@ const {
|
||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||
defaultCdDir: DEFAULT_CD_DIR,
|
||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR
|
||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR
|
||||
} = require('../config');
|
||||
|
||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||
@@ -96,8 +97,7 @@ const PROFILED_SETTINGS = {
|
||||
},
|
||||
output_extension: {
|
||||
bluray: 'output_extension_bluray',
|
||||
dvd: 'output_extension_dvd',
|
||||
audiobook: 'output_extension_audiobook'
|
||||
dvd: 'output_extension_dvd'
|
||||
},
|
||||
output_template: {
|
||||
bluray: 'output_template_bluray',
|
||||
@@ -741,6 +741,9 @@ class SettingsService {
|
||||
effective[legacyKey] = resolvedValue;
|
||||
}
|
||||
|
||||
effective.download_dir = String(sourceMap.download_dir || '').trim() || DEFAULT_DOWNLOAD_DIR;
|
||||
effective.download_dir_owner = String(sourceMap.download_dir_owner || '').trim() || null;
|
||||
|
||||
return effective;
|
||||
}
|
||||
|
||||
@@ -760,12 +763,14 @@ class SettingsService {
|
||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||
downloads: { path: bluray.download_dir },
|
||||
defaults: {
|
||||
raw: DEFAULT_RAW_DIR,
|
||||
movies: DEFAULT_MOVIE_DIR,
|
||||
cd: DEFAULT_CD_DIR,
|
||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR
|
||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||
downloads: DEFAULT_DOWNLOAD_DIR
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+10
-5
@@ -47,6 +47,7 @@ CREATE TABLE jobs (
|
||||
encode_plan_json TEXT,
|
||||
encode_input_path TEXT,
|
||||
encode_review_confirmed INTEGER DEFAULT 0,
|
||||
aax_checksum TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
||||
@@ -164,6 +165,12 @@ CREATE TABLE user_presets (
|
||||
|
||||
CREATE INDEX idx_user_presets_media_type ON user_presets(media_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS aax_activation_bytes (
|
||||
checksum TEXT PRIMARY KEY,
|
||||
activation_bytes TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- Default Settings Seed
|
||||
-- =============================================================================
|
||||
@@ -290,8 +297,10 @@ VALUES ('mediainfo_extra_args_bluray', 'Tools', 'Mediainfo Extra Args', 'string'
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('mediainfo_extra_args_bluray', NULL);
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'mkv: direkte MKV-Dateien; backup: vollständige Blu-ray Struktur im RAW-Ordner.', 'backup', '[{"label":"MKV","value":"mkv"},{"label":"Backup","value":"backup"}]', '{}', 305);
|
||||
VALUES ('makemkv_rip_mode_bluray', 'Tools', 'MakeMKV Rip Modus', 'select', 1, 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).', 'backup', '[{"label":"Backup","value":"backup"},{"label":"MKV","value":"mkv"}]', '{}', 305);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('makemkv_rip_mode_bluray', 'backup');
|
||||
UPDATE settings_schema SET default_value = 'backup', description = 'backup: vollständige Blu-ray Struktur im RAW-Ordner (empfohlen, ermöglicht --decrypt).' WHERE key = 'makemkv_rip_mode_bluray';
|
||||
UPDATE settings_values SET value = 'backup' WHERE key = 'makemkv_rip_mode_bluray';
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('makemkv_analyze_extra_args_bluray', 'Tools', 'MakeMKV Analyze Extra Args', 'string', 0, 'Zusätzliche CLI-Parameter für Analyze (Blu-ray).', NULL, '[]', '{}', 310);
|
||||
@@ -382,10 +391,6 @@ INSERT OR IGNORE INTO settings_values (key, value)
|
||||
VALUES ('cd_output_template', '{artist} - {album} ({year})/{trackNr} {artist} - {title}');
|
||||
|
||||
-- Tools – Audiobook
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_extension_audiobook', 'Tools', 'Ausgabeformat', 'select', 1, 'Dateiendung für finale Audiobook-Datei.', 'mp3', '[{"label":"M4B","value":"m4b"},{"label":"MP3","value":"mp3"},{"label":"FLAC","value":"flac"}]', '{}', 730);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_extension_audiobook', 'mp3');
|
||||
|
||||
INSERT OR IGNORE INTO settings_schema (key, category, label, type, required, description, default_value, options_json, validation_json, order_index)
|
||||
VALUES ('output_template_audiobook', 'Pfade', 'Output Template (Audiobook)', 'string', 1, 'Template für relative Audiobook-Ausgabepfade ohne Dateiendung. Platzhalter: {author}, {title}, {year}, {narrator}, {series}, {part}, {format}. Unterordner sind über "/" möglich.', '{author}/{author} - {title} ({year})', '[]', '{"minLength":1}', 735);
|
||||
INSERT OR IGNORE INTO settings_values (key, value) VALUES ('output_template_audiobook', '{author}/{author} - {title} ({year})');
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+85
-2
@@ -1,14 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from 'primereact/button';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from './api/client';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import DatabasePage from './pages/DatabasePage';
|
||||
import DownloadsPage from './pages/DownloadsPage';
|
||||
|
||||
function normalizeJobId(value) {
|
||||
const parsed = Number(value);
|
||||
@@ -77,6 +79,32 @@ function getAudiobookUploadTagMeta(phase) {
|
||||
return { label: 'Inaktiv', severity: 'secondary' };
|
||||
}
|
||||
|
||||
function getDownloadIndicatorMeta(summary) {
|
||||
const activeCount = Number(summary?.activeCount || 0);
|
||||
const failedCount = Number(summary?.failedCount || 0);
|
||||
const totalCount = Number(summary?.totalCount || 0);
|
||||
|
||||
if (activeCount > 0) {
|
||||
return {
|
||||
icon: 'pi pi-spinner pi-spin',
|
||||
label: activeCount === 1 ? '1 ZIP aktiv' : `${activeCount} ZIPs aktiv`,
|
||||
className: 'zip-status-indicator-active'
|
||||
};
|
||||
}
|
||||
if (totalCount > 0) {
|
||||
return {
|
||||
icon: 'pi pi-check',
|
||||
label: failedCount > 0 ? 'ZIP-Jobs beendet' : 'ZIPs fertig',
|
||||
className: 'zip-status-indicator-ready'
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: 'pi pi-download',
|
||||
label: 'ZIPs',
|
||||
className: 'zip-status-indicator-idle'
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const appVersion = __APP_VERSION__;
|
||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||
@@ -85,9 +113,12 @@ function App() {
|
||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const globalToastRef = useRef(null);
|
||||
|
||||
const refreshPipeline = async () => {
|
||||
const response = await api.getPipelineState();
|
||||
@@ -196,6 +227,11 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
refreshPipeline().catch(() => null);
|
||||
api.getDownloadsSummary()
|
||||
.then((response) => {
|
||||
setDownloadSummary(response?.summary || null);
|
||||
})
|
||||
.catch(() => null);
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
@@ -270,13 +306,47 @@ function App() {
|
||||
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||
setHardwareMonitoring(message.payload || null);
|
||||
}
|
||||
|
||||
if (message.type === 'DOWNLOADS_UPDATED') {
|
||||
const summary = message.payload?.summary && typeof message.payload.summary === 'object'
|
||||
? message.payload.summary
|
||||
: null;
|
||||
const reason = String(message.payload?.reason || '').trim().toLowerCase();
|
||||
const item = message.payload?.item && typeof message.payload.item === 'object'
|
||||
? message.payload.item
|
||||
: null;
|
||||
|
||||
if (summary) {
|
||||
setDownloadSummary(summary);
|
||||
}
|
||||
setDownloadsRefreshToken((prev) => prev + 1);
|
||||
|
||||
if (reason === 'ready' && item) {
|
||||
globalToastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP fertig',
|
||||
detail: `${item.archiveName || 'ZIP-Datei'} steht jetzt auf der Downloads-Seite bereit.`,
|
||||
life: 4500
|
||||
});
|
||||
}
|
||||
|
||||
if (reason === 'failed' && item) {
|
||||
globalToastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP fehlgeschlagen',
|
||||
detail: item.errorMessage || `${item.archiveName || 'ZIP-Datei'} konnte nicht erstellt werden.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{ label: 'Dashboard', path: '/' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Historie', path: '/history' }
|
||||
{ label: 'Historie', path: '/history' },
|
||||
{ label: 'Downloads', path: '/downloads' }
|
||||
];
|
||||
const uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||
@@ -290,9 +360,12 @@ function App() {
|
||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||
const isDashboardRoute = location.pathname === '/';
|
||||
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Toast ref={globalToastRef} position="top-right" />
|
||||
|
||||
<header className="app-header">
|
||||
<div className="brand-block">
|
||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||
@@ -316,6 +389,15 @@ function App() {
|
||||
outlined={location.pathname !== item.path}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`zip-status-indicator ${downloadIndicator.className}`}
|
||||
onClick={() => navigate('/downloads')}
|
||||
title="Downloads-Seite oeffnen"
|
||||
>
|
||||
<i className={downloadIndicator.icon} aria-hidden="true" />
|
||||
<span>{downloadIndicator.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -394,6 +476,7 @@ function App() {
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||
<Route path="/database" element={<DatabasePage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -111,6 +111,69 @@ async function request(path, options = {}) {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function resolveFilenameFromDisposition(contentDisposition, fallback = 'download.zip') {
|
||||
const raw = String(contentDisposition || '').trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const encodedMatch = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1]);
|
||||
} catch (_error) {
|
||||
// ignore malformed content-disposition values
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = raw.match(/filename\s*=\s*"([^"]+)"/i) || raw.match(/filename\s*=\s*([^;]+)/i);
|
||||
if (plainMatch?.[1]) {
|
||||
return String(plainMatch[1]).trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function download(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: options?.headers || {},
|
||||
method: options?.method || 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorPayload = null;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
errorPayload = await response.json();
|
||||
message = errorPayload?.error?.message || message;
|
||||
} catch (_error) {
|
||||
// ignore parse errors
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.details = errorPayload?.error?.details || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const fallbackFilename = String(options?.filename || 'download.zip').trim() || 'download.zip';
|
||||
const filename = resolveFilenameFromDisposition(response.headers.get('content-disposition'), fallbackFilename);
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
return {
|
||||
filename,
|
||||
sizeBytes: blob.size
|
||||
};
|
||||
}
|
||||
|
||||
async function requestWithXhr(path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -241,6 +304,23 @@ export const api = {
|
||||
forceRefresh: options.forceRefresh
|
||||
});
|
||||
},
|
||||
getActivationBytes(options = {}) {
|
||||
return requestCachedGet('/settings/activation-bytes', {
|
||||
ttlMs: 0,
|
||||
forceRefresh: options.forceRefresh ?? true
|
||||
});
|
||||
},
|
||||
async saveActivationBytes(checksum, activationBytes) {
|
||||
const result = await request('/settings/activation-bytes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ checksum, activationBytes })
|
||||
});
|
||||
afterMutationInvalidate(['/settings/activation-bytes']);
|
||||
return result;
|
||||
},
|
||||
getPendingActivation() {
|
||||
return request('/pipeline/audiobook/pending-activation');
|
||||
},
|
||||
getHandBrakePresets(options = {}) {
|
||||
return requestCachedGet('/settings/handbrake-presets', {
|
||||
ttlMs: 10 * 60 * 1000,
|
||||
@@ -613,6 +693,26 @@ export const api = {
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
requestJobArchive(jobId, target = 'raw') {
|
||||
return request(`/downloads/history/${jobId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target })
|
||||
});
|
||||
},
|
||||
getDownloads() {
|
||||
return request('/downloads');
|
||||
},
|
||||
getDownloadsSummary() {
|
||||
return request('/downloads/summary');
|
||||
},
|
||||
downloadPreparedArchive(downloadId) {
|
||||
return download(`/downloads/${encodeURIComponent(downloadId)}/file`);
|
||||
},
|
||||
deleteDownload(downloadId) {
|
||||
return request(`/downloads/${encodeURIComponent(downloadId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
},
|
||||
getJob(jobId, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
const includeLiveLog = Boolean(options.includeLiveLog);
|
||||
|
||||
@@ -175,6 +175,7 @@ const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template
|
||||
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
|
||||
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
|
||||
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
|
||||
const DOWNLOAD_PATH_KEYS = ['download_dir'];
|
||||
const LOG_PATH_KEYS = ['log_dir'];
|
||||
|
||||
function buildSectionsForCategory(categoryName, settings) {
|
||||
@@ -384,6 +385,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const dvdSettings = list.filter((s) => DVD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DVD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const cdSettings = list.filter((s) => CD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && CD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const audiobookSettings = list.filter((s) => AUDIOBOOK_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && AUDIOBOOK_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const downloadSettings = list.filter((s) => DOWNLOAD_PATH_KEYS.includes(s.key) || (s.key.endsWith('_owner') && DOWNLOAD_PATH_KEYS.includes(s.key.replace('_owner', ''))));
|
||||
const logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||
|
||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
||||
@@ -391,6 +393,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
||||
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
||||
const defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
|
||||
|
||||
const ep = effectivePaths || {};
|
||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
||||
@@ -401,6 +404,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
const cdMovies = ep.cd?.movies || cdRaw;
|
||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
||||
const downloadPath = ep.downloads?.path || defaultDownloads;
|
||||
|
||||
const isDefault = (path, def) => path === def;
|
||||
|
||||
@@ -467,6 +471,11 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="path-overview-extra">
|
||||
<strong>ZIP-Downloads:</strong>
|
||||
<code>{downloadPath}</code>
|
||||
{isDefault(downloadPath, defaultDownloads) && <span className="path-default-badge">Standard</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medium-Karten */}
|
||||
@@ -507,6 +516,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<PathMediumCard
|
||||
title="Downloads"
|
||||
pathSettings={downloadSettings}
|
||||
settingsByKey={settingsByKey}
|
||||
values={values}
|
||||
errors={errors}
|
||||
dirtyKeys={dirtyKeys}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log-Ordner */}
|
||||
|
||||
@@ -416,6 +416,42 @@ function BoolState({ value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PathField({
|
||||
label,
|
||||
value,
|
||||
onDownload = null,
|
||||
downloadDisabled = false,
|
||||
downloadLoading = false
|
||||
}) {
|
||||
const hasValue = Boolean(String(value || '').trim());
|
||||
const canDownload = hasValue && typeof onDownload === 'function' && !downloadDisabled;
|
||||
|
||||
return (
|
||||
<div className="job-path-field">
|
||||
<strong>{label}</strong>
|
||||
<div className="job-path-field-value">
|
||||
<span>{hasValue ? value : '-'}</span>
|
||||
{canDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
icon="pi pi-download"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
className="job-path-download-button"
|
||||
aria-label={`${label} als ZIP vorbereiten`}
|
||||
tooltip={`${label} als ZIP vorbereiten`}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={onDownload}
|
||||
disabled={downloadDisabled || downloadLoading}
|
||||
loading={downloadLoading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailDialog({
|
||||
visible,
|
||||
job,
|
||||
@@ -432,13 +468,15 @@ export default function JobDetailDialog({
|
||||
onRetry,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
onDownloadArchive,
|
||||
onRemoveFromQueue,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
cdMetadataAssignBusy = false,
|
||||
actionBusy = false,
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false
|
||||
deleteEntryBusy = false,
|
||||
downloadBusyTarget = null
|
||||
}) {
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
@@ -510,6 +548,8 @@ export default function JobDetailDialog({
|
||||
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
|
||||
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
|
||||
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
|
||||
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -701,12 +741,20 @@ export default function JobDetailDialog({
|
||||
<div>
|
||||
<strong>Ende:</strong> {job.end_time || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isCd ? 'WAV Pfad:' : 'RAW Pfad:'}</strong> {job.raw_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Output:</strong> {job.output_path || '-'}
|
||||
</div>
|
||||
<PathField
|
||||
label={isCd ? 'WAV Pfad:' : 'RAW Pfad:'}
|
||||
value={job.raw_path}
|
||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
<PathField
|
||||
label="Output:"
|
||||
value={job.output_path}
|
||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||
downloadDisabled={!canDownloadOutput}
|
||||
downloadLoading={downloadBusyTarget === 'output'}
|
||||
/>
|
||||
{!isCd ? (
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
|
||||
@@ -7,7 +7,9 @@ import { Tag } from 'primereact/tag';
|
||||
import { ProgressBar } from 'primereact/progressbar';
|
||||
import { Dialog } from 'primereact/dialog';
|
||||
import { InputNumber } from 'primereact/inputnumber';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { api } from '../api/client';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import PipelineStatusCard from '../components/PipelineStatusCard';
|
||||
import MetadataSelectionDialog from '../components/MetadataSelectionDialog';
|
||||
import CdMetadataDialog from '../components/CdMetadataDialog';
|
||||
@@ -129,6 +131,7 @@ function normalizeRuntimeActivitiesPayload(rawPayload) {
|
||||
output: source.output != null ? String(source.output) : null,
|
||||
stdout: source.stdout != null ? String(source.stdout) : null,
|
||||
stderr: source.stderr != null ? String(source.stderr) : null,
|
||||
outputTruncated: Boolean(source.outputTruncated),
|
||||
stdoutTruncated: Boolean(source.stdoutTruncated),
|
||||
stderrTruncated: Boolean(source.stderrTruncated),
|
||||
exitCode: Number.isFinite(Number(source.exitCode)) ? Number(source.exitCode) : null,
|
||||
@@ -189,6 +192,53 @@ function hasRuntimeOutputDetails(item) {
|
||||
);
|
||||
}
|
||||
|
||||
function hasRuntimeLogContent(item) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
String(item.output || '').trim()
|
||||
|| String(item.stdout || '').trim()
|
||||
|| String(item.stderr || '').trim()
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeActivityDetails({
|
||||
item,
|
||||
summary,
|
||||
emptyLabel = 'Noch keine Log-Ausgabe vorhanden.'
|
||||
}) {
|
||||
const hasLogs = hasRuntimeLogContent(item);
|
||||
const hasOutput = Boolean(String(item?.output || '').trim());
|
||||
const hasStdout = Boolean(String(item?.stdout || '').trim());
|
||||
const hasStderr = Boolean(String(item?.stderr || '').trim());
|
||||
|
||||
return (
|
||||
<details className="runtime-activity-details">
|
||||
<summary>{summary}</summary>
|
||||
{!hasLogs ? <small>{emptyLabel}</small> : null}
|
||||
{hasOutput ? (
|
||||
<div className="runtime-activity-details-block">
|
||||
<small><strong>Ausgabe:</strong>{item?.outputTruncated ? ' (gekürzt)' : ''}</small>
|
||||
<pre>{item.output}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{hasStdout ? (
|
||||
<div className="runtime-activity-details-block">
|
||||
<small><strong>stdout:</strong>{item?.stdoutTruncated ? ' (gekürzt)' : ''}</small>
|
||||
<pre>{item.stdout}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{hasStderr ? (
|
||||
<div className="runtime-activity-details-block">
|
||||
<small><strong>stderr:</strong>{item?.stderrTruncated ? ' (gekürzt)' : ''}</small>
|
||||
<pre>{item.stderr}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeHardwareMonitoringPayload(rawPayload) {
|
||||
const payload = rawPayload && typeof rawPayload === 'object' ? rawPayload : {};
|
||||
return {
|
||||
@@ -805,6 +855,10 @@ export default function DashboardPage({
|
||||
const [dashboardJobs, setDashboardJobs] = useState([]);
|
||||
const [expandedJobId, setExpandedJobId] = useState(undefined);
|
||||
const [audiobookUploadFile, setAudiobookUploadFile] = useState(null);
|
||||
const [activationBytesDialog, setActivationBytesDialog] = useState({ visible: false, checksum: null, jobId: null });
|
||||
const [activationBytesInput, setActivationBytesInput] = useState('');
|
||||
const [activationBytesBusy, setActivationBytesBusy] = useState(false);
|
||||
const [pendingActivationJobIds, setPendingActivationJobIds] = useState(() => new Set());
|
||||
const [cpuCoresExpanded, setCpuCoresExpanded] = useState(false);
|
||||
const [expandedQueueScriptKeys, setExpandedQueueScriptKeys] = useState(() => new Set());
|
||||
const [queueCatalog, setQueueCatalog] = useState({ scripts: [], chains: [] });
|
||||
@@ -912,6 +966,25 @@ export default function DashboardPage({
|
||||
}
|
||||
|
||||
setDashboardJobs(deduped);
|
||||
|
||||
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
|
||||
try {
|
||||
const { pending } = await api.getPendingActivation();
|
||||
if (Array.isArray(pending) && pending.length > 0) {
|
||||
setPendingActivationJobIds(new Set(pending.map((p) => p.jobId)));
|
||||
// Modal automatisch öffnen wenn noch nicht sichtbar
|
||||
setActivationBytesDialog((prev) => {
|
||||
if (prev.visible) return prev;
|
||||
const first = pending[0];
|
||||
setActivationBytesInput('');
|
||||
return { visible: true, checksum: first.checksum, jobId: first.jobId };
|
||||
});
|
||||
} else {
|
||||
setPendingActivationJobIds(new Set());
|
||||
}
|
||||
} catch (_err) {
|
||||
// ignorieren
|
||||
}
|
||||
} catch (_error) {
|
||||
setDashboardJobs([]);
|
||||
} finally {
|
||||
@@ -992,13 +1065,23 @@ export default function DashboardPage({
|
||||
void load(false);
|
||||
const interval = setInterval(() => {
|
||||
void load(true);
|
||||
}, 2500);
|
||||
}, 10000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useWebSocket({
|
||||
onMessage: (message) => {
|
||||
if (message?.type !== 'RUNTIME_ACTIVITY_CHANGED') {
|
||||
return;
|
||||
}
|
||||
setRuntimeActivities(normalizeRuntimeActivitiesPayload(message.payload));
|
||||
setRuntimeLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedExpanded = normalizeJobId(expandedJobId);
|
||||
const hasExpanded = dashboardJobs.some((job) => normalizeJobId(job?.id) === normalizedExpanded);
|
||||
@@ -1376,8 +1459,12 @@ export default function DashboardPage({
|
||||
}
|
||||
try {
|
||||
const response = await onAudiobookUpload?.(audiobookUploadFile, { startImmediately: false });
|
||||
const uploadedJobId = normalizeJobId(response?.result?.jobId);
|
||||
if (uploadedJobId) {
|
||||
const result = response?.result || {};
|
||||
const uploadedJobId = normalizeJobId(result.jobId);
|
||||
if (result.needsActivationBytes && result.checksum) {
|
||||
setActivationBytesInput('');
|
||||
setActivationBytesDialog({ visible: true, checksum: result.checksum, jobId: uploadedJobId });
|
||||
} else if (uploadedJobId) {
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Audiobook importiert',
|
||||
@@ -1391,11 +1478,42 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveActivationBytes = async () => {
|
||||
const { checksum, jobId } = activationBytesDialog;
|
||||
const bytes = activationBytesInput.trim().toLowerCase();
|
||||
setActivationBytesBusy(true);
|
||||
try {
|
||||
await api.saveActivationBytes(checksum, bytes);
|
||||
setPendingActivationJobIds(new Set());
|
||||
setActivationBytesDialog({ visible: false, checksum: null, jobId: null });
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Activation Bytes gespeichert',
|
||||
detail: jobId ? `Job #${jobId} kann jetzt gestartet werden.` : 'Bytes wurden lokal gespeichert.',
|
||||
life: 4000
|
||||
});
|
||||
await loadDashboardJobs();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
setActivationBytesBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudiobookStart = async (jobId, audiobookConfig) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (!normalizedJobId) {
|
||||
return;
|
||||
}
|
||||
if (pendingActivationJobIds.has(normalizedJobId)) {
|
||||
setActivationBytesInput('');
|
||||
const pending = await api.getPendingActivation().catch(() => ({ pending: [] }));
|
||||
const entry = (pending?.pending || []).find((p) => p.jobId === normalizedJobId);
|
||||
if (entry) {
|
||||
setActivationBytesDialog({ visible: true, checksum: entry.checksum, jobId: normalizedJobId });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.startAudiobook(normalizedJobId, audiobookConfig || {});
|
||||
@@ -2388,6 +2506,10 @@ export default function DashboardPage({
|
||||
{item?.currentStep ? <small>Schritt: {item.currentStep}</small> : null}
|
||||
{item?.currentScriptName ? <small>Laufendes Skript: {item.currentScriptName}</small> : null}
|
||||
{item?.message ? <small>{item.message}</small> : null}
|
||||
<RuntimeActivityDetails
|
||||
item={item}
|
||||
summary="Live-Ausgabe anzeigen"
|
||||
/>
|
||||
<small>Gestartet: {formatUpdatedAt(item?.startedAt)}</small>
|
||||
{canCancel || canNextStep ? (
|
||||
<div className="runtime-activity-actions">
|
||||
@@ -2456,27 +2578,10 @@ export default function DashboardPage({
|
||||
{item?.message ? <small>{item.message}</small> : null}
|
||||
{item?.errorMessage ? <small className="error-text">{item.errorMessage}</small> : null}
|
||||
{hasRuntimeOutputDetails(item) ? (
|
||||
<details className="runtime-activity-details">
|
||||
<summary>Details anzeigen</summary>
|
||||
{item?.output ? (
|
||||
<div className="runtime-activity-details-block">
|
||||
<small><strong>Ausgabe:</strong></small>
|
||||
<pre>{item.output}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{item?.stderr ? (
|
||||
<div className="runtime-activity-details-block">
|
||||
<small><strong>stderr:</strong>{item?.stderrTruncated ? ' (gekürzt)' : ''}</small>
|
||||
<pre>{item.stderr}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{item?.stdout ? (
|
||||
<div className="runtime-activity-details-block">
|
||||
<small><strong>stdout:</strong>{item?.stdoutTruncated ? ' (gekürzt)' : ''}</small>
|
||||
<pre>{item.stdout}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</details>
|
||||
<RuntimeActivityDetails
|
||||
item={item}
|
||||
summary="Details anzeigen"
|
||||
/>
|
||||
) : null}
|
||||
<small>
|
||||
Ende: {formatUpdatedAt(item?.finishedAt || item?.startedAt)}
|
||||
@@ -2604,14 +2709,26 @@ export default function DashboardPage({
|
||||
);
|
||||
}
|
||||
if (isAudiobookJob) {
|
||||
const needsBytes = pendingActivationJobIds.has(jobId);
|
||||
return (
|
||||
<AudiobookConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(config) => handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
onRetry={() => handleRetry(jobId)}
|
||||
busy={busyJobIds.has(jobId)}
|
||||
/>
|
||||
<>
|
||||
{needsBytes && (
|
||||
<div style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', background: 'var(--yellow-100)', border: '1px solid var(--yellow-400)', borderRadius: '6px', color: 'var(--yellow-900)', fontSize: '0.875rem' }}>
|
||||
<i className="pi pi-lock" style={{ marginRight: '0.5rem' }} />
|
||||
<strong>Activation Bytes fehlen.</strong>{' '}
|
||||
<button type="button" style={{ background: 'none', border: 'none', color: 'var(--primary-color)', cursor: 'pointer', textDecoration: 'underline', padding: 0 }} onClick={() => handleAudiobookStart(jobId, null)}>
|
||||
Jetzt eintragen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<AudiobookConfigPanel
|
||||
pipeline={pipelineForJob}
|
||||
onStart={(config) => handleAudiobookStart(jobId, config)}
|
||||
onCancel={() => handleCancel(jobId, jobState)}
|
||||
onRetry={() => handleRetry(jobId)}
|
||||
busy={busyJobIds.has(jobId) || needsBytes}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -2916,6 +3033,62 @@ export default function DashboardPage({
|
||||
) : null}
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
header="Audible Activation Bytes eintragen"
|
||||
visible={activationBytesDialog.visible}
|
||||
onHide={() => setActivationBytesDialog({ visible: false, checksum: null, jobId: null })}
|
||||
style={{ width: '36rem', maxWidth: '96vw' }}
|
||||
modal
|
||||
footer={(
|
||||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||||
<Button label="Abbrechen" severity="secondary" outlined onClick={() => setActivationBytesDialog({ visible: false, checksum: null, jobId: null })} disabled={activationBytesBusy} />
|
||||
<Button label="Speichern" icon="pi pi-check" onClick={() => void handleSaveActivationBytes()} loading={activationBytesBusy} disabled={activationBytesInput.trim().length !== 8} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.375rem', fontWeight: 600 }}>Checksum (aus der AAX-Datei)</label>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<InputText
|
||||
value={activationBytesDialog.checksum || ''}
|
||||
readOnly
|
||||
style={{ flex: 1, fontFamily: 'monospace', fontSize: '0.85rem' }}
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
outlined
|
||||
tooltip="Kopieren"
|
||||
onClick={() => navigator.clipboard.writeText(activationBytesDialog.checksum || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.375rem', fontWeight: 600 }}>Activation Bytes (8 Hex-Zeichen)</label>
|
||||
<InputText
|
||||
value={activationBytesInput}
|
||||
onChange={(e) => setActivationBytesInput(e.target.value)}
|
||||
placeholder="z.B. 1a2b3c4d"
|
||||
style={{ width: '100%', fontFamily: 'monospace' }}
|
||||
maxLength={8}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ background: 'var(--surface-100)', borderRadius: '6px', padding: '0.875rem', fontSize: '0.875rem', lineHeight: 1.6 }}>
|
||||
<strong>So bekommst du die Activation Bytes:</strong>
|
||||
<ol style={{ margin: '0.5rem 0 0 1.25rem', padding: 0 }}>
|
||||
<li>Öffne <strong>audible-tools.kamsker.at</strong></li>
|
||||
<li>Aktiviere den <strong>Experten-Modus</strong></li>
|
||||
<li>Gib die obige Checksum ein</li>
|
||||
<li>Kopiere die zurückgegebenen Activation Bytes hier rein</li>
|
||||
</ol>
|
||||
<p style={{ margin: '0.5rem 0 0', color: 'var(--text-color-secondary)' }}>
|
||||
Die Bytes werden lokal gespeichert und für alle weiteren AAX-Dateien desselben Accounts wiederverwendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Card } from 'primereact/card';
|
||||
import { DataTable } from 'primereact/datatable';
|
||||
import { Column } from 'primereact/column';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { Toast } from 'primereact/toast';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: 'Alle Stati', value: '' },
|
||||
{ label: 'Wartend', value: 'queued' },
|
||||
{ label: 'Laufend', value: 'processing' },
|
||||
{ label: 'Bereit', value: 'ready' },
|
||||
{ label: 'Fehlgeschlagen', value: 'failed' }
|
||||
];
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
return date.toLocaleString('de-DE', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short'
|
||||
});
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return '-';
|
||||
}
|
||||
if (parsed === 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let unitIndex = 0;
|
||||
let current = parsed;
|
||||
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||
current /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const digits = unitIndex === 0 ? 0 : 2;
|
||||
return `${current.toFixed(digits)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '').trim().toLocaleLowerCase('de-DE');
|
||||
}
|
||||
|
||||
function getStatusMeta(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'queued') {
|
||||
return { label: 'Wartend', severity: 'warning' };
|
||||
}
|
||||
if (normalized === 'processing') {
|
||||
return { label: 'Laeuft', severity: 'info' };
|
||||
}
|
||||
if (normalized === 'ready') {
|
||||
return { label: 'Bereit', severity: 'success' };
|
||||
}
|
||||
return { label: 'Fehlgeschlagen', severity: 'danger' };
|
||||
}
|
||||
|
||||
export default function DownloadsPage({ refreshToken = 0 }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloadBusyId, setDownloadBusyId] = useState(null);
|
||||
const [deleteBusyId, setDeleteBusyId] = useState(null);
|
||||
const toastRef = useRef(null);
|
||||
|
||||
const hasActiveItems = useMemo(
|
||||
() => items.some((item) => ['queued', 'processing'].includes(String(item?.status || '').trim().toLowerCase())),
|
||||
[items]
|
||||
);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const searchText = normalizeSearchText(search);
|
||||
return items.filter((item) => {
|
||||
const matchesStatus = !statusFilter || String(item?.status || '').trim().toLowerCase() === statusFilter;
|
||||
if (!matchesStatus) {
|
||||
return false;
|
||||
}
|
||||
if (!searchText) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
item?.displayTitle,
|
||||
item?.archiveName,
|
||||
item?.label,
|
||||
item?.sourcePath,
|
||||
item?.jobId ? `job ${item.jobId}` : ''
|
||||
]
|
||||
.map((value) => normalizeSearchText(value))
|
||||
.join(' ');
|
||||
return haystack.includes(searchText);
|
||||
});
|
||||
}, [items, search, statusFilter]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getDownloads();
|
||||
setItems(Array.isArray(response?.items) ? response.items : []);
|
||||
setSummary(response?.summary && typeof response.summary === 'object' ? response.summary : null);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Downloads konnten nicht geladen werden',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveItems) {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
void load();
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [hasActiveItems]);
|
||||
|
||||
const handleDownload = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
setDownloadBusyId(id);
|
||||
try {
|
||||
await api.downloadPreparedArchive(id);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'ZIP-Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const label = row?.archiveName || `ZIP ${id}`;
|
||||
const confirmed = window.confirm(`"${label}" wirklich loeschen?`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteBusyId(id);
|
||||
try {
|
||||
await api.deleteDownload(id);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'ZIP geloescht',
|
||||
detail: `"${label}" wurde entfernt.`,
|
||||
life: 3500
|
||||
});
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Loeschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBody = (row) => {
|
||||
const meta = getStatusMeta(row?.status);
|
||||
return <Tag value={meta.label} severity={meta.severity} />;
|
||||
};
|
||||
|
||||
const titleBody = (row) => (
|
||||
<div className="downloads-title-cell">
|
||||
<strong>{row?.displayTitle || '-'}</strong>
|
||||
<small>
|
||||
{row?.jobId ? `Job #${row.jobId}` : 'Ohne Job'} | {row?.label || '-'}
|
||||
</small>
|
||||
{row?.errorMessage ? <small className="downloads-error-text">{row.errorMessage}</small> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const archiveBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.archiveName || '-'}</code>
|
||||
<small>{row?.downloadDir || '-'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sourceBody = (row) => (
|
||||
<div className="downloads-path-cell">
|
||||
<code>{row?.sourcePath || '-'}</code>
|
||||
<small>{row?.sourceType === 'file' ? 'Datei' : 'Ordner'}</small>
|
||||
</div>
|
||||
);
|
||||
|
||||
const actionBody = (row) => {
|
||||
const normalizedStatus = String(row?.status || '').trim().toLowerCase();
|
||||
const canDownload = normalizedStatus === 'ready';
|
||||
const canDelete = !['queued', 'processing'].includes(normalizedStatus);
|
||||
const id = String(row?.id || '').trim();
|
||||
|
||||
return (
|
||||
<div className="downloads-actions">
|
||||
<Button
|
||||
label="Download"
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
onClick={() => handleDownload(row)}
|
||||
disabled={!canDownload || Boolean(deleteBusyId)}
|
||||
loading={downloadBusyId === id}
|
||||
/>
|
||||
<Button
|
||||
label="Loeschen"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => handleDelete(row)}
|
||||
disabled={!canDelete || Boolean(downloadBusyId)}
|
||||
loading={deleteBusyId === id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<Card title="Downloadbare Dateien" subTitle="Vorbereitete ZIP-Dateien aus RAW- und Encode-Inhalten">
|
||||
<div className="table-filters">
|
||||
<InputText
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Suche nach Titel, ZIP-Datei oder Pfad"
|
||||
/>
|
||||
<Dropdown
|
||||
value={statusFilter}
|
||||
options={STATUS_OPTIONS}
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
onChange={(event) => setStatusFilter(event.value || '')}
|
||||
placeholder="Status"
|
||||
/>
|
||||
<Button label="Neu laden" icon="pi pi-refresh" onClick={load} loading={loading} />
|
||||
</div>
|
||||
|
||||
<div className="downloads-summary-tags">
|
||||
<Tag value={`${summary?.activeCount || 0} aktiv`} severity={(summary?.activeCount || 0) > 0 ? 'info' : 'secondary'} />
|
||||
<Tag value={`${summary?.readyCount || 0} bereit`} severity={(summary?.readyCount || 0) > 0 ? 'success' : 'secondary'} />
|
||||
<Tag value={`${summary?.failedCount || 0} Fehler`} severity={(summary?.failedCount || 0) > 0 ? 'danger' : 'secondary'} />
|
||||
</div>
|
||||
|
||||
<div className="table-scroll-wrap table-scroll-wide">
|
||||
<DataTable
|
||||
value={visibleItems}
|
||||
dataKey="id"
|
||||
paginator
|
||||
rows={10}
|
||||
rowsPerPageOptions={[10, 20, 50]}
|
||||
loading={loading}
|
||||
responsiveLayout="scroll"
|
||||
emptyMessage="Keine ZIP-Dateien vorhanden"
|
||||
>
|
||||
<Column header="Status" body={statusBody} style={{ width: '10rem' }} />
|
||||
<Column header="Inhalt" body={titleBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="ZIP-Datei" body={archiveBody} style={{ minWidth: '18rem' }} />
|
||||
<Column header="Quelle" body={sourceBody} style={{ minWidth: '22rem' }} />
|
||||
<Column header="Erstellt" body={(row) => formatDateTime(row?.createdAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Fertig" body={(row) => formatDateTime(row?.finishedAt)} style={{ width: '11rem' }} />
|
||||
<Column header="Groesse" body={(row) => formatBytes(row?.sizeBytes)} style={{ width: '9rem' }} />
|
||||
<Column header="Aktion" body={actionBody} style={{ width: '14rem' }} />
|
||||
</DataTable>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -370,6 +370,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const toastRef = useRef(null);
|
||||
@@ -557,6 +558,40 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadArchive = async (row, target) => {
|
||||
const jobId = Number(row?.id || selectedJob?.id || 0);
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!jobId || !['raw', 'output'].includes(normalizedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadBusyTarget(normalizedTarget);
|
||||
try {
|
||||
const response = await api.requestJobArchive(jobId, normalizedTarget);
|
||||
const item = response?.item && typeof response.item === 'object' ? response.item : null;
|
||||
const label = normalizedTarget === 'raw' ? 'RAW' : 'Encode';
|
||||
const isReady = String(item?.status || '').trim().toLowerCase() === 'ready';
|
||||
const detail = isReady
|
||||
? `${label}-ZIP ist bereits auf der Downloads-Seite verfuegbar.`
|
||||
: `${label}-ZIP wird im Hintergrund erstellt und erscheint danach auf der Downloads-Seite.`;
|
||||
toastRef.current?.show({
|
||||
severity: isReady ? 'success' : 'info',
|
||||
summary: isReady ? 'ZIP bereit' : 'ZIP wird erstellt',
|
||||
detail,
|
||||
life: 4000
|
||||
});
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReencode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
|
||||
@@ -1169,15 +1204,18 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onRetry={handleRetry}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
downloadBusyTarget={downloadBusyTarget}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
setDownloadBusyTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -184,6 +184,9 @@ export default function SettingsPage() {
|
||||
const [chainEditorErrors, setChainEditorErrors] = useState({});
|
||||
const [chainDragSource, setChainDragSource] = useState(null);
|
||||
|
||||
// Activation Bytes state
|
||||
const [activationBytes, setActivationBytes] = useState([]);
|
||||
|
||||
// User presets state
|
||||
const [userPresets, setUserPresets] = useState([]);
|
||||
const [userPresetsLoading, setUserPresetsLoading] = useState(false);
|
||||
@@ -356,6 +359,8 @@ export default function SettingsPage() {
|
||||
setErrors({});
|
||||
loadEffectivePaths({ silent: true });
|
||||
|
||||
api.getActivationBytes().then(r => setActivationBytes(Array.isArray(r?.entries) ? r.entries : [])).catch(() => {});
|
||||
|
||||
const presetsPromise = api.getHandBrakePresets();
|
||||
const scriptsPromise = api.getScripts();
|
||||
const chainsPromise = api.getScriptChains();
|
||||
@@ -1738,6 +1743,33 @@ export default function SettingsPage() {
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</Card>
|
||||
|
||||
{activationBytes.length > 0 && (
|
||||
<Card
|
||||
title="Activation Bytes Cache"
|
||||
subTitle="Lokal gespeicherte AAX-Activation Bytes. Werden beim ersten Upload automatisch über die Audible-Tools API ermittelt."
|
||||
style={{ marginTop: '1.5rem' }}
|
||||
>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Checksum</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Activation Bytes</th>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem 0.75rem' }}>Gespeichert am</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activationBytes.map((entry) => (
|
||||
<tr key={entry.checksum} style={{ borderBottom: '1px solid var(--surface-border)' }}>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{entry.checksum}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', fontWeight: 'bold' }}>{entry.activation_bytes}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem', color: 'var(--text-color-secondary)' }}>{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ body {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-btn.p-button {
|
||||
@@ -195,6 +196,47 @@ body {
|
||||
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.3);
|
||||
}
|
||||
|
||||
.zip-status-indicator {
|
||||
border: 1px solid rgba(58, 29, 18, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 247, 232, 0.55);
|
||||
color: var(--rip-brown-900);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.zip-status-indicator:hover {
|
||||
background: rgba(255, 247, 232, 0.82);
|
||||
border-color: var(--rip-brown-700);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.zip-status-indicator i {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.zip-status-indicator span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.zip-status-indicator-ready {
|
||||
background: rgba(231, 247, 233, 0.9);
|
||||
border-color: rgba(28, 138, 58, 0.28);
|
||||
color: #1f6d35;
|
||||
}
|
||||
|
||||
.zip-status-indicator-error {
|
||||
background: rgba(255, 236, 230, 0.9);
|
||||
border-color: rgba(184, 74, 39, 0.26);
|
||||
color: #9f3b1f;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
width: min(1280px, 96vw);
|
||||
margin: 1rem auto 2rem;
|
||||
@@ -1574,6 +1616,22 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-overview-extra {
|
||||
margin-top: 0.85rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.path-overview-extra code {
|
||||
font-size: 0.8rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.path-medium-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
@@ -1838,6 +1896,47 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.downloads-summary-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.downloads-title-cell,
|
||||
.downloads-path-cell {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-title-cell strong,
|
||||
.downloads-title-cell small,
|
||||
.downloads-path-cell code,
|
||||
.downloads-path-cell small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.downloads-path-cell code {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.downloads-error-text {
|
||||
color: #9f3b1f;
|
||||
}
|
||||
|
||||
.downloads-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.history-dataview .p-dataview-header {
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -2205,6 +2304,30 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-path-field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.job-path-field-value {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.35rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.job-path-field-value > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-path-download-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-meta-col-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -2643,6 +2766,11 @@ body {
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app-upload-banner {
|
||||
width: calc(100% - 1.5rem);
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2807,6 +2935,11 @@ body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.zip-status-indicator {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-dv-item-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -774,6 +774,11 @@ rsync -a --delete \
|
||||
# Datenbank-/Log-Verzeichnisse anlegen
|
||||
mkdir -p "$INSTALL_DIR/backend/data"
|
||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||
|
||||
# Bei Reinstall: Daten wiederherstellen
|
||||
if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
|
||||
@@ -830,6 +835,10 @@ LOG_LEVEL=info
|
||||
|
||||
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
||||
CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
||||
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||
EOF
|
||||
ok "Backend .env erstellt"
|
||||
fi
|
||||
|
||||
@@ -555,6 +555,7 @@ mkdir -p "$INSTALL_DIR/backend/logs"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/raw"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||
|
||||
# Gesicherte Daten zurückspielen
|
||||
@@ -616,6 +617,7 @@ CORS_ORIGIN=http://${FRONTEND_HOST}
|
||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
||||
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||
EOF
|
||||
ok "Backend .env erstellt"
|
||||
fi
|
||||
@@ -631,9 +633,11 @@ ACTUAL_USER="${SUDO_USER:-}"
|
||||
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
||||
chown -R "$ACTUAL_USER:$SERVICE_USER" \
|
||||
"$INSTALL_DIR/backend/data/output" \
|
||||
"$INSTALL_DIR/backend/data/downloads" \
|
||||
"$INSTALL_DIR/backend/data/logs"
|
||||
chmod -R 775 \
|
||||
"$INSTALL_DIR/backend/data/output" \
|
||||
"$INSTALL_DIR/backend/data/downloads" \
|
||||
"$INSTALL_DIR/backend/data/logs"
|
||||
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
||||
else
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster",
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ripster",
|
||||
"private": true,
|
||||
"version": "0.10.0-8",
|
||||
"version": "0.10.2-3",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||
"dev:backend": "npm run dev --prefix backend",
|
||||
|
||||
Reference in New Issue
Block a user