Changeset 060d4a5 in Klonkt
- Timestamp:
- 08/08/2026 10:40:35 PM (4 weeks ago)
- 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)
- Location:
- src/services
- Files:
-
- 1 added
- 1 edited
-
ActivityPubService.js (modified) (4 diffs)
-
music/index.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r8179fdb r060d4a5 32 32 // Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken. 33 33 export { 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. 36 import { 37 playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts, 38 buildTrackAudio, buildTrackCollection, buildTrackCreate, 39 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, 40 licentieUri, channelCategory, 41 } from './music/index.js'; 42 export { 43 playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts, 44 buildTrackAudio, buildTrackCollection, buildTrackCreate, 45 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri, 46 }; 34 47 35 48 … … 245 258 } 246 259 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 als250 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in251 * 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 een256 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch257 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we258 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes259 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen260 // 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 }264 260 265 261 /** … … 969 965 } 970 966 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 track975 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer976 * 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 }992 967 993 968 /** … … 1087 1062 // open deel: een eerlijke telling over wat er werkelijk in de collectie staat, 1088 1063 // 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 pt1098 JOIN audio_tracks t ON t.id = pt.track_id1099 JOIN media m ON m.id = t.media_id1100 WHERE pt.playlist_id = ? AND t.fedi_open = 11101 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 is1109 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit1110 * 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_id1116 WHERE t.site_id = ? AND t.fedi_open = 11117 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_id1125 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 bijlage1133 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het1134 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee1135 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten1136 * precies zo, per stuk, in Create en Delete.1137 *1138 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen1139 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het1140 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een1141 * track wordt getoond binnen een post, en een post over vijf nummers is niet de1142 * pagina van dit ene nummer. Liever geen link dan een link die iets anders1143 * 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, via1149 * een van drie insluitingen in posts.content. Die relatie stond alleen in die1150 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox1151 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.1152 *1153 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open1154 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd1155 * 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 ingesloten1158 * is specifieker dan via een playlist, en die weer specifieker dan via een1159 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het1160 * 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 wanneer1167 FROM audio_tracks t1168 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 = 11171 UNION ALL1172 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)1173 FROM playlist_tracks pt1174 JOIN audio_tracks t ON t.id = pt.track_id1175 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 = 11178 UNION ALL1179 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)1180 FROM audio_tracks t1181 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 DESC1185 `).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 ene1195 // 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 (een1198 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt1199 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.1200 const post = opts.hostPosts !== undefined1201 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null1202 : ((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 IS1207 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei1208 // 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 opgehaalde1218 // track moet zelf kunnen zeggen dat hij openbaar is.1219 to: [PUBLIC],1220 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies1221 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten1222 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt1223 // 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 vocab1227 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een1228 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu1229 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer1230 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart1231 // 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; Funkwhale1240 // 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 rechten1251 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die1252 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het1253 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want1254 * 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 in1268 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen1269 }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 collectie1288 // (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon1289 // parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een1290 // client die hem kent haalt de collectie op. Opgelost uit post.content en1291 // ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus1292 // zonder site-check zou een post van site A naar de collectie van site B1293 // 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 als1311 // followers/following): een stub per playlist met naam, hoes en de EERLIJKE1312 // telling -- totalItems van de stub telt het open deel, dezelfde regel als de1313 // collectie zelf, want ook een lijst mag niet verklappen wat er achter de1314 // 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 omhulsel1325 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op1326 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 het1343 // 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 }1365 1064 1366 1065 // ── followers store (lazy stmts) ──────────────────────────────────
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)