Changeset a66266d in Klonkt


Ignore:
Timestamp:
08/07/2026 07:23:50 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
9588b51
Parents:
72d8eff (diff), a39c9bd (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the (diff) links above to see all the changes relative to each parent.
Message:

Playlist-federatie terug op main (shaer-ayc, stap 1 en 2)

Een playlist heeft een id, en dus een stabiele URI. Daarmee kan hij federeren
waar een album -- nog een tekstveld -- nergens aan kan hangen.

GET /ap/users/:slug/playlists/:id OrderedCollection van Audio-objecten,

dezelfde rijvorm die een post al als
attachment meestuurt

GET /ap/users/:slug/playlists de lijst: kale URI-verwijzingen, en

verrijkte stubs op verzoek (FEP-9876)

streams op de actor AS2-kern, geen eigen vocabulaire nodig
Link-tag op de Note wat er in DEZE post zit; Mastodon

negeert hem geruisloos

De poortregel blijft ongemoeid: alleen fedi_open-tracks, met echte bestands-URL.
Een gated track is geen lege rij maar AFWEZIG, en totalItems telt alleen het
open deel -- ook in een stub. Titel noch bestandsnaam lekt.

Dit werk stond een dag op een eigen branch omdat de interop onbewezen was en aan
main zeven instances hangen zonder terugrolpad. Live geverifieerd op dev: de
hele keten antwoordt, en het bestand is met 206 audio/mpeg ophaalbaar.

12 playlist-tests; suite 607/607.

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

Files:
2 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r72d8eff ra66266d  
    630630});
    631631
     632// ── Playlist als dereferenceerbare AP-collectie (shaer-ayc) ───────
     633// De eerste stap van het Funkwhale-spoor: een playlist heeft een id, dus een
     634// stabiele URI. Alleen het fedi_open-deel staat erin (de poort is per bestand
     635// en eenrichtings; zie setAudioFediOpen in routes/posts.js) — een collectie
     636// zonder open tracks bestaat wel maar is leeg, want de playlist zelf is niet
     637// geheim, alleen de bestanden erachter.
     638// De lijst van alle playlist-collecties (shaer-ayc, stap 2). De actor wijst
     639// hierheen via AS2 `streams`. Kaal standaard; verrijkte stubs op verzoek
     640// (FEP-9876), dezelfde conventie als followers/following.
     641router.get('/ap/users/:slug/playlists', (req, res) => {
     642  const site = publicSite(req.params.slug);
     643  if (!site) return res.status(404).end();
     644  AP.sendAP(res, AP.listPlaylistsAP(baseUrl(req), site, wantsEnriched(req, res)));
     645});
     646
     647router.get('/ap/users/:slug/playlists/:id', (req, res) => {
     648  const site = publicSite(req.params.slug);
     649  if (!site) return res.status(404).end();
     650  const pl = db.prepare('SELECT id, title, artist, year, cover_url, kind FROM playlists WHERE id = ? AND site_id = ?')
     651    .get(req.params.id, site.id);
     652  if (!pl) return res.status(404).end();
     653  AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id)));
     654});
     655
    632656// ── Note ──────────────────────────────────────────────────────────
    633657router.get('/ap/notes/:id', async (req, res) => {
  • src/services/ActivityPubService.js

    r72d8eff ra66266d  
    195195    following: `${id}/following`,
    196196    featured: `${id}/featured`,
     197    // AS2-kern `streams`: "supplementary Collections which may be of
     198    // interest" -- precies wat de playlist-lijst is (shaer-ayc, stap 2).
     199    // Geen eigen vocabulaire nodig, en wie het niet kent negeert het.
     200    streams: [`${id}/playlists`],
    197201    // AP §5.6: the private blocked collection (owner-only GET). The server
    198202    // list is the source of truth for Shaer's "in Orbit"; clients keep no
     
    548552      ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
    549553      ..._mentionCc])],
    550     tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags],
     554    tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content)],
    551555    replies: `${id}/replies`,
    552556    // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
     
    868872    orderedItems: items,
    869873  };
     874}
     875
     876// ── Playlist als AP-collectie (shaer-ayc, stap 1 van het Funkwhale-spoor) ──
     877// Een playlist heeft, anders dan een album-als-tekstveld, een id — dus kan hij
     878// een stabiele URI dragen en federeren. De vorm is bewust kaal AS2: een
     879// OrderedCollection van Audio-objecten, dezelfde rijvorm die een post als
     880// attachment meestuurt, zodat elke client die post-audio al speelt dit ook
     881// speelt.
     882//
     883// De poortregel verandert hier NIET: alleen fedi_open-tracks staan erin, met
     884// echte bestands-URL. Een gated track is niet "een rij zonder url" maar
     885// afwezig — wie de collectie leest ziet het open deel en kan niet aftellen
     886// hoeveel er achter de poort staat. totalItems telt daarom ook alleen het
     887// open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
     888// niet over wat wij thuis in de kast hebben.
     889export function playlistOpenTracks(playlistId) {
     890  return db.prepare(
     891    `SELECT t.title, t.artist, t.duration, t.cover_url, m.filename, m.storage_path, m.mime_type
     892     FROM playlist_tracks pt
     893     JOIN audio_tracks t ON t.id = pt.track_id
     894     JOIN media m ON m.id = t.media_id
     895     WHERE pt.playlist_id = ? AND t.fedi_open = 1
     896     ORDER BY pt.position`
     897  ).all(playlistId);
     898}
     899
     900// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
     901// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
     902// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
     903// client die hem kent haalt de collectie op. Opgelost uit post.content en
     904// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
     905// zonder site-check zou een post van site A naar de collectie van site B
     906// kunnen wijzen.
     907export function playlistLinkTags(base, site, content) {
     908  const out = [];
     909  const seen = new Set();
     910  try {
     911    for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
     912      if (seen.has(m[1])) continue;
     913      seen.add(m[1]);
     914      const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id);
     915      if (!pl) continue;
     916      out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
     917    }
     918  } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
     919  return out;
     920}
     921
     922// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
     923// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
     924// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
     925// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
     926// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
     927// poort staat.
     928export function listPlaylistsAP(base, site, enriched) {
     929  const rows = db.prepare(
     930    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
     931  ).all(site.id);
     932  const colId = `${actorId(base, site.slug)}/playlists`;
     933  const items = rows.map((p) => {
     934    const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
     935    if (!enriched) return uri;
     936    const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
     937    delete stub['@context'];       // genest object draagt de context van zijn omhulsel
     938    delete stub.orderedItems;      // stub: wie de tracks wil, haalt de collectie op
     939    return stub;
     940  });
     941  return {
     942    '@context': AP_CONTEXT,
     943    id: colId,
     944    type: 'OrderedCollection',
     945    attributedTo: actorId(base, site.slug),
     946    totalItems: items.length,
     947    orderedItems: items,
     948  };
     949}
     950
     951export function buildPlaylistCollection(base, site, playlist, rows) {
     952  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     953  // Zelfde afleiding als in buildNote; die daar is functie-lokaal.
     954  const mediaType = (u) => {
     955    const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
     956    return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
     957  };
     958  const items = (rows || []).map((r) => {
     959    const fn = r.filename || (r.storage_path || '').split('/').pop();
     960    const a = {
     961      type: 'Audio',
     962      mediaType: r.mime_type || 'audio/mpeg',
     963      url: `${base}/audio/stream/${encodeURIComponent(fn)}`,
     964      name: r.title || 'Audio',
     965    };
     966    if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
     967    if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
     968    const art = abs(r.cover_url || playlist.cover_url || null);
     969    if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
     970    return a;
     971  });
     972  const out = {
     973    '@context': AP_CONTEXT,
     974    id: `${actorId(base, site.slug)}/playlists/${playlist.id}`,
     975    type: 'OrderedCollection',
     976    name: playlist.title,
     977    attributedTo: actorId(base, site.slug),
     978    totalItems: items.length,
     979    orderedItems: items,
     980  };
     981  // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
     982  const parts = [];
     983  if (playlist.artist) parts.push(playlist.artist);
     984  if (playlist.year) parts.push(String(playlist.year));
     985  if (parts.length) out.summary = parts.join(' · ');
     986  const cover = abs(playlist.cover_url || null);
     987  if (cover) out.icon = { type: 'Image', mediaType: mediaType(cover), url: cover };
     988  return out;
    870989}
    871990
     
    54185537  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
    54195538  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
     5539  buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
    54205540  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    54215541  feedCursor, feedChangesSince, waitForFeedChange,
  • test/activitypub-as2.test.js

    r72d8eff ra66266d  
    3232  // ActivityPub §5.6: the private blocked collection (owner-only GET).
    3333  'blocked',
     34  // ActivityPub §4.1: supplementary collections on the actor — Klonkt wijst
     35  // ermee naar de playlist-lijst (shaer-ayc).
     36  'streams',
    3437  // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
    3538  // sub-keys are the daemon-contract collection names the Shaer clients read.
Note: See TracChangeset for help on using the changeset viewer.