source: Klonkt/src/routes/admin-playlists.js@ fa33214

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

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

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

  • Property mode set to 100644
File size: 7.4 KB
RevLine 
[7bc636b]1/**
2 * Admin: Playlists management — first-class playlist entity (v9 feature).
3 *
4 * GET /admin/playlists -> list page (server-rendered)
5 * GET /admin/playlists/api/list -> JSON list (used by post-editor picker)
6 * GET /admin/playlists/api/tracks -> JSON list of all audio tracks for picker
7 * GET /admin/playlists/api/:id -> JSON get
8 * POST /admin/playlists/api -> create (id assigned, returned in body)
9 * POST /admin/playlists/api/:id -> update
10 * POST /admin/playlists/api/:id/delete -> delete
11 *
12 * All endpoints require god/admin role. CSRF enforced for write ops via the
13 * shared csrf middleware mounted in server.js.
14 */
15
16import express from 'express';
17import path from 'path';
18import fs from 'fs';
19import multer from 'multer';
20import { randomUUID } from 'crypto';
21import db from '../config/database.js';
22import { renderPage } from '../middleware/render.js';
23import { requireGod } from '../middleware/auth.js';
24import PlaylistService from '../services/PlaylistService.js';
[e2c3d09]25import { mediaDir } from '../config/paths.js';
[7bc636b]26
27// Cover storage — same convention as track covers so a single physical
28// directory holds all album/track artwork. Existing covers in the DB
29// already point at /media/audio-covers/<filename> so we reuse the path.
[e2c3d09]30const COVER_DIR = mediaDir('COVER_PATH', 'audio-covers');
[7bc636b]31fs.mkdirSync(COVER_DIR, { recursive: true });
32
33const MAX_COVER_BYTES = 5 * 1024 * 1024;
34const COVER_MIMES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
35
36const coverUpload = multer({
37 storage: multer.diskStorage({
38 destination: (req, file, cb) => cb(null, COVER_DIR),
39 filename: (req, file, cb) => {
40 // <uuid>.<ext> — keep extension so MIME detection works downstream
41 const ext = (path.extname(file.originalname) || '.jpg').toLowerCase();
42 cb(null, `${randomUUID()}${ext}`);
43 },
44 }),
45 limits: { fileSize: MAX_COVER_BYTES },
46 fileFilter: (req, file, cb) => {
47 if (!COVER_MIMES.has(file.mimetype)) {
48 return cb(new Error('Alleen JPEG/PNG/WebP/GIF toegestaan'));
49 }
50 cb(null, true);
51 },
52});
53
54const router = express.Router();
55
56// ─── Page render ──────────────────────────────────────────────────────
57
58router.get('/', requireGod, (req, res) => {
59 const site = res.locals.site;
60 if (!site) return res.status(404).send('Site required');
61
62 const playlists = PlaylistService.list(site.id);
63 renderPage(req, res, 'pages/admin-playlists', {
[3487567]64 pageTitleKey: 'admin.t_playlists',
[7bc636b]65 playlists,
66 bodyClass: 'on-admin',
67 });
68});
69
70// ─── JSON API ─────────────────────────────────────────────────────────
71
72router.get('/api/list', requireGod, (req, res) => {
73 const site = res.locals.site;
74 if (!site) return res.status(404).json({ error: 'Site required' });
75 res.json({ ok: true, playlists: PlaylistService.list(site.id) });
76});
77
78/**
79 * List all audio tracks for picker. Includes a flag whether each track has
80 * a media file (only those are pickable).
81 */
82router.get('/api/tracks', requireGod, (req, res) => {
83 const site = res.locals.site;
84 if (!site) return res.status(404).json({ error: 'Site required' });
85
86 const tracks = db.prepare(`
87 SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
[a95dbfd]88 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
[7bc636b]89 FROM audio_tracks t
90 LEFT JOIN media m ON m.id = t.media_id
91 WHERE t.site_id = ?
92 ORDER BY t.created_at DESC
93 `).all(site.id);
94
95 res.json({
96 ok: true,
97 tracks: tracks.map(t => ({
98 id: t.id,
99 title: t.title || 'Untitled',
100 artist: t.artist || '',
101 duration: t.duration || 0,
102 cover: t.cover_url || '',
[a95dbfd]103 // Insertable if it has a hosted file OR an external link — a link-only track ([[track:]])
104 // still renders its Spotify/YouTube card on the post, so it must not be disabled in the picker.
105 playable: !!t.filename || !!(t.link_spotify || t.link_youtube || t.link_soundcloud),
[7bc636b]106 })),
107 });
108});
109
110router.get('/api/:id', requireGod, (req, res) => {
111 const site = res.locals.site;
112 if (!site) return res.status(404).json({ error: 'Site required' });
113
[21522ae]114 // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
[7bc636b]115 const playlist = PlaylistService.get(site.id, req.params.id, null);
116 if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
117 // Ship just the track ids in order so the editor can populate selection.
118 const trackIds = db.prepare(`
119 SELECT track_id FROM playlist_tracks
120 WHERE playlist_id = ? ORDER BY position ASC
121 `).all(playlist.id).map(r => r.track_id);
122 res.json({ ok: true, playlist: { ...playlist, track_ids: trackIds } });
123});
124
125router.post('/api', requireGod, express.json(), (req, res) => {
126 const site = res.locals.site;
127 if (!site) return res.status(404).json({ error: 'Site required' });
128
129 const id = PlaylistService.create(site.id, req.body || {});
130 if (!id) return res.status(400).json({ error: 'Aanmaken mislukt (titel verplicht)' });
131 res.json({ ok: true, id });
132});
133
134router.post('/api/:id', requireGod, express.json(), (req, res) => {
135 const site = res.locals.site;
136 if (!site) return res.status(404).json({ error: 'Site required' });
137
138 const ok = PlaylistService.update(site.id, req.params.id, req.body || {});
139 if (!ok) return res.status(400).json({ error: 'Bijwerken mislukt' });
140 res.json({ ok: true });
141});
142
143router.post('/api/:id/delete', requireGod, (req, res) => {
144 const site = res.locals.site;
145 if (!site) return res.status(404).json({ error: 'Site required' });
146
147 const ok = PlaylistService.delete(site.id, req.params.id);
148 if (!ok) return res.status(404).json({ error: 'Playlist niet gevonden' });
149 res.json({ ok: true });
150});
151
152/**
153 * POST /admin/playlists/api/:id/cover — upload a new cover image and set
154 * it on the playlist in one request. Returns { ok, url, cover_url } for
155 * the editor modal to preview. Mirrors the track-cover endpoint pattern.
156 */
157router.post('/api/:id/cover', requireGod, (req, res) => {
158 const site = res.locals.site;
159 if (!site) return res.status(404).json({ error: 'Site required' });
160
161 // Confirm ownership (and grab the previous cover for cleanup)
162 const existing = db.prepare(
163 'SELECT id, cover_url FROM playlists WHERE id = ? AND site_id = ?'
164 ).get(req.params.id, site.id);
165 if (!existing) return res.status(404).json({ error: 'Playlist niet gevonden' });
166
167 coverUpload.single('cover')(req, res, (err) => {
168 if (err) return res.status(400).json({ error: err.message });
169 const file = req.file;
170 if (!file) return res.status(400).json({ error: 'Geen bestand' });
171
172 const newUrl = `/media/audio-covers/${file.filename}`;
173 try {
174 db.prepare('UPDATE playlists SET cover_url = ? WHERE id = ? AND site_id = ?')
175 .run(newUrl, req.params.id, site.id);
176 } catch (dbErr) {
177 try { fs.unlinkSync(file.path); } catch {}
178 return res.status(500).json({ error: dbErr.message });
179 }
180
181 // Garbage-collect the previous cover if it was in our managed dir
182 if (existing.cover_url && existing.cover_url.startsWith('/media/audio-covers/')) {
183 const oldName = existing.cover_url.replace(/^\/media\/audio-covers\//, '');
184 try { fs.unlinkSync(path.join(COVER_DIR, oldName)); } catch {}
185 }
186
187 res.json({ ok: true, url: newUrl, cover_url: newUrl });
188 });
189});
190
191export default router;
Note: See TracBrowser for help on using the repository browser.