source: Klonkt/src/services/PlaylistService.js@ 70dbef4

main
Last change on this file since 70dbef4 was 7090fd2, checked in by Robin <roboburr@…>, 3 weeks ago

Album: geen epoch-datum, en een naam die overal hetzelfde is

Gevonden door de twee representaties van hetzelfde id naast elkaar te
leggen, met een echte album-playlist op dev (Robin, 16-8).

EEN ID, TWEE NAMEN. Het ingesloten Album heette "Cartoon Epic" en zijn
eigen URI "Geen koffie, wel thee!". buildPlaylistCollection laat
leenVanPost de titel van de uitgavepost overnemen -- de post IS de
uitgave -- en buildAlbumObject deed dat niet. Nu leent hij dezelfde
titel, met de eigen titel als alsoKnownAs, precies zoals leenVanPost.
De uitgavepost wordt EEN keer per album opgezocht en niet per track:
uitgavePost() leest de typering van de post, en een site heeft veel meer
nummers dan platen.

PUBLISHED 1970-01-01. buildAlbumObject viel terug op epoch als
created_at ontbrak, en dat gebeurde: de AP-route selecteerde id, title,
artist, year, cover_url en kind -- en de rest niet. Daarmee ontbraken
released en musicbrainzId daar trouwens ook.

Twee dingen gerepareerd, en de terugval zelf is het derde: er komt nu
GEEN published meer als er geen datum is. Het veld is bij hen verplicht,
dus dat kost ons een item -- maar 1970 is een antwoord en geen ontbrekend
veld, en een lezer kan een gat opmerken en een leugen niet. Ontbreekt het
nog, dan is er een leespad dat de kolom laat vallen, en dat willen we
zien.

Ook PlaylistService.get() geeft created_at nu terug.

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

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