source: Klonkt/src/services/PlaylistService.js@ 7bc636b

main
Last change on this file since 7bc636b was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

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