| 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 | */
|
|---|
| 36 | import crypto from 'crypto';
|
|---|
| 37 | import db from '../config/database.js';
|
|---|
| 38 | import { AP_CONTEXT, actorId, pagedCollection } from './ap-core.js';
|
|---|
| 39 |
|
|---|
| 40 | // ── Vertaaltabel ──────────────────────────────────────────────────
|
|---|
| 41 |
|
|---|
| 42 | const stmts = {};
|
|---|
| 43 | function q(naam, sql) { return (stmts[naam] ||= db.prepare(sql)); }
|
|---|
| 44 |
|
|---|
| 45 | /** Leg vast dat `origin` hier `target` werd. Idempotent: opnieuw draaien mag. */
|
|---|
| 46 | export 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 | */
|
|---|
| 68 | export 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 |
|
|---|
| 76 | export 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. */
|
|---|
| 84 | export 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 | */
|
|---|
| 97 | export 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 |
|
|---|
| 106 | export 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 |
|
|---|
| 121 | export 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 |
|
|---|
| 127 | export 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 |
|
|---|
| 136 | export 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 | */
|
|---|
| 150 | export 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 | */
|
|---|
| 178 | export 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 |
|
|---|
| 207 | export 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. */
|
|---|
| 217 | export 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. */
|
|---|
| 222 | function 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 | /** De laatste padcomponent van een URI, als beginpunt voor een slug. */
|
|---|
| 231 | function slugUitUri(uri) {
|
|---|
| 232 | try { return decodeURIComponent(new URL(uri).pathname.split('/').filter(Boolean).pop() || ''); } catch { return ''; }
|
|---|
| 233 | }
|
|---|
| 234 |
|
|---|
| 235 | const AFBEELDING = /^image\//i;
|
|---|
| 236 |
|
|---|
| 237 | /** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
|
|---|
| 238 | function duurSeconden(v) {
|
|---|
| 239 | if (v == null) return null;
|
|---|
| 240 | if (typeof v === 'number') return Math.round(v) || null;
|
|---|
| 241 | const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
|
|---|
| 242 | if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
|
|---|
| 243 | const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
|
|---|
| 244 | return sec > 0 ? Math.round(sec) : null;
|
|---|
| 245 | }
|
|---|
| 246 |
|
|---|
| 247 | /**
|
|---|
| 248 | * De titel terugwinnen uit de content.
|
|---|
| 249 | *
|
|---|
| 250 | * Een AS2 Note heeft geen titel: Mastodon negeert `name`, dus Klonkt bakt de
|
|---|
| 251 | * titel als vetgedrukte eerste alinea IN de content (zie buildNote). Over de
|
|---|
| 252 | * lijn is een titel dus geen veld maar een vorm. Doen we hier niets, dan komt
|
|---|
| 253 | * elk bericht titelloos aan en heet het naar zijn id.
|
|---|
| 254 | *
|
|---|
| 255 | * Daarom draaien we precies onze eigen bak terug: alleen als de content BEGINT
|
|---|
| 256 | * met een alinea die niets anders bevat dan vetgedrukte tekst. Dat is de exacte
|
|---|
| 257 | * vorm die buildNote maakt. Een bericht van elders dat toevallig zo begint
|
|---|
| 258 | * verliest die regel niet, hij verhuist naar het titelveld en staat straks
|
|---|
| 259 | * gewoon weer bovenaan.
|
|---|
| 260 | */
|
|---|
| 261 | function titelUitContent(html) {
|
|---|
| 262 | const m = /^\s*<p>\s*<strong>([\s\S]*?)<\/strong>\s*<\/p>/i.exec(String(html || ''));
|
|---|
| 263 | if (!m) return { titel: null, rest: html };
|
|---|
| 264 | const titel = m[1].replace(/<[^>]+>/g, '').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|---|
| 265 | if (!titel || titel.length > 300) return { titel: null, rest: html };
|
|---|
| 266 | return { titel, rest: String(html).slice(m[0].length) };
|
|---|
| 267 | }
|
|---|
| 268 |
|
|---|
| 269 | /**
|
|---|
| 270 | * Haal een bijlage op en zet hem lokaal neer.
|
|---|
| 271 | *
|
|---|
| 272 | * safeFetch is de SSRF-veilige kant van Klonkt; hier is dat geen formaliteit,
|
|---|
| 273 | * want de URL komt van een andere server. Een bron die ons naar 127.0.0.1 wijst
|
|---|
| 274 | * moet stranden, ook als die bron "van onszelf" is.
|
|---|
| 275 | */
|
|---|
| 276 | async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
|
|---|
| 277 | // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
|
|---|
| 278 | // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
|
|---|
| 279 | // niet zien dat wij de doel-actor van zijn Move zijn.
|
|---|
| 280 | const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
|
|---|
| 281 | if (!r || !r.ok) return null;
|
|---|
| 282 | const buf = Buffer.from(await r.arrayBuffer());
|
|---|
| 283 | if (!buf.length || buf.length > maxBytes) return null;
|
|---|
| 284 | const type = String(r.headers.get('content-type') || '').split(';')[0].trim() || 'application/octet-stream';
|
|---|
| 285 | const ext = (() => {
|
|---|
| 286 | const uit = slugUitUri(url);
|
|---|
| 287 | const m = /\.([a-z0-9]{1,5})$/i.exec(uit);
|
|---|
| 288 | if (m) return m[1].toLowerCase();
|
|---|
| 289 | return (type.split('/')[1] || 'bin').replace(/[^a-z0-9]/gi, '').slice(0, 5) || 'bin';
|
|---|
| 290 | })();
|
|---|
| 291 | const naam = `${crypto.randomUUID()}.${ext}`;
|
|---|
| 292 | // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
|
|---|
| 293 | // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
|
|---|
| 294 | // in mappen eronder.
|
|---|
| 295 | const rel = submap ? `${submap}/${naam}` : naam;
|
|---|
| 296 | const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
|
|---|
| 297 | fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|---|
| 298 | fs.writeFileSync(abs, buf);
|
|---|
| 299 | return { url: `/media/${rel}`, mediaType: type, size: buf.length, filename: naam, storage_path: abs };
|
|---|
| 300 | }
|
|---|
| 301 |
|
|---|
| 302 | /**
|
|---|
| 303 | * FEP-1580 ingest-routine, de doelkant.
|
|---|
| 304 | *
|
|---|
| 305 | * De autorisatie wordt hier niet verzonnen maar NAGEKEKEN, en in beide
|
|---|
| 306 | * richtingen, precies zoals de spec het voor derden voorschrijft: `movedTo` op
|
|---|
| 307 | * de bron moet naar ons wijzen EN wij moeten de bron in `alsoKnownAs` hebben.
|
|---|
| 308 | * Eén kant is een bewering, twee kanten is een afspraak. Zou ik alleen op onze
|
|---|
| 309 | * eigen alsoKnownAs afgaan, dan kon iedereen die zichzelf een alias geeft de
|
|---|
| 310 | * geschiedenis van een vreemde opeisen.
|
|---|
| 311 | *
|
|---|
| 312 | * `deps` is er voor de test: die moet dit kunnen draaien zonder netwerk.
|
|---|
| 313 | */
|
|---|
| 314 | export async function ingestFromSource(site, {
|
|---|
| 315 | sourceUri = null, max = 1000, maxBytes = 25 * 1024 * 1024, deps = {},
|
|---|
| 316 | } = {}) {
|
|---|
| 317 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 318 | if (!base || !site || !site.slug) return { error: 'config' };
|
|---|
| 319 | const me = actorId(base, site.slug);
|
|---|
| 320 |
|
|---|
| 321 | const {
|
|---|
| 322 | getJson = null, safeFetch = null, mediaRoot = null, fs = null, path = null, noteId = null,
|
|---|
| 323 | sanitize = (h) => h,
|
|---|
| 324 | // Standaard 'followers': kan iets niet als publiek bewezen worden, dan
|
|---|
| 325 | // hoort het niet in de publieke vertaaltabel. Fail-closed, want dit is een
|
|---|
| 326 | // privacygrens en niet een weergavedetail.
|
|---|
| 327 | noteVisibility = () => 'followers',
|
|---|
| 328 | audioRoot = null, signHeaders = null,
|
|---|
| 329 | } = deps;
|
|---|
| 330 | const zichtbaarheid = noteVisibility;
|
|---|
| 331 | if (!getJson || !noteId) return { error: 'config' };
|
|---|
| 332 |
|
|---|
| 333 | // 1. Welke bron? Zonder opgave: de alias die we zelf claimen.
|
|---|
| 334 | let bron = sourceUri && /^https?:\/\//i.test(sourceUri) ? sourceUri : null;
|
|---|
| 335 | if (!bron) {
|
|---|
| 336 | try {
|
|---|
| 337 | const aka = JSON.parse(site.ap_aliases || '[]');
|
|---|
| 338 | bron = Array.isArray(aka) ? aka.find((u) => typeof u === 'string' && /^https?:\/\//i.test(u)) || null : null;
|
|---|
| 339 | } catch { /* stukke ap_aliases telt als geen alias */ }
|
|---|
| 340 | }
|
|---|
| 341 | if (!bron) return { error: 'no_source' };
|
|---|
| 342 |
|
|---|
| 343 | // 2 + 3. Het bron-actordocument, en de wegwijzer die naar ONS moet wijzen.
|
|---|
| 344 | const bronActor = await getJson(site.slug, bron);
|
|---|
| 345 | if (!bronActor || !bronActor.id) return { error: 'unreachable' };
|
|---|
| 346 | if (bronActor.movedTo !== me) return { error: 'not_moved_here', movedTo: bronActor.movedTo || null };
|
|---|
| 347 |
|
|---|
| 348 | // 4. En de terugverwijzing van onze kant, zodat het een afspraak is.
|
|---|
| 349 | const eigenAka = (() => {
|
|---|
| 350 | try { const a = JSON.parse(site.ap_aliases || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
|
|---|
| 351 | })();
|
|---|
| 352 | if (!eigenAka.includes(bronActor.id)) return { error: 'no_backreference' };
|
|---|
| 353 |
|
|---|
| 354 | // 5 + 6. Vastleggen dat dit een migratie is, en de deur openzetten voor derden.
|
|---|
| 355 | recordMove(site.slug, {
|
|---|
| 356 | moveId: `${bronActor.id}#move`, sourceActor: bronActor.id, targetActor: me,
|
|---|
| 357 | activity: { type: 'Move', actor: bronActor.id, object: bronActor.id, target: me },
|
|---|
| 358 | actorDoc: bronActor,
|
|---|
| 359 | });
|
|---|
| 360 | setMigrationComplete(site.slug, false);
|
|---|
| 361 |
|
|---|
| 362 | const rapport = {
|
|---|
| 363 | bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
|
|---|
| 364 | blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
|
|---|
| 365 | playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
|
|---|
| 366 | };
|
|---|
| 367 |
|
|---|
| 368 | try {
|
|---|
| 369 | // 7. BLOKKADES EERST. De spec is daar streng over, en terecht: ze bepalen
|
|---|
| 370 | // wie de rest te zien krijgt. Andersom importeer je even je hele
|
|---|
| 371 | // geschiedenis zichtbaar voor iemand die je nou juist buiten wilde.
|
|---|
| 372 | if (bronActor.blocked) {
|
|---|
| 373 | const coll = await getJson(site.slug, typeof bronActor.blocked === 'string' ? bronActor.blocked : bronActor.blocked.id);
|
|---|
| 374 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 375 | for (const b of Array.isArray(lijst) ? lijst : []) {
|
|---|
| 376 | const uri = typeof b === 'string' ? b : (b && (b.object || b.id));
|
|---|
| 377 | if (!uri || !/^https?:\/\//i.test(String(uri))) continue;
|
|---|
| 378 | try {
|
|---|
| 379 | db.prepare("INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label) VALUES (?, ?, 'actor', NULL)").run(site.slug, String(uri));
|
|---|
| 380 | rapport.blocks++;
|
|---|
| 381 | } catch { /* tabel ontbreekt op een verse db */ }
|
|---|
| 382 | }
|
|---|
| 383 | } else {
|
|---|
| 384 | rapport.waarschuwingen.push('de bron gaf geen blokkadelijst, zichtbaarheidsvoorkeuren komen niet mee');
|
|---|
| 385 | }
|
|---|
| 386 |
|
|---|
| 387 | // 8. De outbox aflopen. Pagineren zoals de rest van Klonkt dat doet.
|
|---|
| 388 | if (!bronActor.outbox) return { ...rapport, error: 'no_outbox' };
|
|---|
| 389 | let pagina = await getJson(site.slug, typeof bronActor.outbox === 'string' ? bronActor.outbox : bronActor.outbox.id);
|
|---|
| 390 | if (pagina && pagina.first && !(pagina.orderedItems || pagina.items)) {
|
|---|
| 391 | pagina = await getJson(site.slug, typeof pagina.first === 'string' ? pagina.first : pagina.first.id);
|
|---|
| 392 | }
|
|---|
| 393 |
|
|---|
| 394 | const insPost = db.prepare(`INSERT INTO posts
|
|---|
| 395 | (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url,
|
|---|
| 396 | pinned, type, tags, published_at, created_at, updated_at, fan_only, nsfw, language,
|
|---|
| 397 | content_warning, ap_visibility, c2s_attachments, origin_server)
|
|---|
| 398 | VALUES (@id, @site_id, @slug, @author_id, @title, @content, NULL, 'published', @cover_image_url,
|
|---|
| 399 | 0, 'post', @tags, @published_at, @published_at, @updated_at, @fan_only, @nsfw, @language,
|
|---|
| 400 | @content_warning, @ap_visibility, @c2s_attachments, 'migrated')`);
|
|---|
| 401 |
|
|---|
| 402 | let gezien = 0;
|
|---|
| 403 | while (pagina && gezien < max) {
|
|---|
| 404 | const items = (pagina.orderedItems || pagina.items) || [];
|
|---|
| 405 | for (const it of Array.isArray(items) ? items : []) {
|
|---|
| 406 | if (gezien >= max) break;
|
|---|
| 407 | const o = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 408 | if (!o || !o.id) continue;
|
|---|
| 409 | if (o.type && !['Note', 'Article', 'Question'].includes(o.type)) continue;
|
|---|
| 410 | if (o.inReplyTo) continue; // toplevel; antwoorden hangen aan hun ouder
|
|---|
| 411 | const auteur = typeof o.attributedTo === 'string' ? o.attributedTo : (o.attributedTo && o.attributedTo.id);
|
|---|
| 412 | if (auteur && auteur !== bronActor.id) continue; // alleen wat van HEM was
|
|---|
| 413 | gezien++;
|
|---|
| 414 | if (alGemigreerd(site.slug, o.id)) { rapport.overgeslagen++; continue; }
|
|---|
| 415 |
|
|---|
| 416 | // Media eerst, want een post die naar een plaatje wijst dat we niet
|
|---|
| 417 | // hebben opgehaald is een halve post. Mislukt een bijlage, dan gaat de
|
|---|
| 418 | // post wel door en staat het in het verslag.
|
|---|
| 419 | const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
|
|---|
| 420 | const binnen = [];
|
|---|
| 421 | if (safeFetch && fs && path && mediaRoot) {
|
|---|
| 422 | for (const a of bijlagen.slice(0, 20)) {
|
|---|
| 423 | const u = a && (typeof a === 'string' ? a : (a.url && (typeof a.url === 'string' ? a.url : a.url.href)));
|
|---|
| 424 | if (!u || !/^https?:\/\//i.test(String(u))) continue;
|
|---|
| 425 | const g = await haalBijlage(String(u), { safeFetch, mediaRoot, fs, path, maxBytes }).catch(() => null);
|
|---|
| 426 | if (!g) { rapport.mediaMislukt++; rapport.waarschuwingen.push(`bijlage niet opgehaald: ${u}`); continue; }
|
|---|
| 427 | binnen.push({ ...g, naam: (a && a.name) || null, type: (a && a.mediaType) || g.mediaType });
|
|---|
| 428 | rapport.media++;
|
|---|
| 429 | try {
|
|---|
| 430 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?, ?, ?, ?, ?, ?)')
|
|---|
| 431 | .run(crypto.randomUUID(), site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 432 | } catch { /* media-rij is administratie, het bestand staat er */ }
|
|---|
| 433 | }
|
|---|
| 434 | }
|
|---|
| 435 |
|
|---|
| 436 | const id = crypto.randomUUID();
|
|---|
| 437 | const cover = binnen.find((b) => AFBEELDING.test(b.type || ''));
|
|---|
| 438 | const rest = binnen.filter((b) => b !== cover);
|
|---|
| 439 | // De titel zit in de content, niet in een veld (zie titelUitContent).
|
|---|
| 440 | const { titel, rest: body } = o.name ? { titel: o.name, rest: o.content || '' } : titelUitContent(o.content || '');
|
|---|
| 441 | // De slug uit de MENSELIJKE url, niet uit de AP-id. Zo houdt het bericht
|
|---|
| 442 | // hetzelfde webadres als op de oude instantie, en blijft een link die
|
|---|
| 443 | // iemand ergens plakte kloppen op het nieuwe domein.
|
|---|
| 444 | const basisSlug = slugUitUri(o.url || '') || o.name || slugUitUri(o.id) || id;
|
|---|
| 445 | // De publicatiedatum blijft die van het origineel. De spec eist dat, en
|
|---|
| 446 | // het is ook het enige eerlijke: het bericht is niet vandaag geschreven.
|
|---|
| 447 | insPost.run({
|
|---|
| 448 | id, site_id: site.id, slug: vrijeSlug(site.id, basisSlug),
|
|---|
| 449 | author_id: site.owner_id, title: titel || null, content: sanitize(body || ''),
|
|---|
| 450 | cover_image_url: cover ? cover.url : null,
|
|---|
| 451 | tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name || '').replace(/^#/, '')).filter(Boolean).join(', ') || null : null,
|
|---|
| 452 | published_at: o.published || null, updated_at: o.updated || o.published || null,
|
|---|
| 453 | fan_only: 0, nsfw: o.sensitive ? 1 : 0,
|
|---|
| 454 | language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
|
|---|
| 455 | content_warning: o.summary || null,
|
|---|
| 456 | ap_visibility: null,
|
|---|
| 457 | c2s_attachments: rest.length ? JSON.stringify(rest.map((b) => ({ url: b.url, mediaType: b.type, name: b.naam || undefined }))) : null,
|
|---|
| 458 | });
|
|---|
| 459 | recordMigrated(site.slug, {
|
|---|
| 460 | origin: o.id, target: noteId(base, id), sourceActor: bronActor.id,
|
|---|
| 461 | // Publiek in de zin van de spec: gericht aan as:Public. Zo niet, dan
|
|---|
| 462 | // hoort deze regel niet in een publiek leesbare migration-pagina.
|
|---|
| 463 | //
|
|---|
| 464 | // Via noteVisibility en niet met een eigen test op '#Public': die kent
|
|---|
| 465 | // ook de schrijfwijzen 'as:Public' en 'Public', en de rest van Klonkt
|
|---|
| 466 | // beslist er al mee. Een tweede, dunnere versie van dezelfde vraag is
|
|---|
| 467 | // precies hoe twee antwoorden uit elkaar gaan lopen.
|
|---|
| 468 | isPublic: zichtbaarheid(o) === 'public',
|
|---|
| 469 | });
|
|---|
| 470 | rapport.posts++;
|
|---|
| 471 | }
|
|---|
| 472 | const volgende = pagina.next;
|
|---|
| 473 | if (!volgende || gezien >= max) break;
|
|---|
| 474 | pagina = await getJson(site.slug, typeof volgende === 'string' ? volgende : volgende.id);
|
|---|
| 475 | }
|
|---|
| 476 | if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
|
|---|
| 477 |
|
|---|
| 478 | // ── De muziekbibliotheek ──────────────────────────────────────
|
|---|
| 479 | //
|
|---|
| 480 | // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
|
|---|
| 481 | // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
|
|---|
| 482 | // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
|
|---|
| 483 | //
|
|---|
| 484 | // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
|
|---|
| 485 | // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
|
|---|
| 486 | // geldt voor de bestanden zelf, die anders achter de gated audio-route
|
|---|
| 487 | // blijven.
|
|---|
| 488 | const trackKaart = new Map(); // bron-URI van een nummer -> ons nieuwe id
|
|---|
| 489 | const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
|
|---|
| 490 | const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
|
|---|
| 491 | if (tracksUrl && safeFetch && fs && path && audioRoot) {
|
|---|
| 492 | const coll = await getJson(site.slug, tracksUrl);
|
|---|
| 493 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 494 | for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
|
|---|
| 495 | const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
|
|---|
| 496 | if (!a || !a.id) continue;
|
|---|
| 497 | if (a.type && a.type !== 'Audio') continue;
|
|---|
| 498 | // AL BINNEN? Dan AANVULLEN, niet overslaan. Een tweede ronde bestaat
|
|---|
| 499 | // juist omdat er iets bij is gekomen (hoezen, duur, playlists), en een
|
|---|
| 500 | // pull die dan alles passeert laat je met een half resultaat zitten
|
|---|
| 501 | // zonder uitweg: opruimen hielp niet, want deze tabel hield de blokkade
|
|---|
| 502 | // in stand.
|
|---|
| 503 | //
|
|---|
| 504 | // Alleen LEGE velden worden gevuld. Wat jij zelf hebt aangepast blijft
|
|---|
| 505 | // staan; een migratie hoort je correcties niet terug te draaien.
|
|---|
| 506 | const eerder = migrationTarget(site.slug, a.id);
|
|---|
| 507 | if (eerder) {
|
|---|
| 508 | const lokaalId = String(eerder).split('/').pop();
|
|---|
| 509 | const rij = db.prepare('SELECT id, cover_url, duration, artist FROM audio_tracks WHERE id = ? AND site_id = ?')
|
|---|
| 510 | .get(lokaalId, site.id);
|
|---|
| 511 | if (rij) {
|
|---|
| 512 | trackKaart.set(String(a.id), rij.id); // MOET, anders vinden de playlists hem niet
|
|---|
| 513 | const duur = rij.duration ? null : duurSeconden(a.duration);
|
|---|
| 514 | const artiest = rij.artist ? null : (a.summary || a.artist || null);
|
|---|
| 515 | let hoes = null;
|
|---|
| 516 | const hUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
|
|---|
| 517 | if (!rij.cover_url && hUrl && /^https?:\/\//i.test(String(hUrl)) && safeFetch && fs && path && mediaRoot) {
|
|---|
| 518 | const h = await haalBijlage(String(hUrl), {
|
|---|
| 519 | safeFetch, mediaRoot, fs, path, maxBytes,
|
|---|
| 520 | headers: signHeaders ? signHeaders(site.slug, String(hUrl), '*/*') : null,
|
|---|
| 521 | }).catch(() => null);
|
|---|
| 522 | if (h) { hoes = h.url; rapport.media++; }
|
|---|
| 523 | }
|
|---|
| 524 | if (duur || artiest || hoes) {
|
|---|
| 525 | db.prepare(`UPDATE audio_tracks SET
|
|---|
| 526 | duration = COALESCE(?, duration),
|
|---|
| 527 | artist = COALESCE(?, artist),
|
|---|
| 528 | cover_url = COALESCE(?, cover_url)
|
|---|
| 529 | WHERE id = ?`).run(duur, artiest, hoes, rij.id);
|
|---|
| 530 | rapport.tracksBijgewerkt++;
|
|---|
| 531 | } else {
|
|---|
| 532 | rapport.overgeslagenTracks++;
|
|---|
| 533 | }
|
|---|
| 534 | continue;
|
|---|
| 535 | }
|
|---|
| 536 | // De rij is weg maar de mapping staat er nog. Dan is opnieuw ophalen
|
|---|
| 537 | // precies wat je wilt, dus we vallen door naar de gewone tak.
|
|---|
| 538 | }
|
|---|
| 539 | 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));
|
|---|
| 540 | if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
|
|---|
| 541 | const g = await haalBijlage(String(bron), {
|
|---|
| 542 | safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
|
|---|
| 543 | headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
|
|---|
| 544 | }).catch(() => null);
|
|---|
| 545 | if (!g) {
|
|---|
| 546 | rapport.tracksMislukt++;
|
|---|
| 547 | rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
|
|---|
| 548 | continue; // dezelfde regel als bij de zip: geen bestand, geen track
|
|---|
| 549 | }
|
|---|
| 550 | // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
|
|---|
| 551 | // nummer aan met een verwijzing naar een plaatje dat er niet is.
|
|---|
| 552 | let hoes = null;
|
|---|
| 553 | const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
|
|---|
| 554 | if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
|
|---|
| 555 | const h = await haalBijlage(String(hoesUrl), {
|
|---|
| 556 | safeFetch, mediaRoot, fs, path, maxBytes,
|
|---|
| 557 | headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
|
|---|
| 558 | }).catch(() => null);
|
|---|
| 559 | if (h) { hoes = h.url; rapport.media++; }
|
|---|
| 560 | else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
|
|---|
| 561 | }
|
|---|
| 562 | const trackId = crypto.randomUUID();
|
|---|
| 563 | const mediaId = crypto.randomUUID();
|
|---|
| 564 | try {
|
|---|
| 565 | db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
|
|---|
| 566 | .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
|
|---|
| 567 | db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
|
|---|
| 568 | VALUES (?,?,?,?,?,?,?,?,0)`)
|
|---|
| 569 | .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
|
|---|
| 570 | duurSeconden(a.duration), mediaId, hoes);
|
|---|
| 571 | recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
|
|---|
| 572 | trackKaart.set(String(a.id), trackId);
|
|---|
| 573 | rapport.tracksBinnen++;
|
|---|
| 574 | } catch (e) {
|
|---|
| 575 | rapport.tracksMislukt++;
|
|---|
| 576 | rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
|
|---|
| 577 | }
|
|---|
| 578 | }
|
|---|
| 579 | } else if (tracksUrl) {
|
|---|
| 580 | rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
|
|---|
| 581 | }
|
|---|
| 582 |
|
|---|
| 583 | // ── De playlists ──────────────────────────────────────────────
|
|---|
| 584 | //
|
|---|
| 585 | // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
|
|---|
| 586 | // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
|
|---|
| 587 | // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
|
|---|
| 588 | // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
|
|---|
| 589 | // plaat met gaten.
|
|---|
| 590 | const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
|
|---|
| 591 | if (plUrl && trackKaart.size) {
|
|---|
| 592 | const coll = await getJson(site.slug, plUrl);
|
|---|
| 593 | const lijst = (coll && (coll.orderedItems || coll.items)) || [];
|
|---|
| 594 | for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
|
|---|
| 595 | const uri = typeof p === 'string' ? p : (p && p.id);
|
|---|
| 596 | if (!uri) continue;
|
|---|
| 597 | const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
|
|---|
| 598 | if (!plc) { rapport.playlistsMislukt++; continue; }
|
|---|
| 599 | const nummers = (plc.orderedItems || plc.items || [])
|
|---|
| 600 | .map((x) => (x && typeof x === 'object' ? x.id : x))
|
|---|
| 601 | .map((id) => trackKaart.get(String(id)))
|
|---|
| 602 | .filter(Boolean);
|
|---|
| 603 | if (!nummers.length) {
|
|---|
| 604 | rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
|
|---|
| 605 | continue;
|
|---|
| 606 | }
|
|---|
| 607 | // Bestond hij al? Dan dezelfde rij bijwerken. Zonder deze stap levert
|
|---|
| 608 | // elke tweede ronde een dubbele plaat op.
|
|---|
| 609 | const eerderPl = migrationTarget(site.slug, uri);
|
|---|
| 610 | const plId = (() => {
|
|---|
| 611 | if (!eerderPl) return crypto.randomUUID();
|
|---|
| 612 | const bestaand = String(eerderPl).split('/').pop();
|
|---|
| 613 | return db.prepare('SELECT 1 FROM playlists WHERE id = ? AND site_id = ?').get(bestaand, site.id)
|
|---|
| 614 | ? bestaand : crypto.randomUUID();
|
|---|
| 615 | })();
|
|---|
| 616 | try {
|
|---|
| 617 | db.prepare(`INSERT INTO playlists (id, site_id, title, artist, year, kind) VALUES (?,?,?,?,?,?)
|
|---|
| 618 | ON CONFLICT(id) DO UPDATE SET title = excluded.title, artist = COALESCE(playlists.artist, excluded.artist)`)
|
|---|
| 619 | .run(plId, site.id, plc.name || 'zonder titel', plc.artist || null,
|
|---|
| 620 | plc.year || null, plc['shaer:kind'] || null);
|
|---|
| 621 | // De volgorde opnieuw zetten: die IS de plaat, en een halve
|
|---|
| 622 | // bijgewerkte volgorde is erger dan een verse.
|
|---|
| 623 | db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(plId);
|
|---|
| 624 | const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
|
|---|
| 625 | nummers.forEach((tid, i) => ins.run(plId, tid, i));
|
|---|
| 626 | recordMigrated(site.slug, { origin: uri, target: `${me}/ap/playlists/${plId}`, sourceActor: bronActor.id, isPublic: false });
|
|---|
| 627 | rapport.playlistsBinnen++;
|
|---|
| 628 | const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
|
|---|
| 629 | if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
|
|---|
| 630 | } catch (e) {
|
|---|
| 631 | rapport.playlistsMislukt++;
|
|---|
| 632 | rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
|
|---|
| 633 | }
|
|---|
| 634 | }
|
|---|
| 635 | }
|
|---|
| 636 | } catch (e) {
|
|---|
| 637 | // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
|
|---|
| 638 | // en dat is precies goed, want er is nog werk.
|
|---|
| 639 | console.warn('[FEP-1580] ingest afgebroken:', e && e.message);
|
|---|
| 640 | return { ...rapport, error: 'partial', melding: e && e.message };
|
|---|
| 641 | }
|
|---|
| 642 |
|
|---|
| 643 | // 9. Klaar. Nu pas mag een derde stoppen met kijken.
|
|---|
| 644 | setMigrationComplete(site.slug, true);
|
|---|
| 645 | console.log('[FEP-1580] ingest klaar:', site.slug, '<-', bronActor.id, rapport.posts, 'berichten,', rapport.media, 'bestanden');
|
|---|
| 646 | return rapport;
|
|---|
| 647 | }
|
|---|