source: Klonkt/src/services/ArchiveImportService.js@ 096f099

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

Wie je volgt gaat mee bij een verhuizing, als CSV

De exporter dekte posts, tracks en antwoorden, maar niet je relaties. Daarmee was
een verhuizing halfslachtig: de Move vertelt je VOLGERS waar je heen ging, maar
niets vertelde JOU wie jij volgde. Die lijst stond alleen in de database die je
achterlaat, en die negen mensen moest je uit je hoofd opnieuw opzoeken.

Kolomvorm van Mastodon, zodat de lijst beide kanten op werkt: hiervandaan naar een
Mastodon, en die van daar naar hier. Een uitwisselformaat dat alleen met zichzelf
praat is er geen.

Account address,Show boosts,Notify on new posts,Languages,Featured

De vijfde kolom is van ons; Mastodon leest de eerste vier en negeert de rest, dus
het blijft daar importeerbaar. Andersom werkt een bestand uit Mastodon hier ook,
en een kale lijst adressen zonder kopregel eveneens.

Over de naam: featured betekent in Klonkt al de collectie VASTGEZETTE POSTS
(toot:featured), en het commentaar bij auto_boost noemt dat al "feature a
followed account" (hun posts in jouw Cirkel). Drie dingen die featured heten is er
twee te veel, dus de kolom heet in de database highlighted: wie je op je eigen
profiel uitlicht. In de CSV blijft de kop Featured, want dat is het woord dat
mensen en Mastodon kennen. FEP-d471 (Endorsements) modelleert dit als een eigen
object voor webs of trust; dat is een zwaarder ding dan hier bedoeld, dus dit
blijft een lokale vlag.

importFollowing staat BEWUST buiten importArchive. Die draait in een transactie en
raakt alleen de database; opnieuw volgen stuurt activiteiten de deur uit en wacht
op het netwerk. Een trage peer zou de transactie openhouden en een rollback neemt
verzonden Follows niet terug. Het is ook een aparte stap omdat een archief inlezen
stil is en negen mensen aanschrijven niet; dat mag geen bijwerking zijn.

Changed files:
src/config/database.js

  • kolom ap_following.highlighted, met de drie betekenissen van "featured" uit elkaar gehouden

src/services/ArchiveExportService.js

  • followingCsv() en parseFollowingCsv(), met een CSV-lezer die geciteerde velden aankan
  • buildArchive schrijft following.csv en telt hem mee

src/services/ArchiveImportService.js

  • importFollowing(), met injecteerbare followFn zodat de test geen netwerk raakt

New file:
test/following-csv.test.js

  • 13 tests: de kopvorm, openstaande verzoeken die NIET meegaan, terugval op de actor-URI, de ronde export-naar-import, een Mastodon-bestand zonder onze kolom, een kale lijst, een geciteerd veld met een komma, jezelf overslaan, en dat een mislukte follow de rest niet stopt
  • plus de koppeling: dat buildArchive de CSV er echt in stopt en in het manifest zet. Die ontbrak eerst, en dat is precies het gat waar dit op zou stranden: losse functies groen, archief zonder volglijst

remarks: gebouwd op branch following-csv, afgetakt van github/main (0824351). Stond
eerst per ongeluk op ward-pwa; die branch is dev-only en mag niet naar main, en is
onaangeroerd gebleven. Suite 921 groen in UTC en Europe/Amsterdam. Nog te doen: een
UI om highlighted te zetten, en de knop die importFollowing aanroept met
followActor als followFn.

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

  • Property mode set to 100644
