source: Klonkt/src/services/music/index.js@ 8ed36de

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

Een verwijderde track kondigt zichzelf aan, net als een post

Een post stuurt bij verwijderen een Delete(Tombstone) naar zijn volgers; een
track deed dat niet. De rij ging weg, het Audio-object gaf 404, en elke server
die hem had geindexeerd bleef ernaar wijzen. Op de hub stond het er vandaag als
een kaartje met een dode link, en het viel alleen op doordat robo het zag.

Sinds shaer-0nh is een track een eersterangs Audio-object met een eigen id, dus
hij hoort ook een eigen afmelding te krijgen.

  • deliverObjectDelete(site, objectId) is de gedeelde romp; deliverDelete en het nieuwe deliverTrackDelete leunen er allebei op. Het object-id komt van de aanroeper, want bij verwijderen is de rij vaak al weg.
  • trackUri() staat nu op EEN plek in music/index.js. Het formaat werd eerder alleen door de bouwkant gekend, en een tweede plek die het opnieuw in elkaar zet is precies hoe je een Delete stuurt die de ontvanger niet herkent.
  • Beide verwijderroutes in admin-audio melden af: de losse verwijdering en de opruiming van wezen. Een wees is voor ONS een track zonder bestand, maar de buitenwereld heeft een gewoon Audio-object opgeslagen.

Twee tests door de echte route. Een testhaak op deliver() bestaat niet, dus de
proef kijkt in ap_delivery: de volger krijgt een inbox op een dichte poort, de
directe poging faalt, en de activiteit hoort in de wachtrij te belanden. Die
tabel was in het echte geval leeg, dus dat is precies het bewijs. Controleproef
gedraaid: zonder de aankondiging valt de test om.

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

  • Property mode set to 100644
