source: Klonkt/src/services/ArchiveImportService.js@ 2365630

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

Hoezen en playlists reisden nog niet mee

Robin, na de vorige ronde: "de audio tracks hadden images, die zijn niet
meegegaan, de playlists zijn ook nog niet meegegaan via peer to peer."

Allebei terecht, en de eerste is dezelfde fout als bij de audio zelf, een laag
hoger. cover_url ging wel mee als STRING en het bestand niet. Dus kwam een
nummer aan met een verwijzing naar een plaatje dat er niet was: precies de
halve waarheid die deze hele reeks moest opruimen, en ik liep er zelf opnieuw
in.

De playlists gingen wel via de zip en niet via de ophaalknop. Die liep de
tracks-collectie af en de playlist-collectie niet, en dan heb je alle muziek
en geen enkele plaat. De volgorde staat nergens anders.

Wat er nog meer boven kwam bij het naspelen: over AP komt de duur als
ISO-8601 ("PT212S") binnen en de database wil seconden, en de artiest zit in
summary, niet in een artist-veld. Zonder die twee kwamen nummers naamloos en
zonder duur aan.

En de bronkant moest ook hier open: playlistOpenTracks filtert op fedi_open,
dus de doel-actor kreeg een plaat met gaten. Nu geldt daar dezelfde regel als
bij de outbox en de tracks.

Bewezen op twee draaiende instanties, 3 nummers met hoes plus een plaat in de
volgorde c-a-b:

peer to peer mp3 2503b, hoes 74b, artiest, duur, plaat C -> A -> B
zip idem, plus kind=album

Changed files:
src/services/ArchiveExportService.js

  • hoesToevoegen(): de BYTES van een hoes in het archief, voor tracks en playlists, met shaer:coverFile ernaast

src/services/ArchiveImportService.js

  • hoesTerug(): de hoes op schijf en cover_url daarheen; geen bestand betekent geen cover_url, liever niets dan een img die 404't

src/services/MigrationService.js

  • de ingest haalt de hoes op uit icon/image, ONDERTEKEND
  • de playlists erbij, met hun volgorde en een kaart bron-URI -> nieuw id
  • duurSeconden(): PT212S naar 212; artiest uit summary

src/services/music/index.js

  • playlistOpenTracks({ alles }) voor de doel-actor van een Move

src/routes/activitypub.js

  • de playlist-route geeft die doel-actor de volledige plaat

remarks: over AP komt kind (album/playlist) niet mee, want de AP-collectie
draagt dat veld niet; via de zip wel. Klein verschil, apart te repareren als het
hindert.

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

  • Property mode set to 100644