File size: 18.5 KB
Line 
1/**
2 * Import van een draagbaar inhoudsarchief (shaer-pmr).
3 *
4 * Leest wat docs/EXPORT-FORMAT.md beschrijft. Vier regels uit dat document zijn
5 * geen implementatiekeuze maar eis, en ze staan hier alle vier expliciet:
6 *
7 * VERSIE EERST Een hogere onbekende formatVersion wordt in zijn GEHEEL
8 * geweigerd. Een half begrepen herstel is erger dan geen
9 * herstel, want het ziet eruit alsof het gelukt is.
10 * IDENTITEIT De origin uit het manifest bepaalt of de AP-ids behouden
11 * blijven. Dat is geen vraag aan de gebruiker: een verkeerd
12 * antwoord publiceert objecten onder een id dat je niet beheert.
13 * NIETS STILS Ontbrekende media worden geteld en gemeld.
14 * GEEN UITZENDING Geen Update de fediverse in. Verouderde kopieen elders
15 * rechttrekken is een aparte, bewuste actie.
16 *
17 * `readable/` wordt nooit gelezen. Dat is de hele reden dat het afgeleid is.
18 */
19
20import fs from 'fs';
21import path from 'path';
22import zlib from 'zlib';
23import crypto from 'crypto';
24import { randomUUID } from 'crypto';
25import db from '../config/database.js';
26import { MEDIA_ROOT } from '../config/paths.js';
27import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
28
29const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
30// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
31// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
32// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
33const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
34
35// ── Inlezen ───────────────────────────────────────────────────────
36
37/** Lees een archiefmap in als pad -> Buffer. */
38export function readArchiveDir(dir) {
39 const files = new Map();
40 const loop = (sub) => {
41 for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
42 const rel = sub ? `${sub}/${naam.name}` : naam.name;
43 if (naam.isDirectory()) loop(rel);
44 else files.set(rel, fs.readFileSync(path.join(dir, rel)));
45 }
46 };
47 loop('');
48 return files;
49}
50
51/**
52 * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
53 * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
54 */
55export function readArchiveZip(buf) {
56 const files = new Map();
57 const eocd = (() => {
58 for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
59 return -1;
60 })();
61 if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
62 const aantal = buf.readUInt16LE(eocd + 10);
63 let p = buf.readUInt32LE(eocd + 16);
64 for (let n = 0; n < aantal; n++) {
65 if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
66 const methode = buf.readUInt16LE(p + 10);
67 const gecomp = buf.readUInt32LE(p + 20);
68 const naamLen = buf.readUInt16LE(p + 28);
69 const extraLen = buf.readUInt16LE(p + 30);
70 const commentLen = buf.readUInt16LE(p + 32);
71 const lokaalOffset = buf.readUInt32LE(p + 42);
72 const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
73 const lNaam = buf.readUInt16LE(lokaalOffset + 26);
74 const lExtra = buf.readUInt16LE(lokaalOffset + 28);
75 const start = lokaalOffset + 30 + lNaam + lExtra;
76 const rauw = buf.subarray(start, start + gecomp);
77 if (!naam.endsWith('/')) {
78 files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
79 }
80 p += 46 + naamLen + extraLen + commentLen;
81 }
82 return files;
83}
84
85export function readArchive(bron) {
86 const st = fs.statSync(bron);
87 return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
88}
89
90// ── Importeren ────────────────────────────────────────────────────
91
92/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
93function veiligMediaPad(urlPad) {
94 if (!urlPad || !urlPad.startsWith('/media/')) return null;
95 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
96 const root = path.resolve(MEDIA_ROOT);
97 return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
98}
99
100/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
101function padVanOrigineel(u) {
102 const s = String(u || '');
103 if (s.startsWith('/media/')) return s;
104 try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
105}
106
107/**
108 * Waar deze bijlage komt te staan, als site-relatief pad.
109 *
110 * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
111 * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
112 * plek onder /media. Zonder een bestemming zou het bestand wel worden
113 * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
114 * en verwijzen de kolommen daarheen.
115 */
116function bestemming(a) {
117 return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
118}
119
120/**
121 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
122 *
123 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
124 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
125 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
126 * en een rollback neemt verzonden activiteiten niet terug.
127 *
128 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
129 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
130 *
131 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
132 * ActivityPubService niet hoeft te importeren.
133 */
134export async function importFollowing(site, csvText, { followFn = null } = {}) {
135 const rijen = parseFollowingCsv(csvText);
136 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
137 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
138 for (const r of rijen) {
139 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
140 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
141 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
142 try {
143 const ok = await followFn(site, r.address, r.autoBoost);
144 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
145 // De uitgelicht-vlag hangt aan de RELATIE, dus die zetten we pas als de
146 // rij bestaat. Mislukt de Follow, dan valt er niets te markeren, en dat
147 // is juist: iemand uitlichten die je niet volgt klopt niet.
148 if (r.highlighted) {
149 try {
150 db.prepare('UPDATE ap_following SET highlighted = 1 WHERE slug = ? AND (handle = ? OR handle = ?)')
151 .run(site.slug, r.address, `@${r.address}`);
152 } catch { /* oude database zonder de kolom */ }
153 }
154 rapport.gevolgd += 1;
155 } catch (e) {
156 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
157 }
158 }
159 return rapport;
160}
161
162/**
163 * Zet een archief terug in een site.
164 *
165 * @param {Map<string,Buffer>} files het ingelezen archief
166 * @param {object} opts { slug, dryRun, overwrite, origin }
167 */
168export function importArchive(files, opts = {}) {
169 const rapport = {
170 formatVersion: null, origin: null, idsBehouden: null,
171 posts: 0, overgeslagen: 0, overschreven: 0,
172 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
173 };
174
175 const manifestBuf = files.get('manifest.json');
176 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
177 const manifest = JSON.parse(manifestBuf.toString('utf8'));
178 rapport.formatVersion = manifest.formatVersion;
179
180 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
181 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
182 if (manifest.formatVersion > FORMAT_VERSION) {
183 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
184 }
185
186 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
187 if (!site) {
188 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
189 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
190 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
191 let bestaand = [];
192 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
193 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
194 throw new Error(bestaand.length
195 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
196 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
197 }
198 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
199 rapport.origin = manifest.origin || null;
200
201 // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
202 // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
203 // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
204 // servers halen het daar toch op.
205 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
206 rapport.idsBehouden = idsBehouden;
207 if (!idsBehouden) {
208 rapport.waarschuwingen.push(
209 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
210 );
211 }
212
213 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
214 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
215 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
216 const idKaart = new Map(); // oud post-id -> nieuw post-id
217
218 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
219
220 for (const pad of postPaden) {
221 const o = JSON.parse(files.get(pad).toString('utf8'));
222 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
223 const nieuwId = idsBehouden ? oudId : randomUUID();
224 idKaart.set(oudId, nieuwId);
225
226 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
227 if (botsing && !opts.overwrite) {
228 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
229 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
230 // idempotent maakt.
231 rapport.overgeslagen += 1;
232 continue;
233 }
234 if (botsing) rapport.overschreven += 1;
235
236 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
237 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
238 // op een ander domein.
239 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
240 if (a['shaer:availability'] === 'missing') {
241 rapport.mediaMissing += 1;
242 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
243 continue;
244 }
245 const bytes = files.get(a.url);
246 if (!bytes) {
247 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
248 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
249 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
250 continue;
251 }
252 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
253 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
254 continue;
255 }
256 const doel = veiligMediaPad(bestemming(a));
257 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
258 schrijf.push({ soort: 'media', doel, bytes });
259 rapport.media += 1;
260 }
261
262 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o });
263 rapport.posts += 1;
264 }
265
266 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
267 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
268 const coll = JSON.parse(files.get(pad).toString('utf8'));
269 if (coll['shaer:archive'] !== true) {
270 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
271 continue;
272 }
273 const oudId = path.basename(pad, '.json');
274 const postId = idKaart.get(oudId);
275 if (!postId) continue; // post overgeslagen -> antwoorden ook
276 for (const it of (coll.orderedItems || [])) {
277 schrijf.push({ soort: 'reply', postId, it });
278 rapport.replies += 1;
279 }
280 }
281
282 if (opts.dryRun) return rapport;
283
284 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
285 // uitkomst, want dan lijkt het gelukt.
286 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
287 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
288 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
289 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
290 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
291 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
292 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
293 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
294 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
295 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
296
297 db.transaction(() => {
298 for (const s of schrijf) {
299 if (s.soort === 'media') {
300 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
301 fs.writeFileSync(s.doel, s.bytes);
302 continue;
303 }
304 if (s.soort === 'reply') {
305 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
306 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
307 continue;
308 }
309 const o = s.obj;
310 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
311 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
312 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
313 // en dat zie je pas als je alle kolommen vergelijkt.
314 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
315 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
316 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
317 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
318 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
319 return {
320 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
321 poster: poster ? padVan(poster) : undefined,
322 };
323 }).filter((a) => a.url);
324 insPost.run({
325 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
326 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
327 status: o['shaer:status'] || 'draft',
328 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
329 cover_video_url: padVan(metRol('coverVideo')),
330 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
331 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
332 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
333 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
334 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
335 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
336 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
337 content_warning: o.summary || null,
338 poll_json: opties ? JSON.stringify({ multiple: Array.isArray(o.anyOf), options: opties.map((x) => ({ name: x.name })), endTime: o.endTime || null, closed: !!o.closed }) : null,
339 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
340 ap_visibility: o['shaer:apVisibility'] || null,
341 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
342 view_count: o['shaer:viewCount'] || 0,
343 });
344 // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
345 for (const t of (o['shaer:audio'] || [])) {
346 const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
347 if (!trackId) continue;
348 let mediaId = null;
349 const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
350 const doel = bij && veiligMediaPad(bestemming(bij));
351 if (doel) {
352 mediaId = randomUUID();
353 try {
354 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
355 .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
356 } catch { mediaId = null; }
357 }
358 try {
359 db.prepare(`INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, credit, license, link_spotify, link_youtube, link_soundcloud)
360 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
361 .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
362 mediaId, t.credit || null, t.license || null,
363 ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
364 } catch { /* geen audio-tabellen op deze installatie */ }
365 }
366 }
367 })();
368
369 return rapport;
370}
Note: See TracBrowser for help on using the repository browser.