Changeset 060d4a5 in Klonkt


Ignore:
Timestamp:
08/08/2026 10:40:35 PM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
6fab3f3
Parents:
8179fdb
git-author:
Robin <roboburr@…> (08/08/2026 10:40:31 PM)
git-committer:
Robin <roboburr@…> (08/08/2026 10:40:35 PM)
Message:

De muziekkant naar music/ (shaer-drc, stap 2)

src/services/music/index.js 332 regels
ActivityPubService.js 6374 -> 6060 regels

Verhuisd, ongewijzigd: het Audio-object en zijn collecties (buildTrackAudio,
buildTrackCollection, buildTrackCreate, buildPlaylistCollection,
listPlaylistsAP, playlistLinkTags), de zoekopdrachten eromheen
(siteOpenTracks, playlistOpenTracks, openTrack, trackHostPosts), de
licentievertaling en channelCategory.

Dezelfde regel als guardianship: deze map kent alleen db en ap-core en
importeert nooit terug. ActivityPubService importeert hiervandaan en geeft
door wat hij altijd al exporteerde, zodat elke aanroep blijft werken.

WAT ER BEWUST NIET MEE GING: pickLink en timelineFields. Die horen bij de
INKOMENDE kant -- wat een vreemde server ons stuurt -- en niet bij het bouwen
van onze eigen objecten. Dat pickLink toevallig ook over audio-links gaat maakt
hem nog geen muziekcode.

Dit is de plek waar de typering uit shaer-cyg gaat landen: playlist versus
album, afgeleid uit wat er in de post staat. Dat is de reden dat de verhuizing
vooropging -- anders zou die code twee keer verplaatst worden.

