source: Klonkt/src/services/music/index.js@ 1d1fdc9

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

Tijdstempels: de posts-kant ook, en de tijdbom ontmanteld (shaer-a937)

Correctie op mijn eigen sluitreden bij 9946a68: ik schreef daar dat de
posts-sorteringen door isoSql lopen, en dat was niet zo -- ik had ze juist
overgeslagen omdat ze vandaag nog niet scheef staan.

Twee dingen alsnog:

De TIJDBOM uit de bead. Scheduler zette published_at op CURRENT_TIMESTAMP,
dus zodra iemand een post inplant en die vanzelf publiceert, staat er SQL-
notatie tussen de ISO's en sorteert het hele archief door elkaar. Die schrijft
nu ISO, net als de andere zes schrijfwegen.

En het VANGNET voor wat er al staat: de zeven posts-sorteringen (outbox,
backfill, featured tweemaal, muziek tweemaal, beheeroverzicht) lopen nu ook
door isoSql. Half normaliseren is de verwarrendste toestand -- dan hangt het
per scherm af of een gemengde kolom goed valt.

Onderweg gecontroleerd dat elke gebruiker van isoSql en NU_ISO ze ook
importeert: admin.js gebruikte hem zonder import, en dat was pas omgevallen
op het moment dat iemand het beheeroverzicht opende. Alle aangeraakte
bestanden door node --check.

Volle suite 1255 groen.

  • Property mode set to 100644
