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

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

Album als eigen object op de draad (shaer-756s, stap 2)

De uitgave -- een playlist met kind='album' -- gaat nu als fw:Album mee op
elke track die erop staat: type, id, name, published, released,
musicbrainzId, image en een eigen artist_credit.

INGESLOTEN EN NIET ALS URI, en dat is de kern. Hun TrackSerializer heeft
album = AlbumSerializer(): een object met eigen verplichte velden. Een
kale URI expandeert naar een knoop met alleen een @id en valt daar af --
precies waarom Emissary's tracks bij Funkwhale net zo goed stranden,
terwijl ze het veld wel sturen. Als URI staat het album daarnaast op het
Audio-object zelf, zoals Funkwhale 2.0 en Emissary allebei doen.

Het id is de BESTAANDE playlist-collectie. Geen tweede adres voor een
ding dat er al een heeft, en die URI draagt nu dezelfde albumvelden zodat
er hetzelfde staat als in het ingesloten object.

type op die collectie blijft OrderedCollection, en blijft een STRING.
Er stond hier even ['OrderedCollection','Album'] -- geldig AS2, en het is
ook werkelijk allebei -- maar een bestaande test viel erover en had
gelijk: een lezer die type als tekst uitpakt (Shaer) verliest dan in
stilte de hele playlist. Het kost niets, want hun AlbumSerializer
declareert geen type-veld en valideert het dus niet.

trackAlbums() zoekt in EEN query op, zoals trackHostPosts: per track
vragen wordt bij tweehonderd nummers tweehonderd queries. Alleen
kind='album' telt mee -- een mixtape is geen uitgave, en zou hij hier wel
een album opleveren dan was dat onderscheid decoratie.

artistCredit() is nu gedeeld door track en album. Twee keer los bouwen is
hoe een nummer en de plaat waar hij op staat een verschillende artiest
krijgen.

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

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