source: Klonkt/src/services/ArchiveImportService.js@ 6c4ff7e

main
Last change on this file since 6c4ff7e was 6c4ff7e, checked in by Robin <roboburr@…>, 4 weeks ago

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

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

  • Property mode set to 100644
File size: 24.1 KB
Line 
1/**
2 * Import van een draagbaar inhoudsarchief (shaer-pmr).
3 *
4 * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
5 * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
6 *
7 * VERSIE EERST Een hogere onbekende formatVersion wordt in zijn GEHEEL
8 * geweigerd. Een half begrepen herstel is erger dan geen
9 * herstel, want het ziet eruit alsof het gelukt is.
10 * IDENTITEIT De origin uit het manifest bepaalt of de AP-ids behouden
11 * blijven. Dat is geen vraag aan de gebruiker: een verkeerd
12 * antwoord publiceert objecten onder een id dat je niet beheert.
13 * NIETS STILS Ontbrekende media worden geteld en gemeld.
14 * GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
15 * rechttrekken is een aparte, bewuste actie.
16 *
17 * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
18 */
19
20import fs from 'fs';
21import path from 'path';
22import zlib from 'zlib';
23import crypto from 'crypto';
24import { randomUUID } from 'crypto';
25import db from '../config/database.js';
26import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
27import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
28import * as Migration from './MigrationService.js';
29
30const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
31// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
32// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
33// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
34const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
35
36// ── Inlezen ───────────────────────────────────────────────────────
37
38/** Lees een archiefmap in als pad -> Buffer. */
39export function readArchiveDir(dir) {
40 const files = new Map();
41 const loop = (sub) => {
42 for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
43 const rel = sub ? `${sub}/${naam.name}` : naam.name;
44 if (naam.isDirectory()) loop(rel);
45 else files.set(rel, fs.readFileSync(path.join(dir, rel)));
46 }
47 };
48 loop('');
49 return files;
50}
51
52/**
53 * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
54 * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
55 */
56export function readArchiveZip(buf) {
57 const files = new Map();
58 const eocd = (() => {
59 for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
60 return -1;
61 })();
62 if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
63 const aantal = buf.readUInt16LE(eocd + 10);
64 let p = buf.readUInt32LE(eocd + 16);
65 for (let n = 0; n < aantal; n++) {
66 if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
67 const methode = buf.readUInt16LE(p + 10);
68 const gecomp = buf.readUInt32LE(p + 20);
69 const naamLen = buf.readUInt16LE(p + 28);
70 const extraLen = buf.readUInt16LE(p + 30);
71 const commentLen = buf.readUInt16LE(p + 32);
72 const lokaalOffset = buf.readUInt32LE(p + 42);
73 const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
74 const lNaam = buf.readUInt16LE(lokaalOffset + 26);
75 const lExtra = buf.readUInt16LE(lokaalOffset + 28);
76 const start = lokaalOffset + 30 + lNaam + lExtra;
77 const rauw = buf.subarray(start, start + gecomp);
78 if (!naam.endsWith('/')) {
79 files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
80 }
81 p += 46 + naamLen + extraLen + commentLen;
82 }
83 return files;
84}
85
86export function readArchive(bron) {
87 const st = fs.statSync(bron);
88 return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
89}
90
91// ── Importeren ────────────────────────────────────────────────────
92
93/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
94function veiligMediaPad(urlPad) {
95 if (!urlPad || !urlPad.startsWith('/media/')) return null;
96 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
97 const root = path.resolve(MEDIA_ROOT);
98 return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
99}
100
101/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
102function padVanOrigineel(u) {
103 const s = String(u || '');
104 if (s.startsWith('/media/')) return s;
105 try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
106}
107
108/**
109 * Waar deze bijlage komt te staan, als site-relatief pad.
110 *
111 * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
112 * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
113 * plek onder /media. Zonder een bestemming zou het bestand wel worden
114 * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
115 * en verwijzen de kolommen daarheen.
116 */
117function bestemming(a) {
118 return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
119}
120
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 */
137function 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. */
169function 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/**
191 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
192 *
193 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
194 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
195 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
196 * en een rollback neemt verzonden activiteiten niet terug.
197 *
198 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
199 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
200 *
201 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
202 * ActivityPubService niet hoeft te importeren.
203 */
204export async function importFollowing(site, csvText, { followFn = null } = {}) {
205 const rijen = parseFollowingCsv(csvText);
206 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
207 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
208 for (const r of rijen) {
209 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
210 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
211 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
212 try {
213 // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
214 // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
215 // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
216 // kan er precies een halve toestand ontstaan als die faalt.
217 const ok = await followFn(site, r.address, r.featured);
218 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
219 rapport.gevolgd += 1;
220 } catch (e) {
221 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
222 }
223 }
224 return rapport;
225}
226
227/**
228 * Zet een archief terug in een site.
229 *
230 * @param {Map<string,Buffer>} files het ingelezen archief
231 * @param {object} opts { slug, dryRun, overwrite, origin }
232 */
233export function importArchive(files, opts = {}) {
234 const rapport = {
235 formatVersion: null, origin: null, idsBehouden: null,
236 posts: 0, overgeslagen: 0, overschreven: 0,
237 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
238 tracks: 0, tracksMissing: 0, playlists: 0,
239 };
240
241 const manifestBuf = files.get('manifest.json');
242 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
243 const manifest = JSON.parse(manifestBuf.toString('utf8'));
244 rapport.formatVersion = manifest.formatVersion;
245
246 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
247 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
248 if (manifest.formatVersion > FORMAT_VERSION) {
249 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
250 }
251
252 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
253 if (!site) {
254 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
255 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
256 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
257 let bestaand = [];
258 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
259 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
260 throw new Error(bestaand.length
261 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
262 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
263 }
264 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
265 rapport.origin = manifest.origin || null;
266
267 // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
268 // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
269 // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
270 // servers halen het daar toch op.
271 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
272 rapport.idsBehouden = idsBehouden;
273 if (!idsBehouden) {
274 rapport.waarschuwingen.push(
275 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
276 );
277 }
278
279 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
280 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
281 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
282 const idKaart = new Map(); // oud post-id -> nieuw post-id
283
284 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
285
286 for (const pad of postPaden) {
287 const o = JSON.parse(files.get(pad).toString('utf8'));
288 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
289 const nieuwId = idsBehouden ? oudId : randomUUID();
290 idKaart.set(oudId, nieuwId);
291
292 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
293 if (botsing && !opts.overwrite) {
294 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
295 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
296 // idempotent maakt.
297 rapport.overgeslagen += 1;
298 continue;
299 }
300 if (botsing) rapport.overschreven += 1;
301
302 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
303 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
304 // op een ander domein.
305 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
306 if (a['shaer:availability'] === 'missing') {
307 rapport.mediaMissing += 1;
308 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
309 continue;
310 }
311 const bytes = files.get(a.url);
312 if (!bytes) {
313 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
314 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
315 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
316 continue;
317 }
318 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
319 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
320 continue;
321 }
322 const doel = veiligMediaPad(bestemming(a));
323 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
324 schrijf.push({ soort: 'media', doel, bytes });
325 rapport.media += 1;
326 }
327
328 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
329 rapport.posts += 1;
330 }
331
332 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
333 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
334 const coll = JSON.parse(files.get(pad).toString('utf8'));
335 if (coll['shaer:archive'] !== true) {
336 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
337 continue;
338 }
339 const oudId = path.basename(pad, '.json');
340 const postId = idKaart.get(oudId);
341 if (!postId) continue; // post overgeslagen -> antwoorden ook
342 for (const it of (coll.orderedItems || [])) {
343 schrijf.push({ soort: 'reply', postId, it });
344 rapport.replies += 1;
345 }
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);
353
354 if (opts.dryRun) return rapport;
355
356 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
357 // uitkomst, want dan lijkt het gelukt.
358 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
359 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
360 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
361 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
362 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
363 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
364 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
365 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
366 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
367 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
368
369 db.transaction(() => {
370 for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
371 if (s.soort === 'media') {
372 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
373 fs.writeFileSync(s.doel, s.bytes);
374 continue;
375 }
376 if (s.soort === 'reply') {
377 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
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));
409 continue;
410 }
411 const o = s.obj;
412 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
413 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
414 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
415 // en dat zie je pas als je alle kolommen vergelijkt.
416 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
417 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
418 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
419 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
420 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
421 return {
422 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
423 poster: poster ? padVan(poster) : undefined,
424 };
425 }).filter((a) => a.url);
426 insPost.run({
427 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
428 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
429 status: o['shaer:status'] || 'draft',
430 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
431 cover_video_url: padVan(metRol('coverVideo')),
432 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
433 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
434 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
435 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
436 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
437 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
438 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
439 content_warning: o.summary || null,
440 poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
441 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
442 ap_visibility: o['shaer:apVisibility'] || null,
443 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
444 view_count: o['shaer:viewCount'] || 0,
445 });
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.
452 }
453
454 // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
455 // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
456 // behandeld moeten worden als objecten die van de bron zijn opgehaald.
457 // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
458 // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
459 //
460 // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
461 // oude URI al naar het goede object en valt er niets te vertalen.
462 if (!idsBehouden && eigenOrigin) {
463 for (const s of schrijf) {
464 if (s.soort !== 'post' || !s.oudeUri) continue;
465 Migration.recordMigrated(site.slug, {
466 origin: s.oudeUri,
467 target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
468 sourceActor: manifest.actor || '',
469 isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
470 });
471 }
472 }
473 })();
474
475 return rapport;
476}
Note: See TracBrowser for help on using the repository browser.