| 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 | import { SOORTEN } from '../assets/js/shared/post-music-type.js';
|
|---|
| 17 |
|
|---|
| 18 | /**
|
|---|
| 19 | * Een volledige datum of niets (shaer-756s).
|
|---|
| 20 | *
|
|---|
| 21 | * STRIKT, en dat is de hele functie. `year` bestaat al en blijft; dit veld
|
|---|
| 22 | * bestaat juist omdat een jaartal geen uitgavedatum is. Zou hij "2024"
|
|---|
| 23 | * doorlaten en er 2024-01-01 van maken, dan stond er straks een dag op de
|
|---|
| 24 | * federatie die niemand ooit heeft ingevoerd -- en dan hadden we het veld net
|
|---|
| 25 | * zo goed niet kunnen toevoegen.
|
|---|
| 26 | *
|
|---|
| 27 | * Ook 2024-02-31 valt af: dat is geen strengheid om de strengheid, Date rolt
|
|---|
| 28 | * hem stilletjes door naar 2 maart en dan slaan we iets anders op dan er
|
|---|
| 29 | * ingetypt is.
|
|---|
| 30 | */
|
|---|
| 31 | function normDatum(v) {
|
|---|
| 32 | const s = String(v == null ? '' : v).trim();
|
|---|
| 33 | if (!s) return null;
|
|---|
| 34 | if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
|
|---|
| 35 | const d = new Date(`${s}T00:00:00Z`);
|
|---|
| 36 | return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s ? null : s;
|
|---|
| 37 | }
|
|---|
| 38 |
|
|---|
| 39 | /** Een MusicBrainz-id of niets. Zelfde vorm als sites.mb_artist_id. */
|
|---|
| 40 | function normMbid(v) {
|
|---|
| 41 | const s = String(v == null ? '' : v).trim().toLowerCase();
|
|---|
| 42 | return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s) ? s : null;
|
|---|
| 43 | }
|
|---|
| 44 |
|
|---|
| 45 | /**
|
|---|
| 46 | * De drie soorten die een playlist kan zijn, op EEN plek.
|
|---|
| 47 | *
|
|---|
| 48 | * Stond eerder vijf keer als `data.kind === 'playlist' ? 'playlist' : 'album'`
|
|---|
| 49 | * verspreid over dit bestand. Met twee soorten kon dat nog; bij een derde is
|
|---|
| 50 | * het een fout die staat te wachten, want een van de vijf vergeten betekent dat
|
|---|
| 51 | * een mixtape stilletjes als album wordt opgeslagen en als Album de deur uit
|
|---|
| 52 | * gaat.
|
|---|
| 53 | *
|
|---|
| 54 | * Album blijft de terugval: een onbekende waarde hoort niet stilzwijgend iets
|
|---|
| 55 | * nieuws te worden. De lijst zelf staat in de gedeelde pure module, want de
|
|---|
| 56 | * editor in de browser moet dezelfde drie kennen.
|
|---|
| 57 | */
|
|---|
| 58 | export function normKind(v) {
|
|---|
| 59 | const s = String(v == null ? '' : v).trim().toLowerCase();
|
|---|
| 60 | return SOORTEN.includes(s) ? s : 'album';
|
|---|
| 61 | }
|
|---|
| 62 |
|
|---|
| 63 | /**
|
|---|
| 64 | * Draagt deze soort uitgavegegevens? Alleen een album. Een afspeellijst en een
|
|---|
| 65 | * mixtape hebben geen uitgavedatum en geen release-id: het zijn samenstellingen
|
|---|
| 66 | * van andermans of eigen werk, geen uitgave op zichzelf.
|
|---|
| 67 | */
|
|---|
| 68 | export const isUitgave = (kind) => normKind(kind) === 'album';
|
|---|
| 69 |
|
|---|
| 70 | class PlaylistService {
|
|---|
| 71 |
|
|---|
| 72 | // ─── ID NORMALIZATION ─────────────────────────────────────────────
|
|---|
| 73 |
|
|---|
| 74 | /** Slugify to lowercase a-z 0-9 dashes, max 80 chars. */
|
|---|
| 75 | static normalizeId(raw) {
|
|---|
| 76 | if (!raw) return '';
|
|---|
| 77 | return String(raw)
|
|---|
| 78 | .toLowerCase()
|
|---|
| 79 | .replace(/[^a-z0-9]+/g, '-')
|
|---|
| 80 | .replace(/^-+|-+$/g, '')
|
|---|
| 81 | .slice(0, 80);
|
|---|
| 82 | }
|
|---|
| 83 |
|
|---|
| 84 | /** Generate a unique id for a new playlist within a site. */
|
|---|
| 85 | static generateId(siteId, title) {
|
|---|
| 86 | let base = this.normalizeId(title);
|
|---|
| 87 | if (!base) base = 'playlist-' + uuid().slice(0, 6);
|
|---|
| 88 | let id = base, i = 2;
|
|---|
| 89 | const exists = db.prepare(
|
|---|
| 90 | 'SELECT 1 FROM playlists WHERE site_id = ? AND id = ?'
|
|---|
| 91 | );
|
|---|
| 92 | while (exists.get(siteId, id)) {
|
|---|
| 93 | id = `${base}-${i++}`;
|
|---|
| 94 | }
|
|---|
| 95 | return id;
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | // ─── CRUD ──────────────────────────────────────────────────────────
|
|---|
| 99 |
|
|---|
| 100 | /**
|
|---|
| 101 | * List playlists for a site (lightweight — no tracks expanded).
|
|---|
| 102 | * Used by admin grid and the picker in post editor.
|
|---|
| 103 | */
|
|---|
| 104 | static list(siteId) {
|
|---|
| 105 | const rows = db.prepare(`
|
|---|
| 106 | SELECT p.id, p.title, p.artist, p.year, p.cover_url, p.kind,
|
|---|
| 107 | p.release_date, p.mb_release_id,
|
|---|
| 108 | p.created_at, p.updated_at,
|
|---|
| 109 | (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = p.id) AS track_count
|
|---|
| 110 | FROM playlists p
|
|---|
| 111 | WHERE p.site_id = ?
|
|---|
| 112 | ORDER BY p.updated_at DESC
|
|---|
| 113 | `).all(siteId);
|
|---|
| 114 | return rows.map(r => ({
|
|---|
| 115 | id: r.id,
|
|---|
| 116 | title: r.title,
|
|---|
| 117 | artist: r.artist || '',
|
|---|
| 118 | year: r.year || 0,
|
|---|
| 119 | cover: r.cover_url || '',
|
|---|
| 120 | kind: normKind(r.kind),
|
|---|
| 121 | release_date: r.release_date || '',
|
|---|
| 122 | mb_release_id: r.mb_release_id || '',
|
|---|
| 123 | track_count: r.track_count,
|
|---|
| 124 | created_at: r.created_at,
|
|---|
| 125 | updated_at: r.updated_at,
|
|---|
| 126 | }));
|
|---|
| 127 | }
|
|---|
| 128 |
|
|---|
| 129 | /**
|
|---|
| 130 | * Get a playlist with tracks fully hydrated. Tracks NOT in the audio
|
|---|
| 131 | * library anymore are silently dropped (matches v9 behavior).
|
|---|
| 132 | *
|
|---|
| 133 | * Returns null if the playlist doesn't exist.
|
|---|
| 134 | *
|
|---|
| 135 | * `urlFor` is an optional callback that takes a media filename and returns
|
|---|
| 136 | * its stream URL. If not provided, tracks come back with no `url` and the
|
|---|
| 137 | * caller has to resolve them. The render pipeline in posts.js always passes
|
|---|
| 138 | * urlFor.
|
|---|
| 139 | */
|
|---|
| 140 | static get(siteId, id, urlFor) {
|
|---|
| 141 | id = this.normalizeId(id);
|
|---|
| 142 | if (!id) return null;
|
|---|
| 143 | const p = db.prepare(`
|
|---|
| 144 | SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at
|
|---|
| 145 | FROM playlists WHERE site_id = ? AND id = ?
|
|---|
| 146 | `).get(siteId, id);
|
|---|
| 147 | if (!p) return null;
|
|---|
| 148 |
|
|---|
| 149 | // Pull tracks via junction, in order. LEFT JOIN media so we can resolve
|
|---|
| 150 | // filenames (only tracks with a media file are playable).
|
|---|
| 151 | const tracks = db.prepare(`
|
|---|
| 152 | SELECT t.id, t.title, t.artist, t.duration, t.cover_url,
|
|---|
| 153 | t.link_spotify, t.link_youtube, t.link_soundcloud, m.filename
|
|---|
| 154 | FROM playlist_tracks pt
|
|---|
| 155 | JOIN audio_tracks t ON t.id = pt.track_id
|
|---|
| 156 | LEFT JOIN media m ON m.id = t.media_id
|
|---|
| 157 | WHERE pt.playlist_id = ?
|
|---|
| 158 | ORDER BY pt.position ASC
|
|---|
| 159 | `).all(id);
|
|---|
| 160 |
|
|---|
| 161 | const mappedTracks = tracks
|
|---|
| 162 | // Link-only tracks (no media file) remain in the list with url ''.
|
|---|
| 163 | .map(t => ({
|
|---|
| 164 | id: t.id,
|
|---|
| 165 | title: t.title || 'Untitled',
|
|---|
| 166 | artist: t.artist || p.artist || '',
|
|---|
| 167 | cover: t.cover_url || p.cover_url || '',
|
|---|
| 168 | duration: t.duration || 0,
|
|---|
| 169 | link_spotify: t.link_spotify || '',
|
|---|
| 170 | link_youtube: t.link_youtube || '',
|
|---|
| 171 | link_soundcloud: t.link_soundcloud || '',
|
|---|
| 172 | url: (t.filename && urlFor) ? urlFor(t.filename) : '',
|
|---|
| 173 | }));
|
|---|
| 174 | // No playlist cover? Fall back to the first track cover so the card isn't empty.
|
|---|
| 175 | const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
|
|---|
| 176 | return {
|
|---|
| 177 | id: p.id,
|
|---|
| 178 | title: p.title,
|
|---|
| 179 | artist: p.artist || '',
|
|---|
| 180 | year: p.year || 0,
|
|---|
| 181 | cover: p.cover_url || fallbackCover,
|
|---|
| 182 | kind: normKind(p.kind),
|
|---|
| 183 | // created_at hoort erbij omdat de AP-kant er `published` van maakt. Zonder
|
|---|
| 184 | // dit veld viel buildAlbumObject terug op 1970, en dat stond op 16-8
|
|---|
| 185 | // gewoon op de federatie.
|
|---|
| 186 | created_at: p.created_at,
|
|---|
| 187 | // Leeg als het een afspeellijst is -- de opslag houdt ze daar al leeg,
|
|---|
| 188 | // maar dit is de plek waar de editor leest en die mag niet afhangen van
|
|---|
| 189 | // wat er toevallig in de kolom stond.
|
|---|
| 190 | release_date: isUitgave(p.kind) ? (p.release_date || '') : '',
|
|---|
| 191 | mb_release_id: isUitgave(p.kind) ? (p.mb_release_id || '') : '',
|
|---|
| 192 | tracks: mappedTracks,
|
|---|
| 193 | };
|
|---|
| 194 | }
|
|---|
| 195 |
|
|---|
| 196 | /**
|
|---|
| 197 | * Create a new playlist. Returns new id, or null on validation failure.
|
|---|
| 198 | * `data.tracks` is an ordered array of audio_tracks.id values.
|
|---|
| 199 | */
|
|---|
| 200 | static create(siteId, data) {
|
|---|
| 201 | const title = String(data.title || '').trim();
|
|---|
| 202 | if (!title) return null;
|
|---|
| 203 |
|
|---|
| 204 | const id = this.generateId(siteId, title);
|
|---|
| 205 | const now = new Date().toISOString();
|
|---|
| 206 | const kind = normKind(data.kind);
|
|---|
| 207 |
|
|---|
| 208 | // Alleen een UITGAVE draagt deze twee. Een afspeellijst heeft geen
|
|---|
| 209 | // uitgavedatum en geen release-id, en dat onderscheid is precies wat de
|
|---|
| 210 | // keuze album/playlist betekent (shaer-cyg). Het afdwingen gebeurt HIER en
|
|---|
| 211 | // niet alleen in het scherm: een scherm kun je omzeilen -- de API ligt open
|
|---|
| 212 | // voor de post-editor -- en dan staat er stille rommel op een mixtape die
|
|---|
| 213 | // later als Album de deur uit gaat.
|
|---|
| 214 | const uitgave = isUitgave(kind);
|
|---|
| 215 | const releaseDate = uitgave ? normDatum(data.release_date) : null;
|
|---|
| 216 | const mbRelease = uitgave ? normMbid(data.mb_release_id) : null;
|
|---|
| 217 |
|
|---|
| 218 | const tx = db.transaction(() => {
|
|---|
| 219 | db.prepare(`
|
|---|
| 220 | INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at)
|
|---|
| 221 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|---|
| 222 | `).run(
|
|---|
| 223 | id, siteId, title,
|
|---|
| 224 | String(data.artist || '').trim() || null,
|
|---|
| 225 | Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
|
|---|
| 226 | String(data.cover || '').trim() || null,
|
|---|
| 227 | kind, releaseDate, mbRelease, now, now,
|
|---|
| 228 | );
|
|---|
| 229 | this._writeTracks(id, siteId, data.tracks);
|
|---|
| 230 | });
|
|---|
| 231 | try {
|
|---|
| 232 | tx();
|
|---|
| 233 | return id;
|
|---|
| 234 | } catch (err) {
|
|---|
| 235 | console.error('[PlaylistService.create]', err);
|
|---|
| 236 | return null;
|
|---|
| 237 | }
|
|---|
| 238 | }
|
|---|
| 239 |
|
|---|
| 240 | /**
|
|---|
| 241 | * Update an existing playlist. Fields not present in `data` are left alone.
|
|---|
| 242 | * Returns true on success.
|
|---|
| 243 | */
|
|---|
| 244 | static update(siteId, id, data) {
|
|---|
| 245 | id = this.normalizeId(id);
|
|---|
| 246 | if (!id) return false;
|
|---|
| 247 | const existing = db.prepare(
|
|---|
| 248 | 'SELECT id, kind FROM playlists WHERE site_id = ? AND id = ?'
|
|---|
| 249 | ).get(siteId, id);
|
|---|
| 250 | if (!existing) return false;
|
|---|
| 251 |
|
|---|
| 252 | const fields = [];
|
|---|
| 253 | const values = [];
|
|---|
| 254 | // Wat wordt het NA deze wijziging? `kind` hoeft niet in data te staan, dus
|
|---|
| 255 | // val terug op wat er ligt.
|
|---|
| 256 | const nieuwKind = Object.prototype.hasOwnProperty.call(data, 'kind')
|
|---|
| 257 | ? normKind(data.kind)
|
|---|
| 258 | : normKind(existing.kind);
|
|---|
| 259 | if (Object.prototype.hasOwnProperty.call(data, 'title')) {
|
|---|
| 260 | const v = String(data.title || '').trim();
|
|---|
| 261 | if (!v) return false; // title is required, can't blank it
|
|---|
| 262 | fields.push('title = ?'); values.push(v);
|
|---|
| 263 | }
|
|---|
| 264 | if (Object.prototype.hasOwnProperty.call(data, 'artist')) {
|
|---|
| 265 | fields.push('artist = ?'); values.push(String(data.artist || '').trim() || null);
|
|---|
| 266 | }
|
|---|
| 267 | if (Object.prototype.hasOwnProperty.call(data, 'year')) {
|
|---|
| 268 | const y = +data.year;
|
|---|
| 269 | fields.push('year = ?'); values.push(Number.isFinite(y) && y > 0 ? y : null);
|
|---|
| 270 | }
|
|---|
| 271 | if (Object.prototype.hasOwnProperty.call(data, 'cover')) {
|
|---|
| 272 | fields.push('cover_url = ?'); values.push(String(data.cover || '').trim() || null);
|
|---|
| 273 | }
|
|---|
| 274 | if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
|
|---|
| 275 | fields.push('kind = ?'); values.push(nieuwKind);
|
|---|
| 276 | }
|
|---|
| 277 | // De uitgavevelden. Wordt dit een afspeellijst, dan gaan ze ALTIJD leeg --
|
|---|
| 278 | // ook als de aanroeper er niets over zei. Anders houdt een album dat je tot
|
|---|
| 279 | // mixtape ombouwt zijn uitgavedatum en zijn release-id, en die duiken dan
|
|---|
| 280 | // weer op zodra iemand hem terugzet. Een veld dat niet meer mag bestaan
|
|---|
| 281 | // hoort weg te zijn, niet te wachten.
|
|---|
| 282 | if (nieuwKind !== 'album') {
|
|---|
| 283 | fields.push('release_date = ?'); values.push(null);
|
|---|
| 284 | fields.push('mb_release_id = ?'); values.push(null);
|
|---|
| 285 | } else {
|
|---|
| 286 | if (Object.prototype.hasOwnProperty.call(data, 'release_date')) {
|
|---|
| 287 | fields.push('release_date = ?'); values.push(normDatum(data.release_date));
|
|---|
| 288 | }
|
|---|
| 289 | if (Object.prototype.hasOwnProperty.call(data, 'mb_release_id')) {
|
|---|
| 290 | fields.push('mb_release_id = ?'); values.push(normMbid(data.mb_release_id));
|
|---|
| 291 | }
|
|---|
| 292 | }
|
|---|
| 293 | fields.push('updated_at = ?'); values.push(new Date().toISOString());
|
|---|
| 294 |
|
|---|
| 295 | const tx = db.transaction(() => {
|
|---|
| 296 | if (fields.length > 1) { // > 1 because updated_at is always there
|
|---|
| 297 | db.prepare(`UPDATE playlists SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
|
|---|
| 298 | .run(...values, id, siteId);
|
|---|
| 299 | }
|
|---|
| 300 | if (Object.prototype.hasOwnProperty.call(data, 'tracks')) {
|
|---|
| 301 | // Replace track set wholesale — simpler and matches v9 semantics.
|
|---|
| 302 | db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(id);
|
|---|
| 303 | this._writeTracks(id, siteId, data.tracks);
|
|---|
| 304 | }
|
|---|
| 305 | });
|
|---|
| 306 | try {
|
|---|
| 307 | tx();
|
|---|
| 308 | return true;
|
|---|
| 309 | } catch (err) {
|
|---|
| 310 | console.error('[PlaylistService.update]', err);
|
|---|
| 311 | return false;
|
|---|
| 312 | }
|
|---|
| 313 | }
|
|---|
| 314 |
|
|---|
| 315 | /**
|
|---|
| 316 | * Delete a playlist. Track references in playlist_tracks are removed
|
|---|
| 317 | * automatically via ON DELETE CASCADE. Posts that embed this playlist
|
|---|
| 318 | * will render a "playlist not found" placeholder.
|
|---|
| 319 | */
|
|---|
| 320 | static delete(siteId, id) {
|
|---|
| 321 | id = this.normalizeId(id);
|
|---|
| 322 | if (!id) return false;
|
|---|
| 323 | const result = db.prepare(
|
|---|
| 324 | 'DELETE FROM playlists WHERE site_id = ? AND id = ?'
|
|---|
| 325 | ).run(siteId, id);
|
|---|
| 326 | return result.changes > 0;
|
|---|
| 327 | }
|
|---|
| 328 |
|
|---|
| 329 | // ─── INTERNAL ──────────────────────────────────────────────────────
|
|---|
| 330 |
|
|---|
| 331 | /**
|
|---|
| 332 | * Replace a playlist's track list. Skips track ids that don't belong to
|
|---|
| 333 | * this site (defensive — admin form should never send those, but better
|
|---|
| 334 | * safe than cross-site leak).
|
|---|
| 335 | */
|
|---|
| 336 | static _writeTracks(playlistId, siteId, trackIds) {
|
|---|
| 337 | if (!Array.isArray(trackIds) || trackIds.length === 0) return;
|
|---|
| 338 |
|
|---|
| 339 | // Filter to ids that actually exist for this site, preserving order
|
|---|
| 340 | const placeholders = trackIds.map(() => '?').join(',');
|
|---|
| 341 | const valid = new Set(
|
|---|
| 342 | db.prepare(`
|
|---|
| 343 | SELECT id FROM audio_tracks WHERE site_id = ? AND id IN (${placeholders})
|
|---|
| 344 | `).all(siteId, ...trackIds).map(r => r.id)
|
|---|
| 345 | );
|
|---|
| 346 |
|
|---|
| 347 | const insert = db.prepare(`
|
|---|
| 348 | INSERT INTO playlist_tracks (playlist_id, track_id, position)
|
|---|
| 349 | VALUES (?, ?, ?)
|
|---|
| 350 | `);
|
|---|
| 351 | let pos = 0;
|
|---|
| 352 | const seen = new Set();
|
|---|
| 353 | for (const tid of trackIds) {
|
|---|
| 354 | if (!valid.has(tid)) continue;
|
|---|
| 355 | if (seen.has(tid)) continue; // dedupe while preserving order
|
|---|
| 356 | seen.add(tid);
|
|---|
| 357 | insert.run(playlistId, tid, pos++);
|
|---|
| 358 | }
|
|---|
| 359 | }
|
|---|
| 360 | }
|
|---|
| 361 |
|
|---|
| 362 | export default PlaylistService;
|
|---|