File size: 42.6 KB
RevLine 
[060d4a5]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
[1d1fdc9]15import db, { isoSql } from '../../config/database.js';
[9a585bf]16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
[b0ccd0d]17import { afleidenUitInsluitingen, ingeslotenPlaylists, SOORTEN } from '../../assets/js/shared/post-music-type.js';
[732272a]18// De luisteraars horen bij de muziekkant; hier doorgegeven zodat
19// ActivityPubService niet in een submap hoeft te grijpen.
20export * as luisteraars from './luisteraars.js';
[060d4a5]21
22// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
[8455dac]23export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
[060d4a5]24 t.position, t.license,
25 m.filename, m.storage_path, m.mime_type, m.size`;
26
[98acb97]27/**
28 * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
29 * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
30 * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
31 */
32export function playlistOpenTracks(playlistId, { alles = false } = {}) {
[060d4a5]33 return db.prepare(
34 `SELECT ${TRACK_KOLOMMEN}
35 FROM playlist_tracks pt
36 JOIN audio_tracks t ON t.id = pt.track_id
37 JOIN media m ON m.id = t.media_id
[98acb97]38 WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
[060d4a5]39 ORDER BY pt.position`
40 ).all(playlistId);
41}
42
43/**
44 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
45 *
46 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
47 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
48 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
49 */
[6c4ff7e]50/**
51 * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
52 * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
53 * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
54 * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
55 * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
56 * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
57 * moved_to er kwam te staan.
58 */
59export function siteOpenTracks(siteId, { alles = false } = {}) {
[060d4a5]60 return db.prepare(
61 `SELECT ${TRACK_KOLOMMEN}
62 FROM audio_tracks t JOIN media m ON m.id = t.media_id
[6c4ff7e]63 WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
[060d4a5]64 ORDER BY t.position, t.created_at, t.id`
65 ).all(siteId);
66}
67
68export function openTrack(siteId, trackId) {
69 return db.prepare(
70 `SELECT ${TRACK_KOLOMMEN}
71 FROM audio_tracks t JOIN media m ON m.id = t.media_id
72 WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
73 ).get(siteId, trackId);
74}
75
76/**
77 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
78 *
79 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
80 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
81 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
82 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
83 * precies zo, per stuk, in Create en Delete.
84 *
85 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
86 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
87 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
88 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
89 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
90 * belooft.
91 */
92/**
93 * Bij welke post hoort een track? (shaer-0nh)
94 *
95 * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
96 * een van drie insluitingen in posts.content. Die relatie stond alleen in die
97 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
98 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
99 *
100 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
101 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
102 * scans. Nu is het er een, en de map gaat mee als optie.
103 *
104 * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
105 * is specifieker dan via een playlist, en die weer specifieker dan via een
106 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
107 * laatst heeft uitgebracht.
108 */
109export function trackHostPosts(siteId) {
110 const rijen = db.prepare(`
111 SELECT tid, post_id, post_slug, rang, wanneer FROM (
112 SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
113 COALESCE(p.published_at, p.created_at) AS wanneer
114 FROM audio_tracks t
115 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
116 AND p.content LIKE '%[[track:' || t.id || ']]%'
117 WHERE t.site_id = ? AND t.fedi_open = 1
118 UNION ALL
119 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
120 FROM playlist_tracks pt
121 JOIN audio_tracks t ON t.id = pt.track_id
122 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
123 AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
124 WHERE t.site_id = ? AND t.fedi_open = 1
125 UNION ALL
126 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
127 FROM audio_tracks t
128 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
129 AND p.content LIKE '%[[album:' || t.album || ']]%'
130 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
131 ) ORDER BY rang, wanneer DESC
132 `).all(siteId, siteId, siteId);
133 const uit = new Map();
134 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
135 return uit;
136}
137
[aebde2b]138/**
139 * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s).
140 *
141 * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst --
142 * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want
143 * daar verwacht hun model hem. Er een id per artiestnaam van maken zou
144 * identiteit uit een string zijn, en dat is de fout die we bij albums juist
145 * vermijden.
146 *
147 * Eén functie voor beide, zodat een track en het album waar hij op staat nooit
148 * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen.
149 */
150function artistCredit(base, site, creditTekst, wanneer) {
151 const artiest = {
152 type: 'Artist',
153 id: actorId(base, site.slug),
154 name: site.title || site.slug,
155 published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
156 };
157 if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
158 return [{
159 type: 'ArtistCredit',
160 id: `${actorId(base, site.slug)}#artist-credit`,
161 published: artiest.published,
162 artist: artiest,
163 ...(creditTekst ? { credit: creditTekst } : {}),
164 }];
165}
166
[8ed36de]167/**
168 * De identiteit van een track op de draad.
169 *
170 * Staat apart omdat hij op TWEE momenten nodig is die ver uit elkaar liggen:
171 * bij het bouwen van het Audio-object, en bij het verwijderen ervan, wanneer de
172 * rij al weg is en er dus niets meer te bouwen valt. Toen dit nog inline stond,
173 * kende alleen de bouwkant de vorm en ging er bij verwijderen geen Delete uit --
174 * elke server die de track had geindexeerd hield hem voor altijd (21-8).
175 */
176export function trackUri(base, site, id) {
177 return `${actorId(base, site.slug)}/tracks/${encodeURIComponent(id)}`;
178}
179
[060d4a5]180export function buildTrackAudio(base, site, r, opts = {}) {
181 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
182 const fn = r.filename || (r.storage_path || '').split('/').pop();
183 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
184 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
185 // Zo doet Funkwhale het ook.
186 // De post waar dit nummer in staat. Meegegeven door de collectie (een
187 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
188 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
189 const post = opts.hostPosts !== undefined
190 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
191 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
192
193 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
194 if (Number(r.size)) bestand.size = Number(r.size);
195 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
196 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
197 // de getallen hebben -- liever geen veld dan een verzonnen getal.
198 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
199
200 const a = {
201 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
[8ed36de]202 id: trackUri(base, site, r.id),
[060d4a5]203 type: 'Audio',
204 name: r.title || 'Audio',
205 attributedTo: actorId(base, site.slug),
206 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
207 // track moet zelf kunnen zeggen dat hij openbaar is.
208 to: [PUBLIC],
209 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
210 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
211 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
212 // horen, en dat is wat zo'n link betekent.
213 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
214 };
[2091647]215 // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
216 // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
217 // daar een naam zonder geluid.
218 a.library = libraryId(base, site);
[060d4a5]219 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
220 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
221 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
222 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
223 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
224 // van te maken.
225 if (post) a.context = noteId(base, post.id);
[a982e7e]226 // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
227 // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
228 // ervan als eerste wat hem opviel aan onze objecten.
229 //
230 // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
231 // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
232 // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
233 // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
234 // een geldige IRI en wijst naar hetzelfde document.
235 //
236 // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
237 // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
238 // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
[9a585bf]239 //
240 // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
241 // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
242 // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
243 // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
244 // precies waar hun model de credittekst verwacht.
245 //
246 // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
247 // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
248 // toch al gebeurde.
249 //
250 // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
251 // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
252 // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
253 // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
254 // (shaer-756s).
255 const wanneer = r.created_at ? new Date(r.created_at).toISOString()
256 : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
257
[a982e7e]258 a.track = {
259 type: 'Track',
260 id: `${a.id}#track`,
261 name: a.name,
[9a585bf]262 published: wanneer,
[a982e7e]263 ...(Number(r.position) ? { position: Number(r.position) } : {}),
[aebde2b]264 artist_credit: artistCredit(base, site, r.artist, wanneer),
[a982e7e]265 };
[aebde2b]266 // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
267 // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
268 const uitgave = opts.albums !== undefined
269 ? (opts.albums && opts.albums.get(r.id)) || null
270 : ((site.id && trackAlbums(site.id).get(r.id)) || null);
271 if (uitgave) {
272 a.track.album = buildAlbumObject(base, site, uitgave);
273 // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
274 // allebei, en het scheelt een lezer het uitpakken van de track.
275 a.album = a.track.album.id;
276 }
[060d4a5]277 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
278 if (r.created_at) a.published = new Date(r.created_at).toISOString();
279 if (Number(r.position)) a.position = Number(r.position);
280 const lic = licentieUri(r.license);
281 if (lic) a.license = lic;
282 const art = abs(r.cover_url || opts.coverFallback || null);
283 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
284 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
285 if (art) {
286 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
287 a.icon = plaat;
288 a.image = plaat;
289 }
290 return a;
291}
292
293/**
294 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
295 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
296 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
297 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
298 * een lezer gelooft hem.
299 */
300const LICENTIES = {
301 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
302 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
303 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
304 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
305 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
306 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
307};
308export function licentieUri(waarde) {
309 const s = String(waarde || '').trim();
310 if (!s) return null;
311 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
312 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
313}
314
[2091647]315/** Het AS2-id van de bibliotheek van een site. */
316export function libraryId(base, site) {
317 return `${actorId(base, site.slug)}/library`;
318}
319
320/**
321 * De site als Funkwhale-LIBRARY (skelet).
322 *
323 * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
324 * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
325 * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
326 * echte telling en een echt id.
327 *
328 * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
329 * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
330 * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
331 * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
332 * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
333 * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
334 * geprobeerd.
335 *
336 * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
337 * type, id, name, followers, totalItems, first, last, plus attributedTo en
338 * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
339 * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
340 * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
341 */
[5e16b8a]342export function buildLibrary(base, site, rows, { page = false } = {}) {
[2091647]343 const id = libraryId(base, site);
344 const hostPosts = site.id ? trackHostPosts(site.id) : null;
[aebde2b]345 const albums = site.id ? trackAlbums(site.id) : null;
346 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
[2091647]347 return pagedCollection(id, items, {
[5e16b8a]348 page,
[19a9a1e]349 // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
350 // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
351 // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
352 // `CollectionPage` met `items`.
353 ongeordend: true,
[2091647]354 extra: {
355 type: 'Library',
356 name: site.title || site.slug,
357 attributedTo: actorId(base, site.slug),
[19a9a1e]358 // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
359 // `audience` optioneel, maar zijn create() doet er meteen
360 // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
361 // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
362 // teruggaf toen Robin onze library-URI daar opzocht.
363 //
364 // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
365 // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
366 // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
367 audience: 'https://www.w3.org/ns/activitystreams#Public',
[2091647]368 // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
369 // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
370 followers: `${id}/followers`,
371 ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
372 },
373 });
374}
375
[060d4a5]376/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
[5e16b8a]377export function buildTrackCollection(base, site, rows, { page = false } = {}) {
[aed0092]378 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
379 const posts = site.id ? trackHostPosts(site.id) : null;
[aebde2b]380 const albums = site.id ? trackAlbums(site.id) : null;
381 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
[5e16b8a]382 return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
[060d4a5]383}
384
385// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
386// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
387// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
388// client die hem kent haalt de collectie op. Opgelost uit post.content en
389// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
390// zonder site-check zou een post van site A naar de collectie van site B
391// kunnen wijzen.
[a5d14c7]392export function playlistLinkTags(base, site, content, post = null) {
[060d4a5]393 const out = [];
394 try {
[a5d14c7]395 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
396 // krijgt ook geen link. Dit stond hier met een eigen patroon dat
397 // underscores accepteerde die nergens anders meetellen.
398 for (const id of ingeslotenPlaylists(content)) {
399 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
[060d4a5]400 if (!pl) continue;
401 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
402 }
[a5d14c7]403 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
404 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
405 // link zou die collectie bestaan maar door niemand te vinden zijn.
406 if (post && post.id) {
407 const eenheid = postMusicType(content, site.id);
408 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
409 out.push({
410 type: 'Link',
411 href: postTracksId(base, site, post.id),
412 mediaType: 'application/activity+json',
413 name: post.title || 'Tracks',
414 });
415 }
416 }
[060d4a5]417 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
418 return out;
419}
420
421// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
422// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
423// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
424// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
425// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
426// poort staat.
[5e16b8a]427export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
[060d4a5]428 const rows = db.prepare(
429 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
430 ).all(site.id);
431 const colId = `${actorId(base, site.slug)}/playlists`;
432 const items = rows.map((p) => {
433 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
434 if (!enriched) return uri;
435 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
436 delete stub['@context']; // genest object draagt de context van zijn omhulsel
437 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
438 return stub;
439 });
[5e16b8a]440 return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
[060d4a5]441}
442
[aebde2b]443/**
444 * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
445 *
446 * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
447 * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
448 * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
449 * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
450 * mooiste keuze is.
451 *
452 * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
453 * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
454 */
455export function trackAlbums(siteId) {
456 const rijen = db.prepare(`
457 SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
458 p.release_date, p.mb_release_id, p.created_at
459 FROM playlist_tracks pt
460 JOIN playlists p ON p.id = pt.playlist_id
461 WHERE p.site_id = ? AND p.kind = 'album'
462 ORDER BY p.created_at, p.id
463 `).all(siteId);
464 const uit = new Map();
465 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
[7090fd2]466 // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per
467 // track: uitgavePost() doet er echt werk voor (hij leest de typering van de
468 // post) en een site heeft veel meer nummers dan platen.
469 //
470 // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van
471 // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou
472 // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie,
473 // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de
474 // meting rolde.
475 const perAlbum = new Map();
476 for (const r of uit.values()) {
477 if (perAlbum.has(r.id)) continue;
478 perAlbum.set(r.id, uitgavePost(siteId, r.id));
479 }
480 for (const r of uit.values()) r._post = perAlbum.get(r.id) || null;
[aebde2b]481 return uit;
482}
483
484/**
485 * Een uitgave als `fw:Album`.
486 *
487 * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
488 * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
489 * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
490 * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
491 * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
492 *
493 * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
494 * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
495 * een heeft.
496 */
[b0ccd0d]497/**
498 * Het bandje: EEN object, samengesteld uit de nummers van een playlist.
499 *
500 * Waarom het een eigen soort is en geen album met een ander jasje. Een album is
501 * een uitgave: het heeft een uitgavedatum, een release-id, en de nummers
502 * bestaan er los van. Een mixtape is het omgekeerde -- de volgorde IS het werk,
503 * en de nummers zijn er onderdelen van. Vandaar `orderedItems` op het object
504 * zelf in plaats van een collectie ernaast, en vandaar geen `released` en geen
505 * `musicbrainzId`: die zouden beweren dat dit een uitgave is.
506 *
507 * GEEN EIGEN `url`, en dat is een keuze van Robin (21-8) met een prijs die het
508 * waard is om hier op te schrijven. Het bandje is een logische omhulling, geen
509 * gerenderd bestand: er wordt niets samengevoegd. Een ontvanger die `Mixtape`
510 * niet kent heeft dus geen stream om te spelen. Dat is bewust -- de nummers
511 * staan er stuk voor stuk in, met hun eigen id en hun eigen url, dus er gaat
512 * niets verloren; het kost alleen een consument die het type wel begrijpt.
513 *
514 * `type` is een STRING en geen array. Dat is geen slordigheid maar een geleerde
515 * les: er stond bij de playlist-collectie ooit ['OrderedCollection', 'Album'],
516 * geldig AS2 en werkelijk allebei, en een lezer die `type` als tekst uitpakt
517 * (Shaer doet dat) verloor daarmee stil het hele object.
518 */
519export function buildMixtapeObject(base, site, pl, rows) {
520 if (!pl) return null;
521 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
522 const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
523 const wanneer = postDatum ? postDatum.toISOString()
524 : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
525 // Dezelfde lening als bij het album: de post die het bandje uitbrengt geeft
526 // zijn titel, en de eigen titel blijft als alsoKnownAs staan.
527 const titel = (pl._post && pl._post.title) || pl.title;
528 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: pl.cover_url || null }));
529 const tape = {
530 type: 'Mixtape',
531 id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
532 name: titel,
533 ...(wanneer ? { published: wanneer } : {}),
534 attributedTo: actorId(base, site.slug),
535 artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
536 // De kant die het bandje maakt: eerst dit nummer, dan dat. Vooruit en
537 // achteruit is de speler; de volgorde is het object.
538 totalItems: items.length,
539 orderedItems: items,
540 };
541 if (titel !== pl.title) tape.alsoKnownAs = pl.title;
542 // De speelduur van het geheel, want dat is wat een bandje heeft: een lengte.
543 // Alleen als we van ELK nummer de duur kennen -- een som met gaten erin is
544 // een verzonnen getal, en die zetten we hier niet neer (zelfde regel als bij
545 // de bitrate van een track).
546 const duren = (rows || []).map((r) => Number(r.duration) || 0);
547 if (duren.length && duren.every((d) => d > 0)) {
548 tape.duration = `PT${Math.round(duren.reduce((a, b) => a + b, 0))}S`;
549 }
550 const hoes = abs(pl.cover_url || null);
551 if (hoes) tape.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
552 return tape;
553}
554
[aebde2b]555export function buildAlbumObject(base, site, pl) {
556 if (!pl) return null;
557 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
[9026de5]558 // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
559 // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
560 // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
561 // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
562 // openbaar werd, en dat is de post.
563 //
564 // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
565 // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
566 // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
567 // title, artist, year, cover_url en kind selecteerde en de rest niet.
568 //
569 // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
570 // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
571 // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
572 // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
573 // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
574 // uitzondering: bij een heruitgave vul je hem in en die wint.
575 const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
576 const wanneer = postDatum ? postDatum.toISOString()
577 : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
[7090fd2]578 // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
579 // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
580 const titel = (pl._post && pl._post.title) || pl.title;
[aebde2b]581 const album = {
582 type: 'Album',
583 id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
[7090fd2]584 name: titel,
585 ...(wanneer ? { published: wanneer } : {}),
[aebde2b]586 attributedTo: actorId(base, site.slug),
[7090fd2]587 artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
[aebde2b]588 };
[7090fd2]589 if (titel !== pl.title) album.alsoKnownAs = pl.title;
[9026de5]590 // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
591 // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
592 // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
593 // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
[aebde2b]594 if (pl.release_date) album.released = pl.release_date;
[9026de5]595 else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
[aebde2b]596 if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
597 const hoes = abs(pl.cover_url || null);
598 if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
599 return album;
600}
601
[060d4a5]602export function buildPlaylistCollection(base, site, playlist, rows) {
603 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
604 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
605 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
606 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
607 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
608 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
609 const hostPosts = site.id ? trackHostPosts(site.id) : null;
[aebde2b]610 const albums = site.id ? trackAlbums(site.id) : null;
[9026de5]611 // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
612 // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
613 // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
614 // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
615 const post = site.id ? uitgavePost(site.id, playlist.id) : null;
[aebde2b]616 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
[aed0092]617 const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
618 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
619 });
[060d4a5]620 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
621 const parts = [];
622 if (playlist.artist) parts.push(playlist.artist);
623 if (playlist.year) parts.push(String(playlist.year));
624 if (parts.length) out.summary = parts.join(' · ');
625 const cover = abs(playlist.cover_url || null);
626 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
[aebde2b]627
628 // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
629 // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
630 // hoort hier hetzelfde te staan als in het ingesloten object.
631 //
632 // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
633 // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
634 // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
635 // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
636 // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
637 // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
638 // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
639 // staat toch al ingesloten op de track.
[b0ccd0d]640 const soort = SOORTEN.includes(playlist.kind) ? playlist.kind : 'album';
641 if (soort === 'album') {
[9026de5]642 const album = buildAlbumObject(base, site, { ...playlist, _post: post });
[aebde2b]643 for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
644 if (album[veld] !== undefined) out[veld] = album[veld];
645 }
646 }
[b0ccd0d]647 // Een mixtape draagt hier zijn eigen velden, om dezelfde reden als het album:
648 // dit adres is waar een lezer terechtkomt die het bandje wil ophalen, en dan
649 // hoort er hetzelfde te staan als in het ingesloten object. `type` blijft ook
650 // hier OrderedCollection -- zie de uitleg hierboven over Shaer.
651 if (soort === 'mixtape') {
652 const tape = buildMixtapeObject(base, site, { ...playlist, _post: post }, rows);
653 for (const veld of ['published', 'artist_credit', 'image', 'duration', 'alsoKnownAs']) {
654 if (tape[veld] !== undefined) out[veld] = tape[veld];
655 }
656 }
[9026de5]657 return leenVanPost(base, site, out, post);
[a5d14c7]658}
659
660// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
661
662/** Het AS2-id van de collectie losse tracks van een post. */
663function postTracksId(base, site, postId) {
664 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
665}
666
667/**
668 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
669 *
670 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
671 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
672 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
673 * dezelfde regel als in de afleiding, hier alleen toegepast.
674 *
675 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
676 * uitgebracht.
677 */
678export function uitgavePost(siteId, playlistId) {
679 if (!siteId || !playlistId) return null;
680 try {
681 const rijen = db.prepare(`
[9026de5]682 SELECT id, slug, title, excerpt, content, cover_image_url, tags,
[1d1fdc9]683 ${isoSql('COALESCE(published_at, created_at)')} AS uit_wanneer
[a5d14c7]684 FROM posts
685 WHERE site_id = ? AND status = 'published'
686 AND content LIKE '%[[playlist:' || ? || ']]%'
[1d1fdc9]687 ORDER BY ${isoSql('COALESCE(published_at, created_at)')} DESC
[a5d14c7]688 `).all(siteId, playlistId);
689 for (const p of rijen) {
690 const r = postMusicType(p.content, siteId);
691 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
692 }
693 } catch { /* geen lening is geen fout */ }
694 return null;
695}
696
697/**
698 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
699 *
700 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
701 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
702 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
703 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
704 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
705 * hij nu krijgt.
706 *
707 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
708 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
709 */
710function leenVanPost(base, site, obj, post) {
711 if (!post) return obj;
712 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
713
714 if (post.title) {
715 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
716 obj.name = post.title;
717 }
718 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
719 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
720 // staan -- dat is een samenvatting, en de posttekst is dat niet.
[0677bac]721 const tekst = tekstVanPost(post);
[a5d14c7]722 if (tekst) obj.content = tekst;
723
724 const cover = abs(post.cover_image_url || null);
725 if (cover) {
726 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
727 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
728 }
729
[ea3e722]730 // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
731 // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
732 // missen en de rest anders spellen dan dezelfde post elders doet.
[5ae4d61]733 const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
[a5d14c7]734 if (tags.length) obj.tag = tags;
735
736 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
737 // losse track: url wijst een mens naar de post, context zegt waar dit object
738 // thuishoort.
739 obj.url = `${base}/${post.slug}`;
740 obj.context = noteId(base, post.id);
741 return obj;
742}
743
[0677bac]744/**
745 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
746 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
747 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
748 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
749 */
750function tekstVanPost(post) {
751 const excerpt = String(post.excerpt || '').trim();
752 if (excerpt) return excerpt;
753 const kaal = String(post.content || '')
754 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
755 .replace(/<[^>]+>/g, ' ')
756 .replace(/&nbsp;/gi, ' ')
757 .replace(/&[a-z#0-9]+;/gi, ' ')
[d60c498]758 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
759 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
760 // #devs" op als omschrijving van een post die verder geen tekst heeft.
761 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
[0677bac]762 .replace(/\s+/g, ' ')
763 .trim();
764 return kaal || null;
765}
766
[a5d14c7]767/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
768function losseTracksVanPost(siteId, ids) {
769 if (!siteId || !ids?.length) return [];
770 const gaten = ids.map(() => '?').join(',');
771 const rijen = db.prepare(
772 `SELECT ${TRACK_KOLOMMEN}
773 FROM audio_tracks t JOIN media m ON m.id = t.media_id
774 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
775 ).all(siteId, ...ids);
776 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
777 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
778 const opId = new Map(rijen.map((r) => [r.id, r]));
779 return ids.map((id) => opId.get(id)).filter(Boolean);
780}
781
782/**
783 * De losse tracks van een post als EEN uitgave (shaer-38y).
784 *
785 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
786 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
787 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
788 * heeft ze wel en de losse tracks niet.
789 *
790 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
791 * post die geen enkele muzikale eenheid IS.
792 */
793export function buildPostTrackCollection(base, site, post) {
794 if (!post || !post.id) return null;
795 const eenheid = postMusicType(post.content, site.id);
796 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
797
798 const rows = losseTracksVanPost(site.id, eenheid.tracks);
799 if (!rows.length) return null;
800
801 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
[aed0092]802 const out = pagedCollection(postTracksId(base, site, post.id),
803 rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
804 { extra: { attributedTo: actorId(base, site.slug) } });
[a5d14c7]805 return leenVanPost(base, site, out, post);
[060d4a5]806}
807
808/**
809 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
810 *
811 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
812 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
813 * ophaalt niet denkt dat er iets nieuws is.
814 */
815export function buildTrackCreate(base, site, r, opts = {}) {
816 const audio = buildTrackAudio(base, site, r, opts);
817 const me = actorId(base, site.slug);
818 return {
819 '@context': AP_CONTEXT,
820 id: `${audio.id}#create`,
821 type: 'Create',
822 actor: me,
823 published: audio.published,
824 to: [PUBLIC],
825 cc: [`${me}/followers`],
826 object: audio,
827 };
828}
829
830/**
831 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
832 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
833 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
834 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
835 */
836export function channelCategory(site) {
837 try {
838 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
839 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
840 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
841 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
842 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
843 // muziekkanaal.
844 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
845 } catch { return null; }
846}
[cae7bfc]847
[b406d25]848// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
[cae7bfc]849
850/**
[b406d25]851 * Het type van een post afleiden uit de muziek die erin staat.
852 *
853 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
854 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
855 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
856 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
857 * playlist opzoeken.
[c832064]858 *
859 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
860 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
[b406d25]861 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
862 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
[c832064]863 *
864 * @param {string} content de HTML/tekst van de post
865 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
[cae7bfc]866 */
[c832064]867export function postMusicType(content, siteId) {
[b406d25]868 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
[cae7bfc]869}
[c832064]870
871/**
[b0ccd0d]872 * De gekozen soort van een playlist: 'album' | 'playlist' | 'mixtape', of null
873 * als hij niet (op deze site) bestaat. Zelfde lijst als PlaylistService, via
874 * de gedeelde pure module -- alles wat er niet in staat is een album.
[c832064]875 */
876function playlistKind(id, siteId) {
877 if (!siteId) return null;
878 try {
879 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
880 if (!r) return null;
[b0ccd0d]881 return SOORTEN.includes(r.kind) ? r.kind : 'album';
[c832064]882 } catch { return null; }
883}
Note: See TracBrowser for help on using the repository browser.