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

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

Een Library-skelet, want zonder bak blijft een track een naam zonder geluid

GEMETEN, en dat gaf dit zijn richting. open.audio heeft onze vier tracks
binnengehaald langs de AP-weg -- met ONZE track-id's als fid, en met een
artist_credit dat Funkwhale zelf uit onze attributedTo afleidde. Precies het
object waarvan ons gevraagd werd het te sturen; dat hoefde dus niet, hij leidt
het af. Maar uploads is leeg en is_playable false.

Het audiobestand is niet het probleem: /audio/stream geeft 200, audio/mpeg,
2722880 bytes, ook anoniem en met een vreemde User-Agent. Ze hebben het niet
opgehaald. Bij Funkwhale hangt een upload aan een LIBRARY, en die hadden we niet.

DE VORM IS DIE UIT HUN DOCS: type Library, id, name, followers, totalItems,
first, last, plus attributedTo en summary. Onze pagedCollection deed het meeste
al; wat erbij komt is de naam, de volgers en het type.

DE ENIGE TERM DIE WE UIT HUN VOCABULAIRE OVERNEMEN, en het verschil met
track/ArtistCredit is de reden dat dit wel mag: die twee vragen ENTITEITEN waar
wij tekst hebben -- een artiest en een album zijn bij ons kolommen, en er een id
voor verzinnen zou beloven wat we niet kunnen waarmaken. Een library is precies
wat er al staat: onze open tracks, met een echte telling en een echt id. Er valt
niets te verzinnen.

SKELET, letterlijk. Er is GEEN volg-afhandeling. Onze bibliotheek is openbaar --
alles erin heeft fedi_open -- dus er valt niets goed te keuren en de
volgerslijst is leeg en eerlijk. Komt er ooit een besloten variant, dan hoort
daar het Follow/Accept-werk bij en dat is een eigen stuk.

Een test bewaakt dat een GESLOTEN track er niet in komt. De bak is openbaar, en
dat kan alleen als er niets in zit dat het niet is -- anders overrulet de
openbaarheid van de bak de poort van de track.

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

  • Property mode set to 100644
