import crypto from 'node:crypto'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import { Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { HttpError, badRequest, conflict, notFound } from './http-error.js'; import { defaultSettings, mergeSettings } from './settings.js'; export const MAX_CLIPS = 200; export const MAX_CLIP_BYTES = 4 * 1024 ** 3; export const MAX_CHUNK_BYTES = 16 * 1024 * 1024; const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const VIDEO_EXTENSIONS = new Set(['mp4', 'mov', 'm4v', '3gp', 'avi', 'mkv', 'webm']); const AUDIO_EXTENSIONS = new Set([ 'mp3', 'm4a', 'aac', 'wav', 'ogg', 'oga', 'opus', 'flac', 'weba', 'webm', 'mp4', ]); // Zusatzdateien: Intro und Outro sind Videos, Musik und Voice-overs Ton. const ASSET_SLOTS = { intro: { needs: 'video', extensions: VIDEO_EXTENSIONS }, outro: { needs: 'video', extensions: VIDEO_EXTENSIONS }, music: { needs: 'audio', extensions: AUDIO_EXTENSIONS }, voice: { needs: 'audio', extensions: AUDIO_EXTENSIONS }, }; function extensionOf(name) { return name.split('.').pop().toLowerCase(); } function optionalString(value, maxLength) { if (value === null || value === undefined) return null; if (typeof value !== 'string') throw badRequest('Ungültige Angabe.'); return value.slice(0, maxLength); } function validateSize(size, label) { if (!Number.isSafeInteger(size) || size < 1 || size > MAX_CLIP_BYTES) { throw badRequest(`${label}: ungültige Dateigröße.`); } return size; } function validateClip(input, index) { const file = typeof input?.file === 'string' ? input.file : ''; if (!file || file.length > 255 || !VIDEO_EXTENSIONS.has(extensionOf(file))) { throw badRequest(`Clip ${index + 1}: kein unterstütztes Video.`); } if (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) { throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`); } return { index, file, size: validateSize(input.size, `Clip ${index + 1}`), timestamp: input.timestamp, sender: optionalString(input.sender, 200), caption: optionalString(input.caption, 2000) ?? '', status: 'pending', }; } function validateRange(range) { const from = range?.from ?? null; const to = range?.to ?? null; for (const value of [from, to]) { if (value !== null && (typeof value !== 'string' || !DATE_PATTERN.test(value))) { throw badRequest('Ungültiger Zeitraum.'); } } return { from, to }; } // "intro", "outro", "music" oder "voice-" function parseSlot(slot, project) { const match = /^(intro|outro|music|voice)(?:-(\d+))?$/.exec(String(slot)); if (!match) throw notFound('Unbekannte Datei.'); const [, kind, index] = match; if ((kind === 'voice') !== (index !== undefined)) throw notFound('Unbekannte Datei.'); if (kind === 'voice' && !project.clips[Number(index)]) throw notFound('Clip nicht gefunden.'); return { kind, ...ASSET_SLOTS[kind] }; } // Bricht ab, sobald mehr Bytes kommen als erlaubt. function byteLimit(limit) { let count = 0; return new Transform({ transform(chunk, encoding, callback) { count += chunk.length; if (count > limit) return callback(new HttpError(413, 'Zu viele Daten.')); callback(null, chunk); }, }); } async function fileSize(file) { try { return (await fsp.stat(file)).size; } catch (error) { if (error.code === 'ENOENT') return 0; throw error; } } export function createProjectStore({ dataDir, probe }) { const root = path.join(dataDir, 'projects'); const queues = new Map(); const busyUploads = new Set(); function projectDir(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw notFound(); return path.join(root, id); } const projectFile = (id) => path.join(projectDir(id), 'project.json'); // Ein Upload-Ziel ist ein Clip oder eine Zusatzdatei; beide haben file, size, status. function locate(project, target) { if (target.type === 'clip') { const index = String(target.index); const item = project.clips[Number(index)]; if (!/^\d+$/.test(index) || !item) throw notFound('Clip nicht gefunden.'); return { item, needs: 'video', file: path.join(projectDir(project.id), 'clips', `${item.index}.${extensionOf(item.file)}`), pick: (current) => current.clips[item.index], }; } const { needs } = parseSlot(target.slot, project); const item = project.assets?.[target.slot]; if (!item) throw notFound('Datei nicht gefunden.'); return { item, needs, file: path.join(projectDir(project.id), 'assets', `${target.slot}.${extensionOf(item.file)}`), pick: (current) => current.assets[target.slot], }; } async function read(id) { try { const project = JSON.parse(await fsp.readFile(projectFile(id), 'utf8')); // Projekte aus älteren Ständen ergänzen project.settings = { ...defaultSettings(), ...project.settings }; project.assets ??= {}; return project; } catch (error) { if (error.code === 'ENOENT') throw notFound(); throw error; } } async function write(project) { const file = projectFile(project.id); const temp = `${file}.tmp`; await fsp.writeFile(temp, JSON.stringify(project, null, 2)); await fsp.rename(temp, file); } // Änderungen an einem Projekt laufen nacheinander, damit keine verloren geht. function update(id, change) { const previous = queues.get(id) ?? Promise.resolve(); const next = previous.then(async () => { const project = await read(id); await change(project); project.updatedAt = new Date().toISOString(); await write(project); return project; }); queues.set(id, next.catch(() => {})); return next; } async function withProgress(project) { const received = async (item, file) => item.status === 'pending' ? fileSize(`${file}.part`) : item.size; const clips = await Promise.all( project.clips.map(async (clip) => ({ ...clip, received: await received(clip, locate(project, { type: 'clip', index: clip.index }).file), })), ); const assets = {}; for (const slot of Object.keys(project.assets)) { const { item, file } = locate(project, { type: 'asset', slot }); assets[slot] = { ...item, received: await received(item, file) }; } return { ...project, clips, assets }; } return { update, read, projectDir, async create({ range, clips }) { if (!Array.isArray(clips) || clips.length === 0) throw badRequest('Keine Clips ausgewählt.'); if (clips.length > MAX_CLIPS) throw badRequest(`Höchstens ${MAX_CLIPS} Clips.`); const now = new Date().toISOString(); const project = { id: crypto.randomUUID(), createdAt: now, updatedAt: now, range: validateRange(range), clips: clips.map(validateClip), assets: {}, settings: defaultSettings(), }; await fsp.mkdir(path.join(projectDir(project.id), 'clips'), { recursive: true }); await write(project); return withProgress(project); }, async get(id) { return withProgress(await read(id)); }, async list() { let entries = []; try { entries = await fsp.readdir(root); } catch (error) { if (error.code !== 'ENOENT') throw error; } const projects = []; for (const entry of entries) { if (!ID_PATTERN.test(entry)) continue; try { const project = await read(entry); projects.push({ id: project.id, createdAt: project.createdAt, range: project.range, clipCount: project.clips.length, readyCount: project.clips.filter((clip) => clip.status === 'ready').length, }); } catch { // Unvollständig angelegte Projekte überspringen } } return projects.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); }, async remove(id) { const dir = projectDir(id); await read(id); await fsp.rm(dir, { recursive: true, force: true }); }, async updateSettings(id, patch) { const project = await update(id, (current) => { current.settings = mergeSettings(current.settings, patch, current.clips.length); }); return withProgress(project); }, // Meldet eine neue Zusatzdatei an; eine vorhandene im selben Platz wird ersetzt. async createAsset(id, slot, { file, size } = {}) { const project = await read(id); const { extensions } = parseSlot(slot, project); if (typeof file !== 'string' || !file || file.length > 255 || !extensions.has(extensionOf(file))) { throw badRequest('Dateityp wird hier nicht unterstützt.'); } validateSize(size, 'Datei'); if (project.assets[slot]) await this.removeAsset(id, slot); await fsp.mkdir(path.join(projectDir(id), 'assets'), { recursive: true }); const updated = await update(id, (current) => { current.assets[slot] = { file, size, status: 'pending', revision: crypto.randomUUID() }; }); return withProgress(updated); }, async removeAsset(id, slot) { const project = await read(id); const { file } = locate(project, { type: 'asset', slot }); await fsp.rm(file, { force: true }); await fsp.rm(`${file}.part`, { force: true }); const updated = await update(id, (current) => { delete current.assets[slot]; }); return withProgress(updated); }, // Pfad einer fertig hochgeladenen Datei, für Vorschau und Schnitt. readyPath(project, target) { const { item, file } = locate(project, target); if (item.status !== 'ready') throw notFound('Datei ist noch nicht hochgeladen.'); return file; }, // Hängt ein Stück an. Die Größe der Teildatei ist der maßgebliche Stand: // bricht eine Übertragung ab, bleibt ein gültiger Anfang liegen. async appendChunk(id, target, offset, stream) { const project = await read(id); const { item, needs, file: final, pick } = locate(project, target); if (item.status !== 'pending') return { received: item.size, status: item.status }; const key = `${id}/${final}`; if (busyUploads.has(key)) throw conflict('Upload läuft bereits.', { received: null }); busyUploads.add(key); try { const part = `${final}.part`; const received = await fileSize(part); if (offset !== received) throw conflict('Upload-Stand weicht ab.', { received }); // Zu viel Gesendetes wird gar nicht erst geschrieben; der Anfang bleibt gültig. const allowed = Math.min(MAX_CHUNK_BYTES, item.size - received); await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' })); const total = await fileSize(part); if (total < item.size) return { received: total, status: 'pending' }; await fsp.rename(part, final); let media = null; let problem = null; try { media = await probe(final); if (needs === 'video' && !media.hasVideo) problem = 'Keine Videospur gefunden.'; if (needs === 'audio' && !media.hasAudio) problem = 'Keine Tonspur gefunden.'; } catch (error) { problem = error.message; } const updated = await update(id, (current) => { const entry = pick(current); entry.status = problem ? 'error' : 'ready'; entry.media = problem ? null : media; if (problem) entry.error = problem; }); return { received: total, status: pick(updated).status }; } finally { busyUploads.delete(key); } }, }; }