source: Klonkt/src/services/music/index.js@ 0677bac

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

De tags kwamen er als onzin uit, en de tekst helemaal niet (shaer-38y)

Live nagekeken op dev, en twee dingen klopten niet.

DE TAGS. posts.tags staat als JSON-ARRAY in de database, niet als kommalijst.
Mijn eigen split leverde #["Doen we Niet" en #"music" op -- geen fout die
omvalt, een die onzin uitlevert. Nu met normalizeTags en tagParts, dezelfde
parser als de rest, dus ook dezelfde CamelCase voor tags van meerdere woorden.

Die twee stonden in ActivityPubService en music/ mag daar niet uit importeren,
dus ze zijn naar ap-core verhuisd -- de plek voor precies dit soort gedeelde
grondstof. Een kopie maken zou hetzelfde uit elkaar lopen opleveren als bij de
afleidingsregel.

DE TEKST. De excerpt bleek in de praktijk vaak leeg, en dan kwam er geen
content mee terwijl de post wel tekst heeft. Nu: excerpt eerst -- die is
geschreven om samen te vatten -- en anders het lijf zelf, zonder shortcodes
(die zijn de muziek, niet het verhaal erover) en zonder opmaak. Blijft er niets
over, dan geen veld: dat is beter dan een leeg veld.

De test gebruikte de kommavorm en testte dus de terugval in plaats van de echte
weg. Nu JSON, zoals het er staat.

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

  • Property mode set to 100644
