Changeset 6c4ff7e in Klonkt
- Timestamp:
- 08/13/2026 11:39:57 PM (4 weeks ago)
- Branches:
- main
- Children:
- 9529d7b
- Parents:
- fbfd7a1
- Files:
-
- 1 added
- 11 edited
-
src/config/paths.js (modified) (1 diff)
-
src/routes/activitypub.js (modified) (2 diffs)
-
src/routes/admin-migrate.js (modified) (2 diffs)
-
src/routes/audio.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
src/services/ArchiveExportService.js (modified) (7 diffs)
-
src/services/ArchiveImportService.js (modified) (7 diffs)
-
src/services/MigrationService.js (modified) (5 diffs)
-
src/services/music/index.js (modified) (1 diff)
-
test/archive-audio.test.js (added)
-
test/archive-export.test.js (modified) (3 diffs)
-
test/fep1580-migration.test.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/config/paths.js
rfbfd7a1 r6c4ff7e 34 34 return path.resolve(process.env[envVar] || path.join(MEDIA_ROOT, sub)); 35 35 } 36 37 /** 38 * Waar de gehoste audio staat. 39 * 40 * BEWUST BUITEN MEDIA_ROOT: de publieke /media-handler mag er niet bij, elke 41 * fetch loopt via de gated route in routes/audio.js. Diezelfde route resolvet 42 * met AUDIO_DIR + bestandsnaam, en negeert media.storage_path volledig. 43 */ 44 export const AUDIO_ROOT = path.resolve( 45 process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'), 46 ); 47 48 /** 49 * Het echte pad van een audiobestand, op DEZELFDE manier als de speler het zoekt. 50 * 51 * Dit bestaat omdat die twee uit elkaar liepen en dat een verhuizing sloopte. 52 * Op sound-fabrics.com wees media.storage_path voor 124 van de 139 tracks nog 53 * naar /srv/prutfolio/storage/audio (van voor de dataverhuizing), terwijl de 54 * bestanden allang op ~/data/prutfolio/audio stonden. De site merkte er niets 55 * van, want de speler kijkt alleen naar de bestandsnaam. De exporter las wel 56 * storage_path, vond niets, en liet 124 nummers stil achter. 57 * 58 * Volgorde: eerst zoals de speler kijkt (bestandsnaam in AUDIO_ROOT), dan pas 59 * het opgeslagen pad. Zo klopt de export met wat de gebruiker hoort, en niet 60 * met wat de database ooit dacht. 61 * 62 * @returns {string|null} een bestaand pad, of null 63 */ 64 export function resolveAudioPath(storagePath, fs) { 65 const s = String(storagePath || ''); 66 if (!s) return null; 67 const kandidaten = [path.join(AUDIO_ROOT, path.basename(s)), path.resolve(s)]; 68 for (const p of kandidaten) { 69 try { if (fs.statSync(p).isFile()) return p; } catch { /* volgende kandidaat */ } 70 } 71 return null; 72 } -
src/routes/activitypub.js
rfbfd7a1 r6c4ff7e 1043 1043 }); 1044 1044 1045 /** 1046 * Mag deze aanvrager alles van `slug` zien? Waar bij de eigenaar zelf, en waar 1047 * voor de actor waar `slug` naartoe verhuisd is (FEP-1580, Source Instance). 1048 * 1049 * Eén plek voor die vraag, want hij komt op meerdere collecties terug en twee 1050 * antwoorden op dezelfde vraag lopen vroeg of laat uiteen. 1051 */ 1052 async function magAlles(req, slug) { 1053 const auth = OAuth.verifyBearer(req.headers.authorization); 1054 if (auth && auth.site.slug === slug) return true; 1055 if (!req.headers['signature']) return false; 1056 const v = await AP.verifyRequest(req).catch(() => null); 1057 return !!(v && v.id && AP.isMoveTarget(slug, v.id)); 1058 } 1059 1045 1060 // ── FEP-1580: de vertaaltabel van een verhuizing ────────────────── 1046 1061 // … … 1115 1130 // stap 3). Een playlist is een keuze hieruit; deze collectie is alles wat de 1116 1131 // artiest heeft opengezet, ook wat in geen enkele playlist staat. 1117 router.get('/ap/users/:slug/tracks', (req, res) => {1132 router.get('/ap/users/:slug/tracks', async (req, res) => { 1118 1133 const site = publicSite(req.params.slug); 1119 1134 if (!site) return res.status(404).end(); 1120 AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id )));1135 AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) }))); 1121 1136 }); 1122 1137 -
src/routes/admin-migrate.js
rfbfd7a1 r6c4ff7e 29 29 import { requireGod } from '../middleware/auth.js'; 30 30 import ActivityPubService from '../services/ActivityPubService.js'; 31 import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';31 import { safeFetch, signedGetJson, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js'; 32 32 import HtmlSanitizerService from '../services/HtmlSanitizerService.js'; 33 import { MEDIA_ROOT } from '../config/paths.js';33 import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js'; 34 34 import * as Migration from '../services/MigrationService.js'; 35 35 import { buildArchive, zipArchive } from '../services/ArchiveExportService.js'; … … 111 111 sourceUri: opgegeven || null, 112 112 deps: { 113 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, fs, path, noteId, noteVisibility,113 getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders, 114 114 sanitize: (h) => HtmlSanitizerService.sanitize(h || ''), 115 115 }, -
src/routes/audio.js
rfbfd7a1 r6c4ff7e 30 30 import db from '../config/database.js'; 31 31 import { recordPlay } from '../services/StatsService.js'; 32 import AP from '../services/ActivityPubService.js'; 32 33 33 34 const __dirname = path.dirname(fileURLToPath(import.meta.url)); … … 71 72 } 72 73 73 router.get('/stream/:filename', (req, res) => { 74 /** 75 * FEP-1580: de instantie waar dit account naartoe verhuisd is mag ALLE audio 76 * ophalen, ook wat niet fedi_open is. 77 * 78 * Zonder deze tak ziet de nieuwe Klonkt de tracklijst wel en krijgt hij de 79 * bestanden niet, en dan verhuis je een bibliotheek met alleen titels. Dat is 80 * precies de halve waarheid die deze hele ronde moest opruimen. 81 * 82 * Smal gehouden: een geldige handtekening, van precies de actor in moved_to, en 83 * alleen voor een bestand dat van DIE site is. moved_to komt er alleen te staan 84 * als de doel-actor ons in alsoKnownAs had, dus er heeft iemand met beheer aan 85 * beide kanten ja gezegd. 86 */ 87 async function isMoveTargetAudio(req, filename) { 88 if (!req.headers['signature'] || !filename) return false; 89 let rij; 90 try { 91 rij = db.prepare(`SELECT s.slug FROM audio_tracks t 92 JOIN media m ON t.media_id = m.id 93 JOIN sites s ON s.id = t.site_id 94 WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1`) 95 .get(filename, `%${filename}`); 96 } catch { return false; } 97 if (!rij || !rij.slug) return false; 98 const v = await AP.verifyRequest(req).catch(() => null); 99 return !!(v && v.id && AP.isMoveTarget(rij.slug, v.id)); 100 } 101 102 router.get('/stream/:filename', async (req, res) => { 74 103 const { filename } = req.params; 75 104 76 if (!isAllowedAudioRequest(req, filename) ) {105 if (!isAllowedAudioRequest(req, filename) && !(await isMoveTargetAudio(req, filename))) { 77 106 return res.status(403).send('Direct access not allowed'); 78 107 } -
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); -
test/archive-export.test.js
rfbfd7a1 r6c4ff7e 134 134 135 135 test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => { 136 // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op 137 // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier. 136 // Sinds v2 gaat audio NIET meer als media-bijlage mee. Dat kon ook nooit: 137 // gehoste audio staat buiten MEDIA_ROOT, de exporter rekende er met 138 // path.relative een /media/../audio/x.mp3 van, en de importer weigerde dat 139 // terecht. Resultaat op soundfabrics.nl: 13 tracks in de lijst, nul bestanden. 140 // Nu heeft het archief een eigen audio/-gebied. 138 141 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true }); 139 142 const WAV = Buffer.from('RIFF-nep-audio'); … … 147 150 const uit = AX.buildArchive('me'); 148 151 const o = lees(uit, 'posts/metaudio.json'); 149 const a = o.attachment[0]; 150 assert.equal(a['shaer:availability'], 'included'); 151 assert.equal(a.mediaType, 'audio/mpeg'); 152 assert.deepEqual(uit.files.get(a.url), WAV); 152 const t = o['shaer:audio'][0]; 153 153 assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan'); 154 const t = o['shaer:audio'][0];155 154 assert.equal(t.name, 'Kanonnen'); 156 155 assert.equal(t.artist, 'Robin'); 157 156 assert.equal(t.license, 'CC BY'); 158 157 assert.deepEqual(t.url, ['https://open.spotify.com/track/x']); 158 159 // Dit is de assertie die er echt toe doet: de BYTES zitten in het archief. 160 assert.ok(String(t['shaer:media']).startsWith('audio/'), 'de track wijst naar het audio-gebied'); 161 assert.deepEqual(uit.files.get(t['shaer:media']), WAV, 'en daar staat het geluid ook echt'); 162 163 // En hij staat in de bibliotheek, los van de post. 164 const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8')); 165 const rij = lib.orderedItems.find((x) => x.id === 'tr1'); 166 assert.equal(rij['shaer:availability'], 'included'); 167 assert.equal(rij['shaer:file'], t['shaer:media']); 168 assert.equal(uit.counts.tracks, 1); 169 }); 170 171 test('de HELE bibliotheek gaat mee, ook wat in geen enkel bericht staat', () => { 172 // De reden dat dit bestaat: op sound-fabrics.com stonden 140 tracks en gingen 173 // er 14 mee, want alleen wat met [[track:]] in een bericht stond werd 174 // geexporteerd. Een verhuizing die je bibliotheek achterlaat is er geen. 175 fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true }); 176 const B = Buffer.from('RIFF-los-nummer'); 177 fs.writeFileSync(path.join(MEDIA, 'audio', 'los.mp3'), B); 178 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)') 179 .run('m9', 's1', 'los.mp3', 'audio/mpeg', B.length, path.join(MEDIA, 'audio', 'los.mp3')); 180 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('los','s1','Los nummer','m9')").run(); 181 182 const uit = AX.buildArchive('me'); 183 const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8')); 184 const rij = lib.orderedItems.find((x) => x.id === 'los'); 185 assert.ok(rij, 'een track zonder bericht hoort er gewoon in'); 186 assert.deepEqual(uit.files.get(rij['shaer:file']), B); 187 }); 188 189 test('een track waarvan het bestand zoek is wordt gemeld, niet stil weggelaten', () => { 190 db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mz','s1','zoek.mp3','audio/mpeg',1,'/bestaat/niet/zoek.mp3')").run(); 191 db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('zoek','s1','Zoek','mz')").run(); 192 const uit = AX.buildArchive('me'); 193 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'zoek'); 194 assert.equal(rij['shaer:availability'], 'missing'); 195 assert.equal(rij['shaer:file'], undefined, 'geen verwijzing naar een bestand dat er niet is'); 196 assert.ok(uit.counts.audioMissing >= 1); 197 assert.ok(uit.missing.some((m) => m.track === 'Zoek'), 'en de beller kan het aan de gebruiker melden'); 198 }); 199 200 test('audio wordt gezocht zoals de SPELER hem zoekt, niet zoals de database hem onthoudt', async () => { 201 // De stille moordenaar op sound-fabrics.com: na een dataverhuizing wees 202 // storage_path voor 124 van de 139 tracks nog naar /srv/prutfolio/storage/audio. 203 // De speler merkte er niets van, want die pakt AUDIO_ROOT + bestandsnaam en 204 // negeert storage_path. De exporter las wel storage_path, vond niets, en liet 205 // 124 nummers weg zonder dat iemand het zag. 206 // 207 // Deze test pint de VOLGORDE vast met een nep-fs, want dat is precies waar de 208 // twee uit elkaar liepen. AUDIO_ROOT ligt bij import van paths.js al vast, dus 209 // hem hier omzetten kan niet meer; de volgorde toetsen wel. 210 const { resolveAudioPath, AUDIO_ROOT } = await import('../src/config/paths.js'); 211 const gevraagd = []; 212 const nepFs = { 213 statSync(p) { 214 gevraagd.push(p); 215 if (p === path.join(AUDIO_ROOT, 'x.mp3')) return { isFile: () => true }; 216 throw new Error('ENOENT'); 217 }, 218 }; 219 const uit = resolveAudioPath('/een/heel/oud/pad/x.mp3', nepFs); 220 assert.equal(uit, path.join(AUDIO_ROOT, 'x.mp3'), 'de speler-plek wint'); 221 assert.equal(gevraagd[0], path.join(AUDIO_ROOT, 'x.mp3'), 'en wordt als EERSTE geprobeerd'); 222 223 // Staat hij daar niet, dan telt het opgeslagen pad alsnog: installaties die 224 // hun audio ergens anders hebben mogen niet stuk. 225 const gevraagd2 = []; 226 const nepFs2 = { 227 statSync(p) { 228 gevraagd2.push(p); 229 if (p === path.resolve('/ergens/anders/y.mp3')) return { isFile: () => true }; 230 throw new Error('ENOENT'); 231 }, 232 }; 233 assert.equal(resolveAudioPath('/ergens/anders/y.mp3', nepFs2), path.resolve('/ergens/anders/y.mp3')); 234 assert.equal(resolveAudioPath('', nepFs2), null); 159 235 }); 160 236 … … 227 303 const m = AX.buildArchive('me').manifest; 228 304 assert.equal(m.origin, 'https://klonkt.test'); 229 assert.equal(m.formatVersion, 1);305 assert.equal(m.formatVersion, 2, 'v2 = audio zit er echt in'); 230 306 assert.equal(m.actor, 'https://klonkt.test/ap/users/me'); 231 307 }); -
test/fep1580-migration.test.js
rfbfd7a1 r6c4ff7e 13 13 import { test, beforeEach } from 'node:test'; 14 14 import assert from 'node:assert/strict'; 15 import path from 'node:path'; 15 16 16 17 process.env.DATABASE_PATH = ':memory:'; … … 47 48 48 49 beforeEach(() => { 49 for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers' ]) {50 for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers', 'audio_tracks', 'media']) { 50 51 try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet in deze build */ } 51 52 } … … 315 316 }); 316 317 318 test('de muziekbibliotheek komt mee, ook wat niet fedi_open is', async () => { 319 // Losse nummers staan NIET in de outbox: die hangen aan de tracks-collectie 320 // waar de actor via AS2 `streams` naar wijst. Zonder deze tak verhuist een 321 // muzieksite zijn berichten en laat hij zijn bibliotheek achter, en dat is 322 // precies wat er op soundfabrics.nl gebeurde. 323 const s = site({ aliases: [BRON] }); 324 const AUDIO = `${BRON}/audio/x.mp3`; 325 const bytes = Buffer.from('ID3-nep-geluid'); 326 const kaart = new Map([ 327 [BRON, { 328 id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, 329 streams: [`${BRON}/tracks`, `${BRON}/playlists`], 330 }], 331 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }], 332 [`${BRON}/tracks`, { 333 type: 'OrderedCollection', 334 orderedItems: [{ 335 id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Gesloten nummer', artist: 'Robo', 336 url: [{ type: 'Link', href: AUDIO, mediaType: 'audio/mpeg' }], 337 }], 338 }], 339 ]); 340 const geschreven = new Map(); 341 let getekend = false; 342 const r = await stil(() => Mig.ingestFromSource(s, { 343 deps: { 344 getJson: async (_slug, url) => kaart.get(url) || null, 345 noteId: (b, id) => `${b}/ap/notes/${id}`, 346 noteVisibility: AP.noteVisibility, 347 audioRoot: '/nep/audio', 348 // De handtekening is hier geen detail: de audio-route van de bron weigert 349 // een kale fetch, want die kan niet zien dat wij de doel-actor zijn. 350 signHeaders: () => { getekend = true; return { Signature: 'nep' }; }, 351 safeFetch: async (url, opts) => { 352 assert.equal(url, AUDIO); 353 assert.ok(opts.headers && opts.headers.Signature, 'de bytes worden ONDERTEKEND opgehaald'); 354 return { ok: true, arrayBuffer: async () => bytes, headers: { get: () => 'audio/mpeg' } }; 355 }, 356 fs: { mkdirSync() {}, writeFileSync: (p, b) => geschreven.set(p, b) }, 357 path, 358 }, 359 })); 360 assert.equal(r.tracksBinnen, 1); 361 assert.equal(r.tracksMislukt, 0); 362 assert.ok(getekend); 363 assert.equal(geschreven.size, 1, 'het bestand wordt echt weggeschreven'); 364 const [pad] = [...geschreven.keys()]; 365 assert.ok(pad.startsWith('/nep/audio/'), 'audio hoort in AUDIO_ROOT, niet in de mediamap'); 366 assert.ok(!pad.slice('/nep/audio/'.length).includes('/'), 'en er direct in, want de speler zoekt op bestandsnaam'); 367 const t = db.prepare('SELECT title, artist FROM audio_tracks').get(); 368 assert.equal(t.title, 'Gesloten nummer'); 369 }); 370 371 test('een nummer waarvan de bytes niet komen levert GEEN track op', async () => { 372 // Dezelfde regel als bij de zip. Een nummer dat in de lijst staat en 404't is 373 // erger dan een nummer dat ontbreekt. 374 const s = site({ aliases: [BRON] }); 375 const kaart = new Map([ 376 [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }], 377 [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }], 378 [`${BRON}/tracks`, { 379 type: 'OrderedCollection', 380 orderedItems: [{ id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Weg', url: `${BRON}/audio/weg.mp3` }], 381 }], 382 ]); 383 const r = await stil(() => Mig.ingestFromSource(s, { 384 deps: { 385 getJson: async (_slug, url) => kaart.get(url) || null, 386 noteId: (b, id) => `${b}/ap/notes/${id}`, 387 noteVisibility: AP.noteVisibility, 388 audioRoot: '/nep/audio', 389 signHeaders: () => ({ Signature: 'nep' }), 390 safeFetch: async () => ({ ok: false, status: 403 }), 391 fs: { mkdirSync() {}, writeFileSync() { throw new Error('mag niet gebeuren'); } }, 392 path, 393 }, 394 })); 395 assert.equal(r.tracksBinnen, 0); 396 assert.equal(r.tracksMislukt, 1); 397 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0); 398 }); 399 317 400 test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => { 318 401 const s = site({ aliases: [BRON] });
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)