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

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

De afleiding: welk soort uitgave is een post? (shaer-cyg)

Robins regel gaat over identiteit en niet over tellen -- een post neemt het
type van zijn muziek over als hij precies EEN muzikale eenheid bevat. Zijn het
er meer, dan is de post een post die naar muziek verwijst en houden de
collecties hun eigen identiteit; daarmee vervalt de reden om de posttitel en
-cover te lenen vanzelf.

postMusicType() leest de shortcodes uit de post en geeft type, collectie,
bonus-tracks en leentMetadata terug. Een losse track naast een collectie is
geen rommelrestje maar een bonus-track, en dat maakt het geheel een album.

Een keuze die Robins formulering niet maakte: [[album:naam]] telt als
collectie, net als een playlist. Hem als losse tracks tellen zou een albumpost
stilletjes tot playlist maken.

Alleen de afleiding, nog niet aangesloten op de editor of op wat er federeert.

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

  • Property mode set to 100644
File size: 17.8 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 } from '../ap-core.js';
17
18// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
19const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
20 t.position, t.license,
21 m.filename, m.storage_path, m.mime_type, m.size`;
22
23export function playlistOpenTracks(playlistId) {
24 return db.prepare(
25 `SELECT ${TRACK_KOLOMMEN}
26 FROM playlist_tracks pt
27 JOIN audio_tracks t ON t.id = pt.track_id
28 JOIN media m ON m.id = t.media_id
29 WHERE pt.playlist_id = ? AND t.fedi_open = 1
30 ORDER BY pt.position`
31 ).all(playlistId);
32}
33
34/**
35 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
36 *
37 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
38 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
39 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
40 */
41export function siteOpenTracks(siteId) {
42 return db.prepare(
43 `SELECT ${TRACK_KOLOMMEN}
44 FROM audio_tracks t JOIN media m ON m.id = t.media_id
45 WHERE t.site_id = ? AND t.fedi_open = 1
46 ORDER BY t.position, t.created_at, t.id`
47 ).all(siteId);
48}
49
50export function openTrack(siteId, trackId) {
51 return db.prepare(
52 `SELECT ${TRACK_KOLOMMEN}
53 FROM audio_tracks t JOIN media m ON m.id = t.media_id
54 WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
55 ).get(siteId, trackId);
56}
57
58/**
59 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
60 *
61 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
62 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
63 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
64 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
65 * precies zo, per stuk, in Create en Delete.
66 *
67 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
68 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
69 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
70 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
71 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
72 * belooft.
73 */
74/**
75 * Bij welke post hoort een track? (shaer-0nh)
76 *
77 * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
78 * een van drie insluitingen in posts.content. Die relatie stond alleen in die
79 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
80 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
81 *
82 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
83 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
84 * scans. Nu is het er een, en de map gaat mee als optie.
85 *
86 * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
87 * is specifieker dan via een playlist, en die weer specifieker dan via een
88 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
89 * laatst heeft uitgebracht.
90 */
91export function trackHostPosts(siteId) {
92 const rijen = db.prepare(`
93 SELECT tid, post_id, post_slug, rang, wanneer FROM (
94 SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
95 COALESCE(p.published_at, p.created_at) AS wanneer
96 FROM audio_tracks t
97 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
98 AND p.content LIKE '%[[track:' || t.id || ']]%'
99 WHERE t.site_id = ? AND t.fedi_open = 1
100 UNION ALL
101 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
102 FROM playlist_tracks pt
103 JOIN audio_tracks t ON t.id = pt.track_id
104 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
105 AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
106 WHERE t.site_id = ? AND t.fedi_open = 1
107 UNION ALL
108 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
109 FROM audio_tracks t
110 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
111 AND p.content LIKE '%[[album:' || t.album || ']]%'
112 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
113 ) ORDER BY rang, wanneer DESC
114 `).all(siteId, siteId, siteId);
115 const uit = new Map();
116 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
117 return uit;
118}
119
120export function buildTrackAudio(base, site, r, opts = {}) {
121 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
122 const fn = r.filename || (r.storage_path || '').split('/').pop();
123 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
124 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
125 // Zo doet Funkwhale het ook.
126 // De post waar dit nummer in staat. Meegegeven door de collectie (een
127 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
128 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
129 const post = opts.hostPosts !== undefined
130 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
131 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
132
133 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
134 if (Number(r.size)) bestand.size = Number(r.size);
135 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
136 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
137 // de getallen hebben -- liever geen veld dan een verzonnen getal.
138 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
139
140 const a = {
141 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
142 id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
143 type: 'Audio',
144 name: r.title || 'Audio',
145 attributedTo: actorId(base, site.slug),
146 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
147 // track moet zelf kunnen zeggen dat hij openbaar is.
148 to: [PUBLIC],
149 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
150 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
151 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
152 // horen, en dat is wat zo'n link betekent.
153 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
154 };
155 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
156 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
157 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
158 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
159 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
160 // van te maken.
161 if (post) a.context = noteId(base, post.id);
162 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
163 if (r.created_at) a.published = new Date(r.created_at).toISOString();
164 if (Number(r.position)) a.position = Number(r.position);
165 const lic = licentieUri(r.license);
166 if (lic) a.license = lic;
167 const art = abs(r.cover_url || opts.coverFallback || null);
168 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
169 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
170 if (art) {
171 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
172 a.icon = plaat;
173 a.image = plaat;
174 }
175 return a;
176}
177
178/**
179 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
180 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
181 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
182 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
183 * een lezer gelooft hem.
184 */
185const LICENTIES = {
186 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
187 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
188 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
189 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
190 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
191 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
192};
193export function licentieUri(waarde) {
194 const s = String(waarde || '').trim();
195 if (!s) return null;
196 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
197 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
198}
199
200/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
201export function buildTrackCollection(base, site, rows) {
202 return {
203 '@context': AP_CONTEXT,
204 id: `${actorId(base, site.slug)}/tracks`,
205 type: 'OrderedCollection',
206 attributedTo: actorId(base, site.slug),
207 totalItems: (rows || []).length,
208 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
209 orderedItems: (() => {
210 const posts = site.id ? trackHostPosts(site.id) : null;
211 return (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
212 })(),
213 };
214}
215
216// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
217// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
218// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
219// client die hem kent haalt de collectie op. Opgelost uit post.content en
220// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
221// zonder site-check zou een post van site A naar de collectie van site B
222// kunnen wijzen.
223export function playlistLinkTags(base, site, content) {
224 const out = [];
225 const seen = new Set();
226 try {
227 for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
228 if (seen.has(m[1])) continue;
229 seen.add(m[1]);
230 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id);
231 if (!pl) continue;
232 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
233 }
234 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
235 return out;
236}
237
238// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
239// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
240// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
241// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
242// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
243// poort staat.
244export function listPlaylistsAP(base, site, enriched) {
245 const rows = db.prepare(
246 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
247 ).all(site.id);
248 const colId = `${actorId(base, site.slug)}/playlists`;
249 const items = rows.map((p) => {
250 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
251 if (!enriched) return uri;
252 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
253 delete stub['@context']; // genest object draagt de context van zijn omhulsel
254 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
255 return stub;
256 });
257 return {
258 '@context': AP_CONTEXT,
259 id: colId,
260 type: 'OrderedCollection',
261 attributedTo: actorId(base, site.slug),
262 totalItems: items.length,
263 orderedItems: items,
264 };
265}
266
267export function buildPlaylistCollection(base, site, playlist, rows) {
268 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
269 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
270 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
271 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
272 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
273 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
274 const hostPosts = site.id ? trackHostPosts(site.id) : null;
275 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
276 const out = {
277 '@context': AP_CONTEXT,
278 id: `${actorId(base, site.slug)}/playlists/${playlist.id}`,
279 type: 'OrderedCollection',
280 name: playlist.title,
281 attributedTo: actorId(base, site.slug),
282 totalItems: items.length,
283 orderedItems: items,
284 };
285 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
286 const parts = [];
287 if (playlist.artist) parts.push(playlist.artist);
288 if (playlist.year) parts.push(String(playlist.year));
289 if (parts.length) out.summary = parts.join(' · ');
290 const cover = abs(playlist.cover_url || null);
291 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
292 return out;
293}
294
295/**
296 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
297 *
298 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
299 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
300 * ophaalt niet denkt dat er iets nieuws is.
301 */
302export function buildTrackCreate(base, site, r, opts = {}) {
303 const audio = buildTrackAudio(base, site, r, opts);
304 const me = actorId(base, site.slug);
305 return {
306 '@context': AP_CONTEXT,
307 id: `${audio.id}#create`,
308 type: 'Create',
309 actor: me,
310 published: audio.published,
311 to: [PUBLIC],
312 cc: [`${me}/followers`],
313 object: audio,
314 };
315}
316
317/**
318 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
319 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
320 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
321 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
322 */
323export function channelCategory(site) {
324 try {
325 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
326 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
327 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
328 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
329 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
330 // muziekkanaal.
331 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
332 } catch { return null; }
333}
334
335// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────────
336
337/**
338 * Het type van een post afleiden uit de muziek die erin staat.
339 *
340 * DE REGEL GAAT OVER IDENTITEIT, NIET OVER TELLEN (Robins herformulering, 9-8):
341 * een post neemt het type van zijn muziek over als hij precies EEN muzikale
342 * eenheid bevat. Zijn het er meer, dan is de post een post die naar muziek
343 * verwijst, en houden de collecties hun eigen identiteit.
344 *
345 * losse track(s) -> playlist metadata van de post geleend
346 * een collectie -> die soort metadata van de post geleend
347 * een collectie + losse tracks -> album de losse zijn BONUS-TRACKS
348 * twee of meer collecties -> post NIETS geleend
349 *
350 * Waarom de lening bij de laatste vervalt: die bestaat omdat een collectie soms
351 * dun is -- geen eigen hoes, geen eigen titel. Bij twee is de post niet meer de
352 * drager van EEN identiteit, en vervalt de reden vanzelf. Dezelfde regel zet
353 * zichzelf uit.
354 *
355 * EEN COLLECTIE IS EEN PLAYLIST OF EEN ALBUM-INSLUITING. Robins regel noemde
356 * alleen playlists, maar [[album:naam]] groepeert net zo goed tracks en is
357 * letterlijk een album; hem als losse tracks tellen zou een albumpost tot
358 * playlist maken. Telt hij straks anders, dan is dat hier een regel.
359 */
360export function postMusicType(content) {
361 const c = String(content || '');
362 const uniek = (re) => [...new Set([...c.matchAll(re)].map((m) => m[1].trim()))];
363
364 const playlists = uniek(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g);
365 const albums = uniek(/\[\[album:([^\]]+)\]\]/g);
366 const tracks = uniek(/\[\[track:([A-Za-z0-9_-]+)\]\]/g);
367
368 const collecties = [
369 ...playlists.map((id) => ({ soort: 'playlist', id })),
370 ...albums.map((naam) => ({ soort: 'album', naam })),
371 ];
372
373 if (!collecties.length && !tracks.length) return null; // geen muziek
374
375 if (!collecties.length) {
376 // Ook EEN losse track wordt een playlist: naar buiten toe is er dan altijd
377 // een collectie om naar te wijzen. Hoe Klonkt dat toont is een aparte vraag.
378 return { type: 'playlist', collectie: null, tracks, bonus: [], leentMetadata: true };
379 }
380
381 if (collecties.length === 1) {
382 const c0 = collecties[0];
383 // Losse tracks naast een collectie zijn geen rommelrestje maar bonus-tracks,
384 // en dat maakt het geheel een album.
385 if (tracks.length) return { type: 'album', collectie: c0, tracks: [], bonus: tracks, leentMetadata: true };
386 return { type: c0.soort, collectie: c0, tracks: [], bonus: [], leentMetadata: true };
387 }
388
389 return { type: 'post', collecties, tracks, bonus: [], leentMetadata: false };
390}
Note: See TracBrowser for help on using the repository browser.