Projekte anlegen und Clips in Stücken hochladen
Ein Projekt hält die ausgewählten Clips samt Absender und Beschriftung. Videos kommen in Stücken bis 16 MB; die Größe der Teildatei ist der Stand, sodass ein abgebrochener Upload dort weitermacht. Nach dem letzten Stück liest ffprobe Länge, Größe, Drehung und Tonspur aus. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6f583c8f5b
commit
782d306cb1
7 files changed
+549
-2
No files matched your search
+47
-1
@@ -8,8 +8,9 @@ import {
|
||||
readCookie,
|
||||
verifyToken,
|
||||
} from './auth.js';
|
||||
import { HttpError, badRequest } from './http-error.js';
|
||||
|
||||
export function createApp({ config, secret }) {
|
||||
export function createApp({ config, secret, store }) {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', true);
|
||||
@@ -55,7 +56,52 @@ export function createApp({ config, secret }) {
|
||||
next();
|
||||
});
|
||||
|
||||
const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
|
||||
|
||||
app.get('/api/projects', route(async (req, res) => {
|
||||
res.json(await store.list());
|
||||
}));
|
||||
|
||||
app.post('/api/projects', route(async (req, res) => {
|
||||
res.status(201).json(await store.create(req.body ?? {}));
|
||||
}));
|
||||
|
||||
app.get('/api/projects/:id', route(async (req, res) => {
|
||||
res.json(await store.get(req.params.id));
|
||||
}));
|
||||
|
||||
app.delete('/api/projects/:id', route(async (req, res) => {
|
||||
await store.remove(req.params.id);
|
||||
res.status(204).end();
|
||||
}));
|
||||
|
||||
app.put('/api/projects/:id/clips/:index', route(async (req, res) => {
|
||||
const offset = Number(req.query.offset);
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) throw badRequest('Ungültiger Offset.');
|
||||
res.json(await store.appendChunk(req.params.id, req.params.index, offset, req));
|
||||
}));
|
||||
|
||||
app.get('/api/projects/:id/clips/:index/file', route(async (req, res) => {
|
||||
const project = await store.get(req.params.id);
|
||||
res.sendFile(store.clipPath(project, req.params.index));
|
||||
}));
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Nicht gefunden.' }));
|
||||
|
||||
// eslint-disable-next-line no-unused-vars -- Express erkennt Fehlerbehandler an vier Parametern
|
||||
app.use((error, req, res, next) => {
|
||||
if (error instanceof HttpError) {
|
||||
return res.status(error.status).json({ error: error.message, ...error.details });
|
||||
}
|
||||
// Fehler aus express.json (kaputtes oder zu großes JSON)
|
||||
if (error.status >= 400 && error.status < 500) {
|
||||
return res.status(error.status).json({ error: 'Ungültige Anfrage.' });
|
||||
}
|
||||
// Der Browser hat den Upload abgebrochen; der Stand bleibt gültig.
|
||||
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
|
||||
console.error(error);
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Interner Fehler.' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(status, message, details = {}) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export const badRequest = (message) => new HttpError(400, message);
|
||||
export const notFound = (message = 'Nicht gefunden.') => new HttpError(404, message);
|
||||
export const conflict = (message, details) => new HttpError(409, message, details);
|
||||
+4
-1
@@ -5,6 +5,8 @@ import express from 'express';
|
||||
import { createApp } from './app.js';
|
||||
import { loadSessionSecret } from './auth.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { probe } from './media.js';
|
||||
import { createProjectStore } from './projects.js';
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -13,7 +15,8 @@ if (!config.pin) {
|
||||
console.warn('APP_PIN ist nicht gesetzt – Anmeldung ist nicht möglich.');
|
||||
}
|
||||
|
||||
const app = createApp({ config, secret: loadSessionSecret(config) });
|
||||
const store = createProjectStore({ dataDir: config.dataDir, probe });
|
||||
const app = createApp({ config, secret: loadSessionSecret(config), store });
|
||||
const server = http.createServer(app);
|
||||
|
||||
if (config.isProduction) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function parseRate(rate) {
|
||||
const [numerator, denominator] = String(rate || '0/1').split('/').map(Number);
|
||||
return denominator ? numerator / denominator : 0;
|
||||
}
|
||||
|
||||
// Handyvideos liegen oft quer im Container und tragen die Drehung als Metadatum.
|
||||
function readRotation(stream) {
|
||||
const side = stream.side_data_list?.find((entry) => entry.rotation !== undefined);
|
||||
if (side) return Number(side.rotation);
|
||||
if (stream.tags?.rotate) return Number(stream.tags.rotate);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Liefert die Eckdaten eines Videos, Breite und Höhe so, wie es angezeigt wird.
|
||||
export async function probe(file) {
|
||||
const { stdout } = await execFileAsync(
|
||||
'ffprobe',
|
||||
['-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', file],
|
||||
{ maxBuffer: 10 * 1024 * 1024 },
|
||||
);
|
||||
const info = JSON.parse(stdout);
|
||||
const video = info.streams.find(
|
||||
(stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic,
|
||||
);
|
||||
if (!video) throw new Error('Keine Videospur gefunden.');
|
||||
|
||||
const rotation = readRotation(video);
|
||||
const sideways = Math.abs(rotation) % 180 === 90;
|
||||
return {
|
||||
duration: Number(info.format?.duration ?? video.duration ?? 0),
|
||||
width: sideways ? video.height : video.width,
|
||||
height: sideways ? video.width : video.height,
|
||||
rotation,
|
||||
fps: Math.round(parseRate(video.avg_frame_rate || video.r_frame_rate) * 100) / 100,
|
||||
hasAudio: info.streams.some((stream) => stream.codec_type === 'audio'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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';
|
||||
|
||||
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']);
|
||||
|
||||
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 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 (!Number.isSafeInteger(input.size) || input.size < 1 || input.size > MAX_CLIP_BYTES) {
|
||||
throw badRequest(`Clip ${index + 1}: ungültige Dateigröße.`);
|
||||
}
|
||||
if (typeof input.timestamp !== 'string' || !TIMESTAMP_PATTERN.test(input.timestamp)) {
|
||||
throw badRequest(`Clip ${index + 1}: ungültiger Zeitstempel.`);
|
||||
}
|
||||
return {
|
||||
index,
|
||||
file,
|
||||
size: input.size,
|
||||
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 };
|
||||
}
|
||||
|
||||
// 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 busyClips = 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');
|
||||
const clipFile = (id, clip) => path.join(projectDir(id), 'clips', `${clip.index}.${extensionOf(clip.file)}`);
|
||||
|
||||
async function read(id) {
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(projectFile(id), 'utf8'));
|
||||
} 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;
|
||||
}
|
||||
|
||||
function getClip(project, index) {
|
||||
const clip = project.clips[Number(index)];
|
||||
if (!/^\d+$/.test(String(index)) || !clip) throw notFound('Clip nicht gefunden.');
|
||||
return clip;
|
||||
}
|
||||
|
||||
async function withProgress(project) {
|
||||
const clips = await Promise.all(
|
||||
project.clips.map(async (clip) => ({
|
||||
...clip,
|
||||
received:
|
||||
clip.status === 'pending'
|
||||
? await fileSize(`${clipFile(project.id, clip)}.part`)
|
||||
: clip.size,
|
||||
})),
|
||||
);
|
||||
return { ...project, clips };
|
||||
}
|
||||
|
||||
return {
|
||||
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),
|
||||
};
|
||||
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 });
|
||||
},
|
||||
|
||||
clipPath(project, index) {
|
||||
const clip = getClip(project, index);
|
||||
if (clip.status !== 'ready') throw notFound('Clip ist noch nicht hochgeladen.');
|
||||
return clipFile(project.id, clip);
|
||||
},
|
||||
|
||||
// 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, index, offset, stream) {
|
||||
const project = await read(id);
|
||||
const clip = getClip(project, index);
|
||||
if (clip.status !== 'pending') return { received: clip.size, status: clip.status };
|
||||
|
||||
const key = `${id}/${clip.index}`;
|
||||
if (busyClips.has(key)) throw conflict('Upload läuft bereits.', { received: null });
|
||||
busyClips.add(key);
|
||||
try {
|
||||
const final = clipFile(id, clip);
|
||||
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, clip.size - received);
|
||||
await pipeline(stream, byteLimit(allowed), fs.createWriteStream(part, { flags: 'a' }));
|
||||
|
||||
const total = await fileSize(part);
|
||||
if (total < clip.size) return { received: total, status: 'pending' };
|
||||
|
||||
await fsp.rename(part, final);
|
||||
let media = null;
|
||||
let problem = null;
|
||||
try {
|
||||
media = await probe(final);
|
||||
} catch (error) {
|
||||
problem = error.message;
|
||||
}
|
||||
const updated = await update(id, (current) => {
|
||||
const target = current.clips[clip.index];
|
||||
target.status = media ? 'ready' : 'error';
|
||||
target.media = media;
|
||||
if (problem) target.error = problem;
|
||||
});
|
||||
return { received: total, status: updated.clips[clip.index].status };
|
||||
} finally {
|
||||
busyClips.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user