source: Klonkt/src/services/music/index.js@ 732272a

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

Luisteraars: een eigen tab, want het is een eigen soort relatie (shaer-0nh)

Accounts die aan de BIBLIOTHEEK hangen in plaats van aan de actor. Ze krijgen de
muziek en met opzet NIET de gewone posts -- wie zich op een platenkast
abonneert heeft niet om de Krant gevraagd.

EEN EIGEN TABEL EN GEEN VLAG, en dat is de kern. Zolang ze in
ap_library_followers staan kan een postbezorging ze niet per ongeluk meenemen.
Een vlag op ap_followers die iemand vergeet te filteren doet dat wel, en die
fout is aan onze kant onzichtbaar: de posts komen gewoon aan bij mensen die er
niet om vroegen. De vorm moet de fout onmogelijk maken, niet alleen
onwaarschijnlijk. Een test bewaakt dat ap_followers leeg blijft.

WAT ER WERKT

  • Follow op /ap/users/<slug>/library wordt herkend, meteen geaccepteerd en vastgelegd. Meteen, want de bibliotheek is openbaar (alles erin is fedi_open) -- er valt niets goed te keuren, en dan is wachten oneerlijk.
  • Undo(Follow) haalt hem er meteen weer uit.
  • Volgen is idempotent; remote servers sturen een Follow gerust nog eens.
  • inboxen() ontdubbelt op gedeelde inbox: twee luisteraars op dezelfde instance krijgen EEN bezorging.
  • de tab in Mediabeheer, met naam, handle, sinds en laatste bezorging, in drie talen.

DE HERKENNING IS STRENG. libraryOwnerSlug eist dat de uri met ONZE basis begint
en dat de site bestaat -- via localSlugOf, dezelfde zeef als gisteren. Anders
levert andermans /library met dezelfde padstaart hier een luisteraar op onze
naam op.

WAT ER NOG NIET IS, en dat is de volgende stap: de BEZORGING. Er gaat nog geen
Create(Audio) naar deze inboxen. De lijst vult zich dus wel en "laatste
bezorging" blijft leeg -- eerlijk, want er is niets bezorgd.

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

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