source: Klonkt/src/services/PlaylistService.js@ 834bcc3

main
Last change on this file since 834bcc3 was 834bcc3, checked in by Robin Genis <roboburr@…>, 3 months ago

i18n: translate Dutch code comments to English across src/

Comments in routes/services/views/config/middleware/assets translated to
English for the public repo. A few dev-facing throw/console message strings
were Englished too. No user-facing UI strings or i18n dictionary values changed
(src/services/i18n.js untouched). Logic unchanged.

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

  • Property mode set to 100644
File size: 9.3 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,
97 t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
98 FROM playlist_tracks pt
99 JOIN audio_tracks t ON t.id = pt.track_id
100 LEFT JOIN media m ON m.id = t.media_id
101 WHERE pt.playlist_id = ?
102 ORDER BY pt.position ASC
103 `).all(id);
104
105 const mappedTracks = tracks
106 // Link-only tracks (no media file) remain in the list with url ''.
107 .map(t => ({
108 id: t.id,
109 title: t.title || 'Untitled',
110 artist: t.artist || p.artist || '',
111 cover: t.cover_url || p.cover_url || '',
112 duration: t.duration || 0,
113 link_spotify: t.link_spotify || '',
114 link_youtube: t.link_youtube || '',
115 link_soundcloud: t.link_soundcloud || '',
116 url: (t.filename && urlFor) ? urlFor(t.filename) : '',
117 }));
118 // No playlist cover? Fall back to the first track cover so the card isn't empty.
119 const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
120 return {
121 id: p.id,
122 title: p.title,
123 artist: p.artist || '',
124 year: p.year || 0,
125 cover: p.cover_url || fallbackCover,
126 kind: (p.kind === 'playlist') ? 'playlist' : 'album',
127 tracks: mappedTracks,
128 };
129 }
130
131 /**
132 * Create a new playlist. Returns new id, or null on validation failure.
133 * `data.tracks` is an ordered array of audio_tracks.id values.
134 */
135 static create(siteId, data) {
136 const title = String(data.title || '').trim();
137 if (!title) return null;
138
139 const id = this.generateId(siteId, title);
140 const now = new Date().toISOString();
141 const kind = data.kind === 'playlist' ? 'playlist' : 'album';
142
143 const tx = db.transaction(() => {
144 db.prepare(`
145 INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, created_at, updated_at)
146 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
147 `).run(
148 id, siteId, title,
149 String(data.artist || '').trim() || null,
150 Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
151 String(data.cover || '').trim() || null,
152 kind, now, now,
153 );
154 this._writeTracks(id, siteId, data.tracks);
155 });
156 try {
157 tx();
158 return id;
159 } catch (err) {
160 console.error('[PlaylistService.create]', err);
161 return null;
162 }
163 }
164
165 /**
166 * Update an existing playlist. Fields not present in `data` are left alone.
167 * Returns true on success.
168 */
169 static update(siteId, id, data) {
170 id = this.normalizeId(id);
171 if (!id) return false;
172 const existing = db.prepare(
173 'SELECT id FROM playlists WHERE site_id = ? AND id = ?'
174 ).get(siteId, id);
175 if (!existing) return false;
176
177 const fields = [];
178 const values = [];
179 if (Object.prototype.hasOwnProperty.call(data, 'title')) {
180 const v = String(data.title || '').trim();
181 if (!v) return false; // title is required, can't blank it
182 fields.push('title = ?'); values.push(v);
183 }
184 if (Object.prototype.hasOwnProperty.call(data, 'artist')) {
185 fields.push('artist = ?'); values.push(String(data.artist || '').trim() || null);
186 }
187 if (Object.prototype.hasOwnProperty.call(data, 'year')) {
188 const y = +data.year;
189 fields.push('year = ?'); values.push(Number.isFinite(y) && y > 0 ? y : null);
190 }
191 if (Object.prototype.hasOwnProperty.call(data, 'cover')) {
192 fields.push('cover_url = ?'); values.push(String(data.cover || '').trim() || null);
193 }
194 if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
195 fields.push('kind = ?'); values.push(data.kind === 'playlist' ? 'playlist' : 'album');
196 }
197 fields.push('updated_at = ?'); values.push(new Date().toISOString());
198
199 const tx = db.transaction(() => {
200 if (fields.length > 1) { // > 1 because updated_at is always there
201 db.prepare(`UPDATE playlists SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
202 .run(...values, id, siteId);
203 }
204 if (Object.prototype.hasOwnProperty.call(data, 'tracks')) {
205 // Replace track set wholesale — simpler and matches v9 semantics.
206 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(id);
207 this._writeTracks(id, siteId, data.tracks);
208 }
209 });
210 try {
211 tx();
212 return true;
213 } catch (err) {
214 console.error('[PlaylistService.update]', err);
215 return false;
216 }
217 }
218
219 /**
220 * Delete a playlist. Track references in playlist_tracks are removed
221 * automatically via ON DELETE CASCADE. Posts that embed this playlist
222 * will render a "playlist not found" placeholder.
223 */
224 static delete(siteId, id) {
225 id = this.normalizeId(id);
226 if (!id) return false;
227 const result = db.prepare(
228 'DELETE FROM playlists WHERE site_id = ? AND id = ?'
229 ).run(siteId, id);
230 return result.changes > 0;
231 }
232
233 // ─── INTERNAL ──────────────────────────────────────────────────────
234
235 /**
236 * Replace a playlist's track list. Skips track ids that don't belong to
237 * this site (defensive — admin form should never send those, but better
238 * safe than cross-site leak).
239 */
240 static _writeTracks(playlistId, siteId, trackIds) {
241 if (!Array.isArray(trackIds) || trackIds.length === 0) return;
242
243 // Filter to ids that actually exist for this site, preserving order
244 const placeholders = trackIds.map(() => '?').join(',');
245 const valid = new Set(
246 db.prepare(`
247 SELECT id FROM audio_tracks WHERE site_id = ? AND id IN (${placeholders})
248 `).all(siteId, ...trackIds).map(r => r.id)
249 );
250
251 const insert = db.prepare(`
252 INSERT INTO playlist_tracks (playlist_id, track_id, position)
253 VALUES (?, ?, ?)
254 `);
255 let pos = 0;
256 const seen = new Set();
257 for (const tid of trackIds) {
258 if (!valid.has(tid)) continue;
259 if (seen.has(tid)) continue; // dedupe while preserving order
260 seen.add(tid);
261 insert.run(playlistId, tid, pos++);
262 }
263 }
264}
265
266export default PlaylistService;
Note: See TracBrowser for help on using the repository browser.