source: Klonkt/src/services/MigrationService.js@ f704237

main
Last change on this file since f704237 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: 48.9 KB
Line 
1/**
2 * MigrationService.js — FEP-1580: je OBJECTEN verhuizen bij een Move.
3 *
4 * FEP-7628 verhuist je volgers en zegt zelf dat de rest een ander probleem is.
5 * Dit is dat andere probleem: na een Move stonden je berichten nog op de oude
6 * instantie, en elke reactie van een derde wees naar een URI die verdwijnt zodra
7 * dat domein opgezegd wordt.
8 *
9 * DRIE DINGEN OM TE WETEN VOOR JE HIERIN LEEST:
10 *
11 * 1. DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant staat in
12 * ActivityPubService.isMoveTarget: een ondertekend verzoek namens de actor
13 * waar de bron naartoe verhuisde telt als de bron zelf. Dat mag omdat
14 * moveAccount() `no_backreference` weigert, dus `moved_to` staat er alleen
15 * als iemand met beheer op BEIDE kanten dat wilde. Hier in dit bestand zit
16 * de DOELkant, die van die toestemming gebruikmaakt.
17 *
18 * 2. NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een gemigreerd
19 * bericht krijgt hier een eigen URI, want het staat nu op een ander domein.
20 * De `migration`-collectie mapt oud naar nieuw en derden lezen die om hun
21 * eigen verwijzingen bij te werken. Zonder die collectie is de draad kapot,
22 * met die collectie is het een verhuisbericht.
23 *
24 * 3. ER GAAT GEEN Create DE DEUR UIT. De spec is daar expliciet over, en het is
25 * ook gewoon logisch: je volgers hebben deze berichten jaren geleden al
26 * gezien. Een ingest van driehonderd posts die als driehonderd nieuwe posts
27 * de tijdlijn in klettert is geen verhuizing maar spam.
28 *
29 * WAT HIER ONTBREEKT: FEP-8b32 integrity proofs (shaer-j1v0). De `moves`-
30 * collectie hoort ondertekend te zijn en de Moves erin horen een proof van de
31 * bron-actor te dragen. Klonkt kent 8b32 nog niet. We bewaren wel alle
32 * grondstof (de rauwe activity en het actordocument), zodat het later alleen
33 * ondertekenen is. Bewust geen leeg proof-veld: een derde die het controleert
34 * wordt dan misleid, en dat is erger dan een veld dat ontbreekt.
35 */
36import crypto from 'crypto';
37import db from '../config/database.js';
38import { AP_CONTEXT, actorId, pagedCollection } from './ap-core.js';
39
40// ── Vertaaltabel ──────────────────────────────────────────────────
41
42const stmts = {};
43function q(naam, sql) { return (stmts[naam] ||= db.prepare(sql)); }
44
45/** Leg vast dat `origin` hier `target` werd. Idempotent: opnieuw draaien mag. */
46export function recordMigrated(slug, { origin, target, sourceActor = '', isPublic = true } = {}) {
47 if (!slug || !origin || !target) return false;
48 try {
49 q('ins', `INSERT INTO ap_migration (slug, origin, target, source_actor, is_public)
50 VALUES (?, ?, ?, ?, ?)
51 ON CONFLICT(slug, origin) DO UPDATE SET target = excluded.target`)
52 .run(slug, String(origin), String(target), String(sourceActor || ''), isPublic ? 1 : 0);
53 return true;
54 } catch (e) {
55 console.warn('[FEP-1580] mapping niet opgeslagen:', origin, e && e.message);
56 return false;
57 }
58}
59
60/**
61 * De items, nieuwste kopie eerst.
62 *
63 * `alles` is alleen waar voor een geverifieerde lezer uit het publiek van de
64 * niet-publieke objecten. De spec: Moves voor objecten die niet aan as:Public
65 * gericht zijn MOGEN NIET publiek getoond worden. Een migration-collectie die
66 * de URIs van je fan-only posts opsomt is een lek, ook zonder de inhoud.
67 */
68export function migrationItems(slug, { alles = false } = {}) {
69 try {
70 const sql = `SELECT origin, target, source_actor FROM ap_migration
71 WHERE slug = ?${alles ? '' : ' AND is_public = 1'} ORDER BY id DESC`;
72 return db.prepare(sql).all(slug);
73 } catch { return []; }
74}
75
76export function migrationCount(slug, { alles = false } = {}) {
77 try {
78 const sql = `SELECT COUNT(*) n FROM ap_migration WHERE slug = ?${alles ? '' : ' AND is_public = 1'}`;
79 return db.prepare(sql).get(slug).n;
80 } catch { return 0; }
81}
82
83/** Is deze URI hier al binnen? Houdt een tweede ingest-ronde goedkoop. */
84export function alGemigreerd(slug, origin) {
85 try { return !!db.prepare('SELECT 1 FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin)); } catch { return false; }
86}
87
88/**
89 * Waar kwam deze bron-URI hier terecht? Null als hij nog niet gemigreerd is.
90 *
91 * Bestaat omdat "al gehad" en "overslaan" niet hetzelfde horen te zijn. Een
92 * tweede ronde na een uitgebreide ingest (hoezen, duur, playlists erbij) moet
93 * de bestaande nummers KUNNEN AANVULLEN in plaats van ze te passeren. Deed hij
94 * dat niet, dan zat je vast: opnieuw ophalen sloeg alles over, en opruimen hielp
95 * niet omdat deze tabel de blokkade in stand hield.
96 */
97export function migrationTarget(slug, origin) {
98 try {
99 const r = db.prepare('SELECT target FROM ap_migration WHERE slug = ? AND origin = ?').get(slug, String(origin));
100 return r ? r.target : null;
101 } catch { return null; }
102}
103
104// ── De Move-activities ────────────────────────────────────────────
105
106export function recordMove(slug, { moveId, sourceActor, targetActor, activity, actorDoc = null } = {}) {
107 if (!slug || !moveId || !sourceActor || !targetActor) return false;
108 try {
109 q('insMove', `INSERT INTO ap_moves (slug, move_id, source_actor, target_actor, activity_json, actor_json)
110 VALUES (?, ?, ?, ?, ?, ?)
111 ON CONFLICT(slug, move_id) DO NOTHING`)
112 .run(slug, String(moveId), String(sourceActor), String(targetActor),
113 JSON.stringify(activity || {}), actorDoc ? JSON.stringify(actorDoc) : null);
114 return true;
115 } catch (e) {
116 console.warn('[FEP-1580] Move niet opgeslagen:', moveId, e && e.message);
117 return false;
118 }
119}
120
121export function moveRows(slug) {
122 try { return db.prepare('SELECT * FROM ap_moves WHERE slug = ? ORDER BY id').all(slug); } catch { return []; }
123}
124
125// ── Stand van zaken ───────────────────────────────────────────────
126
127export function migrationComplete(slug) {
128 try {
129 const r = db.prepare('SELECT migration_complete FROM sites WHERE slug = ?').get(slug);
130 // Geen kolom of geen rij telt als "klaar": een site die nooit verhuisde
131 // heeft niets openstaan, en derden moeten niet eeuwig blijven pollen.
132 return !r || r.migration_complete === null || r.migration_complete === undefined ? true : !!r.migration_complete;
133 } catch { return true; }
134}
135
136export function setMigrationComplete(slug, klaar) {
137 try { db.prepare('UPDATE sites SET migration_complete = ? WHERE slug = ?').run(klaar ? 1 : 0, slug); } catch { /* kolom ontbreekt op een oude db */ }
138}
139
140// ── De collecties ─────────────────────────────────────────────────
141
142/**
143 * De `migration`-collectie. Items zijn Move-activities per OBJECT (niet per
144 * actor): origin is de oude URI, target de nieuwe.
145 *
146 * De spec wil URI-verwijzingen in origin/target in plaats van ingesloten
147 * objecten, en paginering. `pagedCollection` doet dat al voor de rest van
148 * Klonkt, dus die gebruiken we ook hier.
149 */
150export function buildMigration(base, site, { page = false, alles = false } = {}) {
151 const me = actorId(base, site.slug);
152 const id = `${me}/migration`;
153 const rows = migrationItems(site.slug, { alles });
154 const items = rows.map((r) => ({
155 type: 'Move',
156 actor: r.source_actor || undefined,
157 origin: r.origin,
158 target: r.target,
159 }));
160 return pagedCollection(id, items, {
161 page,
162 extra: {
163 attributedTo: me,
164 moves: `${me}/moves`,
165 migrationComplete: migrationComplete(site.slug),
166 },
167 });
168}
169
170/**
171 * De `moves`-collectie: de Move-activities zelf, met het bron-actordocument
172 * ingesloten zoals de spec aanraadt ("Source instances SHOULD inline the source
173 * Actor object"), zodat een lezer de proof kan nakijken zonder de bron nog te
174 * kunnen bereiken. Dat laatste is precies het geval waarvoor dit bestaat.
175 *
176 * Zonder FEP-8b32 (shaer-j1v0) ontbreekt de handtekening. Zie de kop.
177 */
178export function buildMoves(base, site) {
179 const me = actorId(base, site.slug);
180 const rows = moveRows(site.slug);
181 const orderedItems = rows.map((r) => {
182 let act = {};
183 try { act = JSON.parse(r.activity_json) || {}; } catch { /* onleesbaar, dan de kale vorm hieronder */ }
184 let actorDoc = null;
185 try { actorDoc = r.actor_json ? JSON.parse(r.actor_json) : null; } catch { /* idem */ }
186 return {
187 id: r.move_id,
188 type: 'Move',
189 origin: r.source_actor,
190 target: r.target_actor,
191 actor: actorDoc || r.source_actor,
192 ...(act.published ? { published: act.published } : {}),
193 };
194 });
195 return {
196 '@context': AP_CONTEXT,
197 id: `${me}/moves`,
198 type: 'OrderedCollection',
199 attributedTo: me,
200 totalItems: orderedItems.length,
201 orderedItems,
202 };
203}
204
205// ── Wat de UI wil weten ───────────────────────────────────────────
206
207export function migrationStatus(slug) {
208 return {
209 total: migrationCount(slug, { alles: true }),
210 publiek: migrationCount(slug),
211 moves: moveRows(slug).length,
212 complete: migrationComplete(slug),
213 };
214}
215
216/** Een id dat nergens mee botst, in de vorm die de rest van Klonkt gebruikt. */
217export function nieuwId() { return crypto.randomUUID(); }
218
219// ── De ingest: van de bron hierheen ───────────────────────────────
220
221/** Vrije slug binnen deze site. Botst hij, dan -2, -3, enzovoort. */
222function vrijeSlug(siteId, basis) {
223 const schoon = String(basis || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'bericht';
224 const bestaat = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
225 if (!bestaat.get(siteId, schoon)) return schoon;
226 for (let n = 2; n < 500; n++) if (!bestaat.get(siteId, `${schoon}-${n}`)) return `${schoon}-${n}`;
227 return `${schoon}-${crypto.randomBytes(4).toString('hex')}`;
228}
229
230/** Het kale id uit een track-URI: .../tracks/t-een -> t-een. */
231function ruwId(uri) {
232 try { return decodeURIComponent(String(uri).split('/').filter(Boolean).pop() || ''); } catch { return ''; }
233}
234
235/** De laatste padcomponent van een URI, als beginpunt voor een slug. */
236function slugUitUri(uri) {
237 try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
238}
239
240const AFBEELDING = /^image\//i;
241
242/**
243 * Links naar de BRONPOSTS ombuigen naar hier.
244 *
245 * De gebakken content zit vol met https://oud/<slug>#track-<id> en
246 * https://oud/<slug>?fc=2: de "luister op"-links die buildNote maakt. Die
247 * blijven naar de oude site wijzen, en dat is een tijdbom, want zodra dat
248 * domein opgezegd wordt zijn het dode links in je eigen berichten.
249 *
250 * Kan pas als ALLE posts binnen zijn, en alleen voor een slug die hier echt
251 * bestaat. Een link naar iets dat we niet hebben laten we met rust: dan is een
252 * verwijzing naar de oude site nog altijd beter dan een 404 op de nieuwe.
253 *
254 * De #track-<id>-fragmenten kloppen vanzelf, want sinds "altijd behouden" is
255 * dat id hier hetzelfde.
256 */
257export function postLinksBijtrekken(site, bronOrigin, rapport = {}) {
258 if (!bronOrigin) return 0;
259 let n = 0;
260 const rijen = db.prepare('SELECT id, content FROM posts WHERE site_id = ? AND content LIKE ?')
261 .all(site.id, `%${bronOrigin}/%`);
262 if (!rijen.length) return 0;
263 const heeftSlug = db.prepare('SELECT 1 FROM posts WHERE site_id = ? AND slug = ?');
264 const upd = db.prepare('UPDATE posts SET content = ? WHERE id = ?');
265 const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/([A-Za-z0-9._~-]+)`, 'g');
266 for (const r of rijen) {
267 let inhoud = String(r.content || '');
268 let raak = false;
269 for (const m of [...new Set([...inhoud.matchAll(patroon)].map((x) => x[1]))]) {
270 // media en audio lopen via hun eigen weg; hier gaat het om postpagina's
271 if (m === 'media' || m === 'audio' || m === 'ap') continue;
272 if (!heeftSlug.get(site.id, m)) continue;
273 inhoud = inhoud.split(`${bronOrigin}/${m}`).join(`/${m}`);
274 raak = true;
275 }
276 if (raak) { upd.run(inhoud, r.id); n++; }
277 }
278 if (n) {
279 rapport.linksBijgetrokken = n;
280 console.log('[FEP-1580] postlinks bijgetrokken in', n, 'bericht(en)');
281 }
282 return n;
283}
284
285/**
286 * Waar kan de omslag van een bericht zitten?
287 *
288 * Niet alleen in `attachment`. Klonkt onderdrukt de beeldbijlage met opzet
289 * zodra een post een speler of embed heeft (zie noImages in buildNote), en zet
290 * de cover dan in `image` zodat Mastodon zijn spelerkaart toont en een Klonkt
291 * hem alsnog vindt. Dat is precies wat er bij Robin misging: 18 van de 20
292 * berichten op pagina 1 hadden geen enkele bijlage, en toch een cover, en die
293 * viel er stil tussenuit.
294 *
295 * En een Audio-bijlage draagt zijn eigen hoes in `icon`; die telt ook mee.
296 */
297function coverKandidaten(o) {
298 const uit = [];
299 const pak = (v) => {
300 if (!v) return;
301 const u = typeof v === 'string' ? v : (v.url && (typeof v.url === 'string' ? v.url : v.url.href)) || v.href;
302 if (u && /^https?:\/\//i.test(String(u))) uit.push(String(u));
303 };
304 pak(o.image);
305 pak(o.icon);
306 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) pak(a && a.icon);
307 return [...new Set(uit)];
308}
309
310/**
311 * Hoort deze URL bij de bron, en wijst hij onder /media/?
312 *
313 * Dan behouden we het PAD. Drie redenen tegelijk:
314 * - de gebakken content verwijst relatief of absoluut naar dat pad, en met
315 * hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
316 * - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
317 * databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
318 * onzichtbaar: Robins lege images-tab;
319 * - de zip-import bewaart originele paden al, dus zo convergeren beide
320 * routes op dezelfde bestanden.
321 *
322 * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
323 */
324function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
325 try {
326 const u = new URL(String(url));
327 if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
328 if (!u.pathname.startsWith('/media/')) return null;
329 const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
330 const abs = path.resolve(mediaRoot, rel);
331 const root = path.resolve(mediaRoot);
332 if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
333 return { rel: `/media/${rel}`, abs };
334 } catch { return null; }
335}
336
337/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
338function duurSeconden(v) {
339 if (v == null) return null;
340 if (typeof v === 'number') return Math.round(v) || null;
341 const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
342 if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
343 const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
344 return sec > 0 ? Math.round(sec) : null;
345}
346
347/**
348 * De titel terugwinnen uit de content.
349 *
350 * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
351 * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
352 * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
353 * elk bericht titelloos aan en heet het naar zijn id.
354 *
355 * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
356 * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
357 * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
358 * verliest die regel niet, hij verhuist naar het titelveld en staat straks
359 * gewoon weer bovenaan.
360 */
361function titelUitContent(html) {
362 const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
363 if (!m) return { titel: null, rest: html };
364 const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
365 if (!titel || titel.length > 300) return { titel: null, rest: html };
366 return { titel, rest: String(html).slice(m[0].length) };
367}
368
369/**
370 * Haal een bijlage op en zet hem lokaal neer.
371 *
372 * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
373 * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
374 * moet stranden, ook als die bron "van onszelf" is.
375 */
376async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
377 // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
378 // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
379 // niet zien dat wij de doel-actor van zijn Move zijn.
380 const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
381 if (!r || !r.ok) return null;
382 const buf = Buffer.from(await r.arrayBuffer());
383 if (!buf.length || buf.length > maxBytes) return null;
384 const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
385 const ext = (() => {
386 const uit = slugUitUri(url);
387 const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
388 if (m) return m[1].toLowerCase();
389 return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
390 })();
391 const naam = `${crypto.randomUUID()}.${ext}`;
392 // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
393 // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
394 // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
395 // in mappen eronder.
396 const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
397 const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
398 fs.mkdirSync(path.dirname(abs), { recursive: true });
399 fs.writeFileSync(abs, buf);
400 // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
401 return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
402}
403
404/**
405 * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
406 *
407 * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
408 * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
409 * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
410 * verwarrender dan geen herschrijving.
411 *
412 * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
413 * ronde over dezelfde tekst vindt gewoon niets meer te doen.
414 */
415async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
416 let inhoud = String(html || '');
417 if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
418 // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
419 // enkele \s eet de template literal de backslash op en sluit de klasse de
420 // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
421 // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
422 // meldde, afgekapt precies voor de s.
423 const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
424 const gezien = new Set();
425 let n = 0;
426 for (const m of [...inhoud.matchAll(patroon)]) {
427 const vol = m[0];
428 if (gezien.has(vol)) continue;
429 gezien.add(vol);
430 const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
431 if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
432 let ok = false;
433 try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
434 if (!ok) {
435 const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
436 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
437 rapport.media++;
438 try {
439 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
440 .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
441 } catch { /* administratie */ }
442 }
443 inhoud = inhoud.split(vol).join(doel.rel);
444 n++;
445 }
446 return { inhoud, n };
447}
448
449/**
450 * FEP-1580 ingest-routine, de doelkant.
451 *
452 * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
453 * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
454 * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
455 * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
456 * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
457 * geschiedenis van een vreemde opeisen.
458 *
459 * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
460 */
461export async function ingestFromSource(site, {
462 sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
463} = {}) {
464 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
465 if (!base || !site || !site.slug) return { error: 'config' };
466 const me = actorId(base, site.slug);
467
468 const {
469 getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
470 sanitize = (h) => h,
471 // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
472 // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
473 // privacygrens en niet een weergavedetail.
474 noteVisibility = () => 'followers',
475 audioRoot = null, signHeaders = null,
476 } = deps;
477 const zichtbaarheid = noteVisibility;
478 if (!getJson || !noteId) return { error: 'config' };
479
480 // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
481 let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
482 if (!bron) {
483 try {
484 const aka = JSON.parse(site.ap_aliases || '[]');
485 bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
486 } catch { /* stukke ap_aliases telt als geen alias */ }
487 }
488 if (!bron) return { error: 'no_source' };
489
490 // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
491 const bronActor = await getJson(site.slug, bron);
492 if (!bronActor || !bronActor.id) return { error: 'unreachable' };
493 // De origin van de bron: alles op deze host onder /media/ is van hem en mag
494 // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
495 const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
496 if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
497
498 // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
499 const eigenAka = (() => {
500 try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
501 })();
502 if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
503
504 // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
505 recordMove(site.slug, {
506 moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
507 activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
508 actorDoc: bronActor,
509 });
510 setMigrationComplete(site.slug, false);
511
512 const rapport = {
513 bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
514 blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, tracksLinks: 0, overgeslagenTracks: 0,
515 playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
516 };
517
518 try {
519 // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
520 // wie de rest te zien krijgt. Andersom importeer je even je hele
521 // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
522 if (bronActor.blocked) {
523 const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
524 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
525 for (const b of Array.isArray(lijst) ? lijst : []) {
526 const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
527 if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
528 try {
529 db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
530 rapport.blocks++;
531 } catch { /* tabel ontbreekt op een verse db */ }
532 }
533 } else {
534 rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
535 }
536
537 // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
538 if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
539 let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
540 const verwacht = pagina && Number(pagina.totalItems) || null;
541 // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
542 // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
543 // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
544 // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
545 // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
546 // berichten, zonder één waarschuwing.
547 if (pagina && pagina.first) {
548 pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
549 }
550
551 const insPost = db.prepare(`INSERT INTO posts
552 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
553 pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
554 content_warning, ap_visibility, c2s_attachments, origin_server)
555 VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
556 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
557 @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
558
559 let gezien = 0;
560 while (pagina && gezien < max) {
561 const items = (pagina.orderedItems || pagina.items) || [];
562 for (const it of Array.isArray(items) ? items : []) {
563 if (gezien >= max) break;
564 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
565 if (!o || !o.id) continue;
566 if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
567 if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
568 const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
569 if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
570 gezien++;
571 // Het interne id BLIJFT (Robins besluit, 14-8). Daarmee is "staat hij
572 // hier al" gewoon een blik in de tabel, en niet iets dat je uit een
573 // aparte mapping moet afleiden. Verwijder je een bericht en haal je
574 // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
575 const id = ruwId(o.id) || crypto.randomUUID();
576 const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
577 if (bestaand) {
578 // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
579 // Robins 18 posts stonden er al, met hotlinks naar de bron in de
580 // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
581 // laat je met een site vol verwijzingen naar een domein dat
582 // opgezegd wordt.
583 if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
584 const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
585 if (r2.n) {
586 db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
587 rapport.postsBijgewerkt++;
588 }
589 }
590 if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
591 // De omslag alsnog. Uit de beeldbijlage als die er is, anders uit
592 // image/icon: bij een post met een speler staat hij daar.
593 const uit = [
594 ...(Array.isArray(o.attachment) ? o.attachment : [])
595 .filter((a) => AFBEELDING.test(String((a && a.mediaType) || '')))
596 .map((a) => (typeof a.url === 'string' ? a.url : (a.url && a.url.href)))
597 .filter(Boolean),
598 ...coverKandidaten(o),
599 ];
600 for (const u of uit) {
601 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
602 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
603 if (!g) continue;
604 db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
605 rapport.media++;
606 rapport.postsBijgewerkt++;
607 break;
608 }
609 }
610 rapport.overgeslagen++;
611 continue;
612 }
613 if (migrationTarget(site.slug, o.id)) rapport.opnieuw++; // was er, is weg, komt terug
614
615 // Media eerst, want een post die naar een plaatje wijst dat we niet
616 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
617 // post wel door en staat het in het verslag.
618 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
619 const binnen = [];
620 let inhoud = o.content || '';
621 if (safeFetch && fs && path && mediaRoot) {
622 for (const a of bijlagen.slice(0, 20)) {
623 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
624 if (!u || !/^https?:\/\//i.test(String(u))) continue;
625 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
626 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
627 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
628 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
629 rapport.media++;
630 try {
631 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
632 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
633 } catch { /* media-rij is administratie, het bestand staat er */ }
634 }
635 // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
636 // verwijzingen naar de bron (https://oud/media/...), en die bleven
637 // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
638 // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
639 // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
640 // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
641 // een hotlink.
642 const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
643 inhoud = r2.inhoud;
644 // De omslag zit lang niet altijd in attachment (zie coverKandidaten).
645 if (!binnen.some((b) => AFBEELDING.test(b.type || ''))) {
646 for (const u of coverKandidaten(o)) {
647 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
648 const g = await haalBijlage(u, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
649 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`omslag niet opgehaald: ${u}`); continue; }
650 binnen.unshift({ ...g, naam: null, type: g.mediaType });
651 rapport.media++;
652 try {
653 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
654 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
655 } catch { /* administratie */ }
656 break; // een omslag is genoeg
657 }
658 }
659 }
660
661 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
662 const rest = binnen.filter((b) => b !== cover);
663 // De titel zit in de content, niet in een veld (zie titelUitContent).
664 const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
665 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
666 // hetzelfde webadres als op de oude instantie, en blijft een link die
667 // iemand ergens plakte kloppen op het nieuwe domein.
668 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
669 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
670 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
671 insPost.run({
672 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
673 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
674 cover_image_url: cover ? cover.url : null,
675 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
676 published_at: o.published || null, updated_at: o.updated || o.published || null,
677 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
678 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
679 content_warning: o.summary || null,
680 ap_visibility: null,
681 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
682 });
683 recordMigrated(site.slug, {
684 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
685 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
686 // hoort deze regel niet in een publiek leesbare migration-pagina.
687 //
688 // Via noteVisibility en niet met een eigen test op '#Public': die kent
689 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
690 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
691 // precies hoe twee antwoorden uit elkaar gaan lopen.
692 isPublic: zichtbaarheid(o) === 'public',
693 });
694 rapport.posts++;
695 }
696 const volgende = pagina.next;
697 if (!volgende || gezien >= max) break;
698 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
699 }
700 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
701 // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
702 // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
703 if (verwacht && gezien < verwacht && gezien < max) {
704 rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
705 }
706
707 // ── De muziekbibliotheek ──────────────────────────────────────
708 //
709 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
710 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
711 // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
712 //
713 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
714 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
715 // geldt voor de bestanden zelf, die anders achter de gated audio-route
716 // blijven.
717 const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
718 // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
719 // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
720 // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
721 // heen en zie je de code zelf in je bericht staan.
722 const ruwKaart = new Map(); // ruw bron-id -> ons id
723 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
724 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
725 if (tracksUrl && safeFetch && fs && path && audioRoot) {
726 const coll = await getJson(site.slug, tracksUrl);
727 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
728 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
729 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
730 if (!a || !a.id) continue;
731 if (a.type && a.type !== 'Audio') continue;
732 // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
733 // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
734 // pull die dan alles passeert laat je met een half resultaat zitten
735 // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
736 // in stand.
737 //
738 // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
739 // staan; een migratie hoort je correcties niet terug te draaien.
740 const trackId = ruwId(a.id) || crypto.randomUUID();
741 {
742 const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
743 .get(trackId, site.id);
744 if (rij) {
745 trackKaart.set(String(a.id), rij.id); // MOET, anders vinden de playlists hem niet
746 ruwKaart.set(ruwId(a.id), rij.id);
747 const duur = rij.duration ? null : duurSeconden(a.duration);
748 const artiest = rij.artist ? null : (a.summary || a.artist || null);
749 let hoes = null;
750 const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
751 if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
752 const h = await haalBijlage(String(hUrl), {
753 safeFetch, mediaRoot, fs, path, maxBytes,
754 headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
755 }).catch(() => null);
756 if (h) { hoes = h.url; rapport.media++; }
757 }
758 if (duur || artiest || hoes) {
759 db.prepare(`UPDATE audio_tracks SET
760 duration = COALESCE(?, duration),
761 artist = COALESCE(?, artist),
762 cover_url = COALESCE(?, cover_url)
763 WHERE id = ?`).run(duur, artiest, hoes, rij.id);
764 rapport.tracksBijgewerkt++;
765 } else {
766 rapport.overgeslagenTracks++;
767 }
768 continue;
769 }
770 }
771 // Uit de url-lijst de LINK NAAR HET BESTAND vissen, niet zomaar de eerste:
772 // buildTrackAudio zet er ook een text/html-link naar de post voor. En een
773 // LINK-ONLY track (alleen Spotify of YouTube, nooit een gehost bestand)
774 // heeft er helemaal geen. Die hoort gewoon mee, met media_id NULL.
775 // Weggooien kostte Robin een nummer dat het op de oude site prima deed.
776 const urls = [].concat(a.url || []).map((u) => (typeof u === 'string' ? { href: u } : u)).filter((u) => u && u.href);
777 const bestandLink = urls.find((u) => /^audio\//i.test(String(u.mediaType || '')))
778 || urls.find((u) => /\/audio\/stream\//.test(String(u.href)));
779 const externe = urls.map((u) => String(u.href)).filter((h) => /spotify|youtube|youtu\.be|soundcloud|bandcamp/i.test(h));
780 const bron = bestandLink && bestandLink.href;
781 if (!bron || !/^https?:\/\//i.test(String(bron))) {
782 if (externe.length) {
783 const trackIdL = ruwId(a.id) || crypto.randomUUID();
784 try {
785 db.prepare(`INSERT OR REPLACE INTO audio_tracks
786 (id, site_id, title, artist, media_id, link_spotify, link_youtube, link_soundcloud, fedi_open)
787 VALUES (?,?,?,?,NULL,?,?,?,0)`)
788 .run(trackIdL, site.id, a.name || 'zonder titel', a.summary || a.artist || null,
789 externe.find((u) => /spotify/i.test(u)) || null,
790 externe.find((u) => /youtube|youtu\.be/i.test(u)) || null,
791 externe.find((u) => /soundcloud/i.test(u)) || null);
792 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackIdL}`, sourceActor: bronActor.id, isPublic: false });
793 trackKaart.set(String(a.id), trackIdL);
794 ruwKaart.set(ruwId(a.id), trackIdL);
795 rapport.tracksLinks++;
796 rapport.tracksBinnen++;
797 } catch (e) {
798 rapport.tracksMislukt++;
799 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
800 }
801 continue;
802 }
803 rapport.tracksMislukt++;
804 continue;
805 }
806 const g = await haalBijlage(String(bron), {
807 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
808 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
809 }).catch(() => null);
810 if (!g) {
811 rapport.tracksMislukt++;
812 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
813 continue; // dezelfde regel als bij de zip: geen bestand, geen track
814 }
815 // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
816 // nummer aan met een verwijzing naar een plaatje dat er niet is.
817 let hoes = null;
818 const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
819 if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
820 const h = await haalBijlage(String(hoesUrl), {
821 safeFetch, mediaRoot, fs, path, maxBytes,
822 headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
823 }).catch(() => null);
824 if (h) { hoes = h.url; rapport.media++; }
825 else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
826 }
827 const mediaId = crypto.randomUUID();
828 try {
829 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
830 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
831 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
832 VALUES (?,?,?,?,?,?,?,?,0)`)
833 .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
834 duurSeconden(a.duration), mediaId, hoes);
835 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
836 trackKaart.set(String(a.id), trackId);
837 ruwKaart.set(ruwId(a.id), trackId);
838 rapport.tracksBinnen++;
839 } catch (e) {
840 rapport.tracksMislukt++;
841 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
842 }
843 }
844 } else if (tracksUrl) {
845 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
846 }
847
848 // Postlinks eerst: pas nu zijn ALLE berichten binnen, dus pas nu weten we
849 // welke slugs hier bestaan.
850 postLinksBijtrekken(site, bronOrigin, rapport);
851
852 // ── De verwijzingen in de tekst bijtrekken ────────────────────
853 //
854 // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
855 // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
856 // shorthand nergens heen en zie je de code zelf in je bericht staan in
857 // plaats van een speler. Precies wat Robin op TikTik zag.
858 //
859 // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
860 // id echt veranderde: een gelijk id hoeft niet aangeraakt.
861 {
862 const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
863 if (paren.length) {
864 const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
865 let n = 0;
866 for (const [oud, nieuwId] of paren) {
867 const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
868 if (r && r.changes) n += r.changes;
869 }
870 if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
871 }
872 }
873
874 // ── De playlists ──────────────────────────────────────────────
875 //
876 // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
877 // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
878 // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
879 // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
880 // plaat met gaten.
881 const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
882 if (plUrl && trackKaart.size) {
883 const coll = await getJson(site.slug, plUrl);
884 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
885 for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
886 const uri = typeof p === 'string' ? p : (p && p.id);
887 if (!uri) continue;
888 const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
889 if (!plc) { rapport.playlistsMislukt++; continue; }
890 const nummers = (plc.orderedItems || plc.items || [])
891 .map((x) => (x && typeof x === 'object' ? x.id : x))
892 .map((id) => trackKaart.get(String(id)))
893 .filter(Boolean);
894 if (!nummers.length) {
895 rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
896 continue;
897 }
898 // De hoes van de plaat, net als bij een nummer.
899 let plHoes = null;
900 const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
901 if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
902 const h = await haalBijlage(String(plHoesUrl), {
903 safeFetch, mediaRoot, fs, path, maxBytes,
904 headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
905 }).catch(() => null);
906 if (h) { plHoes = h.url; rapport.media++; }
907 else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
908 }
909 // Ook hier het id van de bron. Dan blijft [[playlist:<id>]] in een
910 // bericht wijzen, en is een tweede ronde vanzelf dezelfde rij.
911 const plId = ruwId(uri) || crypto.randomUUID();
912 try {
913 db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
914 ON CONFLICT(id) DO UPDATE SET
915 title = excluded.title,
916 artist = COALESCE(playlists.artist, excluded.artist),
917 cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
918 .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
919 plc.year || null, plc['shaer:kind'] || null, plHoes);
920 // De volgorde opnieuw zetten: die IS de plaat, en een halve
921 // bijgewerkte volgorde is erger dan een verse.
922 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
923 const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
924 nummers.forEach((tid, i) => ins.run(plId, tid, i));
925 recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
926 rapport.playlistsBinnen++;
927 const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
928 if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
929 } catch (e) {
930 rapport.playlistsMislukt++;
931 rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
932 }
933 }
934 }
935 } catch (e) {
936 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
937 // en dat is precies goed, want er is nog werk.
938 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
939 return { ...rapport, error: 'partial', melding: e && e.message };
940 }
941
942 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
943 setMigrationComplete(site.slug, true);
944 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
945 return rapport;
946}
Note: See TracBrowser for help on using the repository browser.