Ignore:
Timestamp:
08/08/2026 04:50:48 AM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
fb22f78
Parents:
bdcb3a3
git-author:
Robin <roboburr@…> (08/08/2026 04:47:48 AM)
git-committer:
Robin <roboburr@…> (08/08/2026 04:50:48 AM)
Message:

Onze tracks zijn eersterangs Audio-objecten (shaer-0nh, stap 3)

De spiegel van wat sinds bdcb3a3 inkomend werkt. Onze muziek zat alleen als
naamloze bijlage in een Note: iets dat bestaat zolang je het omhullende object
vasthoudt. Nu is een track een DING met een eigen id, los op te halen, en in
twee playlists hetzelfde ding.

DE ACTOR-COLLECTIE IS DE KANONIEKE PLEK, niet de playlist (Robins vraag). Een
playlist is een keuze uit wat de artiest heeft uitgebracht; de collectie is dat
geheel. Een track die in geen enkele playlist stond was tot nu toe onzichtbaar
voor de federatie -- die staat er nu wel in.

GET /ap/users/:slug/tracks alles wat opengezet is
GET /ap/users/:slug/tracks/:id een track, met eigen @context
streams tracks eerst, dan playlists

url wordt een Link-array: de mediaType hoort bij de link, niet bij het
object. Zo doet Funkwhale het, en zo leest onze eigen inbox het sinds bdcb3a3 --
een andere Klonkt kan onze tracks dus als track opnemen in plaats van als
bijlage.

GEEN text/html-link erin. Klonkt heeft geen trackpagina: een track wordt getoond
binnen een post, en een post over vijf nummers is niet de pagina van dit ene
nummer. Liever geen link dan een link die iets anders belooft.

De poortregel loopt door: een gesloten track geeft 404, dezelfde "afwezig, niet
leeg" als in de collectie, zodat het bestaan van een gated nummer niet uit een
ander antwoord valt af te leiden.

ONDERWEG OPGERUIMD. De mediaType-afleiding stond twee keer functie-lokaal, met
een commentaar dat ze "dezelfde afleiding" waren. Dat was niet zo: de ene kende
video, de andere alleen beeld. Nu een kaart, op moduleniveau, met de volledige
map -- gedragsgelijk voor buildNote en een superset voor de playlist.