File size: 26.6 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 } from '../ap-core.js';
17import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
18
19// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
20export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
21 t.position, t.license,
22 m.filename, m.storage_path, m.mime_type, m.size`;
23
24/**
25 * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
26 * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
27 * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
28 */
29export function playlistOpenTracks(playlistId, { alles = false } = {}) {
30 return db.prepare(
31 `SELECT ${TRACK_KOLOMMEN}
32 FROM playlist_tracks pt
33 JOIN audio_tracks t ON t.id = pt.track_id
34 JOIN media m ON m.id = t.media_id
35 WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
36 ORDER BY pt.position`
37 ).all(playlistId);
38}
39
40/**
41 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
42 *
43 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
44 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
45 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
46 */
47/**
48 * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
49 * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
50 * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
51 * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
52 * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
53 * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
54 * moved_to er kwam te staan.
55 */
56export function siteOpenTracks(siteId, { alles = false } = {}) {
57 return db.prepare(
58 `SELECT ${TRACK_KOLOMMEN}
59 FROM audio_tracks t JOIN media m ON m.id = t.media_id
60 WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
61 ORDER BY t.position, t.created_at, t.id`
62 ).all(siteId);
63}
64
65export function openTrack(siteId, trackId) {
66 return db.prepare(
67 `SELECT ${TRACK_KOLOMMEN}
68 FROM audio_tracks t JOIN media m ON m.id = t.media_id
69 WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
70 ).get(siteId, trackId);
71}
72
73/**
74 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
75 *
76 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
77 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
78 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
79 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
80 * precies zo, per stuk, in Create en Delete.
81 *
82 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
83 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
84 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
85 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
86 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
87 * belooft.
88 */
89/**
90 * Bij welke post hoort een track? (shaer-0nh)
91 *
92 * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
93 * een van drie insluitingen in posts.content. Die relatie stond alleen in die
94 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
95 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
96 *
97 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
98 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
99 * scans. Nu is het er een, en de map gaat mee als optie.
100 *
101 * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
102 * is specifieker dan via een playlist, en die weer specifieker dan via een
103 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
104 * laatst heeft uitgebracht.
105 */
106export function trackHostPosts(siteId) {
107 const rijen = db.prepare(`
108 SELECT tid, post_id, post_slug, rang, wanneer FROM (
109 SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
110 COALESCE(p.published_at, p.created_at) AS wanneer
111 FROM audio_tracks t
112 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
113 AND p.content LIKE '%[[track:' || t.id || ']]%'
114 WHERE t.site_id = ? AND t.fedi_open = 1
115 UNION ALL
116 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
117 FROM playlist_tracks pt
118 JOIN audio_tracks t ON t.id = pt.track_id
119 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
120 AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
121 WHERE t.site_id = ? AND t.fedi_open = 1
122 UNION ALL
123 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
124 FROM audio_tracks t
125 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
126 AND p.content LIKE '%[[album:' || t.album || ']]%'
127 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
128 ) ORDER BY rang, wanneer DESC
129 `).all(siteId, siteId, siteId);
130 const uit = new Map();
131 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
132 return uit;
133}
134
135export function buildTrackAudio(base, site, r, opts = {}) {
136 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
137 const fn = r.filename || (r.storage_path || '').split('/').pop();
138 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
139 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
140 // Zo doet Funkwhale het ook.
141 // De post waar dit nummer in staat. Meegegeven door de collectie (een
142 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
143 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
144 const post = opts.hostPosts !== undefined
145 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
146 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
147
148 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
149 if (Number(r.size)) bestand.size = Number(r.size);
150 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
151 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
152 // de getallen hebben -- liever geen veld dan een verzonnen getal.
153 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
154
155 const a = {
156 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
157 id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
158 type: 'Audio',
159 name: r.title || 'Audio',
160 attributedTo: actorId(base, site.slug),
161 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
162 // track moet zelf kunnen zeggen dat hij openbaar is.
163 to: [PUBLIC],
164 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
165 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
166 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
167 // horen, en dat is wat zo'n link betekent.
168 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
169 };
170 // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
171 // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
172 // daar een naam zonder geluid.
173 a.library = libraryId(base, site);
174 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
175 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
176 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
177 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
178 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
179 // van te maken.
180 if (post) a.context = noteId(base, post.id);
181 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
182 if (r.created_at) a.published = new Date(r.created_at).toISOString();
183 if (Number(r.position)) a.position = Number(r.position);
184 const lic = licentieUri(r.license);
185 if (lic) a.license = lic;
186 const art = abs(r.cover_url || opts.coverFallback || null);
187 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
188 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
189 if (art) {
190 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
191 a.icon = plaat;
192 a.image = plaat;
193 }
194 return a;
195}
196
197/**
198 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
199 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
200 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
201 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
202 * een lezer gelooft hem.
203 */
204const LICENTIES = {
205 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
206 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
207 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
208 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
209 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
210 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
211};
212export function licentieUri(waarde) {
213 const s = String(waarde || '').trim();
214 if (!s) return null;
215 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
216 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
217}
218
219/** Het AS2-id van de bibliotheek van een site. */
220export function libraryId(base, site) {
221 return `${actorId(base, site.slug)}/library`;
222}
223
224/**
225 * De site als Funkwhale-LIBRARY (skelet).
226 *
227 * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
228 * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
229 * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
230 * echte telling en een echt id.
231 *
232 * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
233 * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
234 * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
235 * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
236 * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
237 * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
238 * geprobeerd.
239 *
240 * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
241 * type, id, name, followers, totalItems, first, last, plus attributedTo en
242 * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
243 * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
244 * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
245 */
246export function buildLibrary(base, site, rows) {
247 const id = libraryId(base, site);
248 const hostPosts = site.id ? trackHostPosts(site.id) : null;
249 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts }));
250 return pagedCollection(id, items, {
251 extra: {
252 type: 'Library',
253 name: site.title || site.slug,
254 attributedTo: actorId(base, site.slug),
255 // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
256 // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
257 followers: `${id}/followers`,
258 ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
259 },
260 });
261}
262
263/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
264export function buildTrackCollection(base, site, rows) {
265 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
266 const posts = site.id ? trackHostPosts(site.id) : null;
267 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
268 return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { extra: { attributedTo: actorId(base, site.slug) } });
269}
270
271// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
272// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
273// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
274// client die hem kent haalt de collectie op. Opgelost uit post.content en
275// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
276// zonder site-check zou een post van site A naar de collectie van site B
277// kunnen wijzen.
278export function playlistLinkTags(base, site, content, post = null) {
279 const out = [];
280 try {
281 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
282 // krijgt ook geen link. Dit stond hier met een eigen patroon dat
283 // underscores accepteerde die nergens anders meetellen.
284 for (const id of ingeslotenPlaylists(content)) {
285 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
286 if (!pl) continue;
287 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
288 }
289 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
290 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
291 // link zou die collectie bestaan maar door niemand te vinden zijn.
292 if (post && post.id) {
293 const eenheid = postMusicType(content, site.id);
294 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
295 out.push({
296 type: 'Link',
297 href: postTracksId(base, site, post.id),
298 mediaType: 'application/activity+json',
299 name: post.title || 'Tracks',
300 });
301 }
302 }
303 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
304 return out;
305}
306
307// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
308// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
309// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
310// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
311// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
312// poort staat.
313export function listPlaylistsAP(base, site, enriched) {
314 const rows = db.prepare(
315 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
316 ).all(site.id);
317 const colId = `${actorId(base, site.slug)}/playlists`;
318 const items = rows.map((p) => {
319 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
320 if (!enriched) return uri;
321 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
322 delete stub['@context']; // genest object draagt de context van zijn omhulsel
323 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
324 return stub;
325 });
326 return pagedCollection(colId, items, { extra: { attributedTo: actorId(base, site.slug) } });
327}
328
329export function buildPlaylistCollection(base, site, playlist, rows) {
330 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
331 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
332 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
333 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
334 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
335 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
336 const hostPosts = site.id ? trackHostPosts(site.id) : null;
337 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
338 const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
339 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
340 });
341 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
342 const parts = [];
343 if (playlist.artist) parts.push(playlist.artist);
344 if (playlist.year) parts.push(String(playlist.year));
345 if (parts.length) out.summary = parts.join(' · ');
346 const cover = abs(playlist.cover_url || null);
347 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
348 return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
349}
350
351// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
352
353/** Het AS2-id van de collectie losse tracks van een post. */
354function postTracksId(base, site, postId) {
355 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
356}
357
358/**
359 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
360 *
361 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
362 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
363 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
364 * dezelfde regel als in de afleiding, hier alleen toegepast.
365 *
366 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
367 * uitgebracht.
368 */
369export function uitgavePost(siteId, playlistId) {
370 if (!siteId || !playlistId) return null;
371 try {
372 const rijen = db.prepare(`
373 SELECT id, slug, title, excerpt, content, cover_image_url, tags
374 FROM posts
375 WHERE site_id = ? AND status = 'published'
376 AND content LIKE '%[[playlist:' || ? || ']]%'
377 ORDER BY COALESCE(published_at, created_at) DESC
378 `).all(siteId, playlistId);
379 for (const p of rijen) {
380 const r = postMusicType(p.content, siteId);
381 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
382 }
383 } catch { /* geen lening is geen fout */ }
384 return null;
385}
386
387/**
388 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
389 *
390 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
391 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
392 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
393 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
394 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
395 * hij nu krijgt.
396 *
397 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
398 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
399 */
400function leenVanPost(base, site, obj, post) {
401 if (!post) return obj;
402 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
403
404 if (post.title) {
405 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
406 obj.name = post.title;
407 }
408 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
409 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
410 // staan -- dat is een samenvatting, en de posttekst is dat niet.
411 const tekst = tekstVanPost(post);
412 if (tekst) obj.content = tekst;
413
414 const cover = abs(post.cover_image_url || null);
415 if (cover) {
416 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
417 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
418 }
419
420 // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
421 // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
422 // missen en de rest anders spellen dan dezelfde post elders doet.
423 const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
424 if (tags.length) obj.tag = tags;
425
426 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
427 // losse track: url wijst een mens naar de post, context zegt waar dit object
428 // thuishoort.
429 obj.url = `${base}/${post.slug}`;
430 obj.context = noteId(base, post.id);
431 return obj;
432}
433
434/**
435 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
436 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
437 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
438 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
439 */
440function tekstVanPost(post) {
441 const excerpt = String(post.excerpt || '').trim();
442 if (excerpt) return excerpt;
443 const kaal = String(post.content || '')
444 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
445 .replace(/<[^>]+>/g, ' ')
446 .replace(/&nbsp;/gi, ' ')
447 .replace(/&[a-z#0-9]+;/gi, ' ')
448 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
449 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
450 // #devs" op als omschrijving van een post die verder geen tekst heeft.
451 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
452 .replace(/\s+/g, ' ')
453 .trim();
454 return kaal || null;
455}
456
457/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
458function losseTracksVanPost(siteId, ids) {
459 if (!siteId || !ids?.length) return [];
460 const gaten = ids.map(() => '?').join(',');
461 const rijen = db.prepare(
462 `SELECT ${TRACK_KOLOMMEN}
463 FROM audio_tracks t JOIN media m ON m.id = t.media_id
464 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
465 ).all(siteId, ...ids);
466 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
467 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
468 const opId = new Map(rijen.map((r) => [r.id, r]));
469 return ids.map((id) => opId.get(id)).filter(Boolean);
470}
471
472/**
473 * De losse tracks van een post als EEN uitgave (shaer-38y).
474 *
475 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
476 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
477 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
478 * heeft ze wel en de losse tracks niet.
479 *
480 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
481 * post die geen enkele muzikale eenheid IS.
482 */
483export function buildPostTrackCollection(base, site, post) {
484 if (!post || !post.id) return null;
485 const eenheid = postMusicType(post.content, site.id);
486 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
487
488 const rows = losseTracksVanPost(site.id, eenheid.tracks);
489 if (!rows.length) return null;
490
491 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
492 const out = pagedCollection(postTracksId(base, site, post.id),
493 rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
494 { extra: { attributedTo: actorId(base, site.slug) } });
495 return leenVanPost(base, site, out, post);
496}
497
498/**
499 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
500 *
501 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
502 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
503 * ophaalt niet denkt dat er iets nieuws is.
504 */
505export function buildTrackCreate(base, site, r, opts = {}) {
506 const audio = buildTrackAudio(base, site, r, opts);
507 const me = actorId(base, site.slug);
508 return {
509 '@context': AP_CONTEXT,
510 id: `${audio.id}#create`,
511 type: 'Create',
512 actor: me,
513 published: audio.published,
514 to: [PUBLIC],
515 cc: [`${me}/followers`],
516 object: audio,
517 };
518}
519
520/**
521 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
522 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
523 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
524 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
525 */
526export function channelCategory(site) {
527 try {
528 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
529 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
530 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
531 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
532 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
533 // muziekkanaal.
534 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
535 } catch { return null; }
536}
537
538// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
539
540/**
541 * Het type van een post afleiden uit de muziek die erin staat.
542 *
543 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
544 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
545 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
546 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
547 * playlist opzoeken.
548 *
549 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
550 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
551 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
552 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
553 *
554 * @param {string} content de HTML/tekst van de post
555 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
556 */
557export function postMusicType(content, siteId) {
558 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
559}
560
561/**
562 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
563 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
564 * geen 'playlist' zegt is een album.
565 */
566function playlistKind(id, siteId) {
567 if (!siteId) return null;
568 try {
569 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
570 if (!r) return null;
571 return r.kind === 'playlist' ? 'playlist' : 'album';
572 } catch { return null; }
573}
Note: See TracBrowser for help on using the repository browser.