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