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

main
Last change on this file since ef519a3 was 97bcf7e, checked in by Robin <roboburr@…>, 6 weeks ago

Video op maat in de post, en een Video-tab in Media

Robins opdracht (30-7): videoverwerking beter aanpakken. Een video die vanuit
Shaer op de Klonkt van een ward landt, rendert nu op maat in plaats van te
groot: dezelfde regels als afbeeldingen in de post-tekst, plus een plafond
(70vh/540px) en object-fit zodat een portret-video de kolom niet meer
volduwt. Geldt ook in de Krant en Berichten (tl-content), waar dezelfde
content langskomt. En de video-tag uit de composer krijgt preload=metadata,
dus de browser toont het eerste frame als voorlopige thumbnail.

Nieuw: Beheer -> Media -> Video's. De videos die C2S-uploads binnenbrachten
(Shaers composer, de boei), met bestandsnaam, grootte, gebruiksteller uit
content plus c2s_attachments, kopieer-URL, en verwijderen alleen voor
ongebruikte, met dezelfde basename-en-binnen-de-map-teugels als bij
afbeeldingen.

Echte posterframes (server-side thumbnails) kunnen hier nog niet: er staat
geen ffmpeg op de VPS. Dat wordt een bead, met de aantekening dat Shaer
thumbnails straks native kan maken (AVAssetImageGenerator /
MediaMetadataRetriever), zonder server-hulp.

Changed files:
src/assets/css/style.css

  • .post-content video en .tl-content video: maatvoering + plafond

src/services/ActivityPubService.js

  • de gevouwen video-tag preloadt zijn metadata

src/routes/admin-media.js

  • videoEntries + GET /videos + POST /videos/delete

src/views/partials/media-tabs.ejs

  • de Video-tab tussen Afbeeldingen en Audio

src/services/i18n.js

  • media_videos/videos_*-strings, NL/EN/DE

New file:
src/views/pages/admin-videos.ejs

  • de Video-tab zelf, zelfde snit als de afbeeldingenpagina

remarks: 336 tests groen, server start. Draait nog nergens; beta/demo
bijwerken maakt het zichtbaar.

