source: Klonkt/src/routes/admin-media.js@ f2943c1

main
Last change on this file since f2943c1 was f2943c1, checked in by roboburr <roboburr@…>, 2 months ago

refactor(video-cover, media): stream frames + size cap, drop unused poster, de-dup media listing

  • VideoCoverService: composite frames straight to disk (one canvas in memory, not all N) + guard an oversized cover (MAX_FRAMES / W*H*frames) so it keeps the still image instead of spiking memory/CPU; drop the never-used JPG poster (the iOS swap uses the WebP as the video poster).
  • admin-media: extract imageEntries() shared by the list view and the cleanup route (was duplicated).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 4.9 KB
Line 
1/**
2 * admin-media.js — Beheer → Media (image library + cleanup).
3 *
4 * Lists the uploaded images under storage/media/post-images, shows where each is used, and lets the
5 * owner copy a URL or delete unused files. An animated cover's WebP, its loop MP4 (<base>-v.mp4) and
6 * poster (<base>-v.jpg) are treated as one item; deleting removes the trio. The Audio half of "Media"
7 * stays at /admin/audio (linked as a tab) — this page is the new image side.
8 */
9import express from 'express';
10import path from 'path';
11import fs from 'fs';
12import { fileURLToPath } from 'url';
13import db from '../config/database.js';
14import { renderPage } from '../middleware/render.js';
15import { requireGod } from '../middleware/auth.js';
16import { audioEnabled } from '../config/features.js';
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const POST_IMAGES_DIR = path.resolve(
20 process.env.POST_IMAGES_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'post-images')
21);
22
23const router = express.Router();
24
25const IMG_EXT = /\.(jpe?g|png|webp|gif|avif)$/i;
26const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
27
28// Basename of a /media/post-images/<file> URL (or null).
29function baseOf(url) {
30 const m = String(url || '').match(/\/media\/post-images\/([^/?#"'\s)]+)/);
31 return m ? m[1] : null;
32}
33
34// Map filename -> Set(postId) of posts that reference it (as cover or inline image).
35function usageMap(siteId) {
36 const posts = db.prepare('SELECT id, content, cover_image_url, cover_video_url FROM posts WHERE site_id = ?').all(siteId);
37 const map = new Map();
38 const add = (fn, id) => { if (!fn) return; if (!map.has(fn)) map.set(fn, new Set()); map.get(fn).add(id); };
39 for (const p of posts) {
40 add(baseOf(p.cover_image_url), p.id);
41 add(baseOf(p.cover_video_url), p.id);
42 for (const m of String(p.content || '').matchAll(/\/media\/post-images\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
43 }
44 return map;
45}
46
47function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
48function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
49
50// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
51// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
52function imageEntries(siteId) {
53 const used = usageMap(siteId);
54 let all = [];
55 try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
56 const present = new Set(all);
57 return all
58 .filter(f => IMG_EXT.test(f) && !isSibling(f))
59 .map(f => {
60 const stem = f.replace(/\.[^.]+$/, '');
61 const mp4 = `${stem}-v.mp4`;
62 const hasVideo = present.has(mp4);
63 const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
64 return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
65 });
66}
67
68router.get('/', requireGod, (req, res) => {
69 const site = res.locals.site;
70 if (!site) return res.status(404).send('Site required');
71 const items = imageEntries(site.id)
72 .map(e => ({
73 file: e.file,
74 url: `/media/post-images/${e.file}`,
75 kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
76 hasVideo: e.hasVideo,
77 usedCount: e.usedCount,
78 _mtime: statMtime(e.file),
79 }))
80 .sort((a, b) => b._mtime - a._mtime); // newest first
81 renderPage(req, res, 'pages/admin-media', {
82 pageTitleKey: 'admin.t_media',
83 bodyClass: 'on-admin',
84 items,
85 unusedCount: items.filter(i => !i.usedCount).length,
86 audioOn: audioEnabled(),
87 success: req.query.success || null,
88 });
89});
90
91// Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
92router.post('/delete', requireGod, (req, res) => {
93 if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
94 const f = String(req.body.file || '');
95 if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
96 const stem = f.replace(/\.[^.]+$/, '');
97 let removed = 0;
98 for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
99 const full = path.join(POST_IMAGES_DIR, name);
100 if (path.dirname(full) !== POST_IMAGES_DIR) continue;
101 try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
102 }
103 res.json({ ok: true, removed });
104});
105
106// Delete every unused image (orphan) + its siblings.
107router.post('/cleanup', requireGod, (req, res) => {
108 const site = res.locals.site;
109 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
110 let removed = 0;
111 for (const e of imageEntries(site.id)) {
112 if (e.usedCount) continue; // still in use
113 for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
114 try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
115 }
116 }
117 res.json({ ok: true, removed });
118});
119
120export default router;
Note: See TracBrowser for help on using the repository browser.