Changeset 6c4ff7e in Klonkt for src/services/ArchiveExportService.js
- Timestamp:
- 08/13/2026 11:39:57 PM (4 weeks ago)
- Branches:
- main
- Children:
- 9529d7b
- Parents:
- fbfd7a1
- File:
-
- 1 edited
-
src/services/ArchiveExportService.js (modified) (7 diffs)
Legend:
- Unmodified
- Added
- Removed
-
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'));
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)