File size: 24.9 KB
RevLine 
[58cfe5f]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';
[6c4ff7e]26import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
[096f099]27import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
[fbfd7a1]28import * as Migration from './MigrationService.js';
[58cfe5f]29
30const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
[812fea9]31// De tijdstempel gaat er ONGEWIJZIGD in. Omzetten naar SQL-notatie kostte de
32// sub-seconde, en twee posts in dezelfde seconde staan dan in willekeurige
33// volgorde. Klonkt schrijft zelf ook ISO in deze kolommen.
34const tijd = (iso) => (iso && !isNaN(Date.parse(iso)) ? String(iso) : null);
[58cfe5f]35
36// ── Inlezen ───────────────────────────────────────────────────────
37
38/** Lees een archiefmap in als pad -> Buffer. */
39export function readArchiveDir(dir) {
40 const files = new Map();
41 const loop = (sub) => {
42 for (const naam of fs.readdirSync(path.join(dir, sub), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
43 const rel = sub ? `${sub}/${naam.name}` : naam.name;
44 if (naam.isDirectory()) loop(rel);
45 else files.set(rel, fs.readFileSync(path.join(dir, rel)));
46 }
47 };
48 loop('');
49 return files;
50}
51
52/**
53 * Lees een zip in. Onze eigen export is store-only, maar een archief dat elders
54 * gemaakt is mag deflate gebruiken -- anders is het geen uitwisselformaat.
55 */
56export function readArchiveZip(buf) {
57 const files = new Map();
58 const eocd = (() => {
59 for (let i = buf.length - 22; i >= 0 && i > buf.length - 66000; i--) if (buf.readUInt32LE(i) === 0x06054b50) return i;
60 return -1;
61 })();
62 if (eocd < 0) throw new Error('geen zip: het eind-record ontbreekt');
63 const aantal = buf.readUInt16LE(eocd + 10);
64 let p = buf.readUInt32LE(eocd + 16);
65 for (let n = 0; n < aantal; n++) {
66 if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('beschadigde zip: centrale ingang klopt niet');
67 const methode = buf.readUInt16LE(p + 10);
68 const gecomp = buf.readUInt32LE(p + 20);
69 const naamLen = buf.readUInt16LE(p + 28);
70 const extraLen = buf.readUInt16LE(p + 30);
71 const commentLen = buf.readUInt16LE(p + 32);
72 const lokaalOffset = buf.readUInt32LE(p + 42);
73 const naam = buf.toString('utf8', p + 46, p + 46 + naamLen);
74 const lNaam = buf.readUInt16LE(lokaalOffset + 26);
75 const lExtra = buf.readUInt16LE(lokaalOffset + 28);
76 const start = lokaalOffset + 30 + lNaam + lExtra;
77 const rauw = buf.subarray(start, start + gecomp);
78 if (!naam.endsWith('/')) {
79 files.set(naam, methode === 8 ? zlib.inflateRawSync(rauw) : Buffer.from(rauw));
80 }
81 p += 46 + naamLen + extraLen + commentLen;
82 }
83 return files;
84}
85
86export function readArchive(bron) {
87 const st = fs.statSync(bron);
88 return st.isDirectory() ? readArchiveDir(bron) : readArchiveZip(fs.readFileSync(bron));
89}
90
91// ── Importeren ────────────────────────────────────────────────────
92
93/** Een pad onder MEDIA_ROOT houden. Een archief van elders is invoer, geen vriend. */
94function veiligMediaPad(urlPad) {
95 if (!urlPad || !urlPad.startsWith('/media/')) return null;
96 const abs = path.resolve(MEDIA_ROOT, decodeURIComponent(urlPad.slice('/media/'.length)));
97 const root = path.resolve(MEDIA_ROOT);
98 return (abs !== root && abs.startsWith(`${root}${path.sep}`)) ? abs : null;
99}
100
101/** Het pad-deel van een originele media-URL, of null als het er niet een van ons is. */
102function padVanOrigineel(u) {
103 const s = String(u || '');
104 if (s.startsWith('/media/')) return s;
105 try { const x = new URL(s); return x.pathname.startsWith('/media/') ? x.pathname : null; } catch { return null; }
106}
107
[7aa3140]108/**
109 * Waar deze bijlage komt te staan, als site-relatief pad.
110 *
111 * Meestal zijn oorspronkelijke plek en bestemming gelijk. Maar een bestand dat
112 * ELDERS werd geserveerd -- gehoste audio ging via /audio/stream/ -- heeft geen
113 * plek onder /media. Zonder een bestemming zou het bestand wel worden
114 * weggeschreven en toch uit de kolommen verdwijnen. Nu krijgt het een eigen hoek,
115 * en verwijzen de kolommen daarheen.
116 */
117function bestemming(a) {
118 return padVanOrigineel(a && a['shaer:originalUrl']) || `/media/archief/${path.basename(String((a && a.url) || ''))}`;
119}
120
[6c4ff7e]121/**
122 * De audiobibliotheek terugzetten (formaat v2).
123 *
124 * EEN REGEL DIE HIER ALLES BEPAALT: geen bestand, geen track. Dat klinkt
125 * vanzelfsprekend en was het niet. De oude per-post-tak maakte een
126 * audio_tracks-rij aan zodra er metadata was, ook als de bytes ontbraken. Op
127 * soundfabrics.nl leverde dat 13 nummers op die in de lijst stonden en 404'den
128 * bij het afspelen. Dat is erger dan ontbreken: het ziet eruit alsof de
129 * verhuizing gelukt is, dus je gooit de oude instantie weg.
130 *
131 * De bestanden gaan naar AUDIO_ROOT en niet onder MEDIA_ROOT, want daar hoort
132 * gehoste audio: de publieke /media-handler mag er niet bij (routes/audio.js).
133 *
134 * @returns {Array} de schrijfopdrachten; de beller voert ze in zijn transactie uit
135 * (en bij een droogloop dus niet, maar het verslag klopt wel)
136 */
[98acb97]137/**
138 * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
139 * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
140 * verwijzing naar niets.
141 */
142function hoesTerug(files, bestand, werk) {
143 if (!bestand) return null;
144 const bytes = files.get(bestand);
145 if (!bytes || !bytes.length) return null;
146 const naam = path.basename(String(bestand));
147 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
148 const urlPad = `/media/archief/${naam}`;
149 const doel = veiligMediaPad(urlPad);
150 if (!doel) return null;
151 werk.push({ soort: 'media', doel, bytes });
152 return urlPad;
153}
154
[6c4ff7e]155function tracksTerug(files, site, rapport) {
156 const buf = files.get('tracks.json');
157 if (!buf) return [];
158 let coll;
159 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
160 if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
161
162 const werk = [];
163 for (const t of (coll.orderedItems || [])) {
164 const id = String(t.id || '').trim();
165 if (!id) continue;
166 const bestand = t['shaer:file'];
167 const bytes = bestand ? files.get(bestand) : null;
168 if (!bytes || !bytes.length) {
169 rapport.tracksMissing += 1;
170 rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
171 continue; // de hele regel van deze functie
172 }
173 // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
174 // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
175 const naam = path.basename(String(bestand));
176 if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
177 rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
178 continue;
179 }
[98acb97]180 const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
181 werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
[6c4ff7e]182 rapport.tracks += 1;
183 }
184 return werk;
185}
186
187/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
188function playlistsTerug(files, site, rapport, bekendeTracks) {
189 const buf = files.get('playlists.json');
190 if (!buf) return [];
191 let coll;
192 try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
193 if (coll['shaer:archive'] !== true) return [];
194 const werk = [];
195 for (const p of (coll.orderedItems || [])) {
196 const id = String(p.id || '').trim();
197 if (!id) continue;
198 // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
199 // straks een playlist vol gaten die niemand kan afspelen.
200 const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
201 const kwijt = (p['shaer:tracks'] || []).length - items.length;
202 if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
[98acb97]203 const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
204 werk.push({ soort: 'playlist', p, id, items, hoes });
[6c4ff7e]205 rapport.playlists += 1;
206 }
207 return werk;
208}
209
[096f099]210/**
211 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
212 *
213 * BEWUST BUITEN importArchive. Die draait in één transactie en raakt alleen de
214 * database; opnieuw volgen stuurt Follow-activiteiten de deur uit en wacht op
215 * het netwerk. Dat hoort niet in een transactie: een trage peer houdt hem open,
216 * en een rollback neemt verzonden activiteiten niet terug.
217 *
218 * Ook een aparte, expliciete stap omdat een archief inlezen stil is maar negen
219 * mensen aanschrijven niet. Dat mag geen bijwerking zijn van een import.
220 *
221 * `followFn` is injecteerbaar, zodat de test geen netwerk raakt en dit bestand
222 * ActivityPubService niet hoeft te importeren.
223 */
224export async function importFollowing(site, csvText, { followFn = null } = {}) {
225 const rijen = parseFollowingCsv(csvText);
226 const rapport = { totaal: rijen.length, gevolgd: 0, overgeslagen: 0, mislukt: [] };
227 if (!followFn) return { ...rapport, error: 'no_follow_fn' };
228 for (const r of rijen) {
229 // Jezelf volgen is geen relatie maar een lus. Kan echt gebeuren bij een
230 // archief van een instance die je onder een nieuwe naam opnieuw opzet.
231 if (site && site.slug && r.address.startsWith(`${site.slug}@`)) { rapport.overgeslagen += 1; continue; }
232 try {
[e9ec5e4]233 // De uitgelicht-stand gaat MEE in de Follow zelf: followActor neemt hem
234 // als derde argument en zet auto_boost bij het aanmaken van de rij. Een
235 // aparte UPDATE erna zou een tweede pad zijn naar dezelfde vlag, en dan
236 // kan er precies een halve toestand ontstaan als die faalt.
237 const ok = await followFn(site, r.address, r.featured);
[096f099]238 if (ok === false) { rapport.mislukt.push({ adres: r.address, reden: 'geweigerd' }); continue; }
239 rapport.gevolgd += 1;
240 } catch (e) {
241 rapport.mislukt.push({ adres: r.address, reden: (e && e.message) || 'onbekend' });
242 }
243 }
244 return rapport;
245}
246
[58cfe5f]247/**
248 * Zet een archief terug in een site.
249 *
250 * @param {Map<string,Buffer>} files het ingelezen archief
251 * @param {object} opts { slug, dryRun, overwrite, origin }
252 */
253export function importArchive(files, opts = {}) {
254 const rapport = {
255 formatVersion: null, origin: null, idsBehouden: null,
256 posts: 0, overgeslagen: 0, overschreven: 0,
257 replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
[6c4ff7e]258 tracks: 0, tracksMissing: 0, playlists: 0,
[58cfe5f]259 };
260
261 const manifestBuf = files.get('manifest.json');
262 if (!manifestBuf) throw new Error('geen manifest.json: dit is geen inhoudsarchief');
263 const manifest = JSON.parse(manifestBuf.toString('utf8'));
264 rapport.formatVersion = manifest.formatVersion;
265
266 // VERSIE EERST, voordat er ook maar iets gelezen wordt.
267 if (!Number.isInteger(manifest.formatVersion)) throw new Error('manifest zonder bruikbare formatVersion');
268 if (manifest.formatVersion > FORMAT_VERSION) {
269 throw new Error(`archiefversie ${manifest.formatVersion} is nieuwer dan deze Klonkt kent (${FORMAT_VERSION}); geweigerd`);
270 }
271
272 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(opts.slug);
[a4fea5e]273 if (!site) {
274 // De naam van de INSTANCE (de map, de unit) en de slug van de SITE in zijn
275 // database zijn twee dingen. Ze vallen vaak samen en soms niet, en dan zat je
276 // met een foutmelding die je liet raden. Zeg dus wat er wel in staat.
277 let bestaand = [];
278 try { bestaand = db.prepare('SELECT slug FROM sites ORDER BY rowid').all().map((r) => r.slug); } catch { /* geen sites-tabel */ }
279 const wat = opts.slug ? `onbekende site: ${opts.slug}` : 'geen site opgegeven';
280 throw new Error(bestaand.length
281 ? `${wat}. In deze database staat: ${bestaand.join(', ')}`
282 : `${wat}. In deze database staat geen enkele site -- wijst DATABASE_PATH naar de juiste?`);
283 }
[58cfe5f]284 const eigenOrigin = (opts.origin || process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
285 rapport.origin = manifest.origin || null;
286
287 // IDENTITEIT. Gelijke origin -> de AP-ids blijven, en daarmee vinden de boosts
288 // en antwoorden die er al naar wijzen hun post terug. Anders nieuwe ids, want
289 // een id op andermans domein publiceren is een vervalsingsoppervlak en andere
290 // servers halen het daar toch op.
291 const idsBehouden = !!(manifest.origin && eigenOrigin && manifest.origin === eigenOrigin);
292 rapport.idsBehouden = idsBehouden;
293 if (!idsBehouden) {
294 rapport.waarschuwingen.push(
295 `origin verschilt (archief ${manifest.origin || '?'} vs deze site ${eigenOrigin || '?'}): nieuwe AP-ids, de oude blijven als verwijzing staan`,
296 );
297 }
298
299 const postPaden = [...files.keys()].filter((p) => p.startsWith('posts/') && p.endsWith('.json')).sort();
300 const bestaatId = db.prepare('SELECT 1 FROM posts WHERE id = ?');
301 const bestaatSlug = db.prepare('SELECT id FROM posts WHERE site_id = ? AND slug = ?');
302 const idKaart = new Map(); // oud post-id -> nieuw post-id
303
304 const schrijf = []; // alles eerst uitrekenen, dan in EEN transactie
305
306 for (const pad of postPaden) {
307 const o = JSON.parse(files.get(pad).toString('utf8'));
308 const oudId = decodeURIComponent(String(o.id || '').split('/ap/notes/')[1] || path.basename(pad, '.json'));
309 const nieuwId = idsBehouden ? oudId : randomUUID();
310 idKaart.set(oudId, nieuwId);
311
312 const botsing = !!bestaatId.get(nieuwId) || !!bestaatSlug.get(site.id, o['shaer:slug']);
313 if (botsing && !opts.overwrite) {
314 // EEN gedocumenteerde regel, geen gok per post: bestaande inhoud wordt niet
315 // overschreven tenzij dat expliciet gevraagd is. Dit is ook wat de import
316 // idempotent maakt.
317 rapport.overgeslagen += 1;
318 continue;
319 }
320 if (botsing) rapport.overschreven += 1;
321
322 // Media: terug naar hun oorspronkelijke plek onder /media, want de content
323 // van de post verwijst daarnaar. Dat pad is site-relatief, dus het werkt ook
324 // op een ander domein.
325 for (const a of (Array.isArray(o.attachment) ? o.attachment : [])) {
326 if (a['shaer:availability'] === 'missing') {
327 rapport.mediaMissing += 1;
328 rapport.gemist.push({ post: o['shaer:slug'], url: a['shaer:originalUrl'] || a.url });
329 continue;
330 }
331 const bytes = files.get(a.url);
332 if (!bytes) {
333 // Het archief zegt 'included' maar het bestand ontbreekt. Dat is een kapot
334 // archief, geen ontbrekende media -- apart melden, niet stil optellen.
335 rapport.waarschuwingen.push(`archief verwijst naar ${a.url}, dat er niet in zit`);
336 continue;
337 }
338 if (a['shaer:sha256'] && sha256(bytes) !== a['shaer:sha256']) {
339 rapport.waarschuwingen.push(`${a.url}: checksum klopt niet, overgeslagen`);
340 continue;
341 }
[7aa3140]342 const doel = veiligMediaPad(bestemming(a));
[58cfe5f]343 if (!doel) { rapport.waarschuwingen.push(`${a.url}: onbruikbaar doelpad, overgeslagen`); continue; }
344 schrijf.push({ soort: 'media', doel, bytes });
345 rapport.media += 1;
346 }
347
[fbfd7a1]348 schrijf.push({ soort: 'post', id: nieuwId, oudId, obj: o, oudeUri: o.id || null });
[58cfe5f]349 rapport.posts += 1;
350 }
351
352 // Antwoorden: alleen-lezen archief. Nooit opnieuw bezorgd, geen meldingen.
353 for (const pad of [...files.keys()].filter((p) => p.startsWith('replies/')).sort()) {
354 const coll = JSON.parse(files.get(pad).toString('utf8'));
355 if (coll['shaer:archive'] !== true) {
356 rapport.waarschuwingen.push(`${pad}: niet gemarkeerd als archief, overgeslagen`);
357 continue;
358 }
359 const oudId = path.basename(pad, '.json');
360 const postId = idKaart.get(oudId);
361 if (!postId) continue; // post overgeslagen -> antwoorden ook
362 for (const it of (coll.orderedItems || [])) {
363 schrijf.push({ soort: 'reply', postId, it });
364 rapport.replies += 1;
365 }
366 }
367
[6c4ff7e]368 // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
369 // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
370 const trackWerk = tracksTerug(files, site, rapport);
371 const bekendeTracks = new Set(trackWerk.map((w) => w.id));
372 const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
373
[58cfe5f]374 if (opts.dryRun) return rapport;
375
376 // Schrijven pas nu, in EEN transactie: een half ingelezen archief is de ergste
377 // uitkomst, want dan lijkt het gelukt.
378 const insPost = db.prepare(`INSERT OR REPLACE INTO posts
379 (id, site_id, slug, author_id, title, content, excerpt, status, cover_image_url, cover_alt, cover_video_url,
380 pinned, type, tags, published_at, created_at, updated_at, noindex, publish_at, fan_only, nsfw, language,
[812fea9]381 content_warning, poll_json, quote_uri, quote_actor, ap_visibility, paid, paid_min_cents, view_count, c2s_attachments, origin_server)
[58cfe5f]382 VALUES (@id, @site_id, @slug, @author_id, @title, @content, @excerpt, @status, @cover_image_url, @cover_alt, @cover_video_url,
383 @pinned, @type, @tags, @published_at, @created_at, @updated_at, @noindex, @publish_at, @fan_only, @nsfw, @language,
[812fea9]384 @content_warning, @poll_json, @quote_uri, @quote_actor, @ap_visibility, @paid, @paid_min_cents, @view_count, @c2s_attachments, 'import')`);
[58cfe5f]385 const insReply = db.prepare(`INSERT OR IGNORE INTO ap_interactions
386 (kind, post_id, object_uri, actor_uri, actor_name, actor_handle, content, published, parent_uri, created_at)
387 VALUES ('reply', ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`);
388
389 db.transaction(() => {
[6c4ff7e]390 for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
[58cfe5f]391 if (s.soort === 'media') {
392 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
393 fs.writeFileSync(s.doel, s.bytes);
394 continue;
395 }
396 if (s.soort === 'reply') {
397 insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
398 s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
399 continue;
400 }
[6c4ff7e]401 if (s.soort === 'track') {
402 // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
403 // rolt de hele transactie terug: liever geen import dan een track zonder
404 // geluid, want dat is precies de val waar dit uit voortkomt.
405 fs.mkdirSync(path.dirname(s.doel), { recursive: true });
406 fs.writeFileSync(s.doel, s.bytes);
407 const mediaId = randomUUID();
408 db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
409 .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
410 const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
411 db.prepare(`INSERT OR REPLACE INTO audio_tracks
412 (id, site_id, title, artist, album, duration, media_id, position, credit, license,
413 cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
414 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
415 .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
416 s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
[98acb97]417 s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
[6c4ff7e]418 link('spotify'), link('youtube'), link('soundcloud'));
419 continue;
420 }
421 if (s.soort === 'playlist') {
422 db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
423 VALUES (?,?,?,?,?,?,?)`)
424 .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
[98acb97]425 s.hoes || null, s.p['shaer:kind'] || null);
[6c4ff7e]426 db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
427 const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
428 s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
429 continue;
430 }
[58cfe5f]431 const o = s.obj;
432 const opties = (Array.isArray(o.oneOf) ? o.oneOf : (Array.isArray(o.anyOf) ? o.anyOf : null));
[812fea9]433 // De rollen uit het archief terug naar de kolommen. Zonder dit staat het
434 // bestand er wel, maar komt de post zonder cover en zonder speler terug --
435 // en dat zie je pas als je alle kolommen vergelijkt.
436 const bijlagen = Array.isArray(o.attachment) ? o.attachment : [];
[7aa3140]437 const padVan = (a) => (a && a['shaer:availability'] !== 'missing' ? bestemming(a) : (a ? padVanOrigineel(a['shaer:originalUrl']) : null));
[812fea9]438 const metRol = (r) => bijlagen.find((a) => a['shaer:role'] === r);
439 const c2s = bijlagen.filter((a) => a['shaer:role'] === 'c2s').map((a) => {
440 const poster = bijlagen.find((x) => x['shaer:role'] === 'poster' && x['shaer:posterFor'] === padVan(a));
441 return {
442 url: padVan(a), mediaType: a.mediaType, name: a.name || undefined,
443 poster: poster ? padVan(poster) : undefined,
444 };
445 }).filter((a) => a.url);
[58cfe5f]446 insPost.run({
447 id: s.id, site_id: site.id, slug: o['shaer:slug'] || s.id, author_id: site.owner_id,
448 title: o.name || null, content: o.content || '', excerpt: o['shaer:excerpt'] || null,
449 status: o['shaer:status'] || 'draft',
[812fea9]450 cover_image_url: padVan(metRol('cover')), cover_alt: o['shaer:coverAlt'] || null,
451 cover_video_url: padVan(metRol('coverVideo')),
452 c2s_attachments: c2s.length ? JSON.stringify(c2s) : null,
[58cfe5f]453 pinned: o['shaer:pinned'] ? 1 : 0, type: o['shaer:type'] || 'post',
454 tags: Array.isArray(o.tag) ? o.tag.filter((t) => t && t.type === 'Hashtag').map((t) => String(t.name).replace(/^#/, '')).join(', ') : null,
[812fea9]455 published_at: tijd(o.published), created_at: tijd(o.published), updated_at: tijd(o.updated || o.published),
456 noindex: o['shaer:noindex'] ? 1 : 0, publish_at: tijd(o['shaer:publishAt']),
[58cfe5f]457 fan_only: o['shaer:fanOnly'] ? 1 : 0, nsfw: o.sensitive ? 1 : 0,
458 language: (o.contentMap && Object.keys(o.contentMap)[0]) || null,
459 content_warning: o.summary || null,
460 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,
461 quote_uri: o.quoteUrl || null, quote_actor: o['shaer:quoteActor'] || null,
462 ap_visibility: o['shaer:apVisibility'] || null,
463 paid: o['shaer:paid'] ? 1 : 0, paid_min_cents: o['shaer:paidMinCents'] || null,
464 view_count: o['shaer:viewCount'] || 0,
465 });
[6c4ff7e]466 // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
467 // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
468 // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
469 // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
470 // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
471 // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
[58cfe5f]472 }
[fbfd7a1]473
474 // FEP-1580: een import uit een export is GEEN apart geval. De spec zegt
475 // met zoveel woorden dat objecten uit een geëxporteerde collectie net zo
476 // behandeld moeten worden als objecten die van de bron zijn opgehaald.
477 // Dus vult ook deze weg de vertaaltabel, en werkt de reactie van een derde
478 // op een verhuisd bericht straks net zo goed bij als bij een live ingest.
479 //
480 // Alleen zinnig als de ids VERANDERD zijn: bleven ze gelijk, dan wijst de
481 // oude URI al naar het goede object en valt er niets te vertalen.
482 if (!idsBehouden && eigenOrigin) {
483 for (const s of schrijf) {
484 if (s.soort !== 'post' || !s.oudeUri) continue;
485 Migration.recordMigrated(site.slug, {
486 origin: s.oudeUri,
487 target: `${eigenOrigin}/ap/notes/${encodeURIComponent(s.id)}`,
488 sourceActor: manifest.actor || '',
489 isPublic: !(s.obj && (s.obj['shaer:fanOnly'] || s.obj['shaer:apVisibility'] === 'direct')),
490 });
491 }
492 }
[58cfe5f]493 })();
494
495 return rapport;
496}
Note: See TracBrowser for help on using the repository browser.