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

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

fix(track-picker): allow inserting link-only tracks (Spotify/YouTube, no hosted file)

The "Insert track" picker disabled any track without a hosted media file (playable = !!filename), so
a link-only track (external Spotify/YouTube, 0:00) couldn't be inserted — even though [[track:]]
renders its link card fine on the post. Now a track is insertable if it has a file OR a link.

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

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