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

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

Een omschrijving die de tagwolk herhaalt is ruis (shaer-38y)

De terugval op het lijf van de post pikte de losse hashtags mee. Live leverde
dat "#DoenweNiet #DoenWeNiet #devs" op als omschrijving van een post die verder
geen tekst heeft -- en die tags staan al in tag. Nu gaan ze eruit, en houdt
een post zonder prosa gewoon geen content-veld over.

Tekst MET een hashtag erin blijft wel staan; het gaat om losse tags, niet om
het teken.

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

  • Property mode set to 100644
File size: 24.3 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 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
423 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
424 // #devs" op als omschrijving van een post die verder geen tekst heeft.
425 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
426 .replace(/\s+/g, ' ')
427 .trim();
428 return kaal || null;
429}
430
431/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
432function losseTracksVanPost(siteId, ids) {
433 if (!siteId || !ids?.length) return [];
434 const gaten = ids.map(() => '?').join(',');
435 const rijen = db.prepare(
436 `SELECT ${TRACK_KOLOMMEN}
437 FROM audio_tracks t JOIN media m ON m.id = t.media_id
438 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
439 ).all(siteId, ...ids);
440 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
441 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
442 const opId = new Map(rijen.map((r) => [r.id, r]));
443 return ids.map((id) => opId.get(id)).filter(Boolean);
444}
445
446/**
447 * De losse tracks van een post als EEN uitgave (shaer-38y).
448 *
449 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
450 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
451 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
452 * heeft ze wel en de losse tracks niet.
453 *
454 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
455 * post die geen enkele muzikale eenheid IS.
456 */
457export function buildPostTrackCollection(base, site, post) {
458 if (!post || !post.id) return null;
459 const eenheid = postMusicType(post.content, site.id);
460 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
461
462 const rows = losseTracksVanPost(site.id, eenheid.tracks);
463 if (!rows.length) return null;
464
465 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
466 const out = {
467 '@context': AP_CONTEXT,
468 id: postTracksId(base, site, post.id),
469 type: 'OrderedCollection',
470 attributedTo: actorId(base, site.slug),
471 totalItems: rows.length,
472 orderedItems: rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
473 };
474 return leenVanPost(base, site, out, post);
475}
476
477/**
478 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
479 *
480 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
481 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
482 * ophaalt niet denkt dat er iets nieuws is.
483 */
484export function buildTrackCreate(base, site, r, opts = {}) {
485 const audio = buildTrackAudio(base, site, r, opts);
486 const me = actorId(base, site.slug);
487 return {
488 '@context': AP_CONTEXT,
489 id: `${audio.id}#create`,
490 type: 'Create',
491 actor: me,
492 published: audio.published,
493 to: [PUBLIC],
494 cc: [`${me}/followers`],
495 object: audio,
496 };
497}
498
499/**
500 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
501 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
502 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
503 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
504 */
505export function channelCategory(site) {
506 try {
507 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
508 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
509 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
510 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
511 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
512 // muziekkanaal.
513 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
514 } catch { return null; }
515}
516
517// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
518
519/**
520 * Het type van een post afleiden uit de muziek die erin staat.
521 *
522 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
523 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
524 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
525 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
526 * playlist opzoeken.
527 *
528 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
529 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
530 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
531 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
532 *
533 * @param {string} content de HTML/tekst van de post
534 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
535 */
536export function postMusicType(content, siteId) {
537 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
538}
539
540/**
541 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
542 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
543 * geen 'playlist' zegt is een album.
544 */
545function playlistKind(id, siteId) {
546 if (!siteId) return null;
547 try {
548 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
549 if (!r) return null;
550 return r.kind === 'playlist' ? 'playlist' : 'album';
551 } catch { return null; }
552}
Note: See TracBrowser for help on using the repository browser.