Changeset bdcb3a3 in Klonkt


Ignore:
Timestamp:
08/08/2026 04:35:20 AM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
39a9d21
Parents:
553bd70
git-author:
Robin <roboburr@…> (08/08/2026 04:34:22 AM)
git-committer:
Robin <roboburr@…> (08/08/2026 04:35:20 AM)
Message:

De Klonkt-actor is een kanaal, en Audio is een eigen soort

Robins besluit (7-8): onze site-actor past het beste op een Funkwhale-channel,
niet op een Library. En de afstand bleek klein -- beide zijn al Person.

UITGAAND. url wordt een Link-array in plaats van een kale string: de
webpagina en, ernaast, /feed.xml als application/rss+xml. Dat laatste is de
vondst: zo vindt een podcast-app een kanaal, en die feed had Klonkt al. De
text/html staat vooraan, want een lezer die maar een url verwacht pakt de
eerste -- dezelfde vorm die Funkwhale in productie met Mastodon uitwisselt.

De RSS-link gaat alleen mee voor de site waar de instance op gepind staat.
Sinds hub-modus weg is bestaat /user/<slug> niet meer als route, dus een
feed-link voor een andere site zou naar de verkeerde feed wijzen.

category is 'music', en ALLEEN als de site echt audio publiceert -- een blog
zonder muziek als muziekkanaal aankondigen is erger dan geen label. Het signaal
is een track in de kast, niet enable_audio_player, want die staat standaard aan.

INKOMEND. Create(Audio) werd tot nu toe stil weggegooid: de inbox nam alleen
Note, Article en Question aan. Nu staat Audio in TIJDLIJN_SOORTEN en weet
timelineFields per soort wat de tijdlijn nodig heeft.

NIET tot Note omgevormd, en dat is de kern. Een eerdere poging deed dat wel --
kort, elegant, en verkeerd: het maakte de soort onzichtbaar terwijl we juist
meer soorten willen kunnen tonen. Uitbreiden is nu een tak in timelineFields.

