source: Klonkt/src/services/ArchiveImportService.js@ 7e474f7

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

Een track zonder bestand is niet hetzelfde als een kapotte track

Robin, over het ene nummer dat niet meekwam: "ah, dat was een no audio 'audio'
voor alleen spotify en youtube links". Dat is een bestaand Klonkt-type, geen
ongelukje: geen gehost bestand, wel externe links, en buildNote maakt er een
embed-kaart van (trackEmbedLinks). Mijn regel "geen bestand, geen track" was te
grof en gooide hem weg.

"Geen bestand" en "niets om te tonen" zijn niet hetzelfde. De regel blijft
staan waar hij hoort: een track zonder bestand EN zonder links wordt nog steeds
geweigerd, want die kan niets.

Het archiefformaat heeft nu drie staten in plaats van twee: included, missing,
en linkOnly. Een link-only track telt dus ook niet meer als "ontbrekend", want
er ontbreekt niets.

EN EEN TWEEDE FOUT DIE HIERONDER LAG. De pull viste de audio-URL uit de
url-lijst met element NUL. buildTrackAudio zet daar een text/html-link naar de
post voor het bestand: wie blind de eerste pakt downloadt een HTML-pagina en
schrijft die weg als mp3. Nu wordt de link met mediaType audio/* gezocht, met
/audio/stream/ als terugval. Dat het bij Robin niet misging is geluk: zijn
tracks hadden geen host-post in die lijst.

Changed files:
src/services/ArchiveExportService.js

  • shaer:availability kent linkOnly; die telt niet als audioMissing

src/services/ArchiveImportService.js

  • link-only tracks komen mee, met media_id NULL zoals op de bron
  • de schrijftak kan een track zonder bestand aan

src/services/MigrationService.js

  • de pull vist de audio-link uit de lijst in plaats van element nul
  • een track met alleen externe links wordt gewoon aangemaakt

test/archive-audio.test.js

  • link-only reist mee; zonder bestand en zonder links blijft geweigerd

test/fep1580-migration.test.js

  • hetzelfde voor de ophaalknop, plus: de audio-link wordt gevist

remarks: Robins Youngstown-nummer komt hiermee alsnog over. Suite 1009 groen;
tegenproef op beide, en de eerste poging daarvan kwam niet aan (te slordige
sabotage), dus opnieuw gedaan met twee rode tests als bewijs.

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

  • Property mode set to 100644
File size: 27.4 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 */
137/**
138 * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
139 * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
140 * verwijzing naar niets.
141 */
142function hoesTerug(files, bestand, werk) {
143 if (!bestand) return null;
144 const bytes = files.get(bestand);
145 if (!bytes || !bytes.length) return null;
146 const naam = path.basename(String(bestand));
147 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
148 const urlPad = `/media/archief/${naam}`;
149 const doel = veiligMediaPad(urlPad);
150 if (!doel) return null;
151 werk.push({ soort: 'media', doel, bytes });
152 return urlPad;
153}
154
155function tracksTerug(files, site, rapport) {
156 const buf = files.get('tracks.json');
157 if (!buf) return [];
158 let coll;
159 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
160 if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
161
162 const werk = [];
163 for (const t of (coll.orderedItems || [])) {
164 const id = String(t.id || '').trim();
165 if (!id) continue;
166 const bestand = t['shaer:file'];
167 const bytes = bestand ? files.get(bestand) : null;
168 // "Geen bestand" en "niets om te tonen" zijn niet hetzelfde. Een LINK-ONLY
169 // track heeft nooit een bestand gehad: hij bestaat uit een Spotify- of
170 // YouTube-link en Klonkt maakt daar een embed-kaart van. Die hoort gewoon
171 // mee. Mijn eerste regel gooide hem weg, en dat kostte Robin een nummer
172 // (Youngstown) dat op de oude site prima werkte.
173 const links = Array.isArray(t.url) ? t.url.filter(Boolean) : [];
174 const alleenLinks = t['shaer:availability'] === 'linkOnly' || (!bestand && links.length > 0);
175 if ((!bytes || !bytes.length) && !alleenLinks) {
176 rapport.tracksMissing += 1;
177 rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
178 continue;
179 }
180 if (alleenLinks) {
181 // Geen bestand om weg te schrijven, geen mediarij: alleen de track zelf.
182 werk.push({ soort: 'track', id, t, naam: null, bytes: null, doel: null, hoes: hoesTerug(files, t['shaer:coverFile'], werk) });
183 rapport.tracks += 1;
184 rapport.tracksLinks = (rapport.tracksLinks || 0) + 1;
185 continue;
186 }
187 // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
188 // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
189 const naam = path.basename(String(bestand));
190 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
191 rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
192 continue;
193 }
194 const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
195 werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
196 rapport.tracks += 1;
197 }
198 return werk;
199}
200
201/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
202function playlistsTerug(files, site, rapport, bekendeTracks) {
203 const buf = files.get('playlists.json');
204 if (!buf) return [];
205 let coll;
206 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
207 if (coll['shaer:archive'] !== true) return [];
208 const werk = [];
209 for (const p of (coll.orderedItems || [])) {
210 const id = String(p.id || '').trim();
211 if (!id) continue;
212 // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
213 // straks een playlist vol gaten die niemand kan afspelen.
214 const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
215 const kwijt = (p['shaer:tracks'] || []).length - items.length;
216 if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
217 const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
218 werk.push({ soort: 'playlist', p, id, items, hoes });
219 rapport.playlists += 1;
220 }
221 return werk;
222}
223
224/**
225 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
226 *
227 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
228 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
229 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
230 * en een rollback neemt verzonden activiteiten niet terug.
231 *
232 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
233 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
234 *
235 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
236 * ActivityPubService niet hoeft te importeren.
237 */
238export async function importFollowing(site, csvText, { followFn = null } = {}) {
239 const rijen = parseFollowingCsv(csvText);
240 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
241 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
242 for (const r of rijen) {
243 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
244 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
245 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
246 try {
247 // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
248 // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
249 // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
250 // kan er precies een halve toestand ontstaan als die faalt.
251 const ok = await followFn(site, r.address, r.featured);
252 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
253 rapport.gevolgd += 1;
254 } catch (e) {
255 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
256 }
257 }
258 return rapport;
259}
260
261/**
262 * Zet een archief terug in een site.
263 *
264 * @param {Map<string,Buffer>} files het ingelezen archief
265 * @param {object} opts { slug, dryRun, overwrite, origin }
266 */
267export function importArchive(files, opts = {}) {
268 const rapport = {
269 formatVersion: null, origin: null, idsBehouden: null,
270 posts: 0, overgeslagen: 0, overschreven: 0,
271 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
272 tracks: 0, tracksMissing: 0, tracksLinks: 0, playlists: 0, linksBijgetrokken: 0,
273 };
274
275 const manifestBuf = files.get('manifest.json');
276 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
277 const manifest = JSON.parse(manifestBuf.toString('utf8'));
278 rapport.formatVersion = manifest.formatVersion;
279
280 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
281 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
282 if (manifest.formatVersion > FORMAT_VERSION) {
283 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
284 }
285
286 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
287 if (!site) {
288 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
289 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
290 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
291 let bestaand = [];
292 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
293 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
294 throw new Error(bestaand.length
295 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
296 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
297 }
298 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
299 rapport.origin = manifest.origin || null;
300
301 // IDENTITEIT. Het INTERNE id blijft altijd (Robins besluit, 14-8).
302 //
303 // Dat is iets anders dan de AP-URI. Die is domeingebonden en wordt hoe dan
304 // ook nieuw: https://nieuw/ap/notes/<id> is een ander adres dan
305 // https://oud/ap/notes/<id>. Je claimt dus niets van een ander door het GUID
306 // te hergebruiken, en je wint dat elke INTERNE verwijzing blijft kloppen:
307 // [[track:]], [[playlist:]] en [[album:]] wijzen na een verhuizing nog naar
308 // het goede ding.
309 //
310 // Voorheen hing dit aan de origin, en alleen voor posts; tracks en playlists
311 // hielden hun id al wel. Die scheve tabel was precies waarom een post uit de
312 // zip met [[track:oud]] naast een nummer uit de pull met een nieuw id kwam te
313 // staan, en je de shorthand als kale tekst in je bericht zag.
314 //
315 // `idsBehouden` gaat hieronder alleen nog over de AP-URI: gelijke origin
316 // betekent dat ook die identiek blijft, en dan valt er niets te vertalen.
317 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
318 rapport.idsBehouden = idsBehouden;
319 if (!idsBehouden) {
320 rapport.waarschuwingen.push(
321 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): de berichten krijgen een nieuw AP-adres. Hun interne id blijft, dus verwijzingen binnen je site blijven kloppen.`,
322 );
323 }
324
325 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
326 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
327 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
328 const idKaart = new Map(); // oud post-id -> nieuw post-id
329
330 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
331
332 for (const pad of postPaden) {
333 const o = JSON.parse(files.get(pad).toString('utf8'));
334 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
335 // Altijd het id uit het archief. Staat er hier al iets met dat id, dan is
336 // dat hetzelfde object, en dat handelt de botsingscontrole hieronder af.
337 const nieuwId = oudId;
338 idKaart.set(oudId, nieuwId);
339
340 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
341 if (botsing && !opts.overwrite) {
342 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
343 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
344 // idempotent maakt.
345 rapport.overgeslagen += 1;
346 continue;
347 }
348 if (botsing) rapport.overschreven += 1;
349
350 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
351 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
352 // op een ander domein.
353 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
354 if (a['shaer:availability'] === 'missing') {
355 rapport.mediaMissing += 1;
356 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
357 continue;
358 }
359 const bytes = files.get(a.url);
360 if (!bytes) {
361 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
362 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
363 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
364 continue;
365 }
366 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
367 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
368 continue;
369 }
370 const doel = veiligMediaPad(bestemming(a));
371 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
372 schrijf.push({ soort: 'media', doel, bytes });
373 rapport.media += 1;
374 }
375
376 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
377 rapport.posts += 1;
378 }
379
380 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
381 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
382 const coll = JSON.parse(files.get(pad).toString('utf8'));
383 if (coll['shaer:archive'] !== true) {
384 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
385 continue;
386 }
387 const oudId = path.basename(pad, '.json');
388 const postId = idKaart.get(oudId);
389 if (!postId) continue; // post overgeslagen -> antwoorden ook
390 for (const it of (coll.orderedItems || [])) {
391 schrijf.push({ soort: 'reply', postId, it });
392 rapport.replies += 1;
393 }
394 }
395
396 // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
397 // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
398 const trackWerk = tracksTerug(files, site, rapport);
399 const bekendeTracks = new Set(trackWerk.map((w) => w.id));
400 const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
401
402 if (opts.dryRun) return rapport;
403
404 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
405 // uitkomst, want dan lijkt het gelukt.
406 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
407 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
408 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
409 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
410 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
411 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
412 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
413 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
414 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
415 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
416
417 db.transaction(() => {
418 for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
419 if (s.soort === 'media') {
420 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
421 fs.writeFileSync(s.doel, s.bytes);
422 continue;
423 }
424 if (s.soort === 'reply') {
425 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
426 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
427 continue;
428 }
429 if (s.soort === 'track') {
430 // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
431 // rolt de hele transactie terug: liever geen import dan een track zonder
432 // geluid, want dat is precies de val waar dit uit voortkomt.
433 //
434 // Een link-only track heeft geen bestand en dus ook geen mediarij; die
435 // krijgt media_id NULL, precies zoals op de bron.
436 let mediaId = null;
437 if (s.doel && s.bytes) {
438 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
439 fs.writeFileSync(s.doel, s.bytes);
440 mediaId = randomUUID();
441 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
442 .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
443 }
444 const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
445 db.prepare(`INSERT OR REPLACE INTO audio_tracks
446 (id, site_id, title, artist, album, duration, media_id, position, credit, license,
447 cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
448 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
449 .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
450 s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
451 s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
452 link('spotify'), link('youtube'), link('soundcloud'));
453 continue;
454 }
455 if (s.soort === 'playlist') {
456 db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
457 VALUES (?,?,?,?,?,?,?)`)
458 .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
459 s.hoes || null, s.p['shaer:kind'] || null);
460 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
461 const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
462 s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
463 continue;
464 }
465 const o = s.obj;
466 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
467 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
468 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
469 // en dat zie je pas als je alle kolommen vergelijkt.
470 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
471 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
472 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
473 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
474 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
475 return {
476 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
477 poster: poster ? padVan(poster) : undefined,
478 };
479 }).filter((a) => a.url);
480 insPost.run({
481 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
482 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
483 status: o['shaer:status'] || 'draft',
484 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
485 cover_video_url: padVan(metRol('coverVideo')),
486 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
487 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
488 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
489 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
490 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
491 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
492 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
493 content_warning: o.summary || null,
494 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,
495 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
496 ap_visibility: o['shaer:apVisibility'] || null,
497 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
498 view_count: o['shaer:viewCount'] || 0,
499 });
500 // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
501 // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
502 // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
503 // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
504 // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
505 // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
506 }
507
508 // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
509 // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
510 // behandeld moeten worden als objecten die van de bron zijn opgehaald.
511 // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
512 // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
513 //
514 // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
515 // oude URI al naar het goede object en valt er niets te vertalen.
516 if (!idsBehouden && eigenOrigin) {
517 for (const s of schrijf) {
518 if (s.soort !== 'post' || !s.oudeUri) continue;
519 Migration.recordMigrated(site.slug, {
520 origin: s.oudeUri,
521 target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
522 sourceActor: manifest.actor || '',
523 isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
524 });
525 }
526 }
527
528 // Links naar de BRONPOSTS ombuigen naar hier. Ook bij een zip-import: een
529 // verhuizing is een verhuizing, en een bericht dat naar het oude domein
530 // linkt wordt een dode link zodra dat domein opgezegd wordt. Binnen dezelfde
531 // transactie, want half bijgetrokken is erger dan niet.
532 //
533 // Pas hier, aan het eind: nu staan alle berichten er, dus nu weten we welke
534 // slugs bestaan. Een link naar iets dat we niet hebben blijft met rust.
535 if (manifest.origin && manifest.origin !== eigenOrigin) {
536 Migration.postLinksBijtrekken(site, String(manifest.origin).replace(/\/+$/, ''), rapport);
537 }
538 })();
539
540 return rapport;
541}
Note: See TracBrowser for help on using the repository browser.