File size: 38.8 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, isMbid } from '../ap-core.js';
17import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
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';
21
22// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
23export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
24 t.position, t.license,
25 m.filename, m.storage_path, m.mime_type, m.size`;
26
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 } = {}) {
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
38 WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
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 */
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 } = {}) {
60 return db.prepare(
61 `SELECT ${TRACK_KOLOMMEN}
62 FROM audio_tracks t JOIN media m ON m.id = t.media_id
63 WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
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
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
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
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 } : {}),
202 id: trackUri(base, site, r.id),
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 };
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);
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);
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.
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
258 a.track = {
259 type: 'Track',
260 id: `${a.id}#track`,
261 name: a.name,
262 published: wanneer,
263 ...(Number(r.position) ? { position: Number(r.position) } : {}),
264 artist_credit: artistCredit(base, site, r.artist, wanneer),
265 };
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 }
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
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 */
342export function buildLibrary(base, site, rows, { page = false } = {}) {
343 const id = libraryId(base, site);
344 const hostPosts = site.id ? trackHostPosts(site.id) : null;
345 const albums = site.id ? trackAlbums(site.id) : null;
346 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
347 return pagedCollection(id, items, {
348 page,
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,
354 extra: {
355 type: 'Library',
356 name: site.title || site.slug,
357 attributedTo: actorId(base, site.slug),
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',
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
376/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
377export function buildTrackCollection(base, site, rows, { page = false } = {}) {
378 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
379 const posts = site.id ? trackHostPosts(site.id) : null;
380 const albums = site.id ? trackAlbums(site.id) : null;
381 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
382 return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
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.
392export function playlistLinkTags(base, site, content, post = null) {
393 const out = [];
394 try {
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);
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 }
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 }
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.
427export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
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 });
440 return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
441}
442
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);
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;
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 */
497export function buildAlbumObject(base, site, pl) {
498 if (!pl) return null;
499 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
500 // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
501 // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
502 // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
503 // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
504 // openbaar werd, en dat is de post.
505 //
506 // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
507 // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
508 // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
509 // title, artist, year, cover_url en kind selecteerde en de rest niet.
510 //
511 // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
512 // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
513 // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
514 // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
515 // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
516 // uitzondering: bij een heruitgave vul je hem in en die wint.
517 const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
518 const wanneer = postDatum ? postDatum.toISOString()
519 : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
520 // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
521 // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
522 const titel = (pl._post && pl._post.title) || pl.title;
523 const album = {
524 type: 'Album',
525 id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
526 name: titel,
527 ...(wanneer ? { published: wanneer } : {}),
528 attributedTo: actorId(base, site.slug),
529 artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
530 };
531 if (titel !== pl.title) album.alsoKnownAs = pl.title;
532 // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
533 // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
534 // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
535 // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
536 if (pl.release_date) album.released = pl.release_date;
537 else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
538 if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
539 const hoes = abs(pl.cover_url || null);
540 if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
541 return album;
542}
543
544export function buildPlaylistCollection(base, site, playlist, rows) {
545 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
546 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
547 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
548 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
549 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
550 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
551 const hostPosts = site.id ? trackHostPosts(site.id) : null;
552 const albums = site.id ? trackAlbums(site.id) : null;
553 // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
554 // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
555 // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
556 // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
557 const post = site.id ? uitgavePost(site.id, playlist.id) : null;
558 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
559 const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
560 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
561 });
562 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
563 const parts = [];
564 if (playlist.artist) parts.push(playlist.artist);
565 if (playlist.year) parts.push(String(playlist.year));
566 if (parts.length) out.summary = parts.join(' · ');
567 const cover = abs(playlist.cover_url || null);
568 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
569
570 // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
571 // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
572 // hoort hier hetzelfde te staan als in het ingesloten object.
573 //
574 // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
575 // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
576 // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
577 // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
578 // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
579 // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
580 // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
581 // staat toch al ingesloten op de track.
582 if ((playlist.kind || 'album') === 'album') {
583 const album = buildAlbumObject(base, site, { ...playlist, _post: post });
584 for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
585 if (album[veld] !== undefined) out[veld] = album[veld];
586 }
587 }
588 return leenVanPost(base, site, out, post);
589}
590
591// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
592
593/** Het AS2-id van de collectie losse tracks van een post. */
594function postTracksId(base, site, postId) {
595 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
596}
597
598/**
599 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
600 *
601 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
602 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
603 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
604 * dezelfde regel als in de afleiding, hier alleen toegepast.
605 *
606 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
607 * uitgebracht.
608 */
609export function uitgavePost(siteId, playlistId) {
610 if (!siteId || !playlistId) return null;
611 try {
612 const rijen = db.prepare(`
613 SELECT id, slug, title, excerpt, content, cover_image_url, tags,
614 COALESCE(published_at, created_at) AS uit_wanneer
615 FROM posts
616 WHERE site_id = ? AND status = 'published'
617 AND content LIKE '%[[playlist:' || ? || ']]%'
618 ORDER BY COALESCE(published_at, created_at) DESC
619 `).all(siteId, playlistId);
620 for (const p of rijen) {
621 const r = postMusicType(p.content, siteId);
622 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
623 }
624 } catch { /* geen lening is geen fout */ }
625 return null;
626}
627
628/**
629 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
630 *
631 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
632 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
633 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
634 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
635 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
636 * hij nu krijgt.
637 *
638 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
639 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
640 */
641function leenVanPost(base, site, obj, post) {
642 if (!post) return obj;
643 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
644
645 if (post.title) {
646 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
647 obj.name = post.title;
648 }
649 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
650 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
651 // staan -- dat is een samenvatting, en de posttekst is dat niet.
652 const tekst = tekstVanPost(post);
653 if (tekst) obj.content = tekst;
654
655 const cover = abs(post.cover_image_url || null);
656 if (cover) {
657 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
658 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
659 }
660
661 // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
662 // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
663 // missen en de rest anders spellen dan dezelfde post elders doet.
664 const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
665 if (tags.length) obj.tag = tags;
666
667 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
668 // losse track: url wijst een mens naar de post, context zegt waar dit object
669 // thuishoort.
670 obj.url = `${base}/${post.slug}`;
671 obj.context = noteId(base, post.id);
672 return obj;
673}
674
675/**
676 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
677 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
678 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
679 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
680 */
681function tekstVanPost(post) {
682 const excerpt = String(post.excerpt || '').trim();
683 if (excerpt) return excerpt;
684 const kaal = String(post.content || '')
685 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
686 .replace(/<[^>]+>/g, ' ')
687 .replace(/&nbsp;/gi, ' ')
688 .replace(/&[a-z#0-9]+;/gi, ' ')
689 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
690 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
691 // #devs" op als omschrijving van een post die verder geen tekst heeft.
692 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
693 .replace(/\s+/g, ' ')
694 .trim();
695 return kaal || null;
696}
697
698/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
699function losseTracksVanPost(siteId, ids) {
700 if (!siteId || !ids?.length) return [];
701 const gaten = ids.map(() => '?').join(',');
702 const rijen = db.prepare(
703 `SELECT ${TRACK_KOLOMMEN}
704 FROM audio_tracks t JOIN media m ON m.id = t.media_id
705 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
706 ).all(siteId, ...ids);
707 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
708 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
709 const opId = new Map(rijen.map((r) => [r.id, r]));
710 return ids.map((id) => opId.get(id)).filter(Boolean);
711}
712
713/**
714 * De losse tracks van een post als EEN uitgave (shaer-38y).
715 *
716 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
717 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
718 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
719 * heeft ze wel en de losse tracks niet.
720 *
721 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
722 * post die geen enkele muzikale eenheid IS.
723 */
724export function buildPostTrackCollection(base, site, post) {
725 if (!post || !post.id) return null;
726 const eenheid = postMusicType(post.content, site.id);
727 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
728
729 const rows = losseTracksVanPost(site.id, eenheid.tracks);
730 if (!rows.length) return null;
731
732 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
733 const out = pagedCollection(postTracksId(base, site, post.id),
734 rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
735 { extra: { attributedTo: actorId(base, site.slug) } });
736 return leenVanPost(base, site, out, post);
737}
738
739/**
740 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
741 *
742 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
743 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
744 * ophaalt niet denkt dat er iets nieuws is.
745 */
746export function buildTrackCreate(base, site, r, opts = {}) {
747 const audio = buildTrackAudio(base, site, r, opts);
748 const me = actorId(base, site.slug);
749 return {
750 '@context': AP_CONTEXT,
751 id: `${audio.id}#create`,
752 type: 'Create',
753 actor: me,
754 published: audio.published,
755 to: [PUBLIC],
756 cc: [`${me}/followers`],
757 object: audio,
758 };
759}
760
761/**
762 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
763 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
764 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
765 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
766 */
767export function channelCategory(site) {
768 try {
769 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
770 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
771 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
772 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
773 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
774 // muziekkanaal.
775 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
776 } catch { return null; }
777}
778
779// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
780
781/**
782 * Het type van een post afleiden uit de muziek die erin staat.
783 *
784 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
785 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
786 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
787 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
788 * playlist opzoeken.
789 *
790 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
791 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
792 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
793 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
794 *
795 * @param {string} content de HTML/tekst van de post
796 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
797 */
798export function postMusicType(content, siteId) {
799 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
800}
801
802/**
803 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
804 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
805 * geen 'playlist' zegt is een album.
806 */
807function playlistKind(id, siteId) {
808 if (!siteId) return null;
809 try {
810 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
811 if (!r) return null;
812 return r.kind === 'playlist' ? 'playlist' : 'album';
813 } catch { return null; }
814}
Note: See TracBrowser for help on using the repository browser.