source: Klonkt/src/services/music/index.js@ 9a585bf

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

Track: published en artist_credit erbij

Twee van de vijf gaten naar Funkwhale's bibliotheek-ingest.

published was een omissie in de kleine Emissary-vorm, niet een keuze:
MusicEntitySerializer (regel 1278) eist het en we hebben created_at.

artist_credit leek de onmogelijke. Hun keten is Track -> ArtistCredit ->
Artist, en elke schakel wil id, name en published -- terwijl een artiest
bij ons een tekstkolom is. De MusicBrainz-koppeling van vorige week liet
zien dat dat niet klopt: de site-ACTOR is de artiest. Een echt,
opvraagbaar adres, met de sitetitel als naam en een musicbrainzId zodra
hij gekoppeld is. Er valt niets te verzinnen, en het is niet nieuw --
open.audio leidde op 13-8 al zelf een artist_credit af uit onze
attributedTo. We maken expliciet wat daar toch al gebeurde.

De artiestnaam van de track gaat naar credit en niet naar de entiteit.
Dat is waar hun model de credittekst verwacht, en er een id per
artiestnaam van maken zou identiteit uit een string zijn -- dezelfde fout
die we bij het album vermijden (shaer-756s).

@container: @list op artist_credit is geen opsmuk: ze lezen het veld met
first_attr(FW.artist_credit, "@list"), en zonder die declaratie
expandeert onze array er niet naar. Dan staat er iets dat er goed uitziet
en dat hun lezer niet vindt -- precies het soort stil gat waar deze week
al twee keer een dag in ging zitten.

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

  • Property mode set to 100644