7 tests.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rbdcb3a3 r39a9d21  
    179179// ── document builders ─────────────────────────────────────────────
    180180export function actorId(base, slug) { return `${base}/ap/users/${encodeURIComponent(slug)}`; }
     181
     182/**
     183 * mediaType raden uit een bestandsnaam. Stond twee keer functie-lokaal in dit
     184 * bestand, met een commentaar dat ze "dezelfde afleiding" waren -- en dat was
     185 * niet zo: de ene kende video, de andere alleen beeld. Nu een kaart, hier.
     186 * De terugval is image/jpeg omdat dit alleen op omslagen en bijlagen wordt
     187 * losgelaten, nooit op geluid: dat draagt zijn eigen mime_type uit de database.
     188 */
     189export function guessMediaType(u) {
     190  const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
     191  return ({
     192    jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
     193    webp: 'image/webp', avif: 'image/avif',
     194    mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
     195  })[(e || '').toLowerCase()] || 'image/jpeg';
     196}
    181197export function noteId(base, postId) { return `${base}/ap/notes/${encodeURIComponent(postId)}`; }
    182198
     
    311327    // interest" -- precies wat de playlist-lijst is (shaer-ayc, stap 2).
    312328    // Geen eigen vocabulaire nodig, en wie het niet kent negeert het.
    313     streams: [`${id}/playlists`],
     329    streams: [`${id}/tracks`, `${id}/playlists`],
    314330    // AP §5.6: the private blocked collection (owner-only GET). The server
    315331    // list is the source of truth for Shaer's "in Orbit"; clients keep no
     
    483499  // to avoid duplicate rendering on clients that DO keep them.
    484500  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
    485   const mediaType = (u) => {
    486     const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
    487     return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime' })[(e || '').toLowerCase()] || 'image/jpeg';
    488   };
    489501  const hadAudio = /\[\[(track|album|playlist):/i.test(post.content || '');
    490502  const playable = hasPlayableAudio(post.content || '', site && site.id);
     
    580592      // Mastodon renders it as the artwork thumbnail on its native audio player.
    581593      const art = abs(r.cover_url || post.cover_image_url || null);
    582       if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
     594      if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
    583595      openAudio.push(a);
    584596    };
     
    635647  const attachment = urls.filter((x) => x && x.url)
    636648    .filter((x) => { if (seen.has(x.url)) return false; seen.add(x.url); return true; })
    637     .map((x) => { const mt = x.mt || mediaType(x.url); // the stored type wins; the extension map is the fallback
     649    .map((x) => { const mt = x.mt || guessMediaType(x.url); // the stored type wins; the extension map is the fallback
    638650      const ty = /^image\//i.test(mt) ? 'Image' : /^video\//i.test(mt) ? 'Video' : /^audio\//i.test(mt) ? 'Audio' : 'Document';
    639651      const a = { type: ty, mediaType: mt, url: x.url };
     
    684696  if (post.cover_image_url && noImages) {
    685697    const cov = abs(post.cover_image_url);
    686     if (cov) { note.image = { type: 'Image', mediaType: mediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
     698    if (cov) { note.image = { type: 'Image', mediaType: guessMediaType(cov), url: cov }; if (post.cover_alt) note.image.name = String(post.cover_alt).slice(0, 1500); }
    687699  }
    688700  // Experiment (mirrors PeerTube / schema.org `embedUrl`): point at the GATED player page
     
    10001012// open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
    10011013// niet over wat wij thuis in de kast hebben.
     1014const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
     1015     m.filename, m.storage_path, m.mime_type`;
     1016
    10021017export function playlistOpenTracks(playlistId) {
    10031018  return db.prepare(
    1004     `SELECT t.title, t.artist, t.duration, t.cover_url, m.filename, m.storage_path, m.mime_type
     1019    `SELECT ${TRACK_KOLOMMEN}
    10051020     FROM playlist_tracks pt
    10061021     JOIN audio_tracks t ON t.id = pt.track_id
     
    10091024     ORDER BY pt.position`
    10101025  ).all(playlistId);
     1026}
     1027
     1028/**
     1029 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
     1030 *
     1031 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
     1032 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
     1033 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
     1034 */
     1035export function siteOpenTracks(siteId) {
     1036  return db.prepare(
     1037    `SELECT ${TRACK_KOLOMMEN}
     1038     FROM audio_tracks t JOIN media m ON m.id = t.media_id
     1039     WHERE t.site_id = ? AND t.fedi_open = 1
     1040     ORDER BY t.position, t.created_at, t.id`
     1041  ).all(siteId);
     1042}
     1043
     1044export function openTrack(siteId, trackId) {
     1045  return db.prepare(
     1046    `SELECT ${TRACK_KOLOMMEN}
     1047     FROM audio_tracks t JOIN media m ON m.id = t.media_id
     1048     WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
     1049  ).get(siteId, trackId);
     1050}
     1051
     1052/**
     1053 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
     1054 *
     1055 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
     1056 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
     1057 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
     1058 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
     1059 * precies zo, per stuk, in Create en Delete.
     1060 *
     1061 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
     1062 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
     1063 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
     1064 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
     1065 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
     1066 * belooft.
     1067 */
     1068export function buildTrackAudio(base, site, r, opts = {}) {
     1069  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     1070  const fn = r.filename || (r.storage_path || '').split('/').pop();
     1071  const a = {
     1072    ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
     1073    id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
     1074    type: 'Audio',
     1075    name: r.title || 'Audio',
     1076    attributedTo: actorId(base, site.slug),
     1077    url: [{ type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' }],
     1078  };
     1079  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
     1080  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
     1081  if (r.created_at) a.published = new Date(r.created_at).toISOString();
     1082  const art = abs(r.cover_url || opts.coverFallback || null);
     1083  if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
     1084  return a;
     1085}
     1086
     1087/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
     1088export function buildTrackCollection(base, site, rows) {
     1089  return {
     1090    '@context': AP_CONTEXT,
     1091    id: `${actorId(base, site.slug)}/tracks`,
     1092    type: 'OrderedCollection',
     1093    attributedTo: actorId(base, site.slug),
     1094    totalItems: (rows || []).length,
     1095    orderedItems: (rows || []).map((r) => buildTrackAudio(base, site, r)),
     1096  };
    10111097}
    10121098
     
    10641150export function buildPlaylistCollection(base, site, playlist, rows) {
    10651151  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
    1066   // Zelfde afleiding als in buildNote; die daar is functie-lokaal.
    1067   const mediaType = (u) => {
    1068     const e = ((u || '').split('?')[0].match(/\.(\w+)$/) || [])[1];
    1069     return ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif' })[(e || '').toLowerCase()] || 'image/jpeg';
    1070   };
    1071   const items = (rows || []).map((r) => {
    1072     const fn = r.filename || (r.storage_path || '').split('/').pop();
    1073     const a = {
    1074       type: 'Audio',
    1075       mediaType: r.mime_type || 'audio/mpeg',
    1076       url: `${base}/audio/stream/${encodeURIComponent(fn)}`,
    1077       name: r.title || 'Audio',
    1078     };
    1079     if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
    1080     if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
    1081     const art = abs(r.cover_url || playlist.cover_url || null);
    1082     if (art) a.icon = { type: 'Image', mediaType: mediaType(art), url: art };
    1083     return a;
    1084   });
     1152  // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
     1153  // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
     1154  // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
     1155  // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
     1156  // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
     1157  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null }));
    10851158  const out = {
    10861159    '@context': AP_CONTEXT,
     
    10981171  if (parts.length) out.summary = parts.join(' · ');
    10991172  const cover = abs(playlist.cover_url || null);
    1100   if (cover) out.icon = { type: 'Image', mediaType: mediaType(cover), url: cover };
     1173  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
    11011174  return out;
    11021175}
     
    58955968  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions,
    58965969  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, buildFollowing, buildFeatured,
    5897   channelUrls, timelineFields,
     5970  channelUrls, timelineFields, guessMediaType,
     5971  siteOpenTracks, openTrack, buildTrackAudio, buildTrackCollection,
    58985972  buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
    58995973  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
Note: See TracChangeset for help on using the changeset viewer.