source: Klonkt/src/services/PlaylistService.js@ 71e8fa5

main
Last change on this file since 71e8fa5 was 795c925, checked in by roboburr <roboburr@…>, 3 months ago

fix(playlist): album/playlist cover falls back to first track cover

PlaylistService.get returned cover= when the playlist had no own cover_url
→ empty card. Now falls back to the cover of the first track that has one.

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

  • Property mode set to 100644
File size: 9.1 KB
Line 
1/**
2 * PlaylistService — first-class playlist entity (port of v9's pcms-playlists.php).
3 *
4 * Two responsibilities:
5 * 1. CRUD on the playlists + playlist_tracks tables.
6 * 2. Hydration: turn a playlist record into the shape AudioEmbedService
7 * expects (tracks with signed URLs, inherited covers, etc.).
8 *
9 * Posts reference playlists via [[playlist:<id>]] shortcodes. Editing a
10 * playlist propagates to every post that embeds it — that's the whole point
11 * of having playlists as a separate entity instead of inline JSON blobs.
12 */
13
14import db from '../config/database.js';
15import { v4 as uuid } from 'uuid';
16
17class PlaylistService {
18
19 // ─── ID NORMALIZATION ─────────────────────────────────────────────
20
21 /** Slugify to lowercase a-z 0-9 dashes, max 80 chars. */
22 static normalizeId(raw) {
23 if (!raw) return '';
24 return String(raw)
25 .toLowerCase()
26 .replace(/[^a-z0-9]+/g, '-')
27 .replace(/^-+|-+$/g, '')
28 .slice(0, 80);
29 }
30
31 /** Generate a unique id for a new playlist within a site. */
32 static generateId(siteId, title) {
33 let base = this.normalizeId(title);
34 if (!base) base = 'playlist-' + uuid().slice(0, 6);
35 let id = base, i = 2;
36 const exists = db.prepare(
37 'SELECT 1 FROM playlists WHERE site_id = ? AND id = ?'
38 );
39 while (exists.get(siteId, id)) {
40 id = `${base}-${i++}`;
41 }
42 return id;
43 }
44
45 // ─── CRUD ──────────────────────────────────────────────────────────
46
47 /**
48 * List playlists for a site (lightweight — no tracks expanded).
49 * Used by admin grid and the picker in post editor.
50 */
51 static list(siteId) {
52 const rows = db.prepare(`
53 SELECT p.id, p.title, p.artist, p.year, p.cover_url, p.kind,
54 p.created_at, p.updated_at,
55 (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = p.id) AS track_count
56 FROM playlists p
57 WHERE p.site_id = ?
58 ORDER BY p.updated_at DESC
59 `).all(siteId);
60 return rows.map(r => ({
61 id: r.id,
62 title: r.title,
63 artist: r.artist || '',
64 year: r.year || 0,
65 cover: r.cover_url || '',
66 kind: r.kind || 'album',
67 track_count: r.track_count,
68 created_at: r.created_at,
69 updated_at: r.updated_at,
70 }));
71 }
72
73 /**
74 * Get a playlist with tracks fully hydrated. Tracks NOT in the audio
75 * library anymore are silently dropped (matches v9 behavior).
76 *
77 * Returns null if the playlist doesn't exist.
78 *
79 * `urlFor` is an optional callback that takes a media filename and returns
80 * its stream URL. If not provided, tracks come back with no `url` and the
81 * caller has to resolve them. The render pipeline in posts.js always passes
82 * urlFor.
83 */
84 static get(siteId, id, urlFor) {
85 id = this.normalizeId(id);
86 if (!id) return null;
87 const p = db.prepare(`
88 SELECT id, title, artist, year, cover_url, kind, created_at, updated_at
89 FROM playlists WHERE site_id = ? AND id = ?
90 `).get(siteId, id);
91 if (!p) return null;
92
93 // Pull tracks via junction, in order. LEFT JOIN media so we can resolve
94 // filenames (only tracks with a media file are playable).
95 const tracks = db.prepare(`
96 SELECT t.id, t.title, t.artist, t.duration, t.cover_url, m.filename
97 FROM playlist_tracks pt
98 JOIN audio_tracks t ON t.id = pt.track_id
99 LEFT JOIN media m ON m.id = t.media_id
100 WHERE pt.playlist_id = ?
101 ORDER BY pt.position ASC
102 `).all(id);
103
104 const mappedTracks = tracks
105 .filter(t => t.filename) // skip orphaned references
106 .map(t => ({
107 id: t.id,
108 title: t.title || 'Untitled',
109 artist: t.artist || p.artist || '',
110 cover: t.cover_url || p.cover_url || '',
111 duration: t.duration || 0,
112 url: urlFor ? urlFor(t.filename) : null,
113 }));
114 // Geen eigen cover? Val terug op de eerste track-cover, zodat de kaart niet leeg is.
115 const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
116 return {
117 id: p.id,
118 title: p.title,
119 artist: p.artist || '',
120 year: p.year || 0,
121 cover: p.cover_url || fallbackCover,
122 kind: (p.kind === 'playlist') ? 'playlist' : 'album',
123 tracks: mappedTracks,
124 };
125 }
126
127 /**
128 * Create a new playlist. Returns new id, or null on validation failure.
129 * `data.tracks` is an ordered array of audio_tracks.id values.
130 */
131 static create(siteId, data) {
132 const title = String(data.title || '').trim();
133 if (!title) return null;
134
135 const id = this.generateId(siteId, title);
136 const now = new Date().toISOString();
137 const kind = data.kind === 'playlist' ? 'playlist' : 'album';
138
139 const tx = db.transaction(() => {
140 db.prepare(`
141 INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, created_at, updated_at)
142 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
143 `).run(
144 id, siteId, title,
145 String(data.artist || '').trim() || null,
146 Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
147 String(data.cover || '').trim() || null,
148 kind, now, now,
149 );
150 this._writeTracks(id, siteId, data.tracks);
151 });
152 try {
153 tx();
154 return id;
155 } catch (err) {
156 console.error('[PlaylistService.create]', err);
157 return null;
158 }
159 }
160
161 /**
162 * Update an existing playlist. Fields not present in `data` are left alone.
163 * Returns true on success.
164 */
165 static update(siteId, id, data) {
166 id = this.normalizeId(id);
167 if (!id) return false;
168 const existing = db.prepare(
169 'SELECT id FROM playlists WHERE site_id = ? AND id = ?'
170 ).get(siteId, id);
171 if (!existing) return false;
172
173 const fields = [];
174 const values = [];
175 if (Object.prototype.hasOwnProperty.call(data, 'title')) {
176 const v = String(data.title || '').trim();
177 if (!v) return false; // title is required, can't blank it
178 fields.push('title = ?'); values.push(v);
179 }
180 if (Object.prototype.hasOwnProperty.call(data, 'artist')) {
181 fields.push('artist = ?'); values.push(String(data.artist || '').trim() || null);
182 }
183 if (Object.prototype.hasOwnProperty.call(data, 'year')) {
184 const y = +data.year;
185 fields.push('year = ?'); values.push(Number.isFinite(y) && y > 0 ? y : null);
186 }
187 if (Object.prototype.hasOwnProperty.call(data, 'cover')) {
188 fields.push('cover_url = ?'); values.push(String(data.cover || '').trim() || null);
189 }
190 if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
191 fields.push('kind = ?'); values.push(data.kind === 'playlist' ? 'playlist' : 'album');
192 }
193 fields.push('updated_at = ?'); values.push(new Date().toISOString());
194
195 const tx = db.transaction(() => {
196 if (fields.length > 1) { // > 1 because updated_at is always there
197 db.prepare(`UPDATE playlists SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
198 .run(...values, id, siteId);
199 }
200 if (Object.prototype.hasOwnProperty.call(data, 'tracks')) {
201 // Replace track set wholesale — simpler and matches v9 semantics.
202 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(id);
203 this._writeTracks(id, siteId, data.tracks);
204 }
205 });
206 try {
207 tx();
208 return true;
209 } catch (err) {
210 console.error('[PlaylistService.update]', err);
211 return false;
212 }
213 }
214
215 /**
216 * Delete a playlist. Track references in playlist_tracks are removed
217 * automatically via ON DELETE CASCADE. Posts that embed this playlist
218 * will render a "playlist niet gevonden" placeholder.
219 */
220 static delete(siteId, id) {
221 id = this.normalizeId(id);
222 if (!id) return false;
223 const result = db.prepare(
224 'DELETE FROM playlists WHERE site_id = ? AND id = ?'
225 ).run(siteId, id);
226 return result.changes > 0;
227 }
228
229 // ─── INTERNAL ──────────────────────────────────────────────────────
230
231 /**
232 * Replace a playlist's track list. Skips track ids that don't belong to
233 * this site (defensive — admin form should never send those, but better
234 * safe than cross-site leak).
235 */
236 static _writeTracks(playlistId, siteId, trackIds) {
237 if (!Array.isArray(trackIds) || trackIds.length === 0) return;
238
239 // Filter to ids that actually exist for this site, preserving order
240 const placeholders = trackIds.map(() => '?').join(',');
241 const valid = new Set(
242 db.prepare(`
243 SELECT id FROM audio_tracks WHERE site_id = ? AND id IN (${placeholders})
244 `).all(siteId, ...trackIds).map(r => r.id)
245 );
246
247 const insert = db.prepare(`
248 INSERT INTO playlist_tracks (playlist_id, track_id, position)
249 VALUES (?, ?, ?)
250 `);
251 let pos = 0;
252 const seen = new Set();
253 for (const tid of trackIds) {
254 if (!valid.has(tid)) continue;
255 if (seen.has(tid)) continue; // dedupe while preserving order
256 seen.add(tid);
257 insert.run(playlistId, tid, pos++);
258 }
259 }
260}
261
262export default PlaylistService;
Note: See TracBrowser for help on using the repository browser.