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

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

Playlists: uitgavedatum en MusicBrainz release-id op een album

Stap 1 van shaer-756s. Het album bestaat al als ding -- een playlist met
kind='album', met eigen id, titel, artiest, jaar, hoes en een eigen
AP-collectie. Wat ontbrak om het als Album uit te geven zijn twee velden.

release_date en niet year: die kolom blijft en wordt gewoon getoond,
maar hun AlbumSerializer leest released als een DateField. Een jaartal
als 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we
bij artiesten en albums niet doen. Volledige datum of niets -- ook
2024-02-31 valt af, want Date rolt die stil door naar 2 maart en dan
bewaren we iets anders dan er ingetypt is.

mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde
verwijzing naar MusicBrainz, een niveau lager.

ALLEEN BIJ kind='album', en dat wordt in PlaylistService afgedwongen en
niet alleen in het scherm. De API ligt open -- de post-editor gebruikt
hem ook -- en een scherm is geen bewaking. Omschakelen naar afspeellijst
maakt de velden ALTIJD leeg, ook als de aanroeper er niets over zei:
anders houdt een album dat je tot mixtape ombouwt zijn uitgavedatum, en
duikt die weer op zodra iemand hem terugzet. Het leespad geeft ze ook
niet door bij een afspeellijst, zodat een oude rij niet alsnog lekt.

In de editor staan de velden onder Type en verdwijnen ze als je naar
playlist schakelt. Daar hoorde een CSS-regel bij: .pl-field is display:
flex en dat wint van het hidden-attribuut -- zonder .pl-field[hidden]
{ display: none } bleven ze gewoon staan.

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

  • Property mode set to 100644
