0.12.0-15 Fix DVD Review

This commit is contained in:
michael committed 2026-03-24 07:05:45 +00:00
1 parent 3e3f3231be
commit 76b020d5c0
30 files changed
+5148 -44

No files matched your search

+321
View File
@@ -0,0 +1,321 @@
'use strict';
const express = require('express');
const fs = require('fs');
const os = require('os');
const path = require('path');
const multer = require('multer');
const asyncHandler = require('../middleware/asyncHandler');
const pipelineService = require('../services/pipelineService');
const converterScanService = require('../services/converterScanService');
const historyService = require('../services/historyService');
const logger = require('../services/logger').child('CONVERTER_ROUTE');
/** Pfadauflösung mit Traversal-Schutz (wie Klangkiste resolveMediaTarget) */
function resolveTarget(rawDir, input) {
const rel = converterScanService.normalizeRelPath(input != null ? String(input) : '');
if (rel === null) return { error: 'Ungültiger Pfad' };
const absolute = path.join(rawDir, rel || '.');
return { rel: rel || '', absolute };
}
const router = express.Router();
const converterUpload = multer({
dest: path.join(os.tmpdir(), 'ripster-converter-uploads')
});
// ── Scan ──────────────────────────────────────────────────────────────────
/**
* GET /api/converter/tree
* Vollständiger FS-Verzeichnisbaum des converter_raw_dir (keine DB).
*/
router.get(
'/tree',
asyncHandler(async (req, res) => {
logger.debug('get:tree');
const result = await converterScanService.getTree();
res.json(result);
})
);
/**
* GET /api/converter/browse?parent=relPath
* File-Explorer (DB-basiert, wird weiterhin für Job-Zuweisung gebraucht).
*/
router.get(
'/browse',
asyncHandler(async (req, res) => {
const parent = req.query.parent ? String(req.query.parent).trim() : null;
logger.debug('get:browse', { parent });
const entries = await converterScanService.getEntries(parent);
const rawDir = await converterScanService.getRawDir();
res.json({ entries, rawDir });
})
);
/**
* POST /api/converter/scan
* Manuellen Scan des converter_raw_dir auslösen.
*/
router.post(
'/scan',
asyncHandler(async (req, res) => {
logger.info('post:scan');
const result = await converterScanService.scan();
res.json({ result });
})
);
// ── Jobs erstellen ────────────────────────────────────────────────────────
/**
* POST /api/converter/create-jobs
* Body: { entries: [{ relPath, converterMediaType }] }
* Erstellt Jobs aus Scan-Einträgen (File-Explorer-Auswahl).
*/
router.post(
'/create-jobs',
asyncHandler(async (req, res) => {
const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
if (entries.length === 0) {
const error = new Error('Keine Einträge ausgewählt.');
error.statusCode = 400;
throw error;
}
logger.info('post:create-jobs', { count: entries.length });
const jobs = [];
for (const entry of entries) {
const relPath = String(entry?.relPath || '').trim();
if (!relPath) continue;
const job = await pipelineService.createConverterJobFromEntry(relPath, {
converterMediaType: entry?.converterMediaType || null
});
jobs.push(job);
}
res.json({ jobs });
})
);
/**
* POST /api/converter/upload
* Datei(en) hochladen → Unterordner anlegen (kein Job-Anlegen).
* Gibt { folders: [{folderRelPath, fileCount}] } zurück.
*/
router.post(
'/upload',
converterUpload.array('files', 50),
asyncHandler(async (req, res) => {
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
const error = new Error('Keine Dateien hochgeladen.');
error.statusCode = 400;
throw error;
}
const folderName = req.body?.folderName ? String(req.body.folderName).trim() : null;
logger.info('post:upload', {
fileCount: files.length,
folderName,
files: files.map((f) => ({ name: f.originalname, size: f.size }))
});
const result = await pipelineService.uploadConverterFiles(files, { folderName });
res.json(result);
})
);
/**
* POST /api/converter/jobs/from-selection
* Body: { relPaths: string[], audioMode: 'individual'|'shared' }
* Erstellt Jobs aus im File-Explorer ausgewählten Dateien.
*/
router.post(
'/jobs/from-selection',
asyncHandler(async (req, res) => {
const relPaths = Array.isArray(req.body?.relPaths) ? req.body.relPaths : [];
const audioMode = String(req.body?.audioMode || 'individual');
if (relPaths.length === 0) {
const error = new Error('Keine Dateien ausgewählt.');
error.statusCode = 400;
throw error;
}
logger.info('post:jobs:from-selection', { count: relPaths.length, audioMode });
const jobs = await pipelineService.createConverterJobsFromSelection(relPaths, audioMode);
res.json({ jobs });
})
);
// ── Datei-Operationen (reines FS, keine DB) ───────────────────────────────
/**
* DELETE /api/converter/files
* Body: { relPath }
* Datei oder Ordner löschen (fs.rmSync, ohne DB).
*/
router.delete(
'/files',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
if (!relPath) return res.status(400).json({ detail: 'relPath fehlt.' });
const target = resolveTarget(rawDir, relPath);
if (target.error || !target.rel) return res.status(400).json({ detail: target.error || 'Ungültiger Pfad.' });
logger.info('delete:files', { relPath });
fs.rmSync(target.absolute, { recursive: true, force: true });
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/rename
* Body: { relPath, newName }
* Umbenennen (fs.renameSync, ohne DB).
*/
router.post(
'/files/rename',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
const newName = String(req.body?.newName || '').trim();
if (!relPath || !newName) return res.status(400).json({ detail: 'relPath und newName erforderlich.' });
if (newName.includes('/') || newName.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
const source = resolveTarget(rawDir, relPath);
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
const parentAbs = path.dirname(source.absolute);
const destAbs = path.join(parentAbs, newName);
logger.info('post:files:rename', { relPath, newName });
fs.renameSync(source.absolute, destAbs);
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/move
* Body: { relPath, targetParentRelPath }
* Verschieben (fs.renameSync, ohne DB). targetParentRelPath = '' → Root.
*/
router.post(
'/files/move',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const relPath = String(req.body?.relPath || '').trim();
if (!relPath) return res.status(400).json({ detail: 'relPath erforderlich.' });
const targetParentRelPath = req.body?.targetParentRelPath != null ? String(req.body.targetParentRelPath) : '';
const source = resolveTarget(rawDir, relPath);
if (source.error || !source.rel) return res.status(400).json({ detail: source.error || 'Ungültiger Quellpfad.' });
const targetParent = resolveTarget(rawDir, targetParentRelPath);
if (targetParent.error) return res.status(400).json({ detail: targetParent.error });
const name = path.basename(source.absolute);
const destAbs = path.join(targetParent.absolute, name);
logger.info('post:files:move', { relPath, targetParentRelPath });
fs.renameSync(source.absolute, destAbs);
res.json({ ok: true });
})
);
/**
* POST /api/converter/files/folder
* Body: { parentRelPath, name }
* Neuen Ordner anlegen (fs.mkdirSync, ohne DB).
*/
router.post(
'/files/folder',
asyncHandler(async (req, res) => {
const rawDir = await converterScanService.getRawDir();
if (!rawDir) return res.status(400).json({ detail: 'Kein RAW-Verzeichnis konfiguriert.' });
const parentRelPath = req.body?.parentRelPath != null ? String(req.body.parentRelPath) : '';
const name = String(req.body?.name || '').trim();
if (!name || name.includes('/') || name.includes('\\')) return res.status(400).json({ detail: 'Ungültiger Name.' });
const parent = resolveTarget(rawDir, parentRelPath);
if (parent.error) return res.status(400).json({ detail: parent.error });
logger.info('post:files:folder', { parentRelPath, name });
fs.mkdirSync(path.join(parent.absolute, name), { recursive: true });
res.json({ ok: true });
})
);
// ── Job-Status ────────────────────────────────────────────────────────────
/**
* GET /api/converter/jobs
* Alle Converter-Jobs zurückgeben.
*/
router.get(
'/jobs',
asyncHandler(async (req, res) => {
const jobs = await pipelineService.getConverterJobs();
res.json({ jobs });
})
);
/**
* GET /api/converter/jobs/:jobId
* Einzelnen Converter-Job abrufen.
*/
router.get(
'/jobs/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const job = await historyService.getJobById(jobId);
if (!job) {
const error = new Error(`Job ${jobId} nicht gefunden.`);
error.statusCode = 404;
throw error;
}
res.json({ job });
})
);
/**
* POST /api/converter/jobs/:jobId/start
* Job mit finaler Konfiguration starten.
* Body: { converterMediaType, outputFormat, userPreset, trackSelection, handBrakeTitleId, audioFormatOptions }
*/
router.post(
'/jobs/:jobId/start',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
const config = req.body || {};
logger.info('post:jobs:start', {
jobId,
converterMediaType: config.converterMediaType,
outputFormat: config.outputFormat
});
const result = await pipelineService.startConverterJob(jobId, config);
res.json({ result });
})
);
/**
* POST /api/converter/jobs/:jobId/cancel
* Job abbrechen.
*/
router.post(
'/jobs/:jobId/cancel',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('post:jobs:cancel', { jobId });
const result = await pipelineService.cancelJob(jobId);
res.json({ result });
})
);
/**
* DELETE /api/converter/jobs/:jobId
* Job aus der DB löschen.
*/
router.delete(
'/jobs/:jobId',
asyncHandler(async (req, res) => {
const jobId = Number(req.params.jobId);
logger.info('delete:jobs', { jobId });
await historyService.deleteJob(jobId);
res.json({ ok: true });
})
);
module.exports = router;