Changeset da84110 in Klonkt


Ignore:
Timestamp:
08/07/2026 06:46:34 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
3e1aab1, a8364ee
Parents:
4b5db37
Message:

Playlist-federatie van main naar feature/playlists

Robins besluit (7-8): de Funkwhale-interop is nog onbewezen, en aan main
hangen zeven instances zonder terugrolpad (shaer-ska). Het werk is niet weg:
het staat als verse cherry-picks op feature/playlists en komt terug via een
gewone merge zodra de interop-test iets bewijst.

Draait terug: c25eff7 (playlist als AP-collectie) en c1df3e9 (vindbaarheid:
streams, de lijst, de Link-tag).

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

Files:
2 deleted
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r4b5db37 rda84110  
    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.
    641 router.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 
    647 router.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 
    656632// ── Note ──────────────────────────────────────────────────────────
    657633router.get('/ap/notes/:id', async (req, res) => {
  • src/services/ActivityPubService.js

    r4b5db37 rda84110  
    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`],
    201197    // AP §5.6: the private blocked collection (owner-only GET). The server
    202198    // list is the source of truth for Shaer's "in Orbit"; clients keep no
     
    552548      ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]),
    553549      ..._mentionCc])],
    554     tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content)],
     550    tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags],
    555551    replies: `${id}/replies`,
    556552    // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler
     
    872868    orderedItems: items,
    873869  };
    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.
    889 export 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.
    907 export 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.
    928 export 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 
    951 export 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;
    989870}
    990871
     
    55375418  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
    55385419  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    5539   buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
    55405420  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    55415421  feedCursor, feedChangesSince, waitForFeedChange,
  • test/activitypub-as2.test.js

    r4b5db37 rda84110  
    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',
    3734  // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
    3835  // sub-keys are the daemon-contract collection names the Shaer clients read.
Note: See TracChangeset for help on using the changeset viewer.