source: Klonkt/src/services/MigrationService.js@ 9f9b45f

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

De omslag zat in image, en daar keek de ingest niet

Robin: nog steeds niet alle plaatjes. Gemeten op soundfabrics.nl: 35 posts
binnen (de paginatiefix werkt), maar 9 covers van de 36.

DE OORZAAK STAAT IN ONS EIGEN COMMENTAAR. buildNote onderdrukt de beeldbijlage
zodra een post een speler of embed heeft (noImages), zodat Mastodon zijn
spelerkaart toont in plaats van de cover, en zet de omslag dan in image. Bij
dat stuk staat letterlijk "a Klonkt receiver reads it (handleInbox o.image)".
Alleen: de ingest is ook een Klonkt receiver, en die keek uitsluitend naar
attachment. Op pagina 1 hadden 18 van de 20 berichten daardoor geen enkele
bijlage en toch een cover.

Een Audio-bijlage draagt bovendien zijn eigen hoes in icon, en ook die viel
buiten de boot.

coverKandidaten() haalt nu alle drie de plekken op: o.image, o.icon, en de icon
van elke bijlage. Zowel bij een vers bericht als bij het repareren van een
bericht dat er al staat.

WAT GEEN FOUT WAS: de 23 berichten die nog naar sound-fabrics.com wijzen. Dat
zijn geen plaatjes maar de gebakken tracklinks (/tiktik#track-... en ?fc=2),
die bewust naar de bronpost verwijzen. Die laat ik met rust; ze verdwijnen pas
als de oude site weggaat, en dan is een dode link eerlijker dan een link naar
een pagina die hier niet bestaat.

Changed files:
src/services/MigrationService.js

  • coverKandidaten(): image, icon, en de icon van een bijlage
  • beide takken gebruiken hem: verse posts en de reparatieronde

test/fep1580-migration.test.js

  • een speler-post zonder bijlage met omslag in image, plus een Audio-bijlage met hoes in icon

remarks: in de mediamap van soundfabrics.nl staan 308 bestanden onder migrated/
uit de rondes van voor de padbehoud-fix. Die zijn dubbel: dezelfde plaatjes
staan nu ook onder hun eigen pad. Opruimen kan, maar niet blind: eerst
nagaan welke rijen er nog naar wijzen. Suite 998 groen, tegenproef gedaan.

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

  • Property mode set to 100644
File size: 44.9 KB
RevLine 
[fbfd7a1]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
[eb071a7]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
[fbfd7a1]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
[919b82d]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
[fbfd7a1]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
[9f9b45f]242/**
243 * Waar kan de omslag van een bericht zitten?
244 *
245 * Niet alleen in `attachment`. Klonkt onderdrukt de beeldbijlage met opzet
246 * zodra een post een speler of embed heeft (zie noImages in buildNote), en zet
247 * de cover dan in `image` zodat Mastodon zijn spelerkaart toont en een Klonkt
248 * hem alsnog vindt. Dat is precies wat er bij Robin misging: 18 van de 20
249 * berichten op pagina 1 hadden geen enkele bijlage, en toch een cover, en die
250 * viel er stil tussenuit.
251 *
252 * En een Audio-bijlage draagt zijn eigen hoes in `icon`; die telt ook mee.
253 */
254function coverKandidaten(o) {
255 const uit = [];
256 const pak = (v) => {
257 if (!v) return;
258 const u = typeof v === 'string' ? v : (v.url && (typeof v.url === 'string' ? v.url : v.url.href)) || v.href;
259 if (u && /^https?:\/\//i.test(String(u))) uit.push(String(u));
260 };
261 pak(o.image);
262 pak(o.icon);
263 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) pak(a && a.icon);
264 return [...new Set(uit)];
265}
266
[f894be5]267/**
268 * Hoort deze URL bij de bron, en wijst hij onder /media/?
269 *
270 * Dan behouden we het PAD. Drie redenen tegelijk:
271 * - de gebakken content verwijst relatief of absoluut naar dat pad, en met
272 * hetzelfde pad hier klopt elke verwijzing zonder herschrijf-acrobatiek;
273 * - de media-bibliotheek (Beheer, Media) scant de MAP post-images, niet de
274 * databasetabel. Een bestand onder migrated/<uuid> bestaat wel en is
275 * onzichtbaar: Robins lege images-tab;
276 * - de zip-import bewaart originele paden al, dus zo convergeren beide
277 * routes op dezelfde bestanden.
278 *
279 * De ../-bewaking is geen formaliteit: het pad komt van een andere server.
280 */
281function bronMediaPad(url, bronOrigin, { mediaRoot, path }) {
282 try {
283 const u = new URL(String(url));
284 if (`${u.protocol}//${u.host}` !== bronOrigin) return null;
285 if (!u.pathname.startsWith('/media/')) return null;
286 const rel = decodeURIComponent(u.pathname.slice('/media/'.length));
287 const abs = path.resolve(mediaRoot, rel);
288 const root = path.resolve(mediaRoot);
289 if (abs === root || !abs.startsWith(`${root}${path.sep}`)) return null;
290 return { rel: `/media/${rel}`, abs };
291 } catch { return null; }
292}
293
[98acb97]294/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
295function duurSeconden(v) {
296 if (v == null) return null;
297 if (typeof v === 'number') return Math.round(v) || null;
298 const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
299 if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
300 const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
301 return sec > 0 ? Math.round(sec) : null;
302}
303
[fbfd7a1]304/**
305 * De titel terugwinnen uit de content.
306 *
307 * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
308 * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
309 * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
310 * elk bericht titelloos aan en heet het naar zijn id.
311 *
312 * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
313 * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
314 * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
315 * verliest die regel niet, hij verhuist naar het titelveld en staat straks
316 * gewoon weer bovenaan.
317 */
318function titelUitContent(html) {
319 const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
320 if (!m) return { titel: null, rest: html };
321 const titel = m[1].replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').trim();
322 if (!titel || titel.length > 300) return { titel: null, rest: html };
323 return { titel, rest: String(html).slice(m[0].length) };
324}
325
326/**
327 * Haal een bijlage op en zet hem lokaal neer.
328 *
329 * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
330 * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
331 * moet stranden, ook als die bron "van onszelf" is.
332 */
[f894be5]333async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null, doel = null }) {
[6c4ff7e]334 // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
335 // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
336 // niet zien dat wij de doel-actor van zijn Move zijn.
337 const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
[fbfd7a1]338 if (!r || !r.ok) return null;
339 const buf = Buffer.from(await r.arrayBuffer());
340 if (!buf.length || buf.length > maxBytes) return null;
341 const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
342 const ext = (() => {
343 const uit = slugUitUri(url);
344 const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
345 if (m) return m[1].toLowerCase();
346 return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
347 })();
348 const naam = `${crypto.randomUUID()}.${ext}`;
[f894be5]349 // `doel` wint: dan behouden we het pad van de bron (zie bronMediaPad).
[6c4ff7e]350 // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
351 // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
352 // in mappen eronder.
[f894be5]353 const rel = doel ? doel.rel : (submap ? `${submap}/${naam}` : naam);
354 const abs = doel ? doel.abs : (submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam));
[fbfd7a1]355 fs.mkdirSync(path.dirname(abs), { recursive: true });
356 fs.writeFileSync(abs, buf);
[f894be5]357 // doel.rel is al een volledig /media/-pad; de submap-variant is dat nog niet.
358 return { url: doel ? doel.rel : `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
359}
360
361/**
362 * Alle bron-media in een lap HTML binnenhalen en de verwijzingen relatief maken.
363 *
364 * Werkt op ALLE https://bron/media/...-voorkomens, niet alleen op <img src>:
365 * de gebakken content zet dezelfde URL ook in een href om het plaatje groot te
366 * openen, en een half herschreven paar (lokaal plaatje, hotlink eromheen) is
367 * verwarrender dan geen herschrijving.
368 *
369 * Idempotent: wat al gedownload is wordt niet opnieuw gehaald, en een tweede
370 * ronde over dezelfde tekst vindt gewoon niets meer te doen.
371 */
372async function inhoudMediaBinnen(html, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes }) {
373 let inhoud = String(html || '');
374 if (!inhoud || !bronOrigin) return { inhoud, n: 0 };
375 // LET OP de dubbele backslash: dit is een STRING die een RegExp wordt. Met een
376 // enkele \s eet de template literal de backslash op en sluit de klasse de
377 // LETTER s uit; "post-images" knapte dan af op de s en elke URL met een s
378 // erin werd half herschreven. Gevonden doordat de waarschuwing ".../media/po"
379 // meldde, afgekapt precies voor de s.
380 const patroon = new RegExp(`${bronOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/media/[^"'\\s)<>]+)`, 'g');
381 const gezien = new Set();
382 let n = 0;
383 for (const m of [...inhoud.matchAll(patroon)]) {
384 const vol = m[0];
385 if (gezien.has(vol)) continue;
386 gezien.add(vol);
387 const doel = bronMediaPad(vol, bronOrigin, { mediaRoot, path });
388 if (!doel) { rapport.waarschuwingen.push(`onbruikbaar mediapad in tekst: ${vol}`); continue; }
389 let ok = false;
390 try { fs.statSync(doel.abs); ok = true; } catch { /* nog niet binnen */ }
391 if (!ok) {
392 const g = await haalBijlage(vol, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
393 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`plaatje in tekst niet opgehaald: ${vol}`); continue; }
394 rapport.media++;
395 try {
396 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
397 .run(crypto.randomUUID(), site.id, path.basename(doel.abs), g.mediaType, g.size, doel.abs);
398 } catch { /* administratie */ }
399 }
400 inhoud = inhoud.split(vol).join(doel.rel);
401 n++;
402 }
403 return { inhoud, n };
[fbfd7a1]404}
405
406/**
407 * FEP-1580 ingest-routine, de doelkant.
408 *
409 * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
410 * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
411 * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
412 * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
413 * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
414 * geschiedenis van een vreemde opeisen.
415 *
416 * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
417 */
418export async function ingestFromSource(site, {
419 sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
420} = {}) {
421 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
422 if (!base || !site || !site.slug) return { error: 'config' };
423 const me = actorId(base, site.slug);
424
425 const {
426 getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
427 sanitize = (h) => h,
428 // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
429 // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
430 // privacygrens en niet een weergavedetail.
431 noteVisibility = () => 'followers',
[6c4ff7e]432 audioRoot = null, signHeaders = null,
[fbfd7a1]433 } = deps;
434 const zichtbaarheid = noteVisibility;
435 if (!getJson || !noteId) return { error: 'config' };
436
437 // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
438 let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
439 if (!bron) {
440 try {
441 const aka = JSON.parse(site.ap_aliases || '[]');
442 bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
443 } catch { /* stukke ap_aliases telt als geen alias */ }
444 }
445 if (!bron) return { error: 'no_source' };
446
447 // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
448 const bronActor = await getJson(site.slug, bron);
449 if (!bronActor || !bronActor.id) return { error: 'unreachable' };
[f894be5]450 // De origin van de bron: alles op deze host onder /media/ is van hem en mag
451 // naar hetzelfde pad hier. Uit de actor-id, niet uit de invoer.
452 const bronOrigin = (() => { try { const u = new URL(bronActor.id); return `${u.protocol}//${u.host}`; } catch { return null; } })();
[fbfd7a1]453 if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
454
455 // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
456 const eigenAka = (() => {
457 try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
458 })();
459 if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
460
461 // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
462 recordMove(site.slug, {
463 moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
464 activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
465 actorDoc: bronActor,
466 });
467 setMigrationComplete(site.slug, false);
468
469 const rapport = {
[f894be5]470 bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
[eb071a7]471 blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
[98acb97]472 playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
[fbfd7a1]473 };
474
475 try {
476 // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
477 // wie de rest te zien krijgt. Andersom importeer je even je hele
478 // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
479 if (bronActor.blocked) {
480 const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
481 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
482 for (const b of Array.isArray(lijst) ? lijst : []) {
483 const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
484 if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
485 try {
486 db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
487 rapport.blocks++;
488 } catch { /* tabel ontbreekt op een verse db */ }
489 }
490 } else {
491 rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
492 }
493
494 // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
495 if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
496 let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
[f894be5]497 const verwacht = pagina && Number(pagina.totalItems) || null;
498 // Is er een `first`, dan ALTIJD de paginaketen volgen, ook als de kale
499 // collectie zelf items draagt. Klonkt zet daar een kopie van pagina 1 in
500 // (Pleroma eiste een first, en sindsdien staan ze er allebei), maar alleen
501 // echte pagina's dragen een `next`. Wie op de kale collectie blijft hangen
502 // verwerkt pagina 1 en denkt dan klaar te zijn: precies 18 van Robins 35
503 // berichten, zonder één waarschuwing.
504 if (pagina && pagina.first) {
[fbfd7a1]505 pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
506 }
507
508 const insPost = db.prepare(`INSERT INTO posts
509 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
510 pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
511 content_warning, ap_visibility, c2s_attachments, origin_server)
512 VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
513 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
514 @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
515
516 let gezien = 0;
517 while (pagina && gezien < max) {
518 const items = (pagina.orderedItems || pagina.items) || [];
519 for (const it of Array.isArray(items) ? items : []) {
520 if (gezien >= max) break;
521 const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
522 if (!o || !o.id) continue;
523 if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
524 if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
525 const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
526 if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
527 gezien++;
[19430fa]528 // Het interne id BLIJFT (Robins besluit, 14-8). Daarmee is "staat hij
529 // hier al" gewoon een blik in de tabel, en niet iets dat je uit een
530 // aparte mapping moet afleiden. Verwijder je een bericht en haal je
531 // opnieuw op, dan komt het gewoon terug: er staat immers niets meer.
532 const id = ruwId(o.id) || crypto.randomUUID();
[f894be5]533 const bestaand = db.prepare('SELECT id, content, cover_image_url FROM posts WHERE id = ? AND site_id = ?').get(id, site.id);
534 if (bestaand) {
535 // Niet alleen overslaan: REPAREREN wat een eerdere ronde liet liggen.
536 // Robins 18 posts stonden er al, met hotlinks naar de bron in de
537 // tekst en zonder cover. Een tweede ronde die dat ziet en passeert
538 // laat je met een site vol verwijzingen naar een domein dat
539 // opgezegd wordt.
540 if (safeFetch && fs && path && mediaRoot && bronOrigin && String(bestaand.content || '').includes(bronOrigin)) {
541 const r2 = await inhoudMediaBinnen(bestaand.content, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
542 if (r2.n) {
543 db.prepare('UPDATE posts SET content = ? WHERE id = ?').run(r2.inhoud, bestaand.id);
544 rapport.postsBijgewerkt++;
545 }
546 }
547 if (!bestaand.cover_image_url && safeFetch && fs && path && mediaRoot) {
[9f9b45f]548 // De omslag alsnog. Uit de beeldbijlage als die er is, anders uit
549 // image/icon: bij een post met een speler staat hij daar.
550 const uit = [
551 ...(Array.isArray(o.attachment) ? o.attachment : [])
552 .filter((a) => AFBEELDING.test(String((a && a.mediaType) || '')))
553 .map((a) => (typeof a.url === 'string' ? a.url : (a.url && a.url.href)))
554 .filter(Boolean),
555 ...coverKandidaten(o),
556 ];
557 for (const u of uit) {
[f894be5]558 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
559 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
[9f9b45f]560 if (!g) continue;
561 db.prepare('UPDATE posts SET cover_image_url = ? WHERE id = ?').run(g.url, bestaand.id);
562 rapport.media++;
563 rapport.postsBijgewerkt++;
[f894be5]564 break;
565 }
566 }
[19430fa]567 rapport.overgeslagen++;
568 continue;
[919b82d]569 }
[19430fa]570 if (migrationTarget(site.slug, o.id)) rapport.opnieuw++; // was er, is weg, komt terug
[fbfd7a1]571
572 // Media eerst, want een post die naar een plaatje wijst dat we niet
573 // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
574 // post wel door en staat het in het verslag.
575 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
576 const binnen = [];
[f894be5]577 let inhoud = o.content || '';
[fbfd7a1]578 if (safeFetch && fs && path && mediaRoot) {
579 for (const a of bijlagen.slice(0, 20)) {
580 const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
581 if (!u || !/^https?:\/\//i.test(String(u))) continue;
[f894be5]582 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
583 const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
[fbfd7a1]584 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
585 binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
586 rapport.media++;
587 try {
588 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
589 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
590 } catch { /* media-rij is administratie, het bestand staat er */ }
591 }
[f894be5]592 // De PLAATJES IN DE TEKST. De gebakken content draagt absolute
593 // verwijzingen naar de bron (https://oud/media/...), en die bleven
594 // gewoon staan: elke afbeelding hotlinkte naar een domein dat je gaat
595 // opzeggen, en je eigen mediamap bleef leeg. Downloaden naar
596 // HETZELFDE pad en de verwijzing relatief maken; wat niet lukt blijft
597 // absoluut staan en wordt gemeld, want een lokale 404 is erger dan
598 // een hotlink.
599 const r2 = await inhoudMediaBinnen(inhoud, bronOrigin, site, rapport, { safeFetch, mediaRoot, fs, path, maxBytes });
600 inhoud = r2.inhoud;
[9f9b45f]601 // De omslag zit lang niet altijd in attachment (zie coverKandidaten).
602 if (!binnen.some((b) => AFBEELDING.test(b.type || ''))) {
603 for (const u of coverKandidaten(o)) {
604 const doel = bronMediaPad(u, bronOrigin, { mediaRoot, path });
605 const g = await haalBijlage(u, { safeFetch, mediaRoot, fs, path, maxBytes, doel }).catch(() => null);
606 if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`omslag niet opgehaald: ${u}`); continue; }
607 binnen.unshift({ ...g, naam: null, type: g.mediaType });
608 rapport.media++;
609 try {
610 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
611 .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
612 } catch { /* administratie */ }
613 break; // een omslag is genoeg
614 }
615 }
[fbfd7a1]616 }
617
618 const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
619 const rest = binnen.filter((b) => b !== cover);
620 // De titel zit in de content, niet in een veld (zie titelUitContent).
[f894be5]621 const { titel, rest: body } = o.name ? { titel: o.name, rest: inhoud } : titelUitContent(inhoud);
[fbfd7a1]622 // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
623 // hetzelfde webadres als op de oude instantie, en blijft een link die
624 // iemand ergens plakte kloppen op het nieuwe domein.
625 const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
626 // De publicatiedatum blijft die van het origineel. De spec eist dat, en
627 // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
628 insPost.run({
629 id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
630 author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
631 cover_image_url: cover ? cover.url : null,
632 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
633 published_at: o.published || null, updated_at: o.updated || o.published || null,
634 fan_only: 0, nsfw: o.sensitive ? 1 : 0,
635 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
636 content_warning: o.summary || null,
637 ap_visibility: null,
638 c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
639 });
640 recordMigrated(site.slug, {
641 origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
642 // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
643 // hoort deze regel niet in een publiek leesbare migration-pagina.
644 //
645 // Via noteVisibility en niet met een eigen test op '#Public': die kent
646 // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
647 // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
648 // precies hoe twee antwoorden uit elkaar gaan lopen.
649 isPublic: zichtbaarheid(o) === 'public',
650 });
651 rapport.posts++;
652 }
653 const volgende = pagina.next;
654 if (!volgende || gezien >= max) break;
655 pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
656 }
657 if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
[f894be5]658 // Silently minder ophalen dan de bron zegt te hebben is precies hoe 18 van
659 // de 35 wekenlang op "klaar" had kunnen staan. Tel na en zeg het.
660 if (verwacht && gezien < verwacht && gezien < max) {
661 rapport.waarschuwingen.push(`de bron meldt ${verwacht} items en er zijn er ${gezien} verwerkt; een pagina is mogelijk niet opgehaald, probeer het nog eens`);
662 }
[6c4ff7e]663
664 // ── De muziekbibliotheek ──────────────────────────────────────
665 //
666 // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
667 // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
668 // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
669 //
670 // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
671 // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
672 // geldt voor de bestanden zelf, die anders achter de gated audio-route
673 // blijven.
[98acb97]674 const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
[919b82d]675 // En het RUWE id zoals het in de posttekst staat. Klonkt schrijft
676 // [[track:<id>]] in de content, en die tekst reist letterlijk mee over AP.
677 // Krijgt het nummer hier een ander id, dan wijst die shorthand nergens meer
678 // heen en zie je de code zelf in je bericht staan.
679 const ruwKaart = new Map(); // ruw bron-id -> ons id
[6c4ff7e]680 const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
681 const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
682 if (tracksUrl && safeFetch && fs && path && audioRoot) {
683 const coll = await getJson(site.slug, tracksUrl);
684 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
685 for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
686 const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
687 if (!a || !a.id) continue;
688 if (a.type && a.type !== 'Audio') continue;
[eb071a7]689 // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
690 // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
691 // pull die dan alles passeert laat je met een half resultaat zitten
692 // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
693 // in stand.
694 //
695 // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
696 // staan; een migratie hoort je correcties niet terug te draaien.
[19430fa]697 const trackId = ruwId(a.id) || crypto.randomUUID();
698 {
[eb071a7]699 const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
[19430fa]700 .get(trackId, site.id);
[eb071a7]701 if (rij) {
702 trackKaart.set(String(a.id), rij.id); // MOET, anders vinden de playlists hem niet
[919b82d]703 ruwKaart.set(ruwId(a.id), rij.id);
[eb071a7]704 const duur = rij.duration ? null : duurSeconden(a.duration);
705 const artiest = rij.artist ? null : (a.summary || a.artist || null);
706 let hoes = null;
707 const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
708 if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
709 const h = await haalBijlage(String(hUrl), {
710 safeFetch, mediaRoot, fs, path, maxBytes,
711 headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
712 }).catch(() => null);
713 if (h) { hoes = h.url; rapport.media++; }
714 }
715 if (duur || artiest || hoes) {
716 db.prepare(`UPDATE audio_tracks SET
717 duration = COALESCE(?, duration),
718 artist = COALESCE(?, artist),
719 cover_url = COALESCE(?, cover_url)
720 WHERE id = ?`).run(duur, artiest, hoes, rij.id);
721 rapport.tracksBijgewerkt++;
722 } else {
723 rapport.overgeslagenTracks++;
724 }
725 continue;
726 }
727 }
[6c4ff7e]728 const bron = a.url && (typeof a.url === 'string' ? a.url : (Array.isArray(a.url) ? (a.url[0] && (a.url[0].href || a.url[0])) : a.url.href));
729 if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
730 const g = await haalBijlage(String(bron), {
731 safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
732 headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
733 }).catch(() => null);
734 if (!g) {
735 rapport.tracksMislukt++;
736 rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
737 continue; // dezelfde regel als bij de zip: geen bestand, geen track
738 }
[98acb97]739 // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
740 // nummer aan met een verwijzing naar een plaatje dat er niet is.
741 let hoes = null;
742 const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
743 if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
744 const h = await haalBijlage(String(hoesUrl), {
745 safeFetch, mediaRoot, fs, path, maxBytes,
746 headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
747 }).catch(() => null);
748 if (h) { hoes = h.url; rapport.media++; }
749 else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
750 }
[6c4ff7e]751 const mediaId = crypto.randomUUID();
752 try {
753 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
754 .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
[98acb97]755 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
756 VALUES (?,?,?,?,?,?,?,?,0)`)
757 .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
758 duurSeconden(a.duration), mediaId, hoes);
[6c4ff7e]759 recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
[98acb97]760 trackKaart.set(String(a.id), trackId);
[919b82d]761 ruwKaart.set(ruwId(a.id), trackId);
[6c4ff7e]762 rapport.tracksBinnen++;
763 } catch (e) {
764 rapport.tracksMislukt++;
765 rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
766 }
767 }
768 } else if (tracksUrl) {
769 rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
770 }
[98acb97]771
[919b82d]772 // ── De verwijzingen in de tekst bijtrekken ────────────────────
773 //
774 // Klonkt schrijft [[track:<id>]] in posts.content, en die tekst reist
775 // letterlijk mee. Krijgt het nummer hier een ander id, dan wijst de
776 // shorthand nergens heen en zie je de code zelf in je bericht staan in
777 // plaats van een speler. Precies wat Robin op TikTik zag.
778 //
779 // Pas NA de tracks, want daarvoor is de kaart nog leeg. En alleen waar het
780 // id echt veranderde: een gelijk id hoeft niet aangeraakt.
781 {
782 const paren = [...ruwKaart.entries()].filter(([oud, nieuwId]) => oud && oud !== nieuwId);
783 if (paren.length) {
784 const upd = db.prepare('UPDATE posts SET content = REPLACE(content, ?, ?) WHERE site_id = ? AND content LIKE ?');
785 let n = 0;
786 for (const [oud, nieuwId] of paren) {
787 const r = upd.run(`[[track:${oud}]]`, `[[track:${nieuwId}]]`, site.id, `%[[track:${oud}]]%`);
788 if (r && r.changes) n += r.changes;
789 }
790 if (n) { rapport.tekstBijgewerkt = n; console.log('[FEP-1580] track-verwijzingen bijgetrokken in', n, 'bericht(en)'); }
791 }
792 }
793
[98acb97]794 // ── De playlists ──────────────────────────────────────────────
795 //
796 // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
797 // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
798 // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
799 // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
800 // plaat met gaten.
801 const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
802 if (plUrl && trackKaart.size) {
803 const coll = await getJson(site.slug, plUrl);
804 const lijst = (coll && (coll.orderedItems || coll.items)) || [];
805 for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
806 const uri = typeof p === 'string' ? p : (p && p.id);
807 if (!uri) continue;
808 const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
809 if (!plc) { rapport.playlistsMislukt++; continue; }
810 const nummers = (plc.orderedItems || plc.items || [])
811 .map((x) => (x && typeof x === 'object' ? x.id : x))
812 .map((id) => trackKaart.get(String(id)))
813 .filter(Boolean);
814 if (!nummers.length) {
815 rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
816 continue;
817 }
[919b82d]818 // De hoes van de plaat, net als bij een nummer.
819 let plHoes = null;
820 const plHoesUrl = (plc.icon && (plc.icon.url || plc.icon)) || (plc.image && (plc.image.url || plc.image)) || null;
821 if (plHoesUrl && /^https?:\/\//i.test(String(plHoesUrl)) && safeFetch && fs && path && mediaRoot) {
822 const h = await haalBijlage(String(plHoesUrl), {
823 safeFetch, mediaRoot, fs, path, maxBytes,
824 headers: signHeaders ? signHeaders(site.slug, String(plHoesUrl), '*/*') : null,
825 }).catch(() => null);
826 if (h) { plHoes = h.url; rapport.media++; }
827 else rapport.waarschuwingen.push(`hoes van playlist niet opgehaald: ${plc.name || uri}`);
828 }
[19430fa]829 // Ook hier het id van de bron. Dan blijft [[playlist:<id>]] in een
830 // bericht wijzen, en is een tweede ronde vanzelf dezelfde rij.
831 const plId = ruwId(uri) || crypto.randomUUID();
[98acb97]832 try {
[919b82d]833 db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind, cover_url) VALUES (?,?,?,?,?,?,?)
834 ON CONFLICT(id) DO UPDATE SET
835 title = excluded.title,
836 artist = COALESCE(playlists.artist, excluded.artist),
837 cover_url = COALESCE(playlists.cover_url, excluded.cover_url)`)
[eb071a7]838 .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
[919b82d]839 plc.year || null, plc['shaer:kind'] || null, plHoes);
[eb071a7]840 // De volgorde opnieuw zetten: die IS de plaat, en een halve
841 // bijgewerkte volgorde is erger dan een verse.
842 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
[98acb97]843 const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
844 nummers.forEach((tid, i) => ins.run(plId, tid, i));
[eb071a7]845 recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
[98acb97]846 rapport.playlistsBinnen++;
847 const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
848 if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
849 } catch (e) {
850 rapport.playlistsMislukt++;
851 rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
852 }
853 }
854 }
[fbfd7a1]855 } catch (e) {
856 // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
857 // en dat is precies goed, want er is nog werk.
858 console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
859 return { ...rapport, error: 'partial', melding: e && e.message };
860 }
861
862 // 9. Klaar. Nu pas mag een derde stoppen met kijken.
863 setMigrationComplete(site.slug, true);
864 console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
865 return rapport;
866}
Note: See TracBrowser for help on using the repository browser.