From 685e383621ad32e77343813cce4ad168931b4b86 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Thu, 7 May 2026 02:44:04 +0800 Subject: [PATCH] feat(web): add asset CRUD and scanning API Co-Authored-By: Claude Opus 4.7 --- web/server/routes/assets.ts | 117 ++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 web/server/routes/assets.ts diff --git a/web/server/routes/assets.ts b/web/server/routes/assets.ts new file mode 100644 index 0000000..a44a861 --- /dev/null +++ b/web/server/routes/assets.ts @@ -0,0 +1,117 @@ +import { Router } from 'express'; +import { getDb } from '../db'; +import { randomUUID } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +export const assetsRouter = Router(); + +// List assets with optional filters +assetsRouter.get('/', (req, res) => { + const { accountId, type } = req.query; + let sql = 'SELECT * FROM assets WHERE 1=1'; + const params: unknown[] = []; + + if (accountId) { sql += ' AND account_id = ?'; params.push(accountId); } + if (type) { sql += ' AND type = ?'; params.push(type); } + + sql += ' ORDER BY created_at DESC LIMIT 200'; + const rows = getDb().prepare(sql).all(...params); + res.json(rows); +}); + +// Delete asset and its file +assetsRouter.delete('/:id', (req, res) => { + const asset = getDb().prepare('SELECT * FROM assets WHERE id = ?').get(req.params.id) as { + file_path: string; + } | undefined; + + if (!asset) return res.status(404).json({ error: 'Asset not found' }); + + const filePath = path.join(PROJECT_ROOT, asset.file_path); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + + getDb().prepare('DELETE FROM assets WHERE id = ?').run(req.params.id); + res.status(204).send(); +}); + +// Scan output directories and index assets +assetsRouter.post('/scan', (_req, res) => { + const outputDir = path.join(PROJECT_ROOT, 'output'); + if (!fs.existsSync(outputDir)) return res.json({ indexed: 0 }); + + const dirs = fs.readdirSync(outputDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + let indexed = 0; + for (const dir of dirs) { + const manifestPath = path.join(outputDir, dir.name, 'manifest.json'); + if (!fs.existsSync(manifestPath)) continue; + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + } catch { continue; } + + const accountId = manifest.account?.id || ''; + + // Scan images + const imagesDir = path.join(outputDir, dir.name, 'images'); + if (fs.existsSync(imagesDir)) { + const files = fs.readdirSync(imagesDir); + for (const file of files) { + if (!/\.(jpe?g|png|webp)$/i.test(file)) continue; + const id = randomUUID(); + const relPath = path.relative(PROJECT_ROOT, path.join(imagesDir, file)); + const match = file.match(/scene_(\d+)/); + const shotIndex = match ? parseInt(match[1]) : null; + + const exists = getDb().prepare('SELECT id FROM assets WHERE file_path = ?').get(relPath); + if (exists) continue; + + getDb().prepare( + 'INSERT INTO assets (id, account_id, manifest_path, type, file_path, shot_index) VALUES (?,?,?,?,?,?)' + ).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'image', relPath, shotIndex); + indexed++; + } + } + + // Scan videos + const videosDir = path.join(outputDir, dir.name, 'videos'); + if (fs.existsSync(videosDir)) { + const files = fs.readdirSync(videosDir); + for (const file of files) { + if (!/\.mp4$/i.test(file)) continue; + const id = randomUUID(); + const relPath = path.relative(PROJECT_ROOT, path.join(videosDir, file)); + const match = file.match(/scene_(\d+)/); + const shotIndex = match ? parseInt(match[1]) : null; + + const exists = getDb().prepare('SELECT id FROM assets WHERE file_path = ?').get(relPath); + if (exists) continue; + + getDb().prepare( + 'INSERT INTO assets (id, account_id, manifest_path, type, file_path, shot_index) VALUES (?,?,?,?,?,?)' + ).run(id, accountId, path.relative(PROJECT_ROOT, manifestPath), 'video', relPath, shotIndex); + indexed++; + } + } + } + + res.json({ indexed }); +}); + +// Serve asset files +assetsRouter.get('/file', (req, res) => { + const filePath = req.query.path as string; + if (!filePath) return res.status(400).send('Missing path'); + const fullPath = path.resolve(PROJECT_ROOT, filePath); + if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden'); + if (!fs.existsSync(fullPath)) return res.status(404).send('Not found'); + res.sendFile(fullPath); +});