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

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

Track: published en artist_credit erbij

Twee van de vijf gaten naar Funkwhale's bibliotheek-ingest.

published was een omissie in de kleine Emissary-vorm, niet een keuze:
MusicEntitySerializer (regel 1278) eist het en we hebben created_at.

artist_credit leek de onmogelijke. Hun keten is Track -> ArtistCredit ->
Artist, en elke schakel wil id, name en published -- terwijl een artiest
bij ons een tekstkolom is. De MusicBrainz-koppeling van vorige week liet
zien dat dat niet klopt: de site-ACTOR is de artiest. Een echt,
opvraagbaar adres, met de sitetitel als naam en een musicbrainzId zodra
hij gekoppeld is. Er valt niets te verzinnen, en het is niet nieuw --
open.audio leidde op 13-8 al zelf een artist_credit af uit onze
attributedTo. We maken expliciet wat daar toch al gebeurde.

De artiestnaam van de track gaat naar credit en niet naar de entiteit.
Dat is waar hun model de credittekst verwacht, en er een id per
artiestnaam van maken zou identiteit uit een string zijn -- dezelfde fout
die we bij het album vermijden (shaer-756s).

@container: @list op artist_credit is geen opsmuk: ze lezen het veld met
first_attr(FW.artist_credit, "@list"), en zonder die declaratie
expandeert onze array er niet naar. Dan staat er iets dat er goed uitziet
en dat hun lezer niet vindt -- precies het soort stil gat waar deze week
al twee keer een dag in ging zitten.

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

  • Property mode set to 100644
