source: Klonkt/src/services/PlaylistService.js@ db81e56

main
Last change on this file since db81e56 was b0ccd0d, checked in by Robin <roboburr@…>, 3 weeks ago

De mixtape als eigen soort: data en draad

Een bandje is geen album en geen afspeellijst. Een album is een uitgave -- de
nummers bestaan er los van. Bij een mixtape is de VOLGORDE het werk en zijn de
nummers onderdelen. Vandaar een eigen soort, en op de draad een enkel object
met de nummers erin.

De valkuil zat in de vorm die er al stond. De keuze album/playlist stond vijf
keer als x === 'playlist' ? 'playlist' : 'album' verspreid over drie
bestanden, en zo'n vorm valt bij een derde soort niet om -- hij slikt hem. Een
mixtape zou stilzwijgend een album zijn geworden en als Album de deur uit gaan.
De lijst staat nu een keer, in de pure gedeelde module, want die kent de
browser ook.

Op de draad:

  • type is de STRING 'Mixtape'. Geen array, en dat is geleerd: bij de playlist-collectie stond ooit ['OrderedCollection','Album'] -- geldig AS2 en werkelijk allebei -- en een lezer die type als tekst uitpakt (Shaer) verloor daarmee stil het hele object.
  • orderedItems op het object zelf, niet een collectie ernaast.
  • Geen released en geen musicbrainzId: die zouden beweren dat dit een uitgave is.
  • Geen eigen url. Robins keuze: het bandje is een omhulling, geen gerenderd bestand. Prijs ervan is opgeschreven bij de functie -- wie het type niet kent heeft geen stream, al staan de nummers er stuk voor stuk in.

Een mixtape met losse tracks erbij blijft een mixtape. De oude regel maakte
daar een album van met bonustracks; dat klopt voor een plaat en niet voor een
bandje. Album en playlist houden bewust hun oude gedrag, want dat zijn
bestaande posts.

Acht tests, waaronder de weg door de database heen in plaats van langs een
handgemaakte opzoeker.

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

  • Property mode set to 100644
File size: 13.8 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';
16import { 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 */
31function 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. */
40function 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 */
58export 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 */
68export const isUitgave = (kind) => normKind(kind) === 'album';
69
70class 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
362export default PlaylistService;
Note: See TracBrowser for help on using the repository browser.