Geen gedragsverandering.

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

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r8179fdb r060d4a5  
    3232// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    3333export { AP_CONTEXT, actorId, noteId, guessMediaType };
     34// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
     35// vandaan kwam, zodat elke bestaande aanroep blijft werken.
     36import {
     37  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
     38  buildTrackAudio, buildTrackCollection, buildTrackCreate,
     39  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
     40  licentieUri, channelCategory,
     41} from './music/index.js';
     42export {
     43  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
     44  buildTrackAudio, buildTrackCollection, buildTrackCreate,
     45  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
     46};
    3447
    3548
     
    245258}
    246259
    247 /**
    248  * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
    249  * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
    250  * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
    251  * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
    252  */
    253 export function channelCategory(site) {
    254   try {
    255     // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
    256     // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
    257     // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
    258     // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
    259     // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
    260     // muziekkanaal.
    261     return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
    262   } catch { return null; }
    263 }
    264260
    265261/**
     
    969965}
    970966
    971 /**
    972  * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
    973  *
    974  * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
    975  * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
    976  * ophaalt niet denkt dat er iets nieuws is.
    977  */
    978 export function buildTrackCreate(base, site, r, opts = {}) {
    979   const audio = buildTrackAudio(base, site, r, opts);
    980   const me = actorId(base, site.slug);
    981   return {
    982     '@context': AP_CONTEXT,
    983     id: `${audio.id}#create`,
    984     type: 'Create',
    985     actor: me,
    986     published: audio.published,
    987     to: [PUBLIC],
    988     cc: [`${me}/followers`],
    989     object: audio,
    990   };
    991 }
    992967
    993968/**
     
    10871062// open deel: een eerlijke telling over wat er werkelijk in de collectie staat,
    10881063// niet over wat wij thuis in de kast hebben.
    1089 // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
    1090 const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
    1091      t.position, t.license,
    1092      m.filename, m.storage_path, m.mime_type, m.size`;
    1093 
    1094 export function playlistOpenTracks(playlistId) {
    1095   return db.prepare(
    1096     `SELECT ${TRACK_KOLOMMEN}
    1097      FROM playlist_tracks pt
    1098      JOIN audio_tracks t ON t.id = pt.track_id
    1099      JOIN media m ON m.id = t.media_id
    1100      WHERE pt.playlist_id = ? AND t.fedi_open = 1
    1101      ORDER BY pt.position`
    1102   ).all(playlistId);
    1103 }
    1104 
    1105 /**
    1106  * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
    1107  *
    1108  * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
    1109  * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
    1110  * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
    1111  */
    1112 export function siteOpenTracks(siteId) {
    1113   return db.prepare(
    1114     `SELECT ${TRACK_KOLOMMEN}
    1115      FROM audio_tracks t JOIN media m ON m.id = t.media_id
    1116      WHERE t.site_id = ? AND t.fedi_open = 1
    1117      ORDER BY t.position, t.created_at, t.id`
    1118   ).all(siteId);
    1119 }
    1120 
    1121 export function openTrack(siteId, trackId) {
    1122   return db.prepare(
    1123     `SELECT ${TRACK_KOLOMMEN}
    1124      FROM audio_tracks t JOIN media m ON m.id = t.media_id
    1125      WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
    1126   ).get(siteId, trackId);
    1127 }
    1128 
    1129 /**
    1130  * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
    1131  *
    1132  * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
    1133  * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
    1134  * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
    1135  * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
    1136  * precies zo, per stuk, in Create en Delete.
    1137  *
    1138  * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
    1139  * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
    1140  * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
    1141  * track wordt getoond binnen een post, en een post over vijf nummers is niet de
    1142  * pagina van dit ene nummer. Liever geen link dan een link die iets anders
    1143  * belooft.
    1144  */
    1145 /**
    1146  * Bij welke post hoort een track? (shaer-0nh)
    1147  *
    1148  * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
    1149  * een van drie insluitingen in posts.content. Die relatie stond alleen in die
    1150  * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
    1151  * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
    1152  *
    1153  * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
    1154  * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
    1155  * scans. Nu is het er een, en de map gaat mee als optie.
    1156  *
    1157  * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
    1158  * is specifieker dan via een playlist, en die weer specifieker dan via een
    1159  * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
    1160  * laatst heeft uitgebracht.
    1161  */
    1162 export function trackHostPosts(siteId) {
    1163   const rijen = db.prepare(`
    1164     SELECT tid, post_id, post_slug, rang, wanneer FROM (
    1165       SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
    1166              COALESCE(p.published_at, p.created_at) AS wanneer
    1167         FROM audio_tracks t
    1168         JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
    1169                     AND p.content LIKE '%[[track:' || t.id || ']]%'
    1170        WHERE t.site_id = ? AND t.fedi_open = 1
    1171       UNION ALL
    1172       SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
    1173         FROM playlist_tracks pt
    1174         JOIN audio_tracks t ON t.id = pt.track_id
    1175         JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
    1176                     AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
    1177        WHERE t.site_id = ? AND t.fedi_open = 1
    1178       UNION ALL
    1179       SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
    1180         FROM audio_tracks t
    1181         JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
    1182                     AND p.content LIKE '%[[album:' || t.album || ']]%'
    1183        WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
    1184     ) ORDER BY rang, wanneer DESC
    1185   `).all(siteId, siteId, siteId);
    1186   const uit = new Map();
    1187   for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
    1188   return uit;
    1189 }
    1190 
    1191 export function buildTrackAudio(base, site, r, opts = {}) {
    1192   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
    1193   const fn = r.filename || (r.storage_path || '').split('/').pop();
    1194   // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
    1195   // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
    1196   // Zo doet Funkwhale het ook.
    1197   // De post waar dit nummer in staat. Meegegeven door de collectie (een
    1198   // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
    1199   // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
    1200   const post = opts.hostPosts !== undefined
    1201     ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
    1202     : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
    1203 
    1204   const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
    1205   if (Number(r.size)) bestand.size = Number(r.size);
    1206   // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
    1207   // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
    1208   // de getallen hebben -- liever geen veld dan een verzonnen getal.
    1209   if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
    1210 
    1211   const a = {
    1212     ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
    1213     id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
    1214     type: 'Audio',
    1215     name: r.title || 'Audio',
    1216     attributedTo: actorId(base, site.slug),
    1217     // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
    1218     // track moet zelf kunnen zeggen dat hij openbaar is.
    1219     to: [PUBLIC],
    1220     // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
    1221     // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
    1222     // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
    1223     // horen, en dat is wat zo'n link betekent.
    1224     url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
    1225   };
    1226   if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
    1227   // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
    1228   // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
    1229   // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
    1230   // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
    1231   // van te maken.
    1232   if (post) a.context = noteId(base, post.id);
    1233   if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
    1234   if (r.created_at) a.published = new Date(r.created_at).toISOString();
    1235   if (Number(r.position)) a.position = Number(r.position);
    1236   const lic = licentieUri(r.license);
    1237   if (lic) a.license = lic;
    1238   const art = abs(r.cover_url || opts.coverFallback || null);
    1239   // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
    1240   // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
    1241   if (art) {
    1242     const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
    1243     a.icon = plaat;
    1244     a.image = plaat;
    1245   }
    1246   return a;
    1247 }
    1248 
    1249 /**
    1250  * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
    1251  * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
    1252  * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
    1253  * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
    1254  * een lezer gelooft hem.
    1255  */
    1256 const LICENTIES = {
    1257   'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
    1258   'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
    1259   'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
    1260   'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
    1261   'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
    1262   'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
    1263 };
    1264 export function licentieUri(waarde) {
    1265   const s = String(waarde || '').trim();
    1266   if (!s) return null;
    1267   if (/^https?:\/\//i.test(s)) return safeUrl(s);   // iemand vulde al een URI in
    1268   return LICENTIES[s.toLowerCase()] || null;        // "Alle rechten voorbehouden" heeft er geen
    1269 }
    1270 
    1271 /** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
    1272 export function buildTrackCollection(base, site, rows) {
    1273   return {
    1274     '@context': AP_CONTEXT,
    1275     id: `${actorId(base, site.slug)}/tracks`,
    1276     type: 'OrderedCollection',
    1277     attributedTo: actorId(base, site.slug),
    1278     totalItems: (rows || []).length,
    1279     // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
    1280     orderedItems: (() => {
    1281       const posts = site.id ? trackHostPosts(site.id) : null;
    1282       return (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
    1283     })(),
    1284   };
    1285 }
    1286 
    1287 // Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
    1288 // (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
    1289 // parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
    1290 // client die hem kent haalt de collectie op. Opgelost uit post.content en
    1291 // ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
    1292 // zonder site-check zou een post van site A naar de collectie van site B
    1293 // kunnen wijzen.
    1294 export function playlistLinkTags(base, site, content) {
    1295   const out = [];
    1296   const seen = new Set();
    1297   try {
    1298     for (const m of (content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) {
    1299       if (seen.has(m[1])) continue;
    1300       seen.add(m[1]);
    1301       const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(m[1], site.id);
    1302       if (!pl) continue;
    1303       out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
    1304     }
    1305   } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
    1306   return out;
    1307 }
    1308 
    1309 // De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
    1310 // Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
    1311 // followers/following): een stub per playlist met naam, hoes en de EERLIJKE
    1312 // telling -- totalItems van de stub telt het open deel, dezelfde regel als de
    1313 // collectie zelf, want ook een lijst mag niet verklappen wat er achter de
    1314 // poort staat.
    1315 export function listPlaylistsAP(base, site, enriched) {
    1316   const rows = db.prepare(
    1317     'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
    1318   ).all(site.id);
    1319   const colId = `${actorId(base, site.slug)}/playlists`;
    1320   const items = rows.map((p) => {
    1321     const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
    1322     if (!enriched) return uri;
    1323     const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
    1324     delete stub['@context'];       // genest object draagt de context van zijn omhulsel
    1325     delete stub.orderedItems;      // stub: wie de tracks wil, haalt de collectie op
    1326     return stub;
    1327   });
    1328   return {
    1329     '@context': AP_CONTEXT,
    1330     id: colId,
    1331     type: 'OrderedCollection',
    1332     attributedTo: actorId(base, site.slug),
    1333     totalItems: items.length,
    1334     orderedItems: items,
    1335   };
    1336 }
    1337 
    1338 export function buildPlaylistCollection(base, site, playlist, rows) {
    1339   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
    1340   // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
    1341   // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
    1342   // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
    1343   // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
    1344   // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
    1345   const hostPosts = site.id ? trackHostPosts(site.id) : null;
    1346   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
    1347   const out = {
    1348     '@context': AP_CONTEXT,
    1349     id: `${actorId(base, site.slug)}/playlists/${playlist.id}`,
    1350     type: 'OrderedCollection',
    1351     name: playlist.title,
    1352     attributedTo: actorId(base, site.slug),
    1353     totalItems: items.length,
    1354     orderedItems: items,
    1355   };
    1356   // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
    1357   const parts = [];
    1358   if (playlist.artist) parts.push(playlist.artist);
    1359   if (playlist.year) parts.push(String(playlist.year));
    1360   if (parts.length) out.summary = parts.join(' · ');
    1361   const cover = abs(playlist.cover_url || null);
    1362   if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
    1363   return out;
    1364 }
    13651064
    13661065// ── followers store (lazy stmts) ──────────────────────────────────
Note: See TracChangeset for help on using the changeset viewer.