File size: 24.0 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, normalizeTags, tagParts } from '../ap-core.js';
17import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
18
19// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
20const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
21 t.position, t.license,
22 m.filename, m.storage_path, m.mime_type, m.size`;
23
24export function playlistOpenTracks(playlistId) {
25 return db.prepare(
26 `SELECT ${TRACK_KOLOMMEN}
27 FROM playlist_tracks pt
28 JOIN audio_tracks t ON t.id = pt.track_id
29 JOIN media m ON m.id = t.media_id
30 WHERE pt.playlist_id = ? AND t.fedi_open = 1
31 ORDER BY pt.position`
32 ).all(playlistId);
33}
34
35/**
36 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
37 *
38 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
39 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
40 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
41 */
42export function siteOpenTracks(siteId) {
43 return db.prepare(
44 `SELECT ${TRACK_KOLOMMEN}
45 FROM audio_tracks t JOIN media m ON m.id = t.media_id
46 WHERE t.site_id = ? AND t.fedi_open = 1
47 ORDER BY t.position, t.created_at, t.id`
48 ).all(siteId);
49}
50
51export function openTrack(siteId, trackId) {
52 return db.prepare(
53 `SELECT ${TRACK_KOLOMMEN}
54 FROM audio_tracks t JOIN media m ON m.id = t.media_id
55 WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
56 ).get(siteId, trackId);
57}
58
59/**
60 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
61 *
62 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
63 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
64 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
65 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
66 * precies zo, per stuk, in Create en Delete.
67 *
68 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
69 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
70 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
71 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
72 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
73 * belooft.
74 */
75/**
76 * Bij welke post hoort een track? (shaer-0nh)
77 *
78 * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
79 * een van drie insluitingen in posts.content. Die relatie stond alleen in die
80 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
81 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
82 *
83 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
84 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
85 * scans. Nu is het er een, en de map gaat mee als optie.
86 *
87 * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
88 * is specifieker dan via een playlist, en die weer specifieker dan via een
89 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
90 * laatst heeft uitgebracht.
91 */
92export function trackHostPosts(siteId) {
93 const rijen = db.prepare(`
94 SELECT tid, post_id, post_slug, rang, wanneer FROM (
95 SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
96 COALESCE(p.published_at, p.created_at) AS wanneer
97 FROM audio_tracks t
98 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
99 AND p.content LIKE '%[[track:' || t.id || ']]%'
100 WHERE t.site_id = ? AND t.fedi_open = 1
101 UNION ALL
102 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
103 FROM playlist_tracks pt
104 JOIN audio_tracks t ON t.id = pt.track_id
105 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
106 AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
107 WHERE t.site_id = ? AND t.fedi_open = 1
108 UNION ALL
109 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
110 FROM audio_tracks t
111 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
112 AND p.content LIKE '%[[album:' || t.album || ']]%'
113 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
114 ) ORDER BY rang, wanneer DESC
115 `).all(siteId, siteId, siteId);
116 const uit = new Map();
117 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
118 return uit;
119}
120
121export function buildTrackAudio(base, site, r, opts = {}) {
122 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
123 const fn = r.filename || (r.storage_path || '').split('/').pop();
124 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
125 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
126 // Zo doet Funkwhale het ook.
127 // De post waar dit nummer in staat. Meegegeven door de collectie (een
128 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
129 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
130 const post = opts.hostPosts !== undefined
131 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
132 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
133
134 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
135 if (Number(r.size)) bestand.size = Number(r.size);
136 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
137 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
138 // de getallen hebben -- liever geen veld dan een verzonnen getal.
139 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
140
141 const a = {
142 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
143 id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
144 type: 'Audio',
145 name: r.title || 'Audio',
146 attributedTo: actorId(base, site.slug),
147 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
148 // track moet zelf kunnen zeggen dat hij openbaar is.
149 to: [PUBLIC],
150 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
151 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
152 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
153 // horen, en dat is wat zo'n link betekent.
154 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
155 };
156 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
157 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
158 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
159 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
160 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
161 // van te maken.
162 if (post) a.context = noteId(base, post.id);
163 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
164 if (r.created_at) a.published = new Date(r.created_at).toISOString();
165 if (Number(r.position)) a.position = Number(r.position);
166 const lic = licentieUri(r.license);
167 if (lic) a.license = lic;
168 const art = abs(r.cover_url || opts.coverFallback || null);
169 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
170 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
171 if (art) {
172 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
173 a.icon = plaat;
174 a.image = plaat;
175 }
176 return a;
177}
178
179/**
180 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
181 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
182 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
183 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
184 * een lezer gelooft hem.
185 */
186const LICENTIES = {
187 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
188 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
189 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
190 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
191 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
192 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
193};
194export function licentieUri(waarde) {
195 const s = String(waarde || '').trim();
196 if (!s) return null;
197 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
198 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
199}
200
201/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
202export function buildTrackCollection(base, site, rows) {
203 return {
204 '@context': AP_CONTEXT,
205 id: `${actorId(base, site.slug)}/tracks`,
206 type: 'OrderedCollection',
207 attributedTo: actorId(base, site.slug),
208 totalItems: (rows || []).length,
209 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
210 orderedItems: (() => {
211 const posts = site.id ? trackHostPosts(site.id) : null;
212 return (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
213 })(),
214 };
215}
216
217// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
218// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
219// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
220// client die hem kent haalt de collectie op. Opgelost uit post.content en
221// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
222// zonder site-check zou een post van site A naar de collectie van site B
223// kunnen wijzen.
224export function playlistLinkTags(base, site, content, post = null) {
225 const out = [];
226 try {
227 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
228 // krijgt ook geen link. Dit stond hier met een eigen patroon dat
229 // underscores accepteerde die nergens anders meetellen.
230 for (const id of ingeslotenPlaylists(content)) {
231 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
232 if (!pl) continue;
233 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
234 }
235 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
236 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
237 // link zou die collectie bestaan maar door niemand te vinden zijn.
238 if (post && post.id) {
239 const eenheid = postMusicType(content, site.id);
240 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
241 out.push({
242 type: 'Link',
243 href: postTracksId(base, site, post.id),
244 mediaType: 'application/activity+json',
245 name: post.title || 'Tracks',
246 });
247 }
248 }
249 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
250 return out;
251}
252
253// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
254// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
255// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
256// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
257// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
258// poort staat.
259export function listPlaylistsAP(base, site, enriched) {
260 const rows = db.prepare(
261 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
262 ).all(site.id);
263 const colId = `${actorId(base, site.slug)}/playlists`;
264 const items = rows.map((p) => {
265 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
266 if (!enriched) return uri;
267 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
268 delete stub['@context']; // genest object draagt de context van zijn omhulsel
269 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
270 return stub;
271 });
272 return {
273 '@context': AP_CONTEXT,
274 id: colId,
275 type: 'OrderedCollection',
276 attributedTo: actorId(base, site.slug),
277 totalItems: items.length,
278 orderedItems: items,
279 };
280}
281
282export function buildPlaylistCollection(base, site, playlist, rows) {
283 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
284 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
285 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
286 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
287 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
288 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
289 const hostPosts = site.id ? trackHostPosts(site.id) : null;
290 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
291 const out = {
292 '@context': AP_CONTEXT,
293 id: `${actorId(base, site.slug)}/playlists/${playlist.id}`,
294 type: 'OrderedCollection',
295 name: playlist.title,
296 attributedTo: actorId(base, site.slug),
297 totalItems: items.length,
298 orderedItems: items,
299 };
300 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
301 const parts = [];
302 if (playlist.artist) parts.push(playlist.artist);
303 if (playlist.year) parts.push(String(playlist.year));
304 if (parts.length) out.summary = parts.join(' · ');
305 const cover = abs(playlist.cover_url || null);
306 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
307 return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
308}
309
310// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
311
312/** Het AS2-id van de collectie losse tracks van een post. */
313function postTracksId(base, site, postId) {
314 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
315}
316
317/**
318 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
319 *
320 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
321 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
322 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
323 * dezelfde regel als in de afleiding, hier alleen toegepast.
324 *
325 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
326 * uitgebracht.
327 */
328export function uitgavePost(siteId, playlistId) {
329 if (!siteId || !playlistId) return null;
330 try {
331 const rijen = db.prepare(`
332 SELECT id, slug, title, excerpt, content, cover_image_url, tags
333 FROM posts
334 WHERE site_id = ? AND status = 'published'
335 AND content LIKE '%[[playlist:' || ? || ']]%'
336 ORDER BY COALESCE(published_at, created_at) DESC
337 `).all(siteId, playlistId);
338 for (const p of rijen) {
339 const r = postMusicType(p.content, siteId);
340 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
341 }
342 } catch { /* geen lening is geen fout */ }
343 return null;
344}
345
346/**
347 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
348 *
349 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
350 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
351 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
352 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
353 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
354 * hij nu krijgt.
355 *
356 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
357 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
358 */
359function leenVanPost(base, site, obj, post) {
360 if (!post) return obj;
361 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
362
363 if (post.title) {
364 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
365 obj.name = post.title;
366 }
367 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
368 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
369 // staan -- dat is een samenvatting, en de posttekst is dat niet.
370 const tekst = tekstVanPost(post);
371 if (tekst) obj.content = tekst;
372
373 const cover = abs(post.cover_image_url || null);
374 if (cover) {
375 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
376 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
377 }
378
379 const tags = hashtagsVanPost(base, post.tags);
380 if (tags.length) obj.tag = tags;
381
382 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
383 // losse track: url wijst een mens naar de post, context zegt waar dit object
384 // thuishoort.
385 obj.url = `${base}/${post.slug}`;
386 obj.context = noteId(base, post.id);
387 return obj;
388}
389
390/**
391 * De tags van een post als AS2 Hashtags -- zelfde vorm als buildHashtagList.
392 *
393 * Met normalizeTags en niet met een eigen split: het veld staat als JSON-array
394 * in de database, en op komma's splitsen leverde live `#["Doen we Niet"` op.
395 * Dat is het soort fout dat niet omvalt maar onzin uitlevert.
396 */
397function hashtagsVanPost(base, tagsField) {
398 const uit = [], gezien = new Set();
399 for (const t of normalizeTags(tagsField)) {
400 const p = tagParts(t);
401 if (!p || gezien.has(p.slug)) continue;
402 gezien.add(p.slug);
403 uit.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
404 }
405 return uit;
406}
407
408/**
409 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
410 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
411 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
412 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
413 */
414function tekstVanPost(post) {
415 const excerpt = String(post.excerpt || '').trim();
416 if (excerpt) return excerpt;
417 const kaal = String(post.content || '')
418 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
419 .replace(/<[^>]+>/g, ' ')
420 .replace(/&nbsp;/gi, ' ')
421 .replace(/&[a-z#0-9]+;/gi, ' ')
422 .replace(/\s+/g, ' ')
423 .trim();
424 return kaal || null;
425}
426
427/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
428function losseTracksVanPost(siteId, ids) {
429 if (!siteId || !ids?.length) return [];
430 const gaten = ids.map(() => '?').join(',');
431 const rijen = db.prepare(
432 `SELECT ${TRACK_KOLOMMEN}
433 FROM audio_tracks t JOIN media m ON m.id = t.media_id
434 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
435 ).all(siteId, ...ids);
436 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
437 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
438 const opId = new Map(rijen.map((r) => [r.id, r]));
439 return ids.map((id) => opId.get(id)).filter(Boolean);
440}
441
442/**
443 * De losse tracks van een post als EEN uitgave (shaer-38y).
444 *
445 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
446 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
447 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
448 * heeft ze wel en de losse tracks niet.
449 *
450 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
451 * post die geen enkele muzikale eenheid IS.
452 */
453export function buildPostTrackCollection(base, site, post) {
454 if (!post || !post.id) return null;
455 const eenheid = postMusicType(post.content, site.id);
456 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
457
458 const rows = losseTracksVanPost(site.id, eenheid.tracks);
459 if (!rows.length) return null;
460
461 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
462 const out = {
463 '@context': AP_CONTEXT,
464 id: postTracksId(base, site, post.id),
465 type: 'OrderedCollection',
466 attributedTo: actorId(base, site.slug),
467 totalItems: rows.length,
468 orderedItems: rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
469 };
470 return leenVanPost(base, site, out, post);
471}
472
473/**
474 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
475 *
476 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
477 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
478 * ophaalt niet denkt dat er iets nieuws is.
479 */
480export function buildTrackCreate(base, site, r, opts = {}) {
481 const audio = buildTrackAudio(base, site, r, opts);
482 const me = actorId(base, site.slug);
483 return {
484 '@context': AP_CONTEXT,
485 id: `${audio.id}#create`,
486 type: 'Create',
487 actor: me,
488 published: audio.published,
489 to: [PUBLIC],
490 cc: [`${me}/followers`],
491 object: audio,
492 };
493}
494
495/**
496 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
497 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
498 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
499 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
500 */
501export function channelCategory(site) {
502 try {
503 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
504 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
505 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
506 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
507 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
508 // muziekkanaal.
509 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
510 } catch { return null; }
511}
512
513// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
514
515/**
516 * Het type van een post afleiden uit de muziek die erin staat.
517 *
518 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
519 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
520 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
521 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
522 * playlist opzoeken.
523 *
524 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
525 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
526 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
527 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
528 *
529 * @param {string} content de HTML/tekst van de post
530 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
531 */
532export function postMusicType(content, siteId) {
533 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
534}
535
536/**
537 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
538 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
539 * geen 'playlist' zegt is een album.
540 */
541function playlistKind(id, siteId) {
542 if (!siteId) return null;
543 try {
544 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
545 if (!r) return null;
546 return r.kind === 'playlist' ? 'playlist' : 'album';
547 } catch { return null; }
548}
Note: See TracBrowser for help on using the repository browser.