File size: 12.6 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
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
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,
81 p.release_date, p.mb_release_id,
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',
95 release_date: r.release_date || '',
96 mb_release_id: r.mb_release_id || '',
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 *
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.
113 */
114 static get(siteId, id, urlFor) {
115 id = this.normalizeId(id);
116 if (!id) return null;
117 const p = db.prepare(`
118 SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at
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(`
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
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
135 const mappedTracks = tracks
136 // Link-only tracks (no media file) remain in the list with url ''.
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,
143 link_spotify: t.link_spotify || '',
144 link_youtube: t.link_youtube || '',
145 link_soundcloud: t.link_soundcloud || '',
146 url: (t.filename && urlFor) ? urlFor(t.filename) : '',
147 }));
148 // No playlist cover? Fall back to the first track cover so the card isn't empty.
149 const fallbackCover = (mappedTracks.find(t => t.cover) || {}).cover || '';
150 return {
151 id: p.id,
152 title: p.title,
153 artist: p.artist || '',
154 year: p.year || 0,
155 cover: p.cover_url || fallbackCover,
156 kind: (p.kind === 'playlist') ? 'playlist' : 'album',
157 // Leeg als het een afspeellijst is -- de opslag houdt ze daar al leeg,
158 // maar dit is de plek waar de editor leest en die mag niet afhangen van
159 // wat er toevallig in de kolom stond.
160 release_date: p.kind === 'playlist' ? '' : (p.release_date || ''),
161 mb_release_id: p.kind === 'playlist' ? '' : (p.mb_release_id || ''),
162 tracks: mappedTracks,
163 };
164 }
165
166 /**
167 * Create a new playlist. Returns new id, or null on validation failure.
168 * `data.tracks` is an ordered array of audio_tracks.id values.
169 */
170 static create(siteId, data) {
171 const title = String(data.title || '').trim();
172 if (!title) return null;
173
174 const id = this.generateId(siteId, title);
175 const now = new Date().toISOString();
176 const kind = data.kind === 'playlist' ? 'playlist' : 'album';
177
178 // Alleen een UITGAVE draagt deze twee. Een afspeellijst heeft geen
179 // uitgavedatum en geen release-id, en dat onderscheid is precies wat de
180 // keuze album/playlist betekent (shaer-cyg). Het afdwingen gebeurt HIER en
181 // niet alleen in het scherm: een scherm kun je omzeilen -- de API ligt open
182 // voor de post-editor -- en dan staat er stille rommel op een mixtape die
183 // later als Album de deur uit gaat.
184 const uitgave = kind === 'album';
185 const releaseDate = uitgave ? normDatum(data.release_date) : null;
186 const mbRelease = uitgave ? normMbid(data.mb_release_id) : null;
187
188 const tx = db.transaction(() => {
189 db.prepare(`
190 INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at)
191 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
192 `).run(
193 id, siteId, title,
194 String(data.artist || '').trim() || null,
195 Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
196 String(data.cover || '').trim() || null,
197 kind, releaseDate, mbRelease, now, now,
198 );
199 this._writeTracks(id, siteId, data.tracks);
200 });
201 try {
202 tx();
203 return id;
204 } catch (err) {
205 console.error('[PlaylistService.create]', err);
206 return null;
207 }
208 }
209
210 /**
211 * Update an existing playlist. Fields not present in `data` are left alone.
212 * Returns true on success.
213 */
214 static update(siteId, id, data) {
215 id = this.normalizeId(id);
216 if (!id) return false;
217 const existing = db.prepare(
218 'SELECT id, kind FROM playlists WHERE site_id = ? AND id = ?'
219 ).get(siteId, id);
220 if (!existing) return false;
221
222 const fields = [];
223 const values = [];
224 // Wat wordt het NA deze wijziging? `kind` hoeft niet in data te staan, dus
225 // val terug op wat er ligt.
226 const nieuwKind = Object.prototype.hasOwnProperty.call(data, 'kind')
227 ? (data.kind === 'playlist' ? 'playlist' : 'album')
228 : (existing.kind || 'album');
229 if (Object.prototype.hasOwnProperty.call(data, 'title')) {
230 const v = String(data.title || '').trim();
231 if (!v) return false; // title is required, can't blank it
232 fields.push('title = ?'); values.push(v);
233 }
234 if (Object.prototype.hasOwnProperty.call(data, 'artist')) {
235 fields.push('artist = ?'); values.push(String(data.artist || '').trim() || null);
236 }
237 if (Object.prototype.hasOwnProperty.call(data, 'year')) {
238 const y = +data.year;
239 fields.push('year = ?'); values.push(Number.isFinite(y) && y > 0 ? y : null);
240 }
241 if (Object.prototype.hasOwnProperty.call(data, 'cover')) {
242 fields.push('cover_url = ?'); values.push(String(data.cover || '').trim() || null);
243 }
244 if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
245 fields.push('kind = ?'); values.push(nieuwKind);
246 }
247 // De uitgavevelden. Wordt dit een afspeellijst, dan gaan ze ALTIJD leeg --
248 // ook als de aanroeper er niets over zei. Anders houdt een album dat je tot
249 // mixtape ombouwt zijn uitgavedatum en zijn release-id, en die duiken dan
250 // weer op zodra iemand hem terugzet. Een veld dat niet meer mag bestaan
251 // hoort weg te zijn, niet te wachten.
252 if (nieuwKind !== 'album') {
253 fields.push('release_date = ?'); values.push(null);
254 fields.push('mb_release_id = ?'); values.push(null);
255 } else {
256 if (Object.prototype.hasOwnProperty.call(data, 'release_date')) {
257 fields.push('release_date = ?'); values.push(normDatum(data.release_date));
258 }
259 if (Object.prototype.hasOwnProperty.call(data, 'mb_release_id')) {
260 fields.push('mb_release_id = ?'); values.push(normMbid(data.mb_release_id));
261 }
262 }
263 fields.push('updated_at = ?'); values.push(new Date().toISOString());
264
265 const tx = db.transaction(() => {
266 if (fields.length > 1) { // > 1 because updated_at is always there
267 db.prepare(`UPDATE playlists SET ${fields.join(', ')} WHERE id = ? AND site_id = ?`)
268 .run(...values, id, siteId);
269 }
270 if (Object.prototype.hasOwnProperty.call(data, 'tracks')) {
271 // Replace track set wholesale — simpler and matches v9 semantics.
272 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(id);
273 this._writeTracks(id, siteId, data.tracks);
274 }
275 });
276 try {
277 tx();
278 return true;
279 } catch (err) {
280 console.error('[PlaylistService.update]', err);
281 return false;
282 }
283 }
284
285 /**
286 * Delete a playlist. Track references in playlist_tracks are removed
287 * automatically via ON DELETE CASCADE. Posts that embed this playlist
288 * will render a "playlist not found" placeholder.
289 */
290 static delete(siteId, id) {
291 id = this.normalizeId(id);
292 if (!id) return false;
293 const result = db.prepare(
294 'DELETE FROM playlists WHERE site_id = ? AND id = ?'
295 ).run(siteId, id);
296 return result.changes > 0;
297 }
298
299 // ─── INTERNAL ──────────────────────────────────────────────────────
300
301 /**
302 * Replace a playlist's track list. Skips track ids that don't belong to
303 * this site (defensive — admin form should never send those, but better
304 * safe than cross-site leak).
305 */
306 static _writeTracks(playlistId, siteId, trackIds) {
307 if (!Array.isArray(trackIds) || trackIds.length === 0) return;
308
309 // Filter to ids that actually exist for this site, preserving order
310 const placeholders = trackIds.map(() => '?').join(',');
311 const valid = new Set(
312 db.prepare(`
313 SELECT id FROM audio_tracks WHERE site_id = ? AND id IN (${placeholders})
314 `).all(siteId, ...trackIds).map(r => r.id)
315 );
316
317 const insert = db.prepare(`
318 INSERT INTO playlist_tracks (playlist_id, track_id, position)
319 VALUES (?, ?, ?)
320 `);
321 let pos = 0;
322 const seen = new Set();
323 for (const tid of trackIds) {
324 if (!valid.has(tid)) continue;
325 if (seen.has(tid)) continue; // dedupe while preserving order
326 seen.add(tid);
327 insert.run(playlistId, tid, pos++);
328 }
329 }
330}
331
332export default PlaylistService;
Note: See TracBrowser for help on using the repository browser.