Changeset 6c4ff7e in Klonkt for src/services
- Timestamp:
- 08/13/2026 11:39:57 PM (4 weeks ago)
- Branches:
- main
- Children:
- 9529d7b
- Parents:
- fbfd7a1
- Location:
- src/services
- Files:
-
- 5 edited
-
ActivityPubService.js (modified) (2 diffs)
-
ArchiveExportService.js (modified) (7 diffs)
-
ArchiveImportService.js (modified) (7 diffs)
-
MigrationService.js (modified) (5 diffs)
-
music/index.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
rfbfd7a1 r6c4ff7e 5416 5416 * (request-target) host date, the set verifyRequest checks. 5417 5417 */ 5418 /** 5419 * De handtekening-headers voor een GET als `slug`. Losgetrokken uit 5420 * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580: 5421 * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch 5422 * krijgt daar terecht een 403). 5423 */ 5424 export function signedGetHeaders(slug, url, accept = 'application/activity+json') { 5425 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 5426 if (!base || !slug) return null; 5427 const me = actorId(base, slug); 5428 const keys = getOrCreateKeys(slug); 5429 const u = new URL(url); 5430 const date = new Date().toUTCString(); 5431 const target = `${u.pathname}${u.search || ''}`; 5432 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`; 5433 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64'); 5434 return { 5435 Accept: accept, 5436 Date: date, 5437 Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`, 5438 }; 5439 } 5440 5418 5441 export async function signedGetJson(slug, url, onStatus) { 5419 5442 try { 5420 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 5421 if (!base || !slug) return apGetJson(url); 5422 const me = actorId(base, slug); 5423 const keys = getOrCreateKeys(slug); 5424 const u = new URL(url); 5425 const date = new Date().toUTCString(); 5426 const target = `${u.pathname}${u.search || ''}`; 5427 const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`; 5428 const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64'); 5429 const sig = `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`; 5430 const r = await safeFetch(url, { headers: { Accept: 'application/activity+json', Date: date, Signature: sig } }); 5443 const headers = signedGetHeaders(slug, url); 5444 if (!headers) return apGetJson(url); 5445 const r = await safeFetch(url, { headers }); 5431 5446 // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte 5432 5447 // niet", en dat is te weinig om een WEIGERING van een STORING te … … 6757 6772 // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij 6758 6773 // ontbreekt (precies hoe movedLock zich een dag eerder verstopte). 6759 isMoveTarget, signedGetJson, 6774 isMoveTarget, signedGetJson, signedGetHeaders, 6760 6775 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection, 6761 6776 deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE, -
src/services/ArchiveExportService.js
rfbfd7a1 r6c4ff7e 19 19 import crypto from 'crypto'; 20 20 import db from '../config/database.js'; 21 import { MEDIA_ROOT } from '../config/paths.js'; 22 23 export const FORMAT_VERSION = 1; 21 import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js'; 22 23 // v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat 24 // BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief 25 // droeg alleen bestanden onder media/. De exporter rekende er met path.relative 26 // een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er 27 // stond dus wel een track in de database van de nieuwe site, maar nooit een 28 // bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in 29 // plaats van alleen wat in een bericht staat, en neemt de playlists mee. 30 export const FORMAT_VERSION = 2; 24 31 25 32 /** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */ … … 122 129 } 123 130 } catch { /* kapotte kolom blokkeert de export niet */ } 124 // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder. 125 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { 126 try { 127 const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]); 128 if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track'); 129 } catch { /* geen audio-tabellen: niets te doen */ } 130 } 131 // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat 132 // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende 133 // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit 134 // aankomen: de importer weigert zo'n pad, terecht. 131 135 return uit; 132 136 } … … 141 145 * herstel op niets terug. 142 146 */ 143 function audioOf(post, a ttachments) {147 function audioOf(post, audioKaart) { 144 148 const uit = []; 145 149 for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { 146 150 try { 147 const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);151 const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]); 148 152 if (!t) continue; 149 let bestand; 150 if (t.storage_path) { 151 const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`; 152 const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel)); 153 bestand = bij ? bij.url : undefined; 154 } 153 // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in 154 // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een 155 // bijlage te wijzen die er niet is. 156 const bestand = audioKaart.get(t.id) || undefined; 155 157 uit.push({ 156 158 'shaer:ref': `[[track:${t.id}]]`, … … 165 167 } 166 168 169 /** 170 * De HELE audiobibliotheek, plus de playlists. 171 * 172 * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op 173 * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen 174 * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen 175 * verhuizing. 176 * 177 * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de 178 * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139 179 * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon 180 * door, en de export liet ze weg zonder dat iemand het merkte. 181 * 182 * @returns {Map<string,string>} trackId -> pad in het archief 183 */ 184 function audioBibliotheek(site, bestanden, tellingen, ontbrekend) { 185 const kaart = new Map(); 186 let tracks = []; 187 try { 188 tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t 189 LEFT JOIN media m ON m.id = t.media_id 190 WHERE t.site_id = ? 191 ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id); 192 } catch { return kaart; } // installatie zonder audio-tabellen 193 if (!tracks.length) return kaart; 194 195 const items = []; 196 for (const t of tracks) { 197 const schijf = resolveAudioPath(t.storage_path, fs); 198 let naam = null; 199 let hash = null; 200 if (schijf) { 201 try { 202 const bytes = fs.readFileSync(schijf); 203 hash = sha256(bytes); 204 const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase(); 205 naam = `audio/${hash}.${ext}`; 206 if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; } 207 kaart.set(t.id, naam); 208 } catch { naam = null; } // onleesbaar telt als ontbrekend, niet als stilte 209 } 210 if (!naam) { 211 tellingen.audioMissing += 1; 212 ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' }); 213 } 214 items.push({ 215 id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined, 216 duration: t.duration || undefined, position: t.position ?? undefined, 217 credit: t.credit || undefined, license: t.license || undefined, 218 'shaer:coverUrl': t.cover_url || undefined, 219 'shaer:downloadable': t.downloadable ? 1 : 0, 220 'shaer:fediOpen': t.fedi_open ? 1 : 0, 221 'shaer:mediaType': t.mime_type || 'audio/mpeg', 222 'shaer:file': naam || undefined, 223 'shaer:sha256': hash || undefined, 224 // Derde staat, net als bij media: we weten DAT het bestond en waar het 225 // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop 226 // kunnen weigeren in plaats van een track zonder bestand aan te maken. 227 'shaer:availability': naam ? 'included' : 'missing', 228 'shaer:originalPath': naam ? undefined : (t.storage_path || undefined), 229 url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean), 230 }); 231 } 232 bestanden.set('tracks.json', Buffer.from(stableJson({ 233 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }], 234 type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items, 235 }), 'utf8')); 236 tellingen.tracks = items.length; 237 238 // Playlists: de volgorde IS de playlist, dus die moet expliciet mee. 239 try { 240 const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id); 241 if (pls.length) { 242 const lijst = pls.map((p) => ({ 243 id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined, 244 'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined, 245 'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position') 246 .all(p.id).map((r) => ({ id: r.track_id, position: r.position })), 247 })); 248 bestanden.set('playlists.json', Buffer.from(stableJson({ 249 '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }], 250 type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst, 251 }), 'utf8')); 252 tellingen.playlists = lijst.length; 253 } 254 } catch { /* geen playlist-tabellen */ } 255 256 return kaart; 257 } 258 167 259 /** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */ 168 function postObject(post, site, origin, attachments ) {260 function postObject(post, site, origin, attachments, audioKaart) { 169 261 const heeftTitel = !!(post.title && String(post.title).trim()); 170 262 const published = toISO(post.published_at || post.created_at) || toISO(post.created_at); … … 220 312 'shaer:coverAlt': post.cover_alt || undefined, 221 313 'shaer:viewCount': post.view_count || undefined, 222 'shaer:audio': audioOf(post, a ttachments),314 'shaer:audio': audioOf(post, audioKaart), 223 315 }; 224 316 } … … 362 454 363 455 const bestanden = new Map(); // pad -> Buffer 364 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };456 const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 }; 365 457 const ontbrekend = []; // voor de rapportage van de beller 458 459 // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de 460 // kaart moet klaar zijn voor de eerste post gebouwd wordt. 461 const audioKaart = audioBibliotheek(site, bestanden, tellingen, ontbrekend); 366 462 367 463 // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel … … 404 500 } 405 501 406 const obj = postObject(post, site, origin, attachments );502 const obj = postObject(post, site, origin, attachments, audioKaart); 407 503 bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8')); 408 504 bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8')); -
src/services/ArchiveImportService.js
rfbfd7a1 r6c4ff7e 24 24 import { randomUUID } from 'crypto'; 25 25 import db from '../config/database.js'; 26 import { MEDIA_ROOT } from '../config/paths.js';26 import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js'; 27 27 import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js'; 28 28 import * as Migration from './MigrationService.js'; … … 120 120 121 121 /** 122 * De audiobibliotheek terugzetten (formaat v2). 123 * 124 * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt 125 * vanzelfsprekend en was het niet. De oude per-post-tak maakte een 126 * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op 127 * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den 128 * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de 129 * verhuizing gelukt is, dus je gooit de oude instantie weg. 130 * 131 * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort 132 * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js). 133 * 134 * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit 135 * (en bij een droogloop dus niet, maar het verslag klopt wel) 136 */ 137 function tracksTerug(files, site, rapport) { 138 const buf = files.get('tracks.json'); 139 if (!buf) return []; 140 let coll; 141 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; } 142 if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; } 143 144 const werk = []; 145 for (const t of (coll.orderedItems || [])) { 146 const id = String(t.id || '').trim(); 147 if (!id) continue; 148 const bestand = t['shaer:file']; 149 const bytes = bestand ? files.get(bestand) : null; 150 if (!bytes || !bytes.length) { 151 rapport.tracksMissing += 1; 152 rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`); 153 continue; // de hele regel van deze functie 154 } 155 // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler 156 // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt. 157 const naam = path.basename(String(bestand)); 158 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) { 159 rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`); 160 continue; 161 } 162 werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam }); 163 rapport.tracks += 1; 164 } 165 return werk; 166 } 167 168 /** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */ 169 function playlistsTerug(files, site, rapport, bekendeTracks) { 170 const buf = files.get('playlists.json'); 171 if (!buf) return []; 172 let coll; 173 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; } 174 if (coll['shaer:archive'] !== true) return []; 175 const werk = []; 176 for (const p of (coll.orderedItems || [])) { 177 const id = String(p.id || '').trim(); 178 if (!id) continue; 179 // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er 180 // straks een playlist vol gaten die niemand kan afspelen. 181 const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id))); 182 const kwijt = (p['shaer:tracks'] || []).length - items.length; 183 if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`); 184 werk.push({ soort: 'playlist', p, id, items }); 185 rapport.playlists += 1; 186 } 187 return werk; 188 } 189 190 /** 122 191 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief. 123 192 * … … 167 236 posts: 0, overgeslagen: 0, overschreven: 0, 168 237 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [], 238 tracks: 0, tracksMissing: 0, playlists: 0, 169 239 }; 170 240 … … 275 345 } 276 346 } 347 348 // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want 349 // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit. 350 const trackWerk = tracksTerug(files, site, rapport); 351 const bekendeTracks = new Set(trackWerk.map((w) => w.id)); 352 const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks); 277 353 278 354 if (opts.dryRun) return rapport; … … 292 368 293 369 db.transaction(() => { 294 for (const s of schrijf) {370 for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) { 295 371 if (s.soort === 'media') { 296 372 fs.mkdirSync(path.dirname(s.doel), { recursive: true }); … … 301 377 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null, 302 378 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null); 379 continue; 380 } 381 if (s.soort === 'track') { 382 // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en 383 // rolt de hele transactie terug: liever geen import dan een track zonder 384 // geluid, want dat is precies de val waar dit uit voortkomt. 385 fs.mkdirSync(path.dirname(s.doel), { recursive: true }); 386 fs.writeFileSync(s.doel, s.bytes); 387 const mediaId = randomUUID(); 388 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)') 389 .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel); 390 const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null; 391 db.prepare(`INSERT OR REPLACE INTO audio_tracks 392 (id, site_id, title, artist, album, duration, media_id, position, credit, license, 393 cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud) 394 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) 395 .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null, 396 s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null, 397 s.t['shaer:coverUrl'] || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0, 398 link('spotify'), link('youtube'), link('soundcloud')); 399 continue; 400 } 401 if (s.soort === 'playlist') { 402 db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind) 403 VALUES (?,?,?,?,?,?,?)`) 404 .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null, 405 s.p['shaer:coverUrl'] || null, s.p['shaer:kind'] || null); 406 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id); 407 const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)'); 408 s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i)); 303 409 continue; 304 410 } … … 338 444 view_count: o['shaer:viewCount'] || 0, 339 445 }); 340 // Gehoste audio terug: [[track:]] in de content valt anders op niets terug. 341 for (const t of (o['shaer:audio'] || [])) { 342 const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, ''); 343 if (!trackId) continue; 344 let mediaId = null; 345 const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']); 346 const doel = bij && veiligMediaPad(bestemming(bij)); 347 if (doel) { 348 mediaId = randomUUID(); 349 try { 350 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)') 351 .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel); 352 } catch { mediaId = null; } 353 } 354 try { 355 db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud) 356 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) 357 .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null, 358 mediaId, t.credit || null, t.license || null, 359 ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null)); 360 } catch { /* geen audio-tabellen op deze installatie */ } 361 } 446 // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json, 447 // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht 448 // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze 449 // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er 450 // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en 451 // 404'den zodra je op play drukte. Zie tracksTerug hieronder. 362 452 } 363 453 -
src/services/MigrationService.js
rfbfd7a1 r6c4ff7e 248 248 * moet stranden, ook als die bron "van onszelf" is. 249 249 */ 250 async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes }) { 251 const r = await safeFetch(url, { headers: { accept: '*/*' } }).catch(() => null); 250 async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) { 251 // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de 252 // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan 253 // niet zien dat wij de doel-actor van zijn Move zijn. 254 const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null); 252 255 if (!r || !r.ok) return null; 253 256 const buf = Buffer.from(await r.arrayBuffer()); … … 261 264 })(); 262 265 const naam = `${crypto.randomUUID()}.${ext}`; 263 const rel = `migrated/${naam}`; 264 const abs = path.join(mediaRoot, 'migrated', naam); 266 // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio 267 // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet 268 // in mappen eronder. 269 const rel = submap ? `${submap}/${naam}` : naam; 270 const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam); 265 271 fs.mkdirSync(path.dirname(abs), { recursive: true }); 266 272 fs.writeFileSync(abs, buf); … … 294 300 // privacygrens en niet een weergavedetail. 295 301 noteVisibility = () => 'followers', 302 audioRoot = null, signHeaders = null, 296 303 } = deps; 297 304 const zichtbaarheid = noteVisibility; … … 329 336 const rapport = { 330 337 bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0, 331 blocks: 0, waarschuwingen: [],338 blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [], 332 339 }; 333 340 … … 441 448 } 442 449 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`); 450 451 // ── De muziekbibliotheek ────────────────────────────────────── 452 // 453 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie 454 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een 455 // muzieksite zijn berichten en laat hij zijn bibliotheek achter. 456 // 457 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we 458 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde 459 // geldt voor de bestanden zelf, die anders achter de gated audio-route 460 // blijven. 461 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string'); 462 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u)); 463 if (tracksUrl && safeFetch && fs && path && audioRoot) { 464 const coll = await getJson(site.slug, tracksUrl); 465 const lijst = (coll && (coll.orderedItems || coll.items)) || []; 466 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) { 467 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it; 468 if (!a || !a.id) continue; 469 if (a.type && a.type !== 'Audio') continue; 470 if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; } 471 const bron = a.url && (typeof a.url === 'string' ? a.url : (Array.isArray(a.url) ? (a.url[0] && (a.url[0].href || a.url[0])) : a.url.href)); 472 if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; } 473 const g = await haalBijlage(String(bron), { 474 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '', 475 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null, 476 }).catch(() => null); 477 if (!g) { 478 rapport.tracksMislukt++; 479 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`); 480 continue; // dezelfde regel als bij de zip: geen bestand, geen track 481 } 482 const trackId = crypto.randomUUID(); 483 const mediaId = crypto.randomUUID(); 484 try { 485 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)') 486 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path); 487 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open) 488 VALUES (?,?,?,?,?,?,?,0)`) 489 .run(trackId, site.id, a.name || 'zonder titel', a.artist || null, a.album || null, 490 Number(a.duration) || null, mediaId); 491 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false }); 492 rapport.tracksBinnen++; 493 } catch (e) { 494 rapport.tracksMislukt++; 495 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`); 496 } 497 } 498 } else if (tracksUrl) { 499 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven'); 500 } 443 501 } catch (e) { 444 502 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken, -
src/services/music/index.js
rfbfd7a1 r6c4ff7e 40 40 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel. 41 41 */ 42 export function siteOpenTracks(siteId) { 42 /** 43 * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een 44 * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo 45 * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je 46 * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een 47 * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene 48 * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat 49 * moved_to er kwam te staan. 50 */ 51 export function siteOpenTracks(siteId, { alles = false } = {}) { 43 52 return db.prepare( 44 53 `SELECT ${TRACK_KOLOMMEN} 45 54 FROM audio_tracks t JOIN media m ON m.id = t.media_id 46 WHERE t.site_id = ? AND t.fedi_open = 155 WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'} 47 56 ORDER BY t.position, t.created_at, t.id` 48 57 ).all(siteId);
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)