| 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 |
|
|---|
| 14 | import db from '../config/database.js';
|
|---|
| 15 | import { v4 as uuid } from 'uuid';
|
|---|
| 16 |
|
|---|
| 17 | class 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 |
|
|---|
| 259 | export default PlaylistService;
|
|---|