source: Klonkt/src/services/music/index.js@ 76bd53c

main
Last change on this file since 76bd53c was ca260d1, checked in by Robin <roboburr@…>, 6 hours ago

Fedi open audio per track, vanuit de mediamanager

Robin, 30-9: "Fedi open audio ook per track aan kunnen zetten in de media
manager."

De vlag was altijd al per track (audio_tracks.fedi_open): hij opent het
bestand en maakt er een echte Audio-bijlage van, met een speler in Mastodon en
consorten. Aanzetten kon alleen per POST, met het vinkje in de editor, dat elke
track in die post opende. Nu staat er in de mediamanager, tab Audio, per track
een wereldbol.

EENRICHTINGS, net als in de editor. Dicht is het een gedimde knop die eerst
vraagt of het zeker is, want terug kan niet; open is het een slotje in de
accentkleur en geen knop meer. Een bestand dat al gefedereerd is staat op andere
servers, en het hier weer afschermen zou schijnveiligheid zijn. Ook de algemene
bijwerk-API van een track neemt fedi_open niet aan; een toets bewaakt dat.

Alleen bij een track met een eigen bestand: een track die alleen een link is,
heeft niets om te openen. Een formulier en geen fetch, dus het werkt zonder
JavaScript, en data-confirm uit shell.ejs stelt de vraag.

EN DE POSTS GAAN OPNIEUW DE DEUR UIT. Het vinkje in de editor werkt zichtbaar
omdat het opslaan van de post een Update stuurt, met de audio erin. Een knop
die alleen de vlag zet, laat elke post die al verstuurd is zonder speler staan
tot iemand hem bewerkt. Dus hier dezelfde Update, voor elke gepubliceerde post
van deze site waar de track in staat: rechtstreeks, via een playlist of via zijn
album. postsEmbeddingTrack in music/index.js zoekt die op; trackHostPosts kiest
per track maar een post, voor de bibliotheek. De bibliotheek zelf hoeft niets,
die wordt opgehaald en een open track staat er meteen in.

Getoetst: openen zet de vlag en stuurt precies de drie posts (geen concept,
geen post zonder de track, geen post van een andere site); nog eens openen doet
niets; een link-track en een track van een andere site blijven dicht; de route;
geen weg terug via de API; de knop alleen waar het kan, met bevestiging; en de
hulpfunctie rechtstreeks. Tegenproef per zaak. Eentje ving eerst niets: het
sitefilter zit dubbel, want openen haalt elke post nog eens op met site_id. De
hulpfunctie is los te gebruiken, dus die wordt nu ook rechtstreeks getoetst.
Volledige suite 1301 groen.

Nagekeken in de gerenderde mediamanager met de echte stylesheet: open in de
accentkleur, dicht gedimd, geen wereldbol bij een link-track, dezelfde maat als
de andere knoppen in de rij.

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

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