0.10.2-1 Downloads
This commit is contained in:
@@ -9,3 +9,4 @@ LOG_LEVEL=debug
|
|||||||
DEFAULT_RAW_DIR=
|
DEFAULT_RAW_DIR=
|
||||||
DEFAULT_MOVIE_DIR=
|
DEFAULT_MOVIE_DIR=
|
||||||
DEFAULT_CD_DIR=
|
DEFAULT_CD_DIR=
|
||||||
|
DEFAULT_DOWNLOAD_DIR=
|
||||||
|
|||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-backend",
|
"name": "ripster-backend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ module.exports = {
|
|||||||
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
defaultMovieDir: resolveOutputPath(process.env.DEFAULT_MOVIE_DIR, 'output', 'movies'),
|
||||||
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
defaultCdDir: resolveOutputPath(process.env.DEFAULT_CD_DIR, 'output', 'cd'),
|
||||||
defaultAudiobookRawDir: resolveOutputPath(process.env.DEFAULT_AUDIOBOOK_RAW_DIR, 'output', 'audiobook-raw'),
|
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')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -953,6 +953,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)`
|
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_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() {
|
async function getDb() {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ const requestLogger = require('./middleware/requestLogger');
|
|||||||
const settingsRoutes = require('./routes/settingsRoutes');
|
const settingsRoutes = require('./routes/settingsRoutes');
|
||||||
const pipelineRoutes = require('./routes/pipelineRoutes');
|
const pipelineRoutes = require('./routes/pipelineRoutes');
|
||||||
const historyRoutes = require('./routes/historyRoutes');
|
const historyRoutes = require('./routes/historyRoutes');
|
||||||
|
const downloadRoutes = require('./routes/downloadRoutes');
|
||||||
const cronRoutes = require('./routes/cronRoutes');
|
const cronRoutes = require('./routes/cronRoutes');
|
||||||
const runtimeRoutes = require('./routes/runtimeRoutes');
|
const runtimeRoutes = require('./routes/runtimeRoutes');
|
||||||
const wsService = require('./services/websocketService');
|
const wsService = require('./services/websocketService');
|
||||||
const pipelineService = require('./services/pipelineService');
|
const pipelineService = require('./services/pipelineService');
|
||||||
const cronService = require('./services/cronService');
|
const cronService = require('./services/cronService');
|
||||||
|
const downloadService = require('./services/downloadService');
|
||||||
const diskDetectionService = require('./services/diskDetectionService');
|
const diskDetectionService = require('./services/diskDetectionService');
|
||||||
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
const hardwareMonitorService = require('./services/hardwareMonitorService');
|
||||||
const logger = require('./services/logger').child('BOOT');
|
const logger = require('./services/logger').child('BOOT');
|
||||||
@@ -26,6 +28,7 @@ async function start() {
|
|||||||
await initDatabase();
|
await initDatabase();
|
||||||
await pipelineService.init();
|
await pipelineService.init();
|
||||||
await cronService.init();
|
await cronService.init();
|
||||||
|
await downloadService.init();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors({ origin: corsOrigin }));
|
app.use(cors({ origin: corsOrigin }));
|
||||||
@@ -39,6 +42,7 @@ async function start() {
|
|||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
app.use('/api/pipeline', pipelineRoutes);
|
app.use('/api/pipeline', pipelineRoutes);
|
||||||
app.use('/api/history', historyRoutes);
|
app.use('/api/history', historyRoutes);
|
||||||
|
app.use('/api/downloads', downloadRoutes);
|
||||||
app.use('/api/crons', cronRoutes);
|
app.use('/api/crons', cronRoutes);
|
||||||
app.use('/api/runtime', runtimeRoutes);
|
app.use('/api/runtime', runtimeRoutes);
|
||||||
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
app.use('/api/thumbnails', express.static(getThumbnailsDir(), { maxAge: '30d', immutable: true }));
|
||||||
|
|||||||
69
backend/src/routes/downloadRoutes.js
Normal file
69
backend/src/routes/downloadRoutes.js
Normal file
@@ -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;
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const archiver = require('archiver');
|
|
||||||
const asyncHandler = require('../middleware/asyncHandler');
|
const asyncHandler = require('../middleware/asyncHandler');
|
||||||
const historyService = require('../services/historyService');
|
const historyService = require('../services/historyService');
|
||||||
const pipelineService = require('../services/pipelineService');
|
const pipelineService = require('../services/pipelineService');
|
||||||
const logger = require('../services/logger').child('HISTORY_ROUTE');
|
const logger = require('../services/logger').child('HISTORY_ROUTE');
|
||||||
const { errorToMeta } = require('../utils/errorMeta');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -182,72 +180,6 @@ router.post(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/:id/download',
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const id = Number(req.params.id);
|
|
||||||
const target = String(req.query.target || '').trim();
|
|
||||||
const descriptor = await historyService.getJobArchiveDescriptor(id, target);
|
|
||||||
|
|
||||||
logger.info('get:job:download', {
|
|
||||||
reqId: req.reqId,
|
|
||||||
id,
|
|
||||||
target: descriptor.target,
|
|
||||||
sourceType: descriptor.sourceType,
|
|
||||||
sourcePath: descriptor.sourcePath
|
|
||||||
});
|
|
||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
|
||||||
|
|
||||||
const handleArchiveError = (error) => {
|
|
||||||
logger.error('get:job:download:failed', {
|
|
||||||
reqId: req.reqId,
|
|
||||||
id,
|
|
||||||
target: descriptor.target,
|
|
||||||
error: errorToMeta(error)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({
|
|
||||||
error: {
|
|
||||||
message: 'ZIP-Download fehlgeschlagen.'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.destroy(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
archive.on('warning', handleArchiveError);
|
|
||||||
archive.on('error', handleArchiveError);
|
|
||||||
res.on('close', () => {
|
|
||||||
if (!res.writableEnded) {
|
|
||||||
archive.abort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/zip');
|
|
||||||
res.attachment(descriptor.archiveName);
|
|
||||||
archive.pipe(res);
|
|
||||||
|
|
||||||
if (descriptor.sourceType === 'directory') {
|
|
||||||
archive.directory(descriptor.sourcePath, descriptor.entryName);
|
|
||||||
} else {
|
|
||||||
archive.file(descriptor.sourcePath, { name: descriptor.entryName });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const finalizeResult = archive.finalize();
|
|
||||||
if (finalizeResult && typeof finalizeResult.catch === 'function') {
|
|
||||||
finalizeResult.catch(handleArchiveError);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleArchiveError(error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
|||||||
537
backend/src/services/downloadService.js
Normal file
537
backend/src/services/downloadService.js
Normal file
@@ -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();
|
||||||
@@ -1519,9 +1519,12 @@ class HistoryService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: normalizedJobId,
|
jobId: normalizedJobId,
|
||||||
|
displayTitle: buildJobDisplayTitle(job),
|
||||||
target: normalizedTarget,
|
target: normalizedTarget,
|
||||||
sourcePath,
|
sourcePath,
|
||||||
sourceType: sourceStat.isDirectory() ? 'directory' : 'file',
|
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'),
|
entryName: path.basename(sourcePath) || (normalizedTarget === 'raw' ? 'raw' : 'output'),
|
||||||
archiveName: buildJobArchiveName(job, normalizedTarget)
|
archiveName: buildJobArchiveName(job, normalizedTarget)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ const {
|
|||||||
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
defaultMovieDir: DEFAULT_MOVIE_DIR,
|
||||||
defaultCdDir: DEFAULT_CD_DIR,
|
defaultCdDir: DEFAULT_CD_DIR,
|
||||||
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
defaultAudiobookRawDir: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||||
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR
|
defaultAudiobookDir: DEFAULT_AUDIOBOOK_DIR,
|
||||||
|
defaultDownloadDir: DEFAULT_DOWNLOAD_DIR
|
||||||
} = require('../config');
|
} = require('../config');
|
||||||
|
|
||||||
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
const DEFAULT_AUDIO_COPY_MASK = ['copy:aac', 'copy:ac3', 'copy:eac3', 'copy:truehd', 'copy:dts', 'copy:dtshd', 'copy:mp3', 'copy:flac'];
|
||||||
@@ -741,6 +742,9 @@ class SettingsService {
|
|||||||
effective[legacyKey] = resolvedValue;
|
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;
|
return effective;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -760,12 +764,14 @@ class SettingsService {
|
|||||||
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
dvd: { raw: dvd.raw_dir, movies: dvd.movie_dir },
|
||||||
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
cd: { raw: cd.raw_dir, movies: cd.movie_dir },
|
||||||
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
audiobook: { raw: audiobook.raw_dir, movies: audiobook.movie_dir },
|
||||||
|
downloads: { path: bluray.download_dir },
|
||||||
defaults: {
|
defaults: {
|
||||||
raw: DEFAULT_RAW_DIR,
|
raw: DEFAULT_RAW_DIR,
|
||||||
movies: DEFAULT_MOVIE_DIR,
|
movies: DEFAULT_MOVIE_DIR,
|
||||||
cd: DEFAULT_CD_DIR,
|
cd: DEFAULT_CD_DIR,
|
||||||
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
audiobookRaw: DEFAULT_AUDIOBOOK_RAW_DIR,
|
||||||
audiobookMovies: DEFAULT_AUDIOBOOK_DIR
|
audiobookMovies: DEFAULT_AUDIOBOOK_DIR,
|
||||||
|
downloads: DEFAULT_DOWNLOAD_DIR
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.2",
|
"primereact": "^10.9.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster-frontend",
|
"name": "ripster-frontend",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { ProgressBar } from 'primereact/progressbar';
|
import { ProgressBar } from 'primereact/progressbar';
|
||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
|
import { Toast } from 'primereact/toast';
|
||||||
import { api } from './api/client';
|
import { api } from './api/client';
|
||||||
import { useWebSocket } from './hooks/useWebSocket';
|
import { useWebSocket } from './hooks/useWebSocket';
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
import SettingsPage from './pages/SettingsPage';
|
||||||
import HistoryPage from './pages/HistoryPage';
|
import HistoryPage from './pages/HistoryPage';
|
||||||
import DatabasePage from './pages/DatabasePage';
|
import DatabasePage from './pages/DatabasePage';
|
||||||
|
import DownloadsPage from './pages/DownloadsPage';
|
||||||
|
|
||||||
function normalizeJobId(value) {
|
function normalizeJobId(value) {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
@@ -77,6 +79,32 @@ function getAudiobookUploadTagMeta(phase) {
|
|||||||
return { label: 'Inaktiv', severity: 'secondary' };
|
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() {
|
function App() {
|
||||||
const appVersion = __APP_VERSION__;
|
const appVersion = __APP_VERSION__;
|
||||||
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
const [pipeline, setPipeline] = useState({ state: 'IDLE', progress: 0, context: {} });
|
||||||
@@ -85,9 +113,12 @@ function App() {
|
|||||||
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
const [audiobookUpload, setAudiobookUpload] = useState(() => createInitialAudiobookUploadState());
|
||||||
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
const [dashboardJobsRefreshToken, setDashboardJobsRefreshToken] = useState(0);
|
||||||
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
const [historyJobsRefreshToken, setHistoryJobsRefreshToken] = useState(0);
|
||||||
|
const [downloadsRefreshToken, setDownloadsRefreshToken] = useState(0);
|
||||||
|
const [downloadSummary, setDownloadSummary] = useState(null);
|
||||||
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
const [pendingDashboardJobId, setPendingDashboardJobId] = useState(null);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const globalToastRef = useRef(null);
|
||||||
|
|
||||||
const refreshPipeline = async () => {
|
const refreshPipeline = async () => {
|
||||||
const response = await api.getPipelineState();
|
const response = await api.getPipelineState();
|
||||||
@@ -196,6 +227,11 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshPipeline().catch(() => null);
|
refreshPipeline().catch(() => null);
|
||||||
|
api.getDownloadsSummary()
|
||||||
|
.then((response) => {
|
||||||
|
setDownloadSummary(response?.summary || null);
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useWebSocket({
|
useWebSocket({
|
||||||
@@ -270,13 +306,47 @@ function App() {
|
|||||||
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
if (message.type === 'HARDWARE_MONITOR_UPDATE') {
|
||||||
setHardwareMonitoring(message.payload || null);
|
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 = [
|
const nav = [
|
||||||
{ label: 'Dashboard', path: '/' },
|
{ label: 'Dashboard', path: '/' },
|
||||||
{ label: 'Settings', path: '/settings' },
|
{ 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 uploadPhase = String(audiobookUpload?.phase || 'idle').trim().toLowerCase();
|
||||||
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
const showAudiobookUploadBanner = uploadPhase !== 'idle';
|
||||||
@@ -290,9 +360,12 @@ function App() {
|
|||||||
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
const canDismissUploadBanner = uploadPhase === 'completed' || uploadPhase === 'error';
|
||||||
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
const hasUploadedJob = Boolean(normalizeJobId(audiobookUpload?.jobId));
|
||||||
const isDashboardRoute = location.pathname === '/';
|
const isDashboardRoute = location.pathname === '/';
|
||||||
|
const downloadIndicator = getDownloadIndicatorMeta(downloadSummary);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
|
<Toast ref={globalToastRef} position="top-right" />
|
||||||
|
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="brand-block">
|
<div className="brand-block">
|
||||||
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
<img src="/logo.png" alt="Ripster Logo" className="brand-logo" />
|
||||||
@@ -316,6 +389,15 @@ function App() {
|
|||||||
outlined={location.pathname !== item.path}
|
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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -394,6 +476,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
<Route path="/history" element={<HistoryPage refreshToken={historyJobsRefreshToken} />} />
|
||||||
|
<Route path="/downloads" element={<DownloadsPage refreshToken={downloadsRefreshToken} />} />
|
||||||
<Route path="/database" element={<DatabasePage />} />
|
<Route path="/database" element={<DatabasePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -693,10 +693,25 @@ export const api = {
|
|||||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
downloadJobArchive(jobId, target = 'raw') {
|
requestJobArchive(jobId, target = 'raw') {
|
||||||
const query = new URLSearchParams();
|
return request(`/downloads/history/${jobId}`, {
|
||||||
query.set('target', String(target || 'raw').trim());
|
method: 'POST',
|
||||||
return download(`/history/${jobId}/download?${query.toString()}`);
|
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 = {}) {
|
getJob(jobId, options = {}) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
|
|||||||
@@ -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 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 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 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'];
|
const LOG_PATH_KEYS = ['log_dir'];
|
||||||
|
|
||||||
function buildSectionsForCategory(categoryName, settings) {
|
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 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 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 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 logSettings = list.filter((s) => LOG_PATH_KEYS.includes(s.key));
|
||||||
|
|
||||||
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
|
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 defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
|
||||||
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
|
||||||
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
|
||||||
|
const defaultDownloads = effectivePaths?.defaults?.downloads || 'data/downloads';
|
||||||
|
|
||||||
const ep = effectivePaths || {};
|
const ep = effectivePaths || {};
|
||||||
const blurayRaw = ep.bluray?.raw || defaultRaw;
|
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 cdMovies = ep.cd?.movies || cdRaw;
|
||||||
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
|
||||||
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
const audiobookMovies = ep.audiobook?.movies || defaultAudiobookMovies;
|
||||||
|
const downloadPath = ep.downloads?.path || defaultDownloads;
|
||||||
|
|
||||||
const isDefault = (path, def) => path === def;
|
const isDefault = (path, def) => path === def;
|
||||||
|
|
||||||
@@ -467,6 +471,11 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Medium-Karten */}
|
{/* Medium-Karten */}
|
||||||
@@ -507,6 +516,15 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
|
|||||||
dirtyKeys={dirtyKeys}
|
dirtyKeys={dirtyKeys}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
|
<PathMediumCard
|
||||||
|
title="Downloads"
|
||||||
|
pathSettings={downloadSettings}
|
||||||
|
settingsByKey={settingsByKey}
|
||||||
|
values={values}
|
||||||
|
errors={errors}
|
||||||
|
dirtyKeys={dirtyKeys}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log-Ordner */}
|
{/* Log-Ordner */}
|
||||||
|
|||||||
@@ -439,8 +439,8 @@ function PathField({
|
|||||||
rounded
|
rounded
|
||||||
size="small"
|
size="small"
|
||||||
className="job-path-download-button"
|
className="job-path-download-button"
|
||||||
aria-label={`${label} als ZIP herunterladen`}
|
aria-label={`${label} als ZIP vorbereiten`}
|
||||||
tooltip={`${label} als ZIP herunterladen`}
|
tooltip={`${label} als ZIP vorbereiten`}
|
||||||
tooltipOptions={{ position: 'top' }}
|
tooltipOptions={{ position: 'top' }}
|
||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
disabled={downloadDisabled || downloadLoading}
|
disabled={downloadDisabled || downloadLoading}
|
||||||
|
|||||||
305
frontend/src/pages/DownloadsPage.jsx
Normal file
305
frontend/src/pages/DownloadsPage.jsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -567,7 +567,19 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
|||||||
|
|
||||||
setDownloadBusyTarget(normalizedTarget);
|
setDownloadBusyTarget(normalizedTarget);
|
||||||
try {
|
try {
|
||||||
await api.downloadJobArchive(jobId, normalizedTarget);
|
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) {
|
} catch (error) {
|
||||||
toastRef.current?.show({
|
toastRef.current?.show({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn.p-button {
|
.nav-btn.p-button {
|
||||||
@@ -195,6 +196,47 @@ body {
|
|||||||
box-shadow: 0 0 0 1px rgba(58, 29, 18, 0.3);
|
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 {
|
.app-main {
|
||||||
width: min(1280px, 96vw);
|
width: min(1280px, 96vw);
|
||||||
margin: 1rem auto 2rem;
|
margin: 1rem auto 2rem;
|
||||||
@@ -1574,6 +1616,22 @@ body {
|
|||||||
white-space: nowrap;
|
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 {
|
.path-medium-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
@@ -1838,6 +1896,47 @@ body {
|
|||||||
gap: 0.5rem;
|
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 {
|
.history-dataview .p-dataview-header {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2667,6 +2766,11 @@ body {
|
|||||||
padding: 0.8rem 1rem;
|
padding: 0.8rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-buttons {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.app-upload-banner {
|
.app-upload-banner {
|
||||||
width: calc(100% - 1.5rem);
|
width: calc(100% - 1.5rem);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -2831,6 +2935,11 @@ body {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.zip-status-indicator {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.history-dv-item-list {
|
.history-dv-item-list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -774,6 +774,11 @@ rsync -a --delete \
|
|||||||
# Datenbank-/Log-Verzeichnisse anlegen
|
# Datenbank-/Log-Verzeichnisse anlegen
|
||||||
mkdir -p "$INSTALL_DIR/backend/data"
|
mkdir -p "$INSTALL_DIR/backend/data"
|
||||||
mkdir -p "$INSTALL_DIR/backend/logs"
|
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
|
# Bei Reinstall: Daten wiederherstellen
|
||||||
if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
|
if [[ -d "$INSTALL_DIR/../ripster-data-backup" ]]; then
|
||||||
@@ -830,6 +835,10 @@ LOG_LEVEL=info
|
|||||||
|
|
||||||
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
# CORS: Erlaube Anfragen vom Frontend (nginx)
|
||||||
CORS_ORIGIN=http://${FRONTEND_HOST}
|
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
|
EOF
|
||||||
ok "Backend .env erstellt"
|
ok "Backend .env erstellt"
|
||||||
fi
|
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/raw"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
mkdir -p "$INSTALL_DIR/backend/data/output/movies"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
mkdir -p "$INSTALL_DIR/backend/data/output/cd"
|
||||||
|
mkdir -p "$INSTALL_DIR/backend/data/downloads"
|
||||||
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
mkdir -p "$INSTALL_DIR/backend/data/logs"
|
||||||
|
|
||||||
# Gesicherte Daten zurückspielen
|
# Gesicherte Daten zurückspielen
|
||||||
@@ -616,6 +617,7 @@ CORS_ORIGIN=http://${FRONTEND_HOST}
|
|||||||
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
DEFAULT_RAW_DIR=${INSTALL_DIR}/backend/data/output/raw
|
||||||
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
DEFAULT_MOVIE_DIR=${INSTALL_DIR}/backend/data/output/movies
|
||||||
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
DEFAULT_CD_DIR=${INSTALL_DIR}/backend/data/output/cd
|
||||||
|
DEFAULT_DOWNLOAD_DIR=${INSTALL_DIR}/backend/data/downloads
|
||||||
EOF
|
EOF
|
||||||
ok "Backend .env erstellt"
|
ok "Backend .env erstellt"
|
||||||
fi
|
fi
|
||||||
@@ -631,9 +633,11 @@ ACTUAL_USER="${SUDO_USER:-}"
|
|||||||
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
if [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]]; then
|
||||||
chown -R "$ACTUAL_USER:$SERVICE_USER" \
|
chown -R "$ACTUAL_USER:$SERVICE_USER" \
|
||||||
"$INSTALL_DIR/backend/data/output" \
|
"$INSTALL_DIR/backend/data/output" \
|
||||||
|
"$INSTALL_DIR/backend/data/downloads" \
|
||||||
"$INSTALL_DIR/backend/data/logs"
|
"$INSTALL_DIR/backend/data/logs"
|
||||||
chmod -R 775 \
|
chmod -R 775 \
|
||||||
"$INSTALL_DIR/backend/data/output" \
|
"$INSTALL_DIR/backend/data/output" \
|
||||||
|
"$INSTALL_DIR/backend/data/downloads" \
|
||||||
"$INSTALL_DIR/backend/data/logs"
|
"$INSTALL_DIR/backend/data/logs"
|
||||||
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
ok "Verzeichnisse $ACTUAL_USER:$SERVICE_USER (775) zugewiesen"
|
||||||
else
|
else
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.1.2"
|
"concurrently": "^9.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ripster",
|
"name": "ripster",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.10.2",
|
"version": "0.10.2-1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
|
||||||
"dev:backend": "npm run dev --prefix backend",
|
"dev:backend": "npm run dev --prefix backend",
|
||||||
|
|||||||
Reference in New Issue
Block a user