-robo
Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 7.9 KB
RevLine 
[f24b795]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;
[97bcf7e]26const VIDEO_EXT = /\.(mp4|webm|m4v|mov)$/i;
27// C2S uploads (Shaer's composer and the help buoy) land here; the videos among
28// them are what the Video tab shows.
29const REPLY_MEDIA_DIR = path.resolve(
30 process.env.REPLY_MEDIA_PATH || path.join(__dirname, '..', '..', 'storage', 'media', 'reply-media')
31);
[f24b795]32const isSibling = (f) => /-v\.(mp4|jpg)$/i.test(f); // an animated cover's video/poster sibling
33
34// Basename of a /media/post-images/<file> URL (or null).
35function baseOf(url) {
36 const m = String(url || '').match(/\/media\/post-images\/([^/?#"'\s)]+)/);
37 return m ? m[1] : null;
38}
39
40// Map filename -> Set(postId) of posts that reference it (as cover or inline image).
41function usageMap(siteId) {
42 const posts = db.prepare('SELECT id, content, cover_image_url, cover_video_url FROM posts WHERE site_id = ?').all(siteId);
43 const map = new Map();
44 const add = (fn, id) => { if (!fn) return; if (!map.has(fn)) map.set(fn, new Set()); map.get(fn).add(id); };
45 for (const p of posts) {
46 add(baseOf(p.cover_image_url), p.id);
47 add(baseOf(p.cover_video_url), p.id);
48 for (const m of String(p.content || '').matchAll(/\/media\/post-images\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
49 }
50 return map;
51}
52
53function statSize(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).size; } catch { return 0; } }
54function statMtime(name) { try { return fs.statSync(path.join(POST_IMAGES_DIR, name)).mtimeMs; } catch { return 0; } }
55
[f2943c1]56// All non-sibling images, each with its loop-MP4 sibling + how many posts use it. Shared by the
57// list view and the cleanup route so the readdir/filter/usage logic lives in one place.
58function imageEntries(siteId) {
59 const used = usageMap(siteId);
[f24b795]60 let all = [];
61 try { all = fs.readdirSync(POST_IMAGES_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
62 const present = new Set(all);
[f2943c1]63 return all
[f24b795]64 .filter(f => IMG_EXT.test(f) && !isSibling(f))
65 .map(f => {
66 const stem = f.replace(/\.[^.]+$/, '');
67 const mp4 = `${stem}-v.mp4`;
68 const hasVideo = present.has(mp4);
69 const ids = new Set([...(used.get(f) || []), ...(hasVideo ? (used.get(mp4) || []) : [])]);
[f2943c1]70 return { file: f, stem, mp4, hasVideo, usedCount: ids.size };
71 });
72}
73
74router.get('/', requireGod, (req, res) => {
75 const site = res.locals.site;
76 if (!site) return res.status(404).send('Site required');
77 const items = imageEntries(site.id)
78 .map(e => ({
79 file: e.file,
80 url: `/media/post-images/${e.file}`,
81 kb: Math.round((statSize(e.file) + (e.hasVideo ? statSize(e.mp4) : 0)) / 1024),
82 hasVideo: e.hasVideo,
83 usedCount: e.usedCount,
84 _mtime: statMtime(e.file),
85 }))
[f24b795]86 .sort((a, b) => b._mtime - a._mtime); // newest first
87 renderPage(req, res, 'pages/admin-media', {
88 pageTitleKey: 'admin.t_media',
89 bodyClass: 'on-admin',
90 items,
91 unusedCount: items.filter(i => !i.usedCount).length,
92 audioOn: audioEnabled(),
93 success: req.query.success || null,
94 });
95});
96
97// Delete one image + its loop-MP4 / poster siblings. Basename-only + within-dir → no traversal.
[97bcf7e]98// ── The Video tab (Robins opdracht, 30-7) ─────────────────────────────────
99// Videos live in reply-media (C2S uploads: Shaer's composer, the help buoy).
100// Usage is a content/attachment reference from a post, exactly like images.
101
102function videoEntries(siteId) {
103 const posts = db.prepare('SELECT id, content, c2s_attachments FROM posts WHERE site_id = ?').all(siteId);
104 const used = new Map();
105 const add = (fn, id) => { if (!fn) return; if (!used.has(fn)) used.set(fn, new Set()); used.get(fn).add(id); };
106 for (const p of posts) {
107 for (const m of String(p.content || '').matchAll(/\/media\/reply-media\/([^/?#"'\s)]+)/g)) add(m[1], p.id);
108 try { for (const a of JSON.parse(p.c2s_attachments || '[]')) { const m = String(a.url || '').match(/\/media\/reply-media\/([^/?#"'\s)]+)/); if (m) add(m[1], p.id); } } catch { /* malformed never blocks the list */ }
109 }
110 let all = [];
111 try { all = fs.readdirSync(REPLY_MEDIA_DIR).filter(f => !f.startsWith('.')); } catch { /* dir may not exist yet */ }
112 const vstat = (name, key) => { try { const st = fs.statSync(path.join(REPLY_MEDIA_DIR, name)); return key === 'size' ? st.size : st.mtimeMs; } catch { return 0; } };
113 return all
114 .filter(f => VIDEO_EXT.test(f))
115 .map(f => ({
116 file: f,
117 url: `/media/reply-media/${f}`,
118 kb: Math.round(vstat(f, 'size') / 1024),
119 usedCount: (used.get(f) || new Set()).size,
120 _mtime: vstat(f, 'mtime'),
121 }))
122 .sort((a, b) => b._mtime - a._mtime);
123}
124
125router.get('/videos', requireGod, (req, res) => {
126 const site = res.locals.site;
127 if (!site) return res.status(404).send('Site required');
128 renderPage(req, res, 'pages/admin-videos', {
129 pageTitleKey: 'admin.t_media',
130 bodyClass: 'on-admin',
131 items: videoEntries(site.id),
132 audioOn: audioEnabled(),
133 success: req.query.success || null,
134 });
135});
136
137// Delete one video. Basename-only + within-dir, and only when no post uses it:
138// the same guardrails the image delete has.
139router.post('/videos/delete', requireGod, (req, res) => {
140 const site = res.locals.site;
141 if (!site) return res.status(404).json({ error: 'site' });
142 const file = path.basename(String(req.body?.file || ''));
143 if (!file || !VIDEO_EXT.test(file)) return res.status(400).json({ error: 'bad_file' });
144 const entry = videoEntries(site.id).find(e => e.file === file);
145 if (!entry) return res.status(404).json({ error: 'not_found' });
146 if (entry.usedCount) return res.status(409).json({ error: 'in_use' });
147 try { fs.unlinkSync(path.join(REPLY_MEDIA_DIR, file)); } catch { /* already gone is gone */ }
148 res.json({ ok: true });
149});
150
[f24b795]151router.post('/delete', requireGod, (req, res) => {
152 if (!res.locals.site) return res.status(404).json({ ok: false, error: 'Site required' });
153 const f = String(req.body.file || '');
154 if (!f || path.basename(f) !== f || !IMG_EXT.test(f)) return res.status(400).json({ ok: false, error: 'Bad file' });
155 const stem = f.replace(/\.[^.]+$/, '');
156 let removed = 0;
157 for (const name of [f, `${stem}-v.mp4`, `${stem}-v.jpg`]) {
158 const full = path.join(POST_IMAGES_DIR, name);
159 if (path.dirname(full) !== POST_IMAGES_DIR) continue;
160 try { fs.unlinkSync(full); removed++; } catch { /* missing sibling */ }
161 }
162 res.json({ ok: true, removed });
163});
164
165// Delete every unused image (orphan) + its siblings.
166router.post('/cleanup', requireGod, (req, res) => {
167 const site = res.locals.site;
168 if (!site) return res.status(404).json({ ok: false, error: 'Site required' });
169 let removed = 0;
[f2943c1]170 for (const e of imageEntries(site.id)) {
171 if (e.usedCount) continue; // still in use
172 for (const name of [e.file, e.mp4, `${e.stem}-v.jpg`]) {
[f24b795]173 try { fs.unlinkSync(path.join(POST_IMAGES_DIR, name)); removed++; } catch { /* */ }
174 }
175 }
176 res.json({ ok: true, removed });
177});
178
179export default router;
Note: See TracBrowser for help on using the repository browser.