source: Klonkt/src/services/music/index.js@ c832064

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

De soort van een playlist wordt opgezocht, niet afgeleid (shaer-cyg)

Robins afbakening maakt twee dingen tegelijk simpeler. Album of playlist wordt
gekozen wanneer de PLAYLIST wordt gemaakt -- die keuze staat al in
playlists.kind -- en de post neemt bij een enkele insluiting die soort over. Een
post om een album is dus een album, ook al heet de shortcode [[playlist:...]].

En de afleiding is er voor BESTAANDE posts; nieuwe krijgen hun type uit de
keuze. Daarmee is "wie wint, de keuze of de afleiding?" geen vraag meer: ze
komen elkaar niet tegen. De afleiding draait eenmalig, de keuze daarna.

Onbekende situatie -> gewone post met insluitingen die de post als context
hebben. Een onvindbare playlist, of een van een andere site, verzint dus geen
type meer.

De patronen zijn nu die van AudioEmbedService: wat daar niet insluit, telt hier
niet mee. Mijn playlist-patroon accepteerde underscores die de renderer weigert,
en zo'n shortcode had wel het type van de post kunnen bepalen.

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

  • Property mode set to 100644
File size: 20.2 KB
Line 
1/**
2 * De muziekkant van ActivityPub: een track als AS2-object, de collecties
3 * eromheen, en welke post hem uitbrengt.
4 *
5 * Waarom een eigen map (shaer-drc): ActivityPubService was 6400 regels over
6 * tweeentwintig onderwerpen. Dit is het eerste onderwerp dat er als geheel uit
7 * kan, en het is meteen het onderwerp dat gaat GROEIEN -- de typering uit
8 * shaer-cyg (playlist versus album, afgeleid uit wat er in de post staat) landt
9 * hier straks.
10 *
11 * De regel die guardianship al aanhoudt geldt hier ook: deze map importeert
12 * alleen db en ap-core, en NOOIT terug uit ActivityPubService.
13 */
14
15import db from '../../config/database.js';
16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType } from '../ap-core.js';
17
18// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
19const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
20 t.position, t.license,
21 m.filename, m.storage_path, m.mime_type, m.size`;
22
23export function playlistOpenTracks(playlistId) {
24 return db.prepare(
25 `SELECT ${TRACK_KOLOMMEN}
26 FROM playlist_tracks pt
27 JOIN audio_tracks t ON t.id = pt.track_id
28 JOIN media m ON m.id = t.media_id
29 WHERE pt.playlist_id = ? AND t.fedi_open = 1
30 ORDER BY pt.position`
31 ).all(playlistId);
32}
33
34/**
35 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
36 *
37 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
38 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
39 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
40 */
41export function siteOpenTracks(siteId) {
42 return db.prepare(
43 `SELECT ${TRACK_KOLOMMEN}
44 FROM audio_tracks t JOIN media m ON m.id = t.media_id
45 WHERE t.site_id = ? AND t.fedi_open = 1
46 ORDER BY t.position, t.created_at, t.id`
47 ).all(siteId);
48}
49
50export function openTrack(siteId, trackId) {
51 return db.prepare(
52 `SELECT ${TRACK_KOLOMMEN}
53 FROM audio_tracks t JOIN media m ON m.id = t.media_id
54 WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
55 ).get(siteId, trackId);
56}
57
58/**
59 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
60 *
61 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
62 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
63 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
64 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
65 * precies zo, per stuk, in Create en Delete.
66 *
67 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
68 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
69 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
70 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
71 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
72 * belooft.
73 */
74/**
75 * Bij welke post hoort een track? (shaer-0nh)
76 *
77 * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
78 * een van drie insluitingen in posts.content. Die relatie stond alleen in die
79 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
80 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
81 *
82 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
83 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
84 * scans. Nu is het er een, en de map gaat mee als optie.
85 *
86 * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
87 * is specifieker dan via een playlist, en die weer specifieker dan via een
88 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
89 * laatst heeft uitgebracht.
90 */
91export function trackHostPosts(siteId) {
92 const rijen = db.prepare(`
93 SELECT tid, post_id, post_slug, rang, wanneer FROM (
94 SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
95 COALESCE(p.published_at, p.created_at) AS wanneer
96 FROM audio_tracks t
97 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
98 AND p.content LIKE '%[[track:' || t.id || ']]%'
99 WHERE t.site_id = ? AND t.fedi_open = 1
100 UNION ALL
101 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
102 FROM playlist_tracks pt
103 JOIN audio_tracks t ON t.id = pt.track_id
104 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
105 AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
106 WHERE t.site_id = ? AND t.fedi_open = 1
107 UNION ALL
108 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
109 FROM audio_tracks t
110 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
111 AND p.content LIKE '%[[album:' || t.album || ']]%'
112 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
113 ) ORDER BY rang, wanneer DESC
114 `).all(siteId, siteId, siteId);
115 const uit = new Map();
116 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
117 return uit;
118}
119
120export function buildTrackAudio(base, site, r, opts = {}) {
121 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
122 const fn = r.filename || (r.storage_path || '').split('/').pop();
123 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
124 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
125 // Zo doet Funkwhale het ook.
126 // De post waar dit nummer in staat. Meegegeven door de collectie (een
127 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
128 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
129 const post = opts.hostPosts !== undefined
130 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
131 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
132
133 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
134 if (Number(r.size)) bestand.size = Number(r.size);
135 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
136 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
137 // de getallen hebben -- liever geen veld dan een verzonnen getal.
138 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
139
140 const a = {
141 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
142 id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
143 type: 'Audio',
144 name: r.title || 'Audio',
145 attributedTo: actorId(base, site.slug),
146 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
147 // track moet zelf kunnen zeggen dat hij openbaar is.
148 to: [PUBLIC],
149 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
150 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
151 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
152 // horen, en dat is wat zo'n link betekent.
153 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
154 };
155 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
156 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
157 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
158 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
159 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
160 // van te maken.
161 if (post) a.context = noteId(base, post.id);
162 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
163 if (r.created_at) a.published = new Date(r.created_at).toISOString();
164 if (Number(r.position)) a.position = Number(r.position);
165 const lic = licentieUri(r.license);
166 if (lic) a.license = lic;
167 const art = abs(r.cover_url || opts.coverFallback || null);
168 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
169 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
170 if (art) {
171 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
172 a.icon = plaat;
173 a.image = plaat;
174 }
175 return a;
176}
177
178/**
179 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
180 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
181 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
182 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
183 * een lezer gelooft hem.
184 */
185const LICENTIES = {
186 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
187 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
188 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
189 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
190 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
191 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
192};
193export function licentieUri(waarde) {
194 const s = String(waarde || '').trim();
195 if (!s) return null;
196 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
197 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
198}
199
200/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
201export function buildTrackCollection(base, site, rows) {
202 return {
203 '@context': AP_CONTEXT,
204 id: `${actorId(base, site.slug)}/tracks`,
205 type: 'OrderedCollection',
206 attributedTo: actorId(base, site.slug),
207 totalItems: (rows || []).length,
208 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
209 orderedItems: (() => {
210 const posts = site.id ? trackHostPosts(site.id) : null;
211 return (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
212 })(),
213 };
214}
215
216// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
217// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
218// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
219// client die hem kent haalt de collectie op. Opgelost uit post.content en
220// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
221// zonder site-check zou een post van site A naar de collectie van site B
222// kunnen wijzen.
223export function playlistLinkTags(base, site, content) {
224 const out = [];
225 const seen = new Set();
226 try {
227 for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
228 if (seen.has(m[1])) continue;
229 seen.add(m[1]);
230 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id);
231 if (!pl) continue;
232 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
233 }
234 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
235 return out;
236}
237
238// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
239// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
240// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
241// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
242// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
243// poort staat.
244export function listPlaylistsAP(base, site, enriched) {
245 const rows = db.prepare(
246 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
247 ).all(site.id);
248 const colId = `${actorId(base, site.slug)}/playlists`;
249 const items = rows.map((p) => {
250 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
251 if (!enriched) return uri;
252 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
253 delete stub['@context']; // genest object draagt de context van zijn omhulsel
254 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
255 return stub;
256 });
257 return {
258 '@context': AP_CONTEXT,
259 id: colId,
260 type: 'OrderedCollection',
261 attributedTo: actorId(base, site.slug),
262 totalItems: items.length,
263 orderedItems: items,
264 };
265}
266
267export function buildPlaylistCollection(base, site, playlist, rows) {
268 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
269 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
270 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
271 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
272 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
273 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
274 const hostPosts = site.id ? trackHostPosts(site.id) : null;
275 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
276 const out = {
277 '@context': AP_CONTEXT,
278 id: `${actorId(base, site.slug)}/playlists/${playlist.id}`,
279 type: 'OrderedCollection',
280 name: playlist.title,
281 attributedTo: actorId(base, site.slug),
282 totalItems: items.length,
283 orderedItems: items,
284 };
285 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
286 const parts = [];
287 if (playlist.artist) parts.push(playlist.artist);
288 if (playlist.year) parts.push(String(playlist.year));
289 if (parts.length) out.summary = parts.join(' · ');
290 const cover = abs(playlist.cover_url || null);
291 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
292 return out;
293}
294
295/**
296 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
297 *
298 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
299 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
300 * ophaalt niet denkt dat er iets nieuws is.
301 */
302export function buildTrackCreate(base, site, r, opts = {}) {
303 const audio = buildTrackAudio(base, site, r, opts);
304 const me = actorId(base, site.slug);
305 return {
306 '@context': AP_CONTEXT,
307 id: `${audio.id}#create`,
308 type: 'Create',
309 actor: me,
310 published: audio.published,
311 to: [PUBLIC],
312 cc: [`${me}/followers`],
313 object: audio,
314 };
315}
316
317/**
318 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
319 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
320 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
321 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
322 */
323export function channelCategory(site) {
324 try {
325 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
326 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
327 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
328 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
329 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
330 // muziekkanaal.
331 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
332 } catch { return null; }
333}
334
335// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────────
336
337/**
338 * Het type van een BESTAANDE post afleiden uit de muziek die erin staat.
339 *
340 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
341 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
342 * gemaakt, en de post neemt dat over. Deze functie is er voor wat er al staat --
343 * de posts met type=audio uit de tijd voor die keuze bestond. Daarmee is de
344 * vraag "wie wint, de keuze of de afleiding?" geen vraag meer: ze komen elkaar
345 * niet tegen. De afleiding draait eenmalig, de keuze draait daarna.
346 *
347 * DE REGEL GAAT OVER IDENTITEIT, NIET OVER TELLEN (Robins herformulering, 9-8):
348 * een post neemt het type van zijn muziek over als hij precies EEN muzikale
349 * eenheid bevat. Zijn het er meer, dan is de post een post die naar muziek
350 * verwijst, en houden de collecties hun eigen identiteit.
351 *
352 * losse track(s) -> playlist metadata van de post geleend
353 * een collectie -> die soort metadata van de post geleend
354 * een collectie + losse tracks -> album de losse zijn BONUS-TRACKS
355 * twee of meer collecties -> post NIETS geleend
356 *
357 * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
358 * dun is -- geen eigen hoes, geen eigen titel. Bij twee is de post niet meer de
359 * drager van EEN identiteit, en vervalt de reden vanzelf. Dezelfde regel zet
360 * zichzelf uit.
361 *
362 * EEN COLLECTIE IS EEN PLAYLIST OF EEN ALBUM-INSLUITING. Robins regel noemde
363 * alleen playlists, maar [[album:naam]] groepeert net zo goed tracks en is
364 * letterlijk een album; hem als losse tracks tellen zou een albumpost tot
365 * playlist maken. Telt hij straks anders, dan is dat hier een regel.
366 *
367 * WIE KIEST ALBUM OF PLAYLIST: dat gebeurt wanneer de PLAYLIST wordt gemaakt,
368 * en die keuze staat al in playlists.kind. Bij een enkele insluiting neemt de
369 * post dus die soort over -- een post om een album is een album, ook al heet de
370 * shortcode [[playlist:...]]. De soort van een playlist wordt hier dus
371 * OPGEZOCHT en niet afgeleid.
372 *
373 * EN ALS WE HET NIET WETEN: een gewone post met insluitingen die de post als
374 * context hebben. Dat is geen noodgreep maar de rustende toestand -- tracks
375 * wijzen met `context` toch al terug naar hun post, dus er gaat niets verloren
376 * als het label 'post' wordt.
377 *
378 * @param {string} content de HTML/tekst van de post
379 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
380 */
381export function postMusicType(content, siteId) {
382 const c = String(content || '');
383 const uniek = (re) => [...new Set([...c.matchAll(re)].map((m) => m[1].trim()))];
384
385 // Dezelfde patronen als de renderer in AudioEmbedService: wat daar niet
386 // insluit, telt hier niet mee. Anders zou een shortcode die niets oplevert
387 // wel het type van de post kunnen bepalen.
388 const playlists = uniek(/\[\[playlist:([a-z0-9][a-z0-9-]*)\]\]/gi);
389 const albums = uniek(/\[\[album:([^\]]+)\]\]/g);
390 const tracks = uniek(/\[\[track:([A-Za-z0-9_-]+)\]\]/g);
391
392 if (!playlists.length && !albums.length && !tracks.length) return null;
393
394 // De soort van een playlist is een gegeven, geen afleiding.
395 let onbekend = [];
396 const uitPlaylists = playlists.map((id) => {
397 const kind = playlistKind(id, siteId);
398 if (!kind) { onbekend.push(id); return null; }
399 return { soort: kind, id };
400 }).filter(Boolean);
401
402 const collecties = [
403 ...uitPlaylists,
404 ...albums.map((naam) => ({ soort: 'album', naam })),
405 ];
406
407 // Onbekende situatie -> gewone post. De insluitingen blijven staan en houden
408 // de post als context; alleen het label wordt niet verzonnen.
409 if (onbekend.length) {
410 return { type: 'post', collecties, tracks, bonus: [], onbekend, leentMetadata: false };
411 }
412
413 if (!collecties.length) {
414 // Ook EEN losse track wordt een playlist: naar buiten toe is er dan altijd
415 // een collectie om naar te wijzen. Hoe Klonkt dat toont is een aparte vraag.
416 return { type: 'playlist', collectie: null, tracks, bonus: [], leentMetadata: true };
417 }
418
419 if (collecties.length === 1) {
420 const c0 = collecties[0];
421 // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
422 // en dat maakt het geheel een album.
423 if (tracks.length) return { type: 'album', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
424 return { type: c0.soort, collectie: c0, tracks: [], bonus: [], leentMetadata: true };
425 }
426
427 return { type: 'post', collecties, tracks, bonus: [], leentMetadata: false };
428}
429
430/**
431 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
432 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
433 * geen 'playlist' zegt is een album.
434 */
435function playlistKind(id, siteId) {
436 if (!siteId) return null;
437 try {
438 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
439 if (!r) return null;
440 return r.kind === 'playlist' ? 'playlist' : 'album';
441 } catch { return null; }
442}
Note: See TracBrowser for help on using the repository browser.