Changes in / [72d8eff:a66266d] in Klonkt


Ignore:
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.