Een binnengekomen track levert hoes en dan geluid in media_json, en de Krant
rendert audio/* al als speler -- dus hij komt binnen als een echte speler
zonder dat de weergave iets van Funkwhale hoeft te weten. Een kale string-url
telt als webpagina en nooit als geluid: er een speler op zetten zou een
HTML-pagina als audiobestand aanbieden.

Ook opgeschoond: de item-link liep nog langs een rauwe o.url, die bij een Audio
een array is. Dat gaat nu overal door dezelfde link-keuze.

8 tests.

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

Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r553bd70 rbdcb3a3  
    5656    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
    5757    votersCount: 'toot:votersCount',
     58    // Kanaal-vocabulaire (shaer-0nh). Funkwhale declareert `category` niet
     59    // inline maar via zijn eigen remote context https://funkwhale.audio/ns, en
     60    // die host is vanaf hier onbereikbaar -- de IRI hieronder is dus AFGELEID
     61    // en niet geverifieerd. Wat vandaag telt voor interop is de JSON-sleutel,
     62    // want daar matchen lezers op; de declaratie zorgt alleen dat een strikte
     63    // JSON-LD-processor hem niet laat vallen. Nakijken zodra die host weer
     64    // antwoordt.
     65    category: { '@id': 'https://funkwhale.audio/ns#category' },
    5866    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
    5967    // module (src/services/guardianship/).
     
    173181export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
    174182
     183/** Eén Link uit een AS2 `url` kiezen op mediaType. Een `url` mag een string,
     184 *  een Link of een array van beide zijn; dit is de enige plek die dat weet. */
     185function pickLink(url, test) {
     186  const links = Array.isArray(url) ? url : (url ? [url] : []);
     187  for (const l of links) {
     188    const href = safeUrl(typeof l === 'string' ? l : (l && l.href));
     189    const mt = (l && typeof l === 'object' && l.mediaType) || '';
     190    if (href && test(mt)) return { href, mediaType: mt };
     191  }
     192  return null;
     193}
     194
     195/**
     196 * De `url` van de actor als kanaal (shaer-0nh): de webpagina en, als die er is,
     197 * de RSS-feed ernaast.
     198 *
     199 * De RSS-link gaat er ALLEEN in voor de site waar de instance op gepind staat.
     200 * Sinds hub-modus verdween serveert routes/feed.js `/feed.xml` van de primaire
     201 * site en bestaat `/user/<slug>` niet meer als route; een feed-link voor een
     202 * andere site zou naar de verkeerde feed wijzen. Liever een link minder dan een
     203 * link die iemand anders' muziek belooft.
     204 */
     205export function channelUrls(base, site) {
     206  const isPrimair = site.slug === site.primary_slug;
     207  const pagina = `${base}/${isPrimair ? '' : 'user/' + encodeURIComponent(site.slug)}`;
     208  const uit = [{ type: 'Link', href: pagina, mediaType: 'text/html' }];
     209  if (isPrimair) uit.push({ type: 'Link', href: `${base}/feed.xml`, mediaType: 'application/rss+xml' });
     210  return uit;
     211}
     212
     213/**
     214 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
     215 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
     216 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
     217 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
     218 */
     219function channelCategory(site) {
     220  try {
     221    return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? LIMIT 1').get(site.id) ? 'music' : null;
     222  } catch { return null; }
     223}
     224
     225/**
     226 * Welke objectsoorten deze inbox in de tijdlijn opneemt.
     227 *
     228 * `Audio` staat erbij sinds de kanaalbeslissing (shaer-0nh): een Funkwhale-
     229 * kanaal stuurt Create(Audio), geen Note. Uitbreiden gebeurt HIER en in
     230 * timelineFields -- en uitdrukkelijk NIET door vreemde soorten tot Note om te
     231 * vormen. Een Audio is geen Note, en die soort willen we kunnen blijven zien.
     232 */
     233const TIJDLIJN_SOORTEN = new Set(['Note', 'Article', 'Question', 'Audio']);
     234
     235/**
     236 * Wat de tijdlijn van een binnengekomen object nodig heeft, PER SOORT: de
     237 * inhoud-HTML, de bijlagen voor media_json, en de link van het item.
     238 *
     239 * Eén plek, zodat een nieuwe soort erbij een tak is en geen speurtocht. De
     240 * Krant rendert media_json al naar soort -- audio/* wordt een speler -- dus een
     241 * track komt vanzelf als echte speler binnen zonder dat de weergave iets van
     242 * Funkwhale hoeft te weten.
     243 */
     244export function timelineFields(o) {
     245  // De hoes: een `image` op het object. Bij een Note alleen als terugval (daar
     246  // is het de kaart-afbeelding van een player-post), bij een Audio altijd,
     247  // want daar IS het de albumhoes.
     248  const hoes = () => {
     249    if (!o.image) return null;
     250    const im = Array.isArray(o.image) ? o.image[0] : o.image;
     251    const iu = safeUrl(typeof im === 'string' ? im : (im && im.url));
     252    return iu ? { url: iu, type: (im && im.mediaType) || 'image/jpeg' } : null;
     253  };
     254
     255  if (o.type === 'Audio') {
     256    const geluid = pickLink(o.url, (mt) => /^audio\//i.test(mt));
     257    // De webpagina van de track. Zonder mediaType is dat de veilige aanname:
     258    // er een speler op zetten zou een HTML-pagina als geluid aanbieden.
     259    const pagina = pickLink(o.url, (mt) => /^text\/html/i.test(mt)) || pickLink(o.url, (mt) => !mt);
     260    const atts = [];
     261    const h = hoes(); if (h) atts.push(h);              // eerst kijken, dan luisteren
     262    if (geluid) atts.push({ url: geluid.href, type: geluid.mediaType || 'audio/mpeg' });
     263    // Een Audio heeft geen `content`; de titel is wat er te lezen valt. Door de
     264    // sanitizer, want hij komt van een vreemde server.
     265    return {
     266      html: o.name ? HtmlSanitizerService.sanitize(`<p>${o.name}</p>`) : '',
     267      atts,
     268      url: pagina ? pagina.href : null,
     269    };
     270  }
     271
     272  // Note / Article / Question -- ongewijzigd gedrag.
     273  const atts = (Array.isArray(o.attachment) ? o.attachment : [])
     274    .map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' }))
     275    .filter((m) => m.url);
     276  if (!atts.some((m) => !m.type || /image/i.test(m.type))) {
     277    const h = hoes(); if (h) atts.push(h);
     278  }
     279  const pagina = pickLink(o.url, () => true);
     280  return { html: HtmlSanitizerService.sanitize(o.content || ''), atts, url: pagina ? pagina.href : null };
     281}
     282
    175283export function buildActor(base, site) {
    176284  const id = actorId(base, site.slug);
     
    187295    name: site.title || site.slug,
    188296    summary: site.tagline || site.description || '',
    189     url: `${base}/${site.slug === site.primary_slug ? '' : 'user/' + encodeURIComponent(site.slug)}`,
     297    // Een Link-ARRAY in plaats van een kale string (shaer-0nh): zo adverteert
     298    // een kanaal zichzelf, en zo vindt een podcast-app de feed. De text/html
     299    // staat VOORAAN, want een lezer die maar één url verwacht pakt de eerste --
     300    // dezelfde vorm die Funkwhale in productie met Mastodon uitwisselt.
     301    url: channelUrls(base, site),
     302    ...(channelCategory(site) ? { category: channelCategory(site) } : {}),
    190303    manuallyApprovesFollowers: isWard,
    191304    discoverable: true,
     
    21942307
    21952308  // Inbound reply: a Create whose object replies to one of our notes (post OR comment).
    2196   if (type === 'Create' && act.object && (act.object.type === 'Note' || act.object.type === 'Article' || act.object.type === 'Question')) {
     2309  if (type === 'Create' && act.object && TIJDLIJN_SOORTEN.has(act.object.type)) {
    21972310    const o = act.object;
    21982311    // A poll ballot: a Note carrying a `name` (the chosen option) inReplyTo one of OUR poll
     
    22472360      if (subs.length) {
    22482361        const ai = actorInfo(await resolveActor(actorUri), actorUri);
    2249         const html = HtmlSanitizerService.sanitize(o.content || '');
    2250         const _atts = (Array.isArray(o.attachment) ? o.attachment : []).map((a) => ({ url: safeUrl(a && a.url), type: (a && a.mediaType) || '' })).filter((m) => m.url);
    2251         // Fallback cover: a Note's `image` (set when the attachment was suppressed
    2252         // for a player-card post, e.g. hosted-audio posts).
    2253         if (!_atts.some((m) => !m.type || /image/i.test(m.type)) && o.image) {
    2254           const _im = Array.isArray(o.image) ? o.image[0] : o.image;
    2255           const _iu = safeUrl(typeof _im === 'string' ? _im : (_im && _im.url));
    2256           if (_iu) _atts.push({ url: _iu, type: (_im && _im.mediaType) || 'image/jpeg' });
    2257         }
     2362        const { html, atts: _atts, url: _url } = timelineFields(o);
    22582363        const media = JSON.stringify(_atts);
    22592364        const poll = parsePoll(o); // a Question (fediverse poll) → cache its options/counts
     
    22632368        // the timeline).
    22642369        for (const s of subs) {
    2265           tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.url || null, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
     2370          tlStmts().ins.run(o.id, s.slug, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, _url, o.published || null, media, o.sensitive ? 1 : 0, o.summary || null);
    22662371          // FEP-633c §2.2: register the ward hint on the stored object (no action yet).
    22672372          if (Guardianship.objectHasGuardians(o)) { try { db.prepare('UPDATE ap_timeline SET has_guardians = 1 WHERE id = ? AND slug = ?').run(o.id, s.slug); } catch { /* ignore */ } }
     
    57905895  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
    57915896  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
     5897  channelUrls, timelineFields,
    57925898  buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
    57935899  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
Note: See TracChangeset for help on using the changeset viewer.