Changeset a5d14c7 in Klonkt
- Timestamp:
- 08/09/2026 08:20:24 AM (4 weeks ago)
- Branches:
- main
- Children:
- 0677bac
- Parents:
- b2adc20
- Files:
-
- 1 added
- 4 edited
-
src/routes/activitypub.js (modified) (1 diff)
-
src/services/ActivityPubService.js (modified) (4 diffs)
-
src/services/music/index.js (modified) (3 diffs)
-
test/ap-playlist-discovery.test.js (modified) (1 diff)
-
test/music-uitgave.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
rb2adc20 ra5d14c7 784 784 }); 785 785 786 // De losse tracks van een post als EEN uitgave (shaer-38y). Ze gingen tot nu 787 // toe los de deur uit -- Audio-objecten die een lezer nergens kon plaatsen. Ze 788 // horen bij elkaar omdat ze in dezelfde post staan, en die post leent zijn 789 // titel, tekst, hoes en tags uit. 404 als de post geen muzikale eenheid IS: 790 // dan is er niets om naar te wijzen, en dat is geen lege collectie maar een 791 // collectie die niet bestaat. 792 router.get('/ap/users/:slug/posts/:id/tracks', (req, res) => { 793 const site = publicSite(req.params.slug); 794 if (!site) return res.status(404).end(); 795 const post = db.prepare( 796 "SELECT id, slug, title, excerpt, content, cover_image_url, tags FROM posts WHERE id = ? AND site_id = ? AND status = 'published'" 797 ).get(req.params.id, site.id); 798 if (!post) return res.status(404).end(); 799 const col = AP.buildPostTrackCollection(baseUrl(req), site, post); 800 if (!col) return res.status(404).end(); 801 AP.sendAP(res, col); 802 }); 803 786 804 router.get('/ap/users/:slug/playlists/:id', (req, res) => { 787 805 const site = publicSite(req.params.slug); -
src/services/ActivityPubService.js
rb2adc20 ra5d14c7 38 38 buildTrackAudio, buildTrackCollection, buildTrackCreate, 39 39 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, 40 buildPostTrackCollection, uitgavePost, 40 41 licentieUri, channelCategory, 41 42 } from './music/index.js'; … … 44 45 buildTrackAudio, buildTrackCollection, buildTrackCreate, 45 46 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri, 47 buildPostTrackCollection, uitgavePost, 46 48 }; 47 49 … … 697 699 ...((post.fan_only || post.ap_visibility === 'quiet') ? [] : [`${aId}/followers`]), 698 700 ..._mentionCc])], 699 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content )],701 tag: [...buildHashtagList(base, post.tags, body), ..._mentionTags, ...playlistLinkTags(base, site, post.content, post)], 700 702 replies: `${id}/replies`, 701 703 // NSFW → Mastodon-style content warning: sensitive (blurs media) + a summary/spoiler … … 6108 6110 siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection, buildTrackCreate, trackHostPosts, 6109 6111 buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags, 6112 buildPostTrackCollection, uitgavePost, 6110 6113 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 6111 6114 feedCursor, feedChangesSince, waitForFeedChange, -
src/services/music/index.js
rb2adc20 ra5d14c7 15 15 import db from '../../config/database.js'; 16 16 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType } from '../ap-core.js'; 17 import { afleidenUitInsluitingen } from '../../assets/js/shared/post-music-type.js';17 import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js'; 18 18 19 19 // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes. … … 222 222 // zonder site-check zou een post van site A naar de collectie van site B 223 223 // kunnen wijzen. 224 export function playlistLinkTags(base, site, content ) {224 export function playlistLinkTags(base, site, content, post = null) { 225 225 const out = []; 226 const seen = new Set();227 226 try { 228 for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) { 229 if (seen.has(m[1])) continue; 230 seen.add(m[1]); 231 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id); 227 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit, 228 // krijgt ook geen link. Dit stond hier met een eigen patroon dat 229 // underscores accepteerde die nergens anders meetellen. 230 for (const id of ingeslotenPlaylists(content)) { 231 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id); 232 232 if (!pl) continue; 233 233 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title }); 234 } 235 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een 236 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze 237 // link zou die collectie bestaan maar door niemand te vinden zijn. 238 if (post && post.id) { 239 const eenheid = postMusicType(content, site.id); 240 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) { 241 out.push({ 242 type: 'Link', 243 href: postTracksId(base, site, post.id), 244 mediaType: 'application/activity+json', 245 name: post.title || 'Tracks', 246 }); 247 } 234 248 } 235 249 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ } … … 291 305 const cover = abs(playlist.cover_url || null); 292 306 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover }; 293 return out; 307 return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id)); 308 } 309 310 // ── De post als uitgave (shaer-38y) ─────────────────────────────────────── 311 312 /** Het AS2-id van de collectie losse tracks van een post. */ 313 function postTracksId(base, site, postId) { 314 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`; 315 } 316 317 /** 318 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen? 319 * 320 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN 321 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de 322 * post niet meer de drager van een identiteit en houdt de playlist de zijne -- 323 * dezelfde regel als in de afleiding, hier alleen toegepast. 324 * 325 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is 326 * uitgebracht. 327 */ 328 export function uitgavePost(siteId, playlistId) { 329 if (!siteId || !playlistId) return null; 330 try { 331 const rijen = db.prepare(` 332 SELECT id, slug, title, excerpt, content, cover_image_url, tags 333 FROM posts 334 WHERE site_id = ? AND status = 'published' 335 AND content LIKE '%[[playlist:' || ? || ']]%' 336 ORDER BY COALESCE(published_at, created_at) DESC 337 `).all(siteId, playlistId); 338 for (const p of rijen) { 339 const r = postMusicType(p.content, siteId); 340 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p; 341 } 342 } catch { /* geen lening is geen fout */ } 343 return null; 344 } 345 346 /** 347 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3). 348 * 349 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms 350 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum. 351 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem 352 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige 353 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat 354 * hij nu krijgt. 355 * 356 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs` 357 * staan, zodat de eigen naam terug te vinden is als hij afwijkt. 358 */ 359 function leenVanPost(base, site, obj, post) { 360 if (!post) return obj; 361 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`); 362 363 if (post.title) { 364 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name; 365 obj.name = post.title; 366 } 367 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte 368 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary 369 // staan -- dat is een samenvatting, en de posttekst is dat niet. 370 const tekst = (post.excerpt || '').trim(); 371 if (tekst) obj.content = tekst; 372 373 const cover = abs(post.cover_image_url || null); 374 if (cover) { 375 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover }; 376 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post 377 } 378 379 const tags = hashtagsVanPost(base, post.tags); 380 if (tags.length) obj.tag = tags; 381 382 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een 383 // losse track: url wijst een mens naar de post, context zegt waar dit object 384 // thuishoort. 385 obj.url = `${base}/${post.slug}`; 386 obj.context = noteId(base, post.id); 387 return obj; 388 } 389 390 /** De tags van een post als AS2 Hashtags. Zelfde vorm als buildHashtagList. */ 391 function hashtagsVanPost(base, tagsField) { 392 const ruw = Array.isArray(tagsField) 393 ? tagsField 394 : String(tagsField || '').split(','); 395 const uit = [], gezien = new Set(); 396 for (const t of ruw) { 397 const label = String(t || '').trim().replace(/^#/, ''); 398 if (!label) continue; 399 const slug = label.toLowerCase().replace(/\s+/g, '-'); 400 if (gezien.has(slug)) continue; 401 gezien.add(slug); 402 uit.push({ type: 'Hashtag', href: `${base}/tag/${encodeURIComponent(slug)}`, name: '#' + label }); 403 } 404 return uit; 405 } 406 407 /** De open tracks uit een lijst ids, in de volgorde van die lijst. */ 408 function losseTracksVanPost(siteId, ids) { 409 if (!siteId || !ids?.length) return []; 410 const gaten = ids.map(() => '?').join(','); 411 const rijen = db.prepare( 412 `SELECT ${TRACK_KOLOMMEN} 413 FROM audio_tracks t JOIN media m ON m.id = t.media_id 414 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})` 415 ).all(siteId, ...ids); 416 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals 417 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn. 418 const opId = new Map(rijen.map((r) => [r.id, r])); 419 return ids.map((id) => opId.get(id)).filter(Boolean); 420 } 421 422 /** 423 * De losse tracks van een post als EEN uitgave (shaer-38y). 424 * 425 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer 426 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en 427 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die 428 * heeft ze wel en de losse tracks niet. 429 * 430 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een 431 * post die geen enkele muzikale eenheid IS. 432 */ 433 export function buildPostTrackCollection(base, site, post) { 434 if (!post || !post.id) return null; 435 const eenheid = postMusicType(post.content, site.id); 436 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null; 437 438 const rows = losseTracksVanPost(site.id, eenheid.tracks); 439 if (!rows.length) return null; 440 441 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }])); 442 const out = { 443 '@context': AP_CONTEXT, 444 id: postTracksId(base, site, post.id), 445 type: 'OrderedCollection', 446 attributedTo: actorId(base, site.slug), 447 totalItems: rows.length, 448 orderedItems: rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })), 449 }; 450 return leenVanPost(base, site, out, post); 294 451 } 295 452 -
test/ap-playlist-discovery.test.js
rb2adc20 ra5d14c7 108 108 }); 109 109 110 test('een post zonder playlist heeft geen Link-tags', () => { 110 test('losse tracks in een post krijgen sinds shaer-38y wel een Link-tag', () => { 111 // Dit testte eerder dat er GEEN Link-tag was. Dat klopte zolang alleen een 112 // playlist een collectie had; nu zijn losse tracks in een post ook een 113 // uitgave, met een eigen collectie. Zonder deze link bestaat die wel maar 114 // vindt niemand hem -- dus de verwachting is omgedraaid, niet het gedrag 115 // stukgegaan. 111 116 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get(); 112 117 const note = AP.buildNote('https://test.example', site, { id: 'p3', slug: 'p3', title: 'Los', content: 'Gewoon tekst met [[track:t1]]', status: 'published' }); 118 const links = (note.tag || []).filter((t) => t.type === 'Link'); 119 assert.equal(links.length, 1); 120 assert.equal(links[0].href, 'https://test.example/ap/users/band/posts/p3/tracks'); 121 }); 122 123 test('en een post met alleen tekst nog steeds geen enkele Link-tag', () => { 124 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get(); 125 const note = AP.buildNote('https://test.example', site, { id: 'p4', slug: 'p4', title: 'Niets', content: 'Gewoon tekst', status: 'published' }); 126 assert.equal((note.tag || []).filter((t) => t.type === 'Link').length, 0); 127 }); 128 129 test('een post met alleen een DICHTE track ook niet -- geen lege uitgave', () => { 130 const site = db.prepare("SELECT * FROM sites WHERE id = 's1'").get(); 131 const note = AP.buildNote('https://test.example', site, { id: 'p5', slug: 'p5', title: 'Dicht', content: 'Alleen [[track:t2]]', status: 'published' }); 113 132 assert.equal((note.tag || []).filter((t) => t.type === 'Link').length, 0); 114 133 });
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)