const fs = require('fs'); const path = require('path'); const { randomUUID } = require('crypto'); const { spawnSync } = require('child_process'); const archiver = require('archiver'); const settingsService = require('./settingsService'); const historyService = require('./historyService'); const wsService = require('./websocketService'); const logger = require('./logger').child('DOWNLOADS'); function safeJsonParse(raw, fallback = null) { if (!raw) { return fallback; } try { return JSON.parse(raw); } catch (_error) { return fallback; } } function normalizeDownloadId(value) { const raw = String(value || '').trim(); return raw || null; } function normalizeStatus(value) { const raw = String(value || '').trim().toLowerCase(); if (['queued', 'processing', 'ready', 'failed'].includes(raw)) { return raw; } return 'failed'; } function normalizeTarget(value) { const raw = String(value || '').trim().toLowerCase(); if (raw === 'raw') { return 'raw'; } if (raw === 'output') { return 'output'; } return 'output'; } function normalizeDateString(value) { const raw = String(value || '').trim(); if (!raw) { return null; } const parsed = new Date(raw); return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); } function normalizeNumber(value, fallback = null) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function compareCreatedDesc(a, b) { const left = String(a?.createdAt || ''); const right = String(b?.createdAt || ''); return right.localeCompare(left) || String(b?.id || '').localeCompare(String(a?.id || '')); } function applyOwnerToPath(targetPath, ownerSpec) { const spec = String(ownerSpec || '').trim(); if (!targetPath || !spec) { return; } try { const result = spawnSync('chown', [spec, targetPath], { timeout: 15000 }); if (result.status !== 0) { logger.warn('download:chown:failed', { targetPath, spec, stderr: String(result.stderr || '').trim() || null }); } } catch (error) { logger.warn('download:chown:error', { targetPath, spec, error: error?.message || String(error) }); } } class DownloadService { constructor() { this.items = new Map(); this.activeTasks = new Map(); this.initPromise = null; } async init() { if (!this.initPromise) { this.initPromise = this._init(); } return this.initPromise; } async _init() { const settings = await settingsService.getEffectiveSettingsMap(null); const downloadDir = String(settings?.download_dir || '').trim(); const owner = String(settings?.download_dir_owner || '').trim() || null; await fs.promises.mkdir(downloadDir, { recursive: true }); applyOwnerToPath(downloadDir, owner); let entries = []; try { entries = await fs.promises.readdir(downloadDir, { withFileTypes: true }); } catch (error) { logger.warn('download:init:readdir-failed', { downloadDir, error: error?.message || String(error) }); entries = []; } const nowIso = new Date().toISOString(); const pendingResumeIds = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.json')) { continue; } const metaPath = path.join(downloadDir, entry.name); const parsed = safeJsonParse(await fs.promises.readFile(metaPath, 'utf-8').catch(() => null), null); if (!parsed || typeof parsed !== 'object') { continue; } const item = this._normalizeLoadedItem(parsed, downloadDir); if (!item) { continue; } let changed = false; if (item.status === 'queued' || item.status === 'processing') { const archiveExists = await this._pathExists(item.archivePath); if (archiveExists) { const archiveStat = await fs.promises.stat(item.archivePath).catch(() => null); item.status = 'ready'; item.errorMessage = null; item.finishedAt = item.finishedAt || nowIso; item.sizeBytes = Number.isFinite(Number(archiveStat?.size)) ? archiveStat.size : item.sizeBytes; changed = true; logger.warn('download:init:recovered-ready-archive', { id: item.id, archiveName: item.archiveName }); } else { item.status = 'queued'; item.startedAt = null; item.finishedAt = null; item.errorMessage = null; item.sizeBytes = null; changed = true; pendingResumeIds.push(item.id); await this._safeUnlink(item.partialPath); logger.warn('download:init:requeue-interrupted-job', { id: item.id, archiveName: item.archiveName }); } } else if (item.status === 'ready') { const exists = await this._pathExists(item.archivePath); if (!exists) { item.status = 'failed'; item.errorMessage = 'ZIP-Datei wurde nicht gefunden.'; item.finishedAt = nowIso; item.sizeBytes = null; changed = true; } } this.items.set(item.id, item); if (changed) { await this._persistItem(item); } } if (pendingResumeIds.length > 0) { logger.warn('download:init:resume-pending-jobs', { count: pendingResumeIds.length }); setImmediate(() => { for (const id of pendingResumeIds) { void this._startArchiveJob(id); } }); } } _normalizeLoadedItem(rawItem, fallbackDir) { const id = normalizeDownloadId(rawItem?.id); if (!id) { return null; } const downloadDir = String(rawItem?.downloadDir || fallbackDir || '').trim(); if (!downloadDir) { return null; } return { id, kind: String(rawItem?.kind || 'history').trim() || 'history', jobId: normalizeNumber(rawItem?.jobId, null), target: normalizeTarget(rawItem?.target), label: String(rawItem?.label || (rawItem?.target === 'raw' ? 'RAW' : 'Encode')).trim() || 'Download', displayTitle: String(rawItem?.displayTitle || '').trim() || null, sourcePath: String(rawItem?.sourcePath || '').trim() || null, sourceType: String(rawItem?.sourceType || '').trim() === 'file' ? 'file' : 'directory', sourceMtimeMs: normalizeNumber(rawItem?.sourceMtimeMs, null), sourceModifiedAt: normalizeDateString(rawItem?.sourceModifiedAt), entryName: String(rawItem?.entryName || '').trim() || null, archiveName: String(rawItem?.archiveName || `${id}.zip`).trim() || `${id}.zip`, downloadDir, archivePath: String(rawItem?.archivePath || path.join(downloadDir, `${id}.zip`)).trim(), partialPath: String(rawItem?.partialPath || path.join(downloadDir, `${id}.partial.zip`)).trim(), metaPath: String(rawItem?.metaPath || path.join(downloadDir, `${id}.json`)).trim(), ownerSpec: String(rawItem?.ownerSpec || '').trim() || null, status: normalizeStatus(rawItem?.status), createdAt: normalizeDateString(rawItem?.createdAt) || new Date().toISOString(), startedAt: normalizeDateString(rawItem?.startedAt), finishedAt: normalizeDateString(rawItem?.finishedAt), errorMessage: String(rawItem?.errorMessage || '').trim() || null, sizeBytes: normalizeNumber(rawItem?.sizeBytes, null) }; } _serializeItem(item) { return { id: item.id, kind: item.kind, jobId: item.jobId, target: item.target, label: item.label, displayTitle: item.displayTitle, sourcePath: item.sourcePath, sourceType: item.sourceType, archiveName: item.archiveName, downloadDir: item.downloadDir, status: item.status, createdAt: item.createdAt, startedAt: item.startedAt, finishedAt: item.finishedAt, errorMessage: item.errorMessage, sizeBytes: item.sizeBytes, downloadUrl: item.status === 'ready' ? `/api/downloads/${encodeURIComponent(item.id)}/file` : null }; } getSummary() { const items = Array.from(this.items.values()); const queuedCount = items.filter((item) => item.status === 'queued').length; const processingCount = items.filter((item) => item.status === 'processing').length; const readyCount = items.filter((item) => item.status === 'ready').length; const failedCount = items.filter((item) => item.status === 'failed').length; return { totalCount: items.length, queuedCount, processingCount, activeCount: queuedCount + processingCount, readyCount, failedCount }; } _broadcastUpdate(reason, item = null) { wsService.broadcast('DOWNLOADS_UPDATED', { reason: String(reason || 'updated').trim() || 'updated', summary: this.getSummary(), item: item ? this._serializeItem(item) : null }); } async listItems() { await this.init(); return Array.from(this.items.values()) .sort(compareCreatedDesc) .map((item) => this._serializeItem(item)); } async getItem(id) { await this.init(); const normalizedId = normalizeDownloadId(id); if (!normalizedId || !this.items.has(normalizedId)) { const error = new Error('Download nicht gefunden.'); error.statusCode = 404; throw error; } return this.items.get(normalizedId); } async enqueueHistoryJob(jobId, target, options = {}) { await this.init(); const descriptor = await historyService.getJobArchiveDescriptor(jobId, target, options); const settings = await settingsService.getEffectiveSettingsMap(null); const downloadDir = String(settings?.download_dir || '').trim(); const ownerSpec = String(settings?.download_dir_owner || '').trim() || null; await fs.promises.mkdir(downloadDir, { recursive: true }); applyOwnerToPath(downloadDir, ownerSpec); const reusable = await this._findReusableHistoryItem(descriptor, downloadDir); if (reusable) { return { item: this._serializeItem(reusable), reused: true, created: false }; } const id = randomUUID(); const nowIso = new Date().toISOString(); const item = { id, kind: 'history', jobId: descriptor.jobId, target: descriptor.target, label: descriptor.target === 'raw' ? 'RAW' : 'Encode', displayTitle: descriptor.displayTitle, sourcePath: descriptor.sourcePath, sourceType: descriptor.sourceType, sourceMtimeMs: descriptor.sourceMtimeMs, sourceModifiedAt: descriptor.sourceModifiedAt, entryName: descriptor.entryName, archiveName: descriptor.archiveName, downloadDir, archivePath: path.join(downloadDir, `${id}.zip`), partialPath: path.join(downloadDir, `${id}.partial.zip`), metaPath: path.join(downloadDir, `${id}.json`), ownerSpec, status: 'queued', createdAt: nowIso, startedAt: null, finishedAt: null, errorMessage: null, sizeBytes: null }; this.items.set(id, item); await this._persistItem(item); this._broadcastUpdate('queued', item); setImmediate(() => { void this._startArchiveJob(id); }); return { item: this._serializeItem(item), reused: false, created: true }; } async _findReusableHistoryItem(descriptor, downloadDir) { for (const item of this.items.values()) { if (item.kind !== 'history') { continue; } if (item.jobId !== descriptor.jobId || item.target !== descriptor.target) { continue; } if (item.sourcePath !== descriptor.sourcePath || item.sourceMtimeMs !== descriptor.sourceMtimeMs) { continue; } if (item.downloadDir !== downloadDir) { continue; } if (!['queued', 'processing', 'ready'].includes(item.status)) { continue; } if (item.status === 'ready' && !(await this._pathExists(item.archivePath))) { item.status = 'failed'; item.errorMessage = 'ZIP-Datei wurde nicht gefunden.'; item.finishedAt = new Date().toISOString(); item.sizeBytes = null; await this._persistItem(item); this._broadcastUpdate('failed', item); continue; } return item; } return null; } async _startArchiveJob(id) { const item = this.items.get(id); if (!item) { return; } if (this.activeTasks.has(id)) { return this.activeTasks.get(id); } const promise = this._runArchiveJob(item) .catch((error) => { logger.warn('download:job:failed', { id, archiveName: item.archiveName, error: error?.message || String(error) }); }) .finally(() => { this.activeTasks.delete(id); }); this.activeTasks.set(id, promise); return promise; } async _runArchiveJob(item) { item.status = 'processing'; item.startedAt = new Date().toISOString(); item.finishedAt = null; item.errorMessage = null; item.sizeBytes = null; await this._safeUnlink(item.partialPath); await this._persistItem(item); this._broadcastUpdate('processing', item); await fs.promises.mkdir(item.downloadDir, { recursive: true }); applyOwnerToPath(item.downloadDir, item.ownerSpec); await new Promise((resolve, reject) => { let settled = false; const output = fs.createWriteStream(item.partialPath); const archive = archiver('zip', { zlib: { level: 9 } }); const finishError = (error) => { if (settled) { return; } settled = true; output.destroy(); reject(error); }; output.on('close', () => { if (settled) { return; } settled = true; resolve(); }); output.on('error', finishError); archive.on('warning', finishError); archive.on('error', finishError); archive.pipe(output); if (item.sourceType === 'directory') { archive.directory(item.sourcePath, item.entryName); } else { archive.file(item.sourcePath, { name: item.entryName }); } try { const finalizeResult = archive.finalize(); if (finalizeResult && typeof finalizeResult.catch === 'function') { finalizeResult.catch(finishError); } } catch (error) { finishError(error); } }).catch(async (error) => { await this._safeUnlink(item.partialPath); item.status = 'failed'; item.finishedAt = new Date().toISOString(); item.errorMessage = error?.message || 'ZIP-Erstellung fehlgeschlagen.'; item.sizeBytes = null; await this._persistItem(item); this._broadcastUpdate('failed', item); throw error; }); await fs.promises.rename(item.partialPath, item.archivePath); applyOwnerToPath(item.archivePath, item.ownerSpec); const stat = await fs.promises.stat(item.archivePath); item.status = 'ready'; item.finishedAt = new Date().toISOString(); item.errorMessage = null; item.sizeBytes = stat.size; await this._persistItem(item); this._broadcastUpdate('ready', item); } async getDownloadDescriptor(id) { const item = await this.getItem(id); if (item.status !== 'ready') { const error = new Error('ZIP-Datei ist noch nicht fertig.'); error.statusCode = 409; throw error; } const exists = await this._pathExists(item.archivePath); if (!exists) { item.status = 'failed'; item.finishedAt = new Date().toISOString(); item.errorMessage = 'ZIP-Datei wurde nicht gefunden.'; item.sizeBytes = null; await this._persistItem(item); this._broadcastUpdate('failed', item); const error = new Error('ZIP-Datei wurde nicht gefunden.'); error.statusCode = 404; throw error; } return { path: item.archivePath, archiveName: item.archiveName }; } async deleteItem(id) { const item = await this.getItem(id); if (item.status === 'queued' || item.status === 'processing' || this.activeTasks.has(item.id)) { const error = new Error('Laufende ZIP-Jobs können nicht gelöscht werden.'); error.statusCode = 409; throw error; } await this._safeUnlink(item.archivePath); await this._safeUnlink(item.partialPath); await this._safeUnlink(item.metaPath); this.items.delete(item.id); this._broadcastUpdate('deleted', item); return { deleted: true, id: item.id }; } async _persistItem(item) { const next = { ...item, metaPath: item.metaPath, archivePath: item.archivePath, partialPath: item.partialPath }; const tmpMetaPath = `${item.metaPath}.tmp`; await fs.promises.writeFile(tmpMetaPath, JSON.stringify(next, null, 2), 'utf-8'); await fs.promises.rename(tmpMetaPath, item.metaPath); applyOwnerToPath(item.metaPath, item.ownerSpec); } async _safeUnlink(targetPath) { if (!targetPath) { return; } try { await fs.promises.rm(targetPath, { force: true }); } catch (_error) { // ignore cleanup errors } } async _pathExists(targetPath) { if (!targetPath) { return false; } try { await fs.promises.access(targetPath, fs.constants.F_OK); return true; } catch (_error) { return false; } } } module.exports = new DownloadService();