From ca2bd765721152403943f556144c848dfea3216f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sun, 15 Mar 2026 13:04:05 +0000 Subject: [PATCH] 0.10.2-1 Downloads --- backend/.env.example | 1 + backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/config.js | 3 +- backend/src/db/database.js | 12 + backend/src/index.js | 4 + backend/src/routes/downloadRoutes.js | 69 +++ backend/src/routes/historyRoutes.js | 68 --- backend/src/services/downloadService.js | 537 ++++++++++++++++++ backend/src/services/historyService.js | 3 + backend/src/services/settingsService.js | 10 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 87 ++- frontend/src/api/client.js | 23 +- .../src/components/DynamicSettingsForm.jsx | 18 + frontend/src/components/JobDetailDialog.jsx | 4 +- frontend/src/pages/DownloadsPage.jsx | 305 ++++++++++ frontend/src/pages/HistoryPage.jsx | 14 +- frontend/src/styles/app.css | 109 ++++ install-dev.sh | 9 + install.sh | 4 + package-lock.json | 4 +- package.json | 2 +- 24 files changed, 1209 insertions(+), 89 deletions(-) create mode 100644 backend/src/routes/downloadRoutes.js create mode 100644 backend/src/services/downloadService.js create mode 100644 frontend/src/pages/DownloadsPage.jsx diff --git a/backend/.env.example b/backend/.env.example index 4ad0dc4..70e6726 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,3 +9,4 @@ LOG_LEVEL=debug DEFAULT_RAW_DIR= DEFAULT_MOVIE_DIR= DEFAULT_CD_DIR= +DEFAULT_DOWNLOAD_DIR= diff --git a/backend/package-lock.json b/backend/package-lock.json index 7bab27a..5e60318 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-backend", - "version": "0.10.2", + "version": "0.10.2-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-backend", - "version": "0.10.2", + "version": "0.10.2-1", "dependencies": { "archiver": "^7.0.1", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index bf712f7..b1f71d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-backend", - "version": "0.10.2", + "version": "0.10.2-1", "private": true, "type": "commonjs", "scripts": { diff --git a/backend/src/config.js b/backend/src/config.js index bbd6e10..6402427 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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') }; diff --git a/backend/src/db/database.js b/backend/src/db/database.js index 22f8b24..b538630 100644 --- a/backend/src/db/database.js +++ b/backend/src/db/database.js @@ -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)` ); 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() { diff --git a/backend/src/index.js b/backend/src/index.js index ad9dd52..cde9e65 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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 })); diff --git a/backend/src/routes/downloadRoutes.js b/backend/src/routes/downloadRoutes.js new file mode 100644 index 0000000..7906581 --- /dev/null +++ b/backend/src/routes/downloadRoutes.js @@ -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; diff --git a/backend/src/routes/historyRoutes.js b/backend/src/routes/historyRoutes.js index 3708261..36500b9 100644 --- a/backend/src/routes/historyRoutes.js +++ b/backend/src/routes/historyRoutes.js @@ -1,10 +1,8 @@ const express = require('express'); -const archiver = require('archiver'); const asyncHandler = require('../middleware/asyncHandler'); const historyService = require('../services/historyService'); const pipelineService = require('../services/pipelineService'); const logger = require('../services/logger').child('HISTORY_ROUTE'); -const { errorToMeta } = require('../utils/errorMeta'); 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( '/:id', asyncHandler(async (req, res) => { diff --git a/backend/src/services/downloadService.js b/backend/src/services/downloadService.js new file mode 100644 index 0000000..4566b49 --- /dev/null +++ b/backend/src/services/downloadService.js @@ -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(); diff --git a/backend/src/services/historyService.js b/backend/src/services/historyService.js index e57b784..369a599 100644 --- a/backend/src/services/historyService.js +++ b/backend/src/services/historyService.js @@ -1519,9 +1519,12 @@ class HistoryService { 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) }; diff --git a/backend/src/services/settingsService.js b/backend/src/services/settingsService.js index a821e08..71756f8 100644 --- a/backend/src/services/settingsService.js +++ b/backend/src/services/settingsService.js @@ -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']; @@ -741,6 +742,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 +764,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 } }; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5aeddf8..bed5a80 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ripster-frontend", - "version": "0.10.2", + "version": "0.10.2-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ripster-frontend", - "version": "0.10.2", + "version": "0.10.2-1", "dependencies": { "primeicons": "^7.0.0", "primereact": "^10.9.2", diff --git a/frontend/package.json b/frontend/package.json index a4be1e2..7a2ba23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ripster-frontend", - "version": "0.10.2", + "version": "0.10.2-1", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9e06222..0f3c528 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
+ +
Ripster Logo @@ -316,6 +389,15 @@ function App() { outlined={location.pathname !== item.path} /> ))} +