source: Klonkt/src/services/music/index.js@ 8455dac

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

De outbox bladert nu echt door (shaer-sk4)

Wat er stond was geen paginering maar een KAP: de route haalde twintig posts uit
SQL en hield daar twintig items van over. Alles daarvoor lag niet op een volgende
pagina maar was helemaal onbereikbaar -- ook voor een lezer die netjes next
volgt.

DE MOEILIJKHEID, en waarom dit niet met een tweede slice kon: de outbox mengt
twee bronnen, posts en open tracks, gevlochten op datum. Met twee losse queries
en een offset per tabel weet je niet hoeveel van elk er in pagina drie horen.
outboxSlice doet daarom een UNION met de datum als sleutel, zet de LIMIT/OFFSET
daaroverheen, en haalt pas dan de rijen zelf op -- twee kleine queries voor de
ids van die ene pagina.

De fans-only voorwaarde zit in de UNION, dus de telling klopt per lezer: een
vreemde ziet 50 en een vriend 51, en niet allebei hetzelfde getal met een andere
lijst eronder.

pagedCollection kent nu alGesneden. Zonder dat zou een volle pagina zichzelf
als de enige zien -- hij telt het aantal pagina's uit de LIJST, en die is bij
SQL-paginering precies een pagina lang. Nu telt hij uit totalItems en biedt hij
een next zolang er meer is.

TWEE DINGEN DIE IK BIJNA VERKEERD DEED. outboxSlice en PAGINA_GROOTTE stonden
niet in de DEFAULT export, en de routes importeren AP zo -- dan is het undefined
bij het VERZOEK, niet bij het importeren. Nagelopen op de server: function,
number. En mijn eigen testdata maakte "2026-01-45" voor de 45e post; dat is geen
datum, en toISOString viel er terecht over om.

Zes tests, waaronder de kern: opeenvolgende pagina's overlappen niet en laten
niets vallen -- drie pagina's dekken alle 50, en niets twee keer.

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

  • Property mode set to 100644
