source: Klonkt/src/services/ap-core.js@ 5280532

main
Last change on this file since 5280532 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: 16.7 KB
Line 
1/**
2 * De primitieven die iedereen die ActivityPub uitzendt nodig heeft.
3 *
4 * Waarom dit bestand er is (shaer-drc): ActivityPubService.js was 6436 regels
5 * met 166 exports en tweeentwintig secties. Een submap zoals music/ kan pas
6 * zelfstandig bestaan als deze zes dingen ergens staan waar BEIDE uit kunnen
7 * putten -- anders importeert de submap uit ActivityPubService en importeert
8 * die weer terug, en dat is een kring.
9 *
10 * Guardianship laat zien hoe het wel moet: die map importeert alleen db en zijn
11 * eigen buren, nooit terug. Dit bestand maakt datzelfde mogelijk voor de rest.
12 *
13 * Alles hier is PUUR: geen database, geen netwerk, geen toestand. Dat is de
14 * grens -- komt daar iets bij dat wel iets weet, dan hoort het hier niet.
15 *
16 * EEN UITZONDERING OP "PUUR": AP_CONTEXT stelt zichzelf samen uit
17 * Guardianship.SHAER_CONTEXT. Die context is nu eenmaal de optelsom van ieders
18 * termen, dus dat hoort zo. Het maakt geen kring: guardianship kent alleen db
19 * en zijn eigen buren en importeert nooit terug.
20 */
21
22import * as Guardianship from './guardianship/index.js';
23
24export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
25// Full JSON-LD context for every AP object we emit: AS2 core + security (publicKey) + the
26// extension terms we actually use (Mastodon/toot + schema.org), each with a term definition
27// so a strict JSON-LD processor resolves them instead of dropping them → valid AS2/JSON-LD.
28// This is the same context shape Mastodon publishes, so Mastodon sees no change.
29export const AP_CONTEXT = [
30 'https://www.w3.org/ns/activitystreams',
31 'https://w3id.org/security/v1',
32 {
33 toot: 'http://joinmastodon.org/ns#',
34 schema: 'http://schema.org#',
35 sensitive: 'as:sensitive',
36 Hashtag: 'as:Hashtag',
37 manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
38 discoverable: 'toot:discoverable',
39 // FEP-7628 (account moves): same term declaration Mastodon ships.
40 alsoKnownAs: { '@id': 'as:alsoKnownAs', '@type': '@id' },
41 movedTo: { '@id': 'as:movedTo', '@type': '@id' },
42 featured: { '@id': 'toot:featured', '@type': '@id' },
43 PropertyValue: 'schema:PropertyValue',
44 value: 'schema:value',
45 embedUrl: { '@id': 'schema:embedUrl', '@type': '@id' },
46 // Wat een track beschrijft en AS2 niet kent (shaer-0nh). Funkwhale zet deze
47 // vier op zijn Audio; het bleken geen eigen verzinsels maar termen die
48 // schema.org gewoon heeft -- en schema.org stond hier al. De SLEUTELS zijn
49 // die van Funkwhale, want daar leest hij op; de BETEKENIS komt van
50 // schema.org, dus we hoeven geen vreemd vocabulaire binnen te halen.
51 license: { '@id': 'schema:license', '@type': '@id' },
52 // "Dit ding is ook bekend onder die URI" -- voor de MusicBrainz-koppeling
53 // van een artiest (shaer-mbz). Bewust NIET alsoKnownAs: dat is in AS2
54 // gereserveerd voor vroegere IDENTITEITEN van dezelfde actor, en een
55 // verhuizing leunt erop (FEP-7628). Een verwijzing naar een register is
56 // iets anders dan een oud account van jezelf, en die twee door elkaar halen
57 // zou een verhuizing kunnen laten mislukken.
58 sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
59 // ── Wat we uit Funkwhale's vocabulaire overnemen, en waarom ──
60 //
61 // LIBRARY. Gemeten op 13-8: open.audio had onze vier tracks binnengehaald
62 // via hun AP-id, met een artist_credit dat het zelf uit onze attributedTo
63 // afleidde -- maar uploads LEEG en is_playable false. Bij Funkwhale hangt
64 // een upload aan een library; zonder die bak blijft een track een naam
65 // zonder geluid.
66 //
67 // TRACK. Hier stond dat we deze NIET namen, met als reden: hij vraagt een
68 // entiteit waar wij tekst hebben, en er een id voor verzinnen belooft wat
69 // we niet waarmaken. Die redenering klopte half, en de Emissary-meting van
70 // 16-8 (shaer-3f8a) laat zien welke helft. Een TRACK heeft bij ons wel
71 // degelijk een eigen identiteit -- het is een rij met een titel en een
72 // plaats in een uitgave. Wat wij niet hebben is een ALBUM als entiteit, en
73 // dat is een andere vraag (shaer-k37k). Emissary stuurt precies die kleine
74 // vorm: type, id, name, position. Wij ook, en album blijft eruit tot het
75 // een echt object is -- een verzonnen album-URI is nu juist wel de belofte
76 // die we niet kunnen waarmaken.
77 //
78 // Twee onafhankelijke implementaties zenden dit nu, en het is de kant waar
79 // FEP-be68 heen beweegt. ArtistCredit blijft eruit: dat is nog steeds een
80 // entiteit die wij niet hebben.
81 //
82 // De vorm van de termen is letterlijk die van hun contexts.py (regel
83 // 293-306), zodat een lezer die hun context laadt en een lezer die de onze
84 // leest op dezelfde IRI's uitkomen.
85 fw: 'https://funkwhale.audio/ns#',
86 Library: 'fw:Library',
87 library: { '@id': 'fw:library', '@type': '@id' },
88 Track: 'fw:Track',
89 track: { '@id': 'fw:track', '@type': '@id' },
90 position: 'schema:position',
91 bitrate: 'schema:bitrate',
92 size: 'schema:contentSize',
93 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
94 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
95 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
96 votersCount: 'toot:votersCount',
97 // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
98 // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
99 // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
100 // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
101 // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
102 // `migration`, want daar leest een consument op.
103 migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
104 moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
105 migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
106 migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
107 migratedAt: 'https://w3id.org/fep/1580/migratedAt',
108 // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
109 // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
110 // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
111 // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
112 // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
113 // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
114 // antwoordt.
115 category: { '@id': 'https://funkwhale.audio/ns#category' },
116 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
117 // module (src/services/guardianship/).
118 ...Guardianship.SHAER_CONTEXT,
119 },
120];
121
122/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
123 * bruikbare URL vinden. */
124export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
125
126export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
127export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
128
129/**
130 * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
131 * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
132 * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
133 * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
134 * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
135 */
136export function guessMediaType(u) {
137 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
138 return ({
139 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
140 webp: 'image/webp', avif: 'image/avif',
141 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
142 })[(e || '').toLowerCase()] || 'image/jpeg';
143}
144
145/**
146 * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
147 * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
148 * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
149 * terugval voor wat er handmatig is ingevuld.
150 */
151export function normalizeTags(t) {
152 if (Array.isArray(t)) return t;
153 if (typeof t === 'string') {
154 const s = t.trim(); if (!s) return [];
155 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
156 return s.split(',').map((x) => x.trim()).filter(Boolean);
157 }
158 return [];
159}
160
161/**
162 * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
163 * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
164 * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
165 * blijven kleingeschreven ("livemusic").
166 */
167export function tagParts(raw) {
168 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
169 if (!words.length) return null;
170 const slug = words.join('').toLowerCase();
171 if (!slug) return null;
172 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
173 return { label, slug };
174}
175
176/**
177 * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
178 * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
179 * naam niet: #DoenweNiet blijft #DoenweNiet.
180 */
181export function hashtagTags(base, content) {
182 const tags = [], seen = new Set();
183 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
184 let m;
185 while ((m = re.exec(content || ''))) {
186 const k = m[1].toLowerCase();
187 if (seen.has(k)) continue; seen.add(k);
188 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
189 }
190 return tags;
191}
192
193/**
194 * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
195 *
196 * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
197 * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
198 * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
199 * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
200 * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
201 * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
202 *
203 * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
204 * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
205 * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
206 * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
207 * tagveld, precies de vorm die hier juist niet moest winnen.
208 */
209export function hashtagTagsRuw(base, content) {
210 const tags = [], seen = new Set();
211 // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
212 // tag, en die zou hier anders als hashtag de deur uit gaan.
213 const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
214 let m;
215 while ((m = re.exec(content || ''))) {
216 const k = m[2].toLowerCase();
217 if (seen.has(k)) continue; seen.add(k);
218 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
219 }
220 return tags;
221}
222
223export function buildHashtagList(base, tagsField, content, opts = {}) {
224 const out = [], seen = new Set();
225 const uitLijf = opts.ruw
226 ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
227 : hashtagTags(base, content);
228 for (const h of uitLijf) {
229 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
230 out.push(h);
231 }
232 for (const t of normalizeTags(tagsField)) {
233 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
234 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
235 }
236 return out;
237}
238
239/**
240 * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
241 *
242 * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
243 * "first: This field is required" en "last: This field is required" -- de eerste
244 * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
245 * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
246 * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
247 * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
248 * opnieuw vergeet.
249 *
250 * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
251 * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
252 * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
253 * naar dezelfde.
254 *
255 * @param {string} id de collectie-uri, zonder query
256 * @param {Array} items wat erin zit (mag leeg)
257 * @param {object} opts
258 * totalItems als de telling niet items.length is (followers geeft publiek
259 * alleen een AANTAL en houdt de lijst dicht)
260 * page true -> een OrderedCollectionPage met partOf in plaats van de wortel
261 * extra velden die op de wortel horen (attributedTo, shaer:*)
262 */
263/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
264export const PAGINA_GROOTTE = 20;
265
266/**
267 * Een collectie, met ECHTE paginering (shaer-sk4).
268 *
269 * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
270 * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
271 * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
272 * pagina's identiek bleven; dit is waarom.
273 *
274 * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
275 * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
276 * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
277 * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
278 *
279 * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
280 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
281 * antwoord zijn dat over zichzelf liegt.
282 *
283 * `ongeordend` maakt er de NIET-geordende vorm van: `Collection` met
284 * `CollectionPage` en `items`, in plaats van `OrderedCollection` met
285 * `OrderedCollectionPage` en `orderedItems`. Dat is geen dialect maar de andere
286 * helft van AS2 -- en de bibliotheek hoort daar: een platenkast heeft geen
287 * volgorde die iets betekent, en `Library` is bij Funkwhale expliciet een
288 * `Collection`. Onze outbox is wél geordend (chronologie is daar de inhoud) en
289 * blijft dus zoals hij was.
290 *
291 * De pagina draagt in die vorm ook `first` en `last`. AS2 staat dat toe --
292 * CollectionPage erft van Collection -- en een lezer die halverwege binnenkomt
293 * kan zo terug naar het begin zonder eerst de wortel op te halen.
294 */
295export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) {
296 const lijst = items || [];
297 const telling = totalItems === undefined ? lijst.length : totalItems;
298 const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
299 // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
300 // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
301 // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
302 // en nooit een `next` aanbieden.
303 const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
304 const url = (n) => `${id}?page=${n}`;
305
306 if (page) {
307 const n = Math.max(1, Math.floor(Number(page)) || 1);
308 const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
309 return {
310 '@context': AP_CONTEXT,
311 id: url(n),
312 type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage',
313 partOf: id,
314 totalItems: telling,
315 ...(ongeordend ? { first: url(1), last: url(paginas) } : {}),
316 ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}),
317 ...(n > 1 ? { prev: url(n - 1) } : {}),
318 ...(n < paginas ? { next: url(n + 1) } : {}),
319 ...(ongeordend ? { items: deel } : { orderedItems: deel }),
320 };
321 }
322 return {
323 '@context': AP_CONTEXT,
324 id,
325 type: ongeordend ? 'Collection' : 'OrderedCollection',
326 ...extra,
327 totalItems: telling,
328 first: url(1),
329 last: url(paginas),
330 ...(ongeordend ? { items: lijst } : { orderedItems: lijst }),
331 };
332}
333
334/** Is dit een MBID? Een UUID, en niets anders. */
335export function isMbid(s) {
336 return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(s || '').trim());
337}
338
339/** De publieke pagina van een artiest, of null als het geen MBID is. */
340export function artiestUrl(mbid) {
341 return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
342}
Note: See TracBrowser for help on using the repository browser.