File size: 17.8 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 // ARTIEST-CREDIT. Hun TrackSerializer eist minstens een artist_credit, en
91 // dat leek lang onmogelijk: het vraagt een Artist met een eigen id, en bij
92 // ons was een artiest tekst. Sinds de MusicBrainz-koppeling (shaer-mbz) is
93 // dat niet meer waar -- de site-ACTOR is de artiest. Een echte, opvraagbare
94 // URI, met de sitetitel als naam en een musicbrainzId als hij gekoppeld is.
95 // Er valt hier niets te verzinnen; open.audio leidde dit zelfs al zelf af
96 // uit onze attributedTo (gemeten 13-8).
97 //
98 // `@container: @list` is GEEN opsmuk. Ze lezen dit veld met
99 // first_attr(FW.artist_credit, "@list"), en zonder die declaratie expandeert
100 // onze array niet naar een @list -- dan staat er iets dat er goed uitziet en
101 // door hun lezer niet gevonden wordt. Letterlijk hun contexts.py regel 311.
102 Artist: 'fw:Artist',
103 ArtistCredit: 'fw:ArtistCredit',
104 artist: { '@id': 'fw:artist', '@type': '@id' },
105 artist_credit: { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' },
106 credit: 'fw:credit',
107 musicbrainzId: 'fw:musicbrainzId',
108 position: 'schema:position',
109 bitrate: 'schema:bitrate',
110 size: 'schema:contentSize',
111 // Poll (Question) extension: Question/oneOf/anyOf/endTime/closed are AS2 core, but the
112 // per-poll unique-voter count is a Mastodon (toot) term — declare it so the emitted
113 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
114 votersCount: 'toot:votersCount',
115 // FEP-1580 (objectmigratie bij een Move). FEP-7628 verhuist je VOLGERS en
116 // zegt dat zelf met zoveel woorden: de objecten zijn een ander probleem, en
117 // dit is de FEP waar dat geregeld wordt. De namespace is die van de FEP zelf
118 // (aangemeld via FEP-888d). De CURIE van de collectie is `migration:migration`,
119 // door de auteur zelf "maybe unhelpfully" genoemd; wij emitteren de JSON-sleutel
120 // `migration`, want daar leest een consument op.
121 migration: { '@id': 'https://w3id.org/fep/1580/migration', '@type': '@id' },
122 moves: { '@id': 'https://w3id.org/fep/1580/moves', '@type': '@id' },
123 migrationComplete: 'https://w3id.org/fep/1580/migrationComplete',
124 migratedFrom: { '@id': 'https://w3id.org/fep/1580/migratedFrom', '@type': '@id' },
125 migratedAt: 'https://w3id.org/fep/1580/migratedAt',
126 // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
127 // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
128 // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
129 // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
130 // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
131 // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
132 // antwoordt.
133 category: { '@id': 'https://funkwhale.audio/ns#category' },
134 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
135 // module (src/services/guardianship/).
136 ...Guardianship.SHAER_CONTEXT,
137 },
138];
139
140/** Een absolute http(s)-URL, of leeg. De enige plek die bepaalt wat wij een
141 * bruikbare URL vinden. */
142export const safeUrl = (u) => { const s = String(u == null ? '' : u).trim(); return /^https?:\/\//i.test(s) ? s : ''; };
143
144export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
145export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
146
147/**
148 * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
149 * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
150 * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
151 * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
152 * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
153 */
154export function guessMediaType(u) {
155 const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
156 return ({
157 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
158 webp: 'image/webp', avif: 'image/avif',
159 mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
160 })[(e || '').toLowerCase()] || 'image/jpeg';
161}
162
163/**
164 * Het tagveld van een post als lijst. Het staat in de database als JSON-ARRAY
165 * en niet als kommalijst -- op komma's splitsen levert `#["Doen we Niet"` op,
166 * en dat faalt niet, het liegt. Vandaar een echte parser, met de kommavorm als
167 * terugval voor wat er handmatig is ingevuld.
168 */
169export function normalizeTags(t) {
170 if (Array.isArray(t)) return t;
171 if (typeof t === 'string') {
172 const s = t.trim(); if (!s) return [];
173 if (s[0] === '[') { try { const a = JSON.parse(s); return Array.isArray(a) ? a : []; } catch { /* dan toch als kommalijst */ } }
174 return s.split(',').map((x) => x.trim()).filter(Boolean);
175 }
176 return [];
177}
178
179/**
180 * Een tag -> { label, slug }. Tags van meerdere woorden worden CamelCase
181 * (#LiveMusic) voor de weergavenaam -- een Mastodon-hashtag mag geen spaties
182 * bevatten en CamelCase is daar de toegankelijkheidsnorm; de slug en de href
183 * blijven kleingeschreven ("livemusic").
184 */
185export function tagParts(raw) {
186 const words = String(raw || '').trim().split(/[\s_]+/).map((w) => w.replace(/[^\p{L}\p{M}\p{N}]/gu, '')).filter(Boolean);
187 if (!words.length) return null;
188 const slug = words.join('').toLowerCase();
189 if (!slug) return null;
190 const label = words.length > 1 ? words.map((w) => w[0].toUpperCase() + w.slice(1)).join('') : words[0];
191 return { label, slug };
192}
193
194/**
195 * De #hashtags die in het LIJF van een post gelinkt staan, zoals ze GESCHREVEN
196 * zijn. De slug in de href is kleingeschreven -- dat is een adres -- maar de
197 * naam niet: #DoenweNiet blijft #DoenweNiet.
198 */
199export function hashtagTags(base, content) {
200 const tags = [], seen = new Set();
201 const re = /class="[^"]*\bhashtag\b[^"]*"[^>]*>#([\p{L}\p{M}\p{N}_]+)</giu;
202 let m;
203 while ((m = re.exec(content || ''))) {
204 const k = m[1].toLowerCase();
205 if (seen.has(k)) continue; seen.add(k);
206 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[1] });
207 }
208 return tags;
209}
210
211/**
212 * Het tagveld van een post en de #hashtags uit het lijf, samen en ontdubbeld.
213 *
214 * HET LIJF GAAT VOOR (Robin, 9-8): staat een tag allebei, dan wint de vorm
215 * zoals hij GESCHREVEN is. Het tagveld gaat door tagParts, en die maakt van
216 * "Doen we Niet" het CamelCase #DoenWeNiet -- nodig, want een hashtag mag geen
217 * spaties bevatten. Maar als iemand in zijn tekst #DoenweNiet heeft getypt is
218 * dat geen benadering meer maar de tag zelf, en dan hoort die te staan zoals
219 * hij er staat. Eerder won het veld, en verdween de geschreven vorm.
220 *
221 * `opts.ruw` voor inhoud die nog niet door de renderer is geweest: dan staan de
222 * hashtags er als kale tekst en niet als <a class="hashtag">. buildNote krijgt
223 * het bewerkte lijf en heeft dit niet nodig; wie rechtstreeks uit posts.content
224 * leest wel -- anders vindt hij er geen enkele en valt hij stil terug op het
225 * tagveld, precies de vorm die hier juist niet moest winnen.
226 */
227export function hashtagTagsRuw(base, content) {
228 const tags = [], seen = new Set();
229 // Moet met een LETTER beginnen: "#12" in "issue #12" is een nummer en geen
230 // tag, en die zou hier anders als hashtag de deur uit gaan.
231 const re = /(^|[\s>(\[])#(\p{L}[\p{L}\p{M}\p{N}_]*)/gu;
232 let m;
233 while ((m = re.exec(content || ''))) {
234 const k = m[2].toLowerCase();
235 if (seen.has(k)) continue; seen.add(k);
236 tags.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(k)}`, name: '#' + m[2] });
237 }
238 return tags;
239}
240
241export function buildHashtagList(base, tagsField, content, opts = {}) {
242 const out = [], seen = new Set();
243 const uitLijf = opts.ruw
244 ? [...hashtagTags(base, content), ...hashtagTagsRuw(base, content)]
245 : hashtagTags(base, content);
246 for (const h of uitLijf) {
247 const k = h.name.slice(1).toLowerCase(); if (seen.has(k)) continue; seen.add(k);
248 out.push(h);
249 }
250 for (const t of normalizeTags(tagsField)) {
251 const p = tagParts(t); if (!p || seen.has(p.slug)) continue; seen.add(p.slug);
252 out.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(p.slug)}`, name: '#' + p.label });
253 }
254 return out;
255}
256
257/**
258 * Een AS2-collectie MET de paginavelden erbij (shaer-0nh, 11-8).
259 *
260 * WAAROM DIT EEN HELPER IS EN GEEN REGELS. Funkwhale weigerde onze outbox met
261 * "first: This field is required" en "last: This field is required" -- de eerste
262 * concrete reden die we hoorden waarom er niets van ons binnenkwam. AS2 EIST die
263 * velden niet, maar bijna iedereen pagineert, en een lezer die de paginaweg
264 * volgt liep dood. Toen dat voor de outbox gerepareerd was misten alle andere
265 * collecties ze nog steeds. Een helper zorgt dat de volgende collectie ze niet
266 * opnieuw vergeet.
267 *
268 * DE ITEMS BLIJVEN INLINE op de wortel. Shaer bouwt zijn feed daaruit, en wie
269 * hem vandaag leest hoort er morgen niet voor te hoeven pagineren. Onze
270 * collecties zijn gekapt, dus er is precies EEN pagina en wijzen first en last
271 * naar dezelfde.
272 *
273 * @param {string} id de collectie-uri, zonder query
274 * @param {Array} items wat erin zit (mag leeg)
275 * @param {object} opts
276 * totalItems als de telling niet items.length is (followers geeft publiek
277 * alleen een AANTAL en houdt de lijst dicht)
278 * page true -> een OrderedCollectionPage met partOf in plaats van de wortel
279 * extra velden die op de wortel horen (attributedTo, shaer:*)
280 */
281/** Hoeveel items op een pagina. Gelijk aan wat de outbox vroeger als KAP had. */
282export const PAGINA_GROOTTE = 20;
283
284/**
285 * Een collectie, met ECHTE paginering (shaer-sk4).
286 *
287 * Wat hier stond was een omhulsel: `page` veranderde alleen de VORM en er werd
288 * nooit gesneden. `first` en `last` wezen allebei naar ?page=1, elke ?page=N gaf
289 * dezelfde items, en pagina 99 noemde zichzelf pagina 1. Robin zag dat de
290 * pagina's identiek bleven; dit is waarom.
291 *
292 * DE WORTEL BLIJFT ZIJN ITEMS INLINE DRAGEN, en dat is geen slordigheid maar de
293 * hele reden dat dit veilig is. Shaer leest één document en volgt `next` niet;
294 * zou de wortel nu leeg worden, dan kreeg elke draaiende app nul items en geen
295 * foutmelding. Eerst de clients leren pagineren, dan pas de wortel afslanken.
296 *
297 * Een pagina VOORBIJ het einde is leeg en zegt dat ook -- met zijn eigen nummer
298 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
299 * antwoord zijn dat over zichzelf liegt.
300 *
301 * `ongeordend` maakt er de NIET-geordende vorm van: `Collection` met
302 * `CollectionPage` en `items`, in plaats van `OrderedCollection` met
303 * `OrderedCollectionPage` en `orderedItems`. Dat is geen dialect maar de andere
304 * helft van AS2 -- en de bibliotheek hoort daar: een platenkast heeft geen
305 * volgorde die iets betekent, en `Library` is bij Funkwhale expliciet een
306 * `Collection`. Onze outbox is wél geordend (chronologie is daar de inhoud) en
307 * blijft dus zoals hij was.
308 *
309 * De pagina draagt in die vorm ook `first` en `last`. AS2 staat dat toe --
310 * CollectionPage erft van Collection -- en een lezer die halverwege binnenkomt
311 * kan zo terug naar het begin zonder eerst de wortel op te halen.
312 */
313export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) {
314 const lijst = items || [];
315 const telling = totalItems === undefined ? lijst.length : totalItems;
316 const grootte = Math.max(1, Number(perPage) || PAGINA_GROOTTE);
317 // `alGesneden` voor wie in SQL al gepagineerd heeft (de outbox): dan is `lijst`
318 // een PAGINA en zegt hij niets over het geheel, dus telt het aantal pagina's
319 // uit `totalItems`. Zonder dat zou een volle pagina zichzelf als de enige zien
320 // en nooit een `next` aanbieden.
321 const paginas = Math.max(1, Math.ceil((alGesneden ? telling : lijst.length) / grootte));
322 const url = (n) => `${id}?page=${n}`;
323
324 if (page) {
325 const n = Math.max(1, Math.floor(Number(page)) || 1);
326 const deel = alGesneden ? lijst : lijst.slice((n - 1) * grootte, n * grootte);
327 return {
328 '@context': AP_CONTEXT,
329 id: url(n),
330 type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage',
331 partOf: id,
332 totalItems: telling,
333 ...(ongeordend ? { first: url(1), last: url(paginas) } : {}),
334 ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}),
335 ...(n > 1 ? { prev: url(n - 1) } : {}),
336 ...(n < paginas ? { next: url(n + 1) } : {}),
337 ...(ongeordend ? { items: deel } : { orderedItems: deel }),
338 };
339 }
340 return {
341 '@context': AP_CONTEXT,
342 id,
343 type: ongeordend ? 'Collection' : 'OrderedCollection',
344 ...extra,
345 totalItems: telling,
346 first: url(1),
347 last: url(paginas),
348 ...(ongeordend ? { items: lijst } : { orderedItems: lijst }),
349 };
350}
351
352/** Is dit een MBID? Een UUID, en niets anders. */
353export function isMbid(s) {
354 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());
355}
356
357/** De publieke pagina van een artiest, of null als het geen MBID is. */
358export function artiestUrl(mbid) {
359 return isMbid(mbid) ? `https://musicbrainz.org/artist/${String(mbid).trim().toLowerCase()}` : null;
360}
Note: See TracBrowser for help on using the repository browser.