source: Klonkt/src/services/music/index.js@ 060d4a5

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

De muziekkant naar music/ (shaer-drc, stap 2)

src/services/music/index.js 332 regels
ActivityPubService.js 6374 -> 6060 regels

Verhuisd, ongewijzigd: het Audio-object en zijn collecties (buildTrackAudio,
buildTrackCollection, buildTrackCreate, buildPlaylistCollection,
listPlaylistsAP, playlistLinkTags), de zoekopdrachten eromheen
(siteOpenTracks, playlistOpenTracks, openTrack, trackHostPosts), de
licentievertaling en channelCategory.

Dezelfde regel als guardianship: deze map kent alleen db en ap-core en
importeert nooit terug. ActivityPubService importeert hiervandaan en geeft
door wat hij altijd al exporteerde, zodat elke aanroep blijft werken.

WAT ER BEWUST NIET MEE GING: pickLink en timelineFields. Die horen bij de
INKOMENDE kant -- wat een vreemde server ons stuurt -- en niet bij het bouwen
van onze eigen objecten. Dat pickLink toevallig ook over audio-links gaat maakt
hem nog geen muziekcode.

Dit is de plek waar de typering uit shaer-cyg gaat landen: playlist versus
album, afgeleid uit wat er in de post staat. Dat is de reden dat de verhuizing
vooropging -- anders zou die code twee keer verplaatst worden.

Geen gedragsverandering.

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

  • Property mode set to 100644
File size: 15.2 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}
Note: See TracBrowser for help on using the repository browser.