Changeset a66266d in Klonkt
- Timestamp:
- 08/07/2026 07:23:50 PM (5 weeks ago)
- 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. - Files:
-
- 2 added
- 3 edited
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
test/activitypub-as2.test.js (modified) (1 diff)
-
test/ap-playlist-discovery.test.js (added)
-
test/ap-playlist.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r72d8eff ra66266d 630 630 }); 631 631 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 632 656 // ── Note ────────────────────────────────────────────────────────── 633 657 router.get('/ap/notes/:id', async (req, res) => { -
src/services/ActivityPubService.js
r72d8eff ra66266d 195 195 following: `${id}/following`, 196 196 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`], 197 201 // AP §5.6: the private blocked collection (owner-only GET). The server 198 202 // list is the source of truth for Shaer's "in Orbit"; clients keep no … … 548 552 ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]), 549 553 ..._mentionCc])], 550 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags ],554 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content)], 551 555 replies: `${id}/replies`, 552 556 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler … … 868 872 orderedItems: items, 869 873 }; 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; 870 989 } 871 990 … … 5418 5537 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, 5419 5538 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured, 5539 buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags, 5420 5540 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 5421 5541 feedCursor, feedChangesSince, waitForFeedChange, -
test/activitypub-as2.test.js
r72d8eff ra66266d 32 32 // ActivityPub §5.6: the private blocked collection (owner-only GET). 33 33 'blocked', 34 // ActivityPub §4.1: supplementary collections on the actor — Klonkt wijst 35 // ermee naar de playlist-lijst (shaer-ayc). 36 'streams', 34 37 // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the 35 38 // sub-keys are the daemon-contract collection names the Shaer clients read.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)