File size: 23.6 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, buildHashtagList, pagedCollection } 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.
20export const 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 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
204 const posts = site.id ? trackHostPosts(site.id) : null;
205 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
206 return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { extra: { attributedTo: actorId(base, site.slug) } });
207}
208
209// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
210// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
211// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
212// client die hem kent haalt de collectie op. Opgelost uit post.content en
213// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
214// zonder site-check zou een post van site A naar de collectie van site B
215// kunnen wijzen.
216export function playlistLinkTags(base, site, content, post = null) {
217 const out = [];
218 try {
219 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
220 // krijgt ook geen link. Dit stond hier met een eigen patroon dat
221 // underscores accepteerde die nergens anders meetellen.
222 for (const id of ingeslotenPlaylists(content)) {
223 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
224 if (!pl) continue;
225 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
226 }
227 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
228 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
229 // link zou die collectie bestaan maar door niemand te vinden zijn.
230 if (post && post.id) {
231 const eenheid = postMusicType(content, site.id);
232 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
233 out.push({
234 type: 'Link',
235 href: postTracksId(base, site, post.id),
236 mediaType: 'application/activity+json',
237 name: post.title || 'Tracks',
238 });
239 }
240 }
241 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
242 return out;
243}
244
245// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
246// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
247// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
248// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
249// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
250// poort staat.
251export function listPlaylistsAP(base, site, enriched) {
252 const rows = db.prepare(
253 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
254 ).all(site.id);
255 const colId = `${actorId(base, site.slug)}/playlists`;
256 const items = rows.map((p) => {
257 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
258 if (!enriched) return uri;
259 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
260 delete stub['@context']; // genest object draagt de context van zijn omhulsel
261 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
262 return stub;
263 });
264 return pagedCollection(colId, items, { extra: { attributedTo: actorId(base, site.slug) } });
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 = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
277 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
278 });
279 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
280 const parts = [];
281 if (playlist.artist) parts.push(playlist.artist);
282 if (playlist.year) parts.push(String(playlist.year));
283 if (parts.length) out.summary = parts.join(' · ');
284 const cover = abs(playlist.cover_url || null);
285 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
286 return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
287}
288
289// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
290
291/** Het AS2-id van de collectie losse tracks van een post. */
292function postTracksId(base, site, postId) {
293 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
294}
295
296/**
297 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
298 *
299 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
300 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
301 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
302 * dezelfde regel als in de afleiding, hier alleen toegepast.
303 *
304 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
305 * uitgebracht.
306 */
307export function uitgavePost(siteId, playlistId) {
308 if (!siteId || !playlistId) return null;
309 try {
310 const rijen = db.prepare(`
311 SELECT id, slug, title, excerpt, content, cover_image_url, tags
312 FROM posts
313 WHERE site_id = ? AND status = 'published'
314 AND content LIKE '%[[playlist:' || ? || ']]%'
315 ORDER BY COALESCE(published_at, created_at) DESC
316 `).all(siteId, playlistId);
317 for (const p of rijen) {
318 const r = postMusicType(p.content, siteId);
319 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
320 }
321 } catch { /* geen lening is geen fout */ }
322 return null;
323}
324
325/**
326 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
327 *
328 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
329 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
330 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
331 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
332 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
333 * hij nu krijgt.
334 *
335 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
336 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
337 */
338function leenVanPost(base, site, obj, post) {
339 if (!post) return obj;
340 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
341
342 if (post.title) {
343 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
344 obj.name = post.title;
345 }
346 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
347 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
348 // staan -- dat is een samenvatting, en de posttekst is dat niet.
349 const tekst = tekstVanPost(post);
350 if (tekst) obj.content = tekst;
351
352 const cover = abs(post.cover_image_url || null);
353 if (cover) {
354 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
355 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
356 }
357
358 // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
359 // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
360 // missen en de rest anders spellen dan dezelfde post elders doet.
361 const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
362 if (tags.length) obj.tag = tags;
363
364 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
365 // losse track: url wijst een mens naar de post, context zegt waar dit object
366 // thuishoort.
367 obj.url = `${base}/${post.slug}`;
368 obj.context = noteId(base, post.id);
369 return obj;
370}
371
372/**
373 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
374 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
375 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
376 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
377 */
378function tekstVanPost(post) {
379 const excerpt = String(post.excerpt || '').trim();
380 if (excerpt) return excerpt;
381 const kaal = String(post.content || '')
382 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
383 .replace(/<[^>]+>/g, ' ')
384 .replace(/&nbsp;/gi, ' ')
385 .replace(/&[a-z#0-9]+;/gi, ' ')
386 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
387 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
388 // #devs" op als omschrijving van een post die verder geen tekst heeft.
389 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
390 .replace(/\s+/g, ' ')
391 .trim();
392 return kaal || null;
393}
394
395/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
396function losseTracksVanPost(siteId, ids) {
397 if (!siteId || !ids?.length) return [];
398 const gaten = ids.map(() => '?').join(',');
399 const rijen = db.prepare(
400 `SELECT ${TRACK_KOLOMMEN}
401 FROM audio_tracks t JOIN media m ON m.id = t.media_id
402 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
403 ).all(siteId, ...ids);
404 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
405 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
406 const opId = new Map(rijen.map((r) => [r.id, r]));
407 return ids.map((id) => opId.get(id)).filter(Boolean);
408}
409
410/**
411 * De losse tracks van een post als EEN uitgave (shaer-38y).
412 *
413 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
414 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
415 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
416 * heeft ze wel en de losse tracks niet.
417 *
418 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
419 * post die geen enkele muzikale eenheid IS.
420 */
421export function buildPostTrackCollection(base, site, post) {
422 if (!post || !post.id) return null;
423 const eenheid = postMusicType(post.content, site.id);
424 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
425
426 const rows = losseTracksVanPost(site.id, eenheid.tracks);
427 if (!rows.length) return null;
428
429 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
430 const out = pagedCollection(postTracksId(base, site, post.id),
431 rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
432 { extra: { attributedTo: actorId(base, site.slug) } });
433 return leenVanPost(base, site, out, post);
434}
435
436/**
437 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
438 *
439 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
440 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
441 * ophaalt niet denkt dat er iets nieuws is.
442 */
443export function buildTrackCreate(base, site, r, opts = {}) {
444 const audio = buildTrackAudio(base, site, r, opts);
445 const me = actorId(base, site.slug);
446 return {
447 '@context': AP_CONTEXT,
448 id: `${audio.id}#create`,
449 type: 'Create',
450 actor: me,
451 published: audio.published,
452 to: [PUBLIC],
453 cc: [`${me}/followers`],
454 object: audio,
455 };
456}
457
458/**
459 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
460 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
461 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
462 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
463 */
464export function channelCategory(site) {
465 try {
466 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
467 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
468 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
469 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
470 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
471 // muziekkanaal.
472 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
473 } catch { return null; }
474}
475
476// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
477
478/**
479 * Het type van een post afleiden uit de muziek die erin staat.
480 *
481 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
482 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
483 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
484 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
485 * playlist opzoeken.
486 *
487 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
488 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
489 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
490 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
491 *
492 * @param {string} content de HTML/tekst van de post
493 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
494 */
495export function postMusicType(content, siteId) {
496 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
497}
498
499/**
500 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
501 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
502 * geen 'playlist' zegt is een album.
503 */
504function playlistKind(id, siteId) {
505 if (!siteId) return null;
506 try {
507 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
508 if (!r) return null;
509 return r.kind === 'playlist' ? 'playlist' : 'album';
510 } catch { return null; }
511}
Note: See TracBrowser for help on using the repository browser.