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

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

Het type volgt de muziek, en de regel staat nog maar op een plek (shaer-cyg)

Robins regel zegt dat de post de soort van zijn muziek overneemt bij een enkele
insluiting. Dat deed hij nog niet: je koos het type zelf en de afleiding draaide
alleen bij de migratie. Nu verspringt de balk mee zodra je een playlist invoegt,
en zie je wat je aan het maken bent in plaats van het zelf bij te houden.

DE REGEL VERHUIST NAAR assets/js/shared/post-music-type.js. Hij wordt nu op twee
plaatsen gebruikt -- de server leidt het type van bestaande posts ermee af, de
editor laat het live volgen -- en dat MOET dezelfde regel zijn. Twee kopieen
zouden niet luidruchtig kapotgaan maar stil uit elkaar lopen: de editor zegt
album, de server zegt playlist, en niemand merkt het. De browser kan alleen bij
/assets en de server kan overal bij, dus staat hij daar en importeert
services/music hem. De module is puur: geen db, geen DOM, geen fetch -- wie hem
gebruikt levert zelf een opzoeker voor de soort van een playlist.

DRIE DINGEN DIE HIJ MET RUST LAAT. Klik je zelf een type aan, dan wint die
keuze en wordt er daarna niets meer voor je omgezet. Foto en Video zijn een
bewuste andere keuze over dezelfde post en worden nooit overschreven. En bij het
OPENEN van een post gebeurt er niets: de eerste afleiding wordt alleen als
ijkpunt onthouden, anders zou een oude post van type veranderen door hem te
bekijken.

De editor leidt af uit precies de tekst die straks wordt opgeslagen (chips terug
naar shortcodes via serializeChips), zodat hij niet iets anders ziet dan de
server. Zonder de playlist-soorten leidt de regel niets af in plaats van te
gokken.

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

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