File size: 30.3 KB
Line 
1/**
2 * De muziekkant van ActivityPub: een track als AS2-object, de collecties
3 * eromheen, en welke post hem uitbrengt.
4 *
5 * Waarom een eigen map (shaer-drc): ActivityPubService was 6400 regels over
6 * tweeentwintig onderwerpen. Dit is het eerste onderwerp dat er als geheel uit
7 * kan, en het is meteen het onderwerp dat gaat GROEIEN -- de typering uit
8 * shaer-cyg (playlist versus album, afgeleid uit wat er in de post staat) landt
9 * hier straks.
10 *
11 * De regel die guardianship al aanhoudt geldt hier ook: deze map importeert
12 * alleen db en ap-core, en NOOIT terug uit ActivityPubService.
13 */
14
15import db from '../../config/database.js';
16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, 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
138export function buildTrackAudio(base, site, r, opts = {}) {
139 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
140 const fn = r.filename || (r.storage_path || '').split('/').pop();
141 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
142 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
143 // Zo doet Funkwhale het ook.
144 // De post waar dit nummer in staat. Meegegeven door de collectie (een
145 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
146 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
147 const post = opts.hostPosts !== undefined
148 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
149 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
150
151 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
152 if (Number(r.size)) bestand.size = Number(r.size);
153 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
154 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
155 // de getallen hebben -- liever geen veld dan een verzonnen getal.
156 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
157
158 const a = {
159 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
160 id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
161 type: 'Audio',
162 name: r.title || 'Audio',
163 attributedTo: actorId(base, site.slug),
164 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
165 // track moet zelf kunnen zeggen dat hij openbaar is.
166 to: [PUBLIC],
167 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
168 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
169 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
170 // horen, en dat is wat zo'n link betekent.
171 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
172 };
173 // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
174 // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
175 // daar een naam zonder geluid.
176 a.library = libraryId(base, site);
177 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
178 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
179 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
180 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
181 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
182 // van te maken.
183 if (post) a.context = noteId(base, post.id);
184 // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
185 // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
186 // ervan als eerste wat hem opviel aan onze objecten.
187 //
188 // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
189 // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
190 // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
191 // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
192 // een geldige IRI en wijst naar hetzelfde document.
193 //
194 // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
195 // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
196 // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
197 //
198 // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
199 // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
200 // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
201 // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
202 // precies waar hun model de credittekst verwacht.
203 //
204 // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
205 // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
206 // toch al gebeurde.
207 //
208 // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
209 // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
210 // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
211 // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
212 // (shaer-756s).
213 const wanneer = r.created_at ? new Date(r.created_at).toISOString()
214 : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
215 const artiest = {
216 type: 'Artist',
217 id: actorId(base, site.slug),
218 name: site.title || site.slug,
219 published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
220 };
221 if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
222
223 a.track = {
224 type: 'Track',
225 id: `${a.id}#track`,
226 name: a.name,
227 published: wanneer,
228 ...(Number(r.position) ? { position: Number(r.position) } : {}),
229 artist_credit: [{
230 type: 'ArtistCredit',
231 id: `${a.id}#artist-credit`,
232 published: wanneer,
233 artist: artiest,
234 ...(r.artist ? { credit: r.artist } : {}),
235 }],
236 };
237 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
238 if (r.created_at) a.published = new Date(r.created_at).toISOString();
239 if (Number(r.position)) a.position = Number(r.position);
240 const lic = licentieUri(r.license);
241 if (lic) a.license = lic;
242 const art = abs(r.cover_url || opts.coverFallback || null);
243 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
244 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
245 if (art) {
246 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
247 a.icon = plaat;
248 a.image = plaat;
249 }
250 return a;
251}
252
253/**
254 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
255 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
256 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
257 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
258 * een lezer gelooft hem.
259 */
260const LICENTIES = {
261 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
262 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
263 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
264 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
265 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
266 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
267};
268export function licentieUri(waarde) {
269 const s = String(waarde || '').trim();
270 if (!s) return null;
271 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
272 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
273}
274
275/** Het AS2-id van de bibliotheek van een site. */
276export function libraryId(base, site) {
277 return `${actorId(base, site.slug)}/library`;
278}
279
280/**
281 * De site als Funkwhale-LIBRARY (skelet).
282 *
283 * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
284 * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
285 * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
286 * echte telling en een echt id.
287 *
288 * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
289 * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
290 * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
291 * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
292 * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
293 * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
294 * geprobeerd.
295 *
296 * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
297 * type, id, name, followers, totalItems, first, last, plus attributedTo en
298 * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
299 * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
300 * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
301 */
302export function buildLibrary(base, site, rows, { page = false } = {}) {
303 const id = libraryId(base, site);
304 const hostPosts = site.id ? trackHostPosts(site.id) : null;
305 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts }));
306 return pagedCollection(id, items, {
307 page,
308 // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
309 // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
310 // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
311 // `CollectionPage` met `items`.
312 ongeordend: true,
313 extra: {
314 type: 'Library',
315 name: site.title || site.slug,
316 attributedTo: actorId(base, site.slug),
317 // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
318 // `audience` optioneel, maar zijn create() doet er meteen
319 // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
320 // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
321 // teruggaf toen Robin onze library-URI daar opzocht.
322 //
323 // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
324 // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
325 // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
326 audience: 'https://www.w3.org/ns/activitystreams#Public',
327 // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
328 // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
329 followers: `${id}/followers`,
330 ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
331 },
332 });
333}
334
335/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
336export function buildTrackCollection(base, site, rows, { page = false } = {}) {
337 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
338 const posts = site.id ? trackHostPosts(site.id) : null;
339 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
340 return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
341}
342
343// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
344// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
345// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
346// client die hem kent haalt de collectie op. Opgelost uit post.content en
347// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
348// zonder site-check zou een post van site A naar de collectie van site B
349// kunnen wijzen.
350export function playlistLinkTags(base, site, content, post = null) {
351 const out = [];
352 try {
353 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
354 // krijgt ook geen link. Dit stond hier met een eigen patroon dat
355 // underscores accepteerde die nergens anders meetellen.
356 for (const id of ingeslotenPlaylists(content)) {
357 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
358 if (!pl) continue;
359 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
360 }
361 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
362 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
363 // link zou die collectie bestaan maar door niemand te vinden zijn.
364 if (post && post.id) {
365 const eenheid = postMusicType(content, site.id);
366 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
367 out.push({
368 type: 'Link',
369 href: postTracksId(base, site, post.id),
370 mediaType: 'application/activity+json',
371 name: post.title || 'Tracks',
372 });
373 }
374 }
375 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
376 return out;
377}
378
379// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
380// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
381// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
382// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
383// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
384// poort staat.
385export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
386 const rows = db.prepare(
387 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
388 ).all(site.id);
389 const colId = `${actorId(base, site.slug)}/playlists`;
390 const items = rows.map((p) => {
391 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
392 if (!enriched) return uri;
393 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
394 delete stub['@context']; // genest object draagt de context van zijn omhulsel
395 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
396 return stub;
397 });
398 return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
399}
400
401export function buildPlaylistCollection(base, site, playlist, rows) {
402 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
403 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
404 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
405 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
406 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
407 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
408 const hostPosts = site.id ? trackHostPosts(site.id) : null;
409 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
410 const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
411 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
412 });
413 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
414 const parts = [];
415 if (playlist.artist) parts.push(playlist.artist);
416 if (playlist.year) parts.push(String(playlist.year));
417 if (parts.length) out.summary = parts.join(' · ');
418 const cover = abs(playlist.cover_url || null);
419 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
420 return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
421}
422
423// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
424
425/** Het AS2-id van de collectie losse tracks van een post. */
426function postTracksId(base, site, postId) {
427 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
428}
429
430/**
431 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
432 *
433 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
434 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
435 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
436 * dezelfde regel als in de afleiding, hier alleen toegepast.
437 *
438 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
439 * uitgebracht.
440 */
441export function uitgavePost(siteId, playlistId) {
442 if (!siteId || !playlistId) return null;
443 try {
444 const rijen = db.prepare(`
445 SELECT id, slug, title, excerpt, content, cover_image_url, tags
446 FROM posts
447 WHERE site_id = ? AND status = 'published'
448 AND content LIKE '%[[playlist:' || ? || ']]%'
449 ORDER BY COALESCE(published_at, created_at) DESC
450 `).all(siteId, playlistId);
451 for (const p of rijen) {
452 const r = postMusicType(p.content, siteId);
453 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
454 }
455 } catch { /* geen lening is geen fout */ }
456 return null;
457}
458
459/**
460 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
461 *
462 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
463 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
464 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
465 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
466 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
467 * hij nu krijgt.
468 *
469 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
470 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
471 */
472function leenVanPost(base, site, obj, post) {
473 if (!post) return obj;
474 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
475
476 if (post.title) {
477 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
478 obj.name = post.title;
479 }
480 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
481 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
482 // staan -- dat is een samenvatting, en de posttekst is dat niet.
483 const tekst = tekstVanPost(post);
484 if (tekst) obj.content = tekst;
485
486 const cover = abs(post.cover_image_url || null);
487 if (cover) {
488 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
489 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
490 }
491
492 // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
493 // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
494 // missen en de rest anders spellen dan dezelfde post elders doet.
495 const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
496 if (tags.length) obj.tag = tags;
497
498 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
499 // losse track: url wijst een mens naar de post, context zegt waar dit object
500 // thuishoort.
501 obj.url = `${base}/${post.slug}`;
502 obj.context = noteId(base, post.id);
503 return obj;
504}
505
506/**
507 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
508 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
509 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
510 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
511 */
512function tekstVanPost(post) {
513 const excerpt = String(post.excerpt || '').trim();
514 if (excerpt) return excerpt;
515 const kaal = String(post.content || '')
516 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
517 .replace(/<[^>]+>/g, ' ')
518 .replace(/&nbsp;/gi, ' ')
519 .replace(/&[a-z#0-9]+;/gi, ' ')
520 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
521 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
522 // #devs" op als omschrijving van een post die verder geen tekst heeft.
523 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
524 .replace(/\s+/g, ' ')
525 .trim();
526 return kaal || null;
527}
528
529/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
530function losseTracksVanPost(siteId, ids) {
531 if (!siteId || !ids?.length) return [];
532 const gaten = ids.map(() => '?').join(',');
533 const rijen = db.prepare(
534 `SELECT ${TRACK_KOLOMMEN}
535 FROM audio_tracks t JOIN media m ON m.id = t.media_id
536 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
537 ).all(siteId, ...ids);
538 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
539 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
540 const opId = new Map(rijen.map((r) => [r.id, r]));
541 return ids.map((id) => opId.get(id)).filter(Boolean);
542}
543
544/**
545 * De losse tracks van een post als EEN uitgave (shaer-38y).
546 *
547 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
548 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
549 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
550 * heeft ze wel en de losse tracks niet.
551 *
552 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
553 * post die geen enkele muzikale eenheid IS.
554 */
555export function buildPostTrackCollection(base, site, post) {
556 if (!post || !post.id) return null;
557 const eenheid = postMusicType(post.content, site.id);
558 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
559
560 const rows = losseTracksVanPost(site.id, eenheid.tracks);
561 if (!rows.length) return null;
562
563 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
564 const out = pagedCollection(postTracksId(base, site, post.id),
565 rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
566 { extra: { attributedTo: actorId(base, site.slug) } });
567 return leenVanPost(base, site, out, post);
568}
569
570/**
571 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
572 *
573 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
574 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
575 * ophaalt niet denkt dat er iets nieuws is.
576 */
577export function buildTrackCreate(base, site, r, opts = {}) {
578 const audio = buildTrackAudio(base, site, r, opts);
579 const me = actorId(base, site.slug);
580 return {
581 '@context': AP_CONTEXT,
582 id: `${audio.id}#create`,
583 type: 'Create',
584 actor: me,
585 published: audio.published,
586 to: [PUBLIC],
587 cc: [`${me}/followers`],
588 object: audio,
589 };
590}
591
592/**
593 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
594 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
595 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
596 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
597 */
598export function channelCategory(site) {
599 try {
600 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
601 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
602 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
603 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
604 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
605 // muziekkanaal.
606 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
607 } catch { return null; }
608}
609
610// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
611
612/**
613 * Het type van een post afleiden uit de muziek die erin staat.
614 *
615 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
616 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
617 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
618 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
619 * playlist opzoeken.
620 *
621 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
622 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
623 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
624 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
625 *
626 * @param {string} content de HTML/tekst van de post
627 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
628 */
629export function postMusicType(content, siteId) {
630 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
631}
632
633/**
634 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
635 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
636 * geen 'playlist' zegt is een album.
637 */
638function playlistKind(id, siteId) {
639 if (!siteId) return null;
640 try {
641 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
642 if (!r) return null;
643 return r.kind === 'playlist' ? 'playlist' : 'album';
644 } catch { return null; }
645}
Note: See TracBrowser for help on using the repository browser.