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

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

Audio: fw:track meesturen in de kleine Emissary-vorm

Spoor B stap 1 uit shaer-3f8a. Twee onafhankelijke implementaties lezen
dit veld: Funkwhale (verplicht in UploadSerializer) en Emissary 0.9.0,
gemeten op bandwagon.fm 16-8. petitminion noemde het ontbreken ervan als
eerste wat hem aan onze objecten opviel.

De vorm is die van Emissary: type, id, name, position. Klein, en dat is
het punt -- Funkwhale's eigen TrackSerializer wil er artists en
musicbrainzId bij, en dat is de grote versie die we niet nodig hebben om
gelezen te worden.

EIGEN ID MET #track. Emissary hergebruikt daar het id van het
Audio-object, maar dan zijn de Audio en de Track in JSON-LD een knoop met
twee typen, en een bestand is geen werk. Dat onderscheid moeten we straks
toch maken -- een album verzamelt nummers, geen mp3's.

GEEN album in de track. Dat is bij hen een URI naar een Album-object en
bij ons een tekstkolom; er een adres van maken belooft een ding dat niet
bestaat. Die keuze staat apart (shaer-k37k) en moet eerst vallen.

Het commentaar in ap-core dat uitlegde waarom we track NIET namen is
bijgesteld in plaats van weggehaald: die redenering klopte half. Een
track heeft bij ons wel een eigen identiteit, een album niet -- dat is
waar de grens werkelijk ligt.

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

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