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

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

De post is de uitgave: losse tracks worden een collectie (shaer-38y)

Losse tracks gingen tot nu toe los de deur uit -- Audio-objecten die een lezer
nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
dat is wat /ap/users/<slug>/posts/<id>/tracks nu zegt. De post wijst er langs
dezelfde weg naartoe als naar een playlist: een Link-tag in de Note, want zonder
die link bestaat de collectie wel maar vindt niemand hem.

PUNT 1, DE VOLGORDE. De tracks staan in de volgorde van de POST en niet die van
de tabel. Zoals iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.

PUNT 3, DE METADATA. Een playlist heeft een titel en soms een hoes; een post
heeft een titel, een tekst, een hoes, tags en een datum. Voor audio-gebaseerde
inhoud is de post de uitgave -- dat is waar iemand hem heeft uitgebracht en waar
het verhaal erbij staat. Zowel de collectie van losse tracks als een
playlist-collectie leent nu van de post die haar uitbrengt.

De posttekst wordt content en niet summary: in AS2 is summary de korte
samenvatting en content het lijf. Artiest en jaar blijven dus in summary staan,
want dat IS een samenvatting en de posttekst is dat niet. De eigen naam van de
playlist gaat niet verloren maar verhuist naar alsoKnownAs.

WIE LEENT ER UIT: alleen een post die er EEN muzikale eenheid van maakt. Staan
er twee collecties in, dan is de post niet meer de drager van een identiteit en
houdt de playlist de zijne. Dezelfde regel als in de afleiding, hier alleen
toegepast.

Een test die eerder vastlegde dat losse tracks GEEN Link-tag kregen is
omgedraaid, met de reden erbij: dat klopte zolang alleen een playlist een
collectie had.

Punt 2 van de bead (type afleiden) stond al in postMusicType.

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

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