Changeset 6c4ff7e in Klonkt


Ignore:
Timestamp:
08/13/2026 11:39:57 PM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
9529d7b
Parents:
fbfd7a1
Message:

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

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

Files:
1 added
11 edited

Legend:

Unmodified
Added
Removed
  • src/config/paths.js

    rfbfd7a1 r6c4ff7e  
    3434  return path.resolve(process.env[envVar] || path.join(MEDIA_ROOT, sub));
    3535}
     36
     37/**
     38 * Waar de gehoste audio staat.
     39 *
     40 * BEWUST BUITEN MEDIA_ROOT: de publieke /media-handler mag er niet bij, elke
     41 * fetch loopt via de gated route in routes/audio.js. Diezelfde route resolvet
     42 * met AUDIO_DIR + bestandsnaam, en negeert media.storage_path volledig.
     43 */
     44export const AUDIO_ROOT = path.resolve(
     45  process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio'),
     46);
     47
     48/**
     49 * Het echte pad van een audiobestand, op DEZELFDE manier als de speler het zoekt.
     50 *
     51 * Dit bestaat omdat die twee uit elkaar liepen en dat een verhuizing sloopte.
     52 * Op sound-fabrics.com wees media.storage_path voor 124 van de 139 tracks nog
     53 * naar /srv/prutfolio/storage/audio (van voor de dataverhuizing), terwijl de
     54 * bestanden allang op ~/data/prutfolio/audio stonden. De site merkte er niets
     55 * van, want de speler kijkt alleen naar de bestandsnaam. De exporter las wel
     56 * storage_path, vond niets, en liet 124 nummers stil achter.
     57 *
     58 * Volgorde: eerst zoals de speler kijkt (bestandsnaam in AUDIO_ROOT), dan pas
     59 * het opgeslagen pad. Zo klopt de export met wat de gebruiker hoort, en niet
     60 * met wat de database ooit dacht.
     61 *
     62 * @returns {string|null} een bestaand pad, of null
     63 */
     64export function resolveAudioPath(storagePath, fs) {
     65  const s = String(storagePath || '');
     66  if (!s) return null;
     67  const kandidaten = [path.join(AUDIO_ROOT, path.basename(s)), path.resolve(s)];
     68  for (const p of kandidaten) {
     69    try { if (fs.statSync(p).isFile()) return p; } catch { /* volgende kandidaat */ }
     70  }
     71  return null;
     72}
  • src/routes/activitypub.js

    rfbfd7a1 r6c4ff7e  
    10431043});
    10441044
     1045/**
     1046 * Mag deze aanvrager alles van `slug` zien? Waar bij de eigenaar zelf, en waar
     1047 * voor de actor waar `slug` naartoe verhuisd is (FEP-1580, Source Instance).
     1048 *
     1049 * Eén plek voor die vraag, want hij komt op meerdere collecties terug en twee
     1050 * antwoorden op dezelfde vraag lopen vroeg of laat uiteen.
     1051 */
     1052async function magAlles(req, slug) {
     1053  const auth = OAuth.verifyBearer(req.headers.authorization);
     1054  if (auth && auth.site.slug === slug) return true;
     1055  if (!req.headers['signature']) return false;
     1056  const v = await AP.verifyRequest(req).catch(() => null);
     1057  return !!(v && v.id && AP.isMoveTarget(slug, v.id));
     1058}
     1059
    10451060// ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
    10461061//
     
    11151130// stap 3). Een playlist is een keuze hieruit; deze collectie is alles wat de
    11161131// artiest heeft opengezet, ook wat in geen enkele playlist staat.
    1117 router.get('/ap/users/:slug/tracks', (req, res) => {
     1132router.get('/ap/users/:slug/tracks', async (req, res) => {
    11181133  const site = publicSite(req.params.slug);
    11191134  if (!site) return res.status(404).end();
    1120   AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id)));
     1135  AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) })));
    11211136});
    11221137
  • src/routes/admin-migrate.js

    rfbfd7a1 r6c4ff7e  
    2929import { requireGod } from '../middleware/auth.js';
    3030import ActivityPubService from '../services/ActivityPubService.js';
    31 import { safeFetch, signedGetJson, noteId, noteVisibility } from '../services/ActivityPubService.js';
     31import { safeFetch, signedGetJson, signedGetHeaders, noteId, noteVisibility } from '../services/ActivityPubService.js';
    3232import HtmlSanitizerService from '../services/HtmlSanitizerService.js';
    33 import { MEDIA_ROOT } from '../config/paths.js';
     33import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
    3434import * as Migration from '../services/MigrationService.js';
    3535import { buildArchive, zipArchive } from '../services/ArchiveExportService.js';
     
    111111      sourceUri: opgegeven || null,
    112112      deps: {
    113         getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, fs, path, noteId, noteVisibility,
     113        getJson: signedGetJson, safeFetch, mediaRoot: MEDIA_ROOT, audioRoot: AUDIO_ROOT, fs, path, noteId, noteVisibility, signHeaders: signedGetHeaders,
    114114        sanitize: (h) => HtmlSanitizerService.sanitize(h || ''),
    115115      },
  • src/routes/audio.js

    rfbfd7a1 r6c4ff7e  
    3030import db from '../config/database.js';
    3131import { recordPlay } from '../services/StatsService.js';
     32import AP from '../services/ActivityPubService.js';
    3233
    3334const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    7172}
    7273
    73 router.get('/stream/:filename', (req, res) => {
     74/**
     75 * FEP-1580: de instantie waar dit account naartoe verhuisd is mag ALLE audio
     76 * ophalen, ook wat niet fedi_open is.
     77 *
     78 * Zonder deze tak ziet de nieuwe Klonkt de tracklijst wel en krijgt hij de
     79 * bestanden niet, en dan verhuis je een bibliotheek met alleen titels. Dat is
     80 * precies de halve waarheid die deze hele ronde moest opruimen.
     81 *
     82 * Smal gehouden: een geldige handtekening, van precies de actor in moved_to, en
     83 * alleen voor een bestand dat van DIE site is. moved_to komt er alleen te staan
     84 * als de doel-actor ons in alsoKnownAs had, dus er heeft iemand met beheer aan
     85 * beide kanten ja gezegd.
     86 */
     87async function isMoveTargetAudio(req, filename) {
     88  if (!req.headers['signature'] || !filename) return false;
     89  let rij;
     90  try {
     91    rij = db.prepare(`SELECT s.slug FROM audio_tracks t
     92                        JOIN media m ON t.media_id = m.id
     93                        JOIN sites s ON s.id = t.site_id
     94                       WHERE m.storage_path = ? OR m.storage_path LIKE ? LIMIT 1`)
     95      .get(filename, `%${filename}`);
     96  } catch { return false; }
     97  if (!rij || !rij.slug) return false;
     98  const v = await AP.verifyRequest(req).catch(() => null);
     99  return !!(v && v.id && AP.isMoveTarget(rij.slug, v.id));
     100}
     101
     102router.get('/stream/:filename', async (req, res) => {
    74103  const { filename } = req.params;
    75104
    76   if (!isAllowedAudioRequest(req, filename)) {
     105  if (!isAllowedAudioRequest(req, filename) && !(await isMoveTargetAudio(req, filename))) {
    77106    return res.status(403).send('Direct access not allowed');
    78107  }
  • src/services/ActivityPubService.js

    rfbfd7a1 r6c4ff7e  
    54165416 * (request-target) host date, the set verifyRequest checks.
    54175417 */
     5418/**
     5419 * De handtekening-headers voor een GET als `slug`. Losgetrokken uit
     5420 * signedGetJson omdat een verhuizing ook BYTES moet kunnen ophalen (FEP-1580:
     5421 * gehoste audio zit achter dezelfde poort als de rest, en een ongetekende fetch
     5422 * krijgt daar terecht een 403).
     5423 */
     5424export function signedGetHeaders(slug, url, accept = 'application/activity+json') {
     5425  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     5426  if (!base || !slug) return null;
     5427  const me = actorId(base, slug);
     5428  const keys = getOrCreateKeys(slug);
     5429  const u = new URL(url);
     5430  const date = new Date().toUTCString();
     5431  const target = `${u.pathname}${u.search || ''}`;
     5432  const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
     5433  const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
     5434  return {
     5435    Accept: accept,
     5436    Date: date,
     5437    Signature: `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`,
     5438  };
     5439}
     5440
    54185441export async function signedGetJson(slug, url, onStatus) {
    54195442  try {
    5420     const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    5421     if (!base || !slug) return apGetJson(url);
    5422     const me = actorId(base, slug);
    5423     const keys = getOrCreateKeys(slug);
    5424     const u = new URL(url);
    5425     const date = new Date().toUTCString();
    5426     const target = `${u.pathname}${u.search || ''}`;
    5427     const signingString = `(request-target): get ${target}\nhost: ${u.host}\ndate: ${date}`;
    5428     const signature = crypto.sign('sha256', Buffer.from(signingString), keys.private_pem).toString('base64');
    5429     const sig = `keyId="${me}#main-key",algorithm="rsa-sha256",headers="(request-target) host date",signature="${signature}"`;
    5430     const r = await safeFetch(url, { headers: { Accept: 'application/activity+json', Date: date, Signature: sig } });
     5443    const headers = signedGetHeaders(slug, url);
     5444    if (!headers) return apGetJson(url);
     5445    const r = await safeFetch(url, { headers });
    54315446    // De status doorgeven aan wie erom vroeg: null alleen zegt "het lukte
    54325447    // niet", en dat is te weinig om een WEIGERING van een STORING te
     
    67576772  // aanroept een 500 en lijkt het alsof de poort dicht staat terwijl hij
    67586773  // ontbreekt (precies hoe movedLock zich een dag eerder verstopte).
    6759   isMoveTarget, signedGetJson,
     6774  isMoveTarget, signedGetJson, signedGetHeaders,
    67606775  AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, stripLeadingMentions, pagedCollection,
    67616776  deriveHandle, localSlugOf, outboxSlice, PAGINA_GROOTTE,
  • src/services/ArchiveExportService.js

    rfbfd7a1 r6c4ff7e  
    1919import crypto from 'crypto';
    2020import db from '../config/database.js';
    21 import { MEDIA_ROOT } from '../config/paths.js';
    22 
    23 export const FORMAT_VERSION = 1;
     21import { MEDIA_ROOT, resolveAudioPath } from '../config/paths.js';
     22
     23// v2: audio zit er eindelijk echt in. Tot v1 kon dat niet: gehoste audio staat
     24// BUITEN MEDIA_ROOT (eigen gated route, zie routes/audio.js), en het archief
     25// droeg alleen bestanden onder media/. De exporter rekende er met path.relative
     26// een /media/../audio/x.mp3 van, en de importer weigerde dat pad terecht. Er
     27// stond dus wel een track in de database van de nieuwe site, maar nooit een
     28// bestand. v2 heeft een eigen audio/-gebied, exporteert de HELE bibliotheek in
     29// plaats van alleen wat in een bericht staat, en neemt de playlists mee.
     30export const FORMAT_VERSION = 2;
    2431
    2532/** JSON met gesorteerde sleutels: zonder vaste volgorde is byte-gelijkheid toeval. */
     
    122129    }
    123130  } catch { /* kapotte kolom blokkeert de export niet */ }
    124   // Gehoste audio: [[track:id]] verwijst naar een audio_tracks-rij met een media-rij eronder.
    125   for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
    126     try {
    127       const t = db.prepare('SELECT t.title, m.storage_path FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id WHERE t.id = ?').get(m[1]);
    128       if (t && t.storage_path) voegToe(`/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`, t.title, 'track');
    129     } catch { /* geen audio-tabellen: niets te doen */ }
    130   }
     131  // Gehoste audio staat hier NIET meer bij. Die leeft buiten MEDIA_ROOT en gaat
     132  // sinds v2 via het audio/-gebied (zie audioBibliotheek). De oude regel rekende
     133  // met path.relative een pad naar buiten MEDIA_ROOT uit, en dat kon nooit
     134  // aankomen: de importer weigert zo'n pad, terecht.
    131135  return uit;
    132136}
     
    141145 * herstel op niets terug.
    142146 */
    143 function audioOf(post, attachments) {
     147function audioOf(post, audioKaart) {
    144148  const uit = [];
    145149  for (const m of String(post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) {
    146150    try {
    147       const t = db.prepare('SELECT t.*, md.storage_path FROM audio_tracks t LEFT JOIN media md ON md.id = t.media_id WHERE t.id = ?').get(m[1]);
     151      const t = db.prepare('SELECT * FROM audio_tracks WHERE id = ?').get(m[1]);
    148152      if (!t) continue;
    149       let bestand;
    150       if (t.storage_path) {
    151         const rel = `/media/${path.relative(path.resolve(MEDIA_ROOT), path.resolve(t.storage_path))}`;
    152         const bij = attachments.find((a) => String(a['shaer:originalUrl'] || '').endsWith(rel));
    153         bestand = bij ? bij.url : undefined;
    154       }
     153      // Sinds v2 wijst dit naar het audio/-gebied. Staat de track er niet in
     154      // (bestand onvindbaar), dan blijft het veld LEEG in plaats van naar een
     155      // bijlage te wijzen die er niet is.
     156      const bestand = audioKaart.get(t.id) || undefined;
    155157      uit.push({
    156158        'shaer:ref': `[[track:${t.id}]]`,
     
    165167}
    166168
     169/**
     170 * De HELE audiobibliotheek, plus de playlists.
     171 *
     172 * Tot v1 ging alleen mee wat met [[track:]] in een bericht stond. Op
     173 * sound-fabrics.com waren dat er 14 van de 140, en de 11 playlists gingen
     174 * helemaal niet mee. Een verhuizing die je bibliotheek achterlaat is geen
     175 * verhuizing.
     176 *
     177 * Het bestand wordt gezocht met resolveAudioPath, dus op DEZELFDE manier als de
     178 * speler het zoekt. Dat verschil was de stille moordenaar: 124 van de 139
     179 * storage_paths waren verouderd na een dataverhuizing, de site speelde gewoon
     180 * door, en de export liet ze weg zonder dat iemand het merkte.
     181 *
     182 * @returns {Map<string,string>} trackId -> pad in het archief
     183 */
     184function audioBibliotheek(site, bestanden, tellingen, ontbrekend) {
     185  const kaart = new Map();
     186  let tracks = [];
     187  try {
     188    tracks = db.prepare(`SELECT t.*, m.storage_path, m.mime_type FROM audio_tracks t
     189                          LEFT JOIN media m ON m.id = t.media_id
     190                         WHERE t.site_id = ?
     191                         ORDER BY COALESCE(t.position, 999999), t.created_at, t.id`).all(site.id);
     192  } catch { return kaart; }              // installatie zonder audio-tabellen
     193  if (!tracks.length) return kaart;
     194
     195  const items = [];
     196  for (const t of tracks) {
     197    const schijf = resolveAudioPath(t.storage_path, fs);
     198    let naam = null;
     199    let hash = null;
     200    if (schijf) {
     201      try {
     202        const bytes = fs.readFileSync(schijf);
     203        hash = sha256(bytes);
     204        const ext = (path.extname(schijf).slice(1) || 'mp3').toLowerCase();
     205        naam = `audio/${hash}.${ext}`;
     206        if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.audio += 1; }
     207        kaart.set(t.id, naam);
     208      } catch { naam = null; }           // onleesbaar telt als ontbrekend, niet als stilte
     209    }
     210    if (!naam) {
     211      tellingen.audioMissing += 1;
     212      ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
     213    }
     214    items.push({
     215      id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
     216      duration: t.duration || undefined, position: t.position ?? undefined,
     217      credit: t.credit || undefined, license: t.license || undefined,
     218      'shaer:coverUrl': t.cover_url || undefined,
     219      'shaer:downloadable': t.downloadable ? 1 : 0,
     220      'shaer:fediOpen': t.fedi_open ? 1 : 0,
     221      'shaer:mediaType': t.mime_type || 'audio/mpeg',
     222      'shaer:file': naam || undefined,
     223      'shaer:sha256': hash || undefined,
     224      // Derde staat, net als bij media: we weten DAT het bestond en waar het
     225      // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
     226      // kunnen weigeren in plaats van een track zonder bestand aan te maken.
     227      'shaer:availability': naam ? 'included' : 'missing',
     228      'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
     229      url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
     230    });
     231  }
     232  bestanden.set('tracks.json', Buffer.from(stableJson({
     233    '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
     234    type: 'OrderedCollection', 'shaer:archive': true, totalItems: items.length, orderedItems: items,
     235  }), 'utf8'));
     236  tellingen.tracks = items.length;
     237
     238  // Playlists: de volgorde IS de playlist, dus die moet expliciet mee.
     239  try {
     240    const pls = db.prepare('SELECT * FROM playlists WHERE site_id = ? ORDER BY created_at, id').all(site.id);
     241    if (pls.length) {
     242      const lijst = pls.map((p) => ({
     243        id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
     244        'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
     245        'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
     246          .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
     247      }));
     248      bestanden.set('playlists.json', Buffer.from(stableJson({
     249        '@context': ['https://www.w3.org/ns/activitystreams', { shaer: 'https://klonkt.com/ns#' }],
     250        type: 'OrderedCollection', 'shaer:archive': true, totalItems: lijst.length, orderedItems: lijst,
     251      }), 'utf8'));
     252      tellingen.playlists = lijst.length;
     253    }
     254  } catch { /* geen playlist-tabellen */ }
     255
     256  return kaart;
     257}
     258
    167259/** Eén post als AS2-object volgens het formaat. Bijlagen komen van de beller. */
    168 function postObject(post, site, origin, attachments) {
     260function postObject(post, site, origin, attachments, audioKaart) {
    169261  const heeftTitel = !!(post.title && String(post.title).trim());
    170262  const published = toISO(post.published_at || post.created_at) || toISO(post.created_at);
     
    220312    'shaer:coverAlt': post.cover_alt || undefined,
    221313    'shaer:viewCount': post.view_count || undefined,
    222     'shaer:audio': audioOf(post, attachments),
     314    'shaer:audio': audioOf(post, audioKaart),
    223315  };
    224316}
     
    362454
    363455  const bestanden = new Map();       // pad -> Buffer
    364   const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0 };
     456  const tellingen = { posts: 0, replies: 0, media: 0, mediaMissing: 0, audio: 0, audioMissing: 0, tracks: 0, playlists: 0 };
    365457  const ontbrekend = [];             // voor de rapportage van de beller
     458
     459  // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
     460  // kaart moet klaar zijn voor de eerste post gebouwd wordt.
     461  const audioKaart = audioBibliotheek(site, bestanden, tellingen, ontbrekend);
    366462
    367463  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
     
    404500    }
    405501
    406     const obj = postObject(post, site, origin, attachments);
     502    const obj = postObject(post, site, origin, attachments, audioKaart);
    407503    bestanden.set(`posts/${post.id}.json`, Buffer.from(stableJson(obj), 'utf8'));
    408504    bestanden.set(`readable/${post.slug}.md`, Buffer.from(readableMarkdown(post, obj), 'utf8'));
  • src/services/ArchiveImportService.js

    rfbfd7a1 r6c4ff7e  
    2424import { randomUUID } from 'crypto';
    2525import db from '../config/database.js';
    26 import { MEDIA_ROOT } from '../config/paths.js';
     26import { MEDIA_ROOT, AUDIO_ROOT } from '../config/paths.js';
    2727import { FORMAT_VERSION, parseFollowingCsv } from './ArchiveExportService.js';
    2828import * as Migration from './MigrationService.js';
     
    120120
    121121/**
     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 */
     137function tracksTerug(files, site, rapport) {
     138  const buf = files.get('tracks.json');
     139  if (!buf) return [];
     140  let coll;
     141  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('tracks.json is onleesbaar'); return []; }
     142  if (coll['shaer:archive'] !== true) { rapport.waarschuwingen.push('tracks.json: niet gemarkeerd als archief, overgeslagen'); return []; }
     143
     144  const werk = [];
     145  for (const t of (coll.orderedItems || [])) {
     146    const id = String(t.id || '').trim();
     147    if (!id) continue;
     148    const bestand = t['shaer:file'];
     149    const bytes = bestand ? files.get(bestand) : null;
     150    if (!bytes || !bytes.length) {
     151      rapport.tracksMissing += 1;
     152      rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
     153      continue;                       // de hele regel van deze functie
     154    }
     155    // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
     156    // zoekt op bestandsnaam in AUDIO_ROOT, dus dit is meteen het pad dat werkt.
     157    const naam = path.basename(String(bestand));
     158    if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) {
     159      rapport.waarschuwingen.push(`${t.name || id}: onbruikbare bestandsnaam, overgeslagen`);
     160      continue;
     161    }
     162    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam });
     163    rapport.tracks += 1;
     164  }
     165  return werk;
     166}
     167
     168/** De playlists terug, inclusief hun volgorde: die volgorde IS de playlist. */
     169function playlistsTerug(files, site, rapport, bekendeTracks) {
     170  const buf = files.get('playlists.json');
     171  if (!buf) return [];
     172  let coll;
     173  try { coll = JSON.parse(buf.toString('utf8')); } catch { rapport.waarschuwingen.push('playlists.json is onleesbaar'); return []; }
     174  if (coll['shaer:archive'] !== true) return [];
     175  const werk = [];
     176  for (const p of (coll.orderedItems || [])) {
     177    const id = String(p.id || '').trim();
     178    if (!id) continue;
     179    // Alleen verwijzen naar tracks die er echt gekomen zijn, anders staat er
     180    // straks een playlist vol gaten die niemand kan afspelen.
     181    const items = (p['shaer:tracks'] || []).filter((x) => bekendeTracks.has(String(x && x.id)));
     182    const kwijt = (p['shaer:tracks'] || []).length - items.length;
     183    if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
     184    werk.push({ soort: 'playlist', p, id, items });
     185    rapport.playlists += 1;
     186  }
     187  return werk;
     188}
     189
     190/**
    122191 * Volg opnieuw wie je volgde, uit de `following.csv` van een archief.
    123192 *
     
    167236    posts: 0, overgeslagen: 0, overschreven: 0,
    168237    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
     238    tracks: 0, tracksMissing: 0, playlists: 0,
    169239  };
    170240
     
    275345    }
    276346  }
     347
     348  // De audiobibliotheek. Telt ook in een droogloop mee in het verslag, want
     349  // "hoeveel nummers komen er" is precies wat je wilt weten voor je besluit.
     350  const trackWerk = tracksTerug(files, site, rapport);
     351  const bekendeTracks = new Set(trackWerk.map((w) => w.id));
     352  const playlistWerk = playlistsTerug(files, site, rapport, bekendeTracks);
    277353
    278354  if (opts.dryRun) return rapport;
     
    292368
    293369  db.transaction(() => {
    294     for (const s of schrijf) {
     370    for (const s of [...schrijf, ...trackWerk, ...playlistWerk]) {
    295371      if (s.soort === 'media') {
    296372        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
     
    301377        insReply.run(s.postId, s.it.id || '', s.it.attributedTo || '', s.it['shaer:actorName'] || null,
    302378          s.it['shaer:actorHandle'] || null, s.it.content || '', s.it.published || null, s.it.inReplyTo || null);
     379        continue;
     380      }
     381      if (s.soort === 'track') {
     382        // Bestand eerst, dan pas de rijen. Faalt het schrijven, dan gooit dit en
     383        // rolt de hele transactie terug: liever geen import dan een track zonder
     384        // geluid, want dat is precies de val waar dit uit voortkomt.
     385        fs.mkdirSync(path.dirname(s.doel), { recursive: true });
     386        fs.writeFileSync(s.doel, s.bytes);
     387        const mediaId = randomUUID();
     388        db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
     389          .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
     390        const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
     391        db.prepare(`INSERT OR REPLACE INTO audio_tracks
     392            (id, site_id, title, artist, album, duration, media_id, position, credit, license,
     393             cover_url, downloadable, fedi_open, link_spotify, link_youtube, link_soundcloud)
     394          VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
     395          .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
     396            s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
     397            s.t['shaer:coverUrl'] || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
     398            link('spotify'), link('youtube'), link('soundcloud'));
     399        continue;
     400      }
     401      if (s.soort === 'playlist') {
     402        db.prepare(`INSERT OR REPLACE INTO playlists (id, site_id, title, artist, year, cover_url, kind)
     403                    VALUES (?,?,?,?,?,?,?)`)
     404          .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
     405            s.p['shaer:coverUrl'] || null, s.p['shaer:kind'] || null);
     406        db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
     407        const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
     408        s.items.forEach((it, i) => insPT.run(s.id, String(it.id), it.position ?? i));
    303409        continue;
    304410      }
     
    338444        view_count: o['shaer:viewCount'] || 0,
    339445      });
    340       // Gehoste audio terug: [[track:]] in de content valt anders op niets terug.
    341       for (const t of (o['shaer:audio'] || [])) {
    342         const trackId = String(t['shaer:ref'] || '').replace(/^\[\[track:|\]\]$/g, '');
    343         if (!trackId) continue;
    344         let mediaId = null;
    345         const bij = (o.attachment || []).find((a) => a.url === t['shaer:media']);
    346         const doel = bij && veiligMediaPad(bestemming(bij));
    347         if (doel) {
    348           mediaId = randomUUID();
    349           try {
    350             db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
    351               .run(mediaId, site.id, path.basename(doel), bij.mediaType || 'audio/mpeg', (files.get(bij.url) || []).length || 0, doel);
    352           } catch { mediaId = null; }
    353         }
    354         try {
    355           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)
    356                       VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
    357             .run(trackId, site.id, t.name || 'zonder titel', t.artist || null, t.album || null, t.duration || null,
    358               mediaId, t.credit || null, t.license || null,
    359               ...['spotify', 'youtube', 'soundcloud'].map((k) => (t.url || []).find((u) => String(u).includes(k)) || null));
    360         } catch { /* geen audio-tabellen op deze installatie */ }
    361       }
     446      // De per-post audio-tak is weg. Tracks komen sinds v2 uit tracks.json,
     447      // dat de HELE bibliotheek draagt in plaats van alleen wat in een bericht
     448      // stond. Hier stond bovendien de fout die soundfabrics.nl opleverde: deze
     449      // lus maakte een audio_tracks-rij aan ZONDER te kijken of het bestand er
     450      // wel was, dus je kreeg 13 nummers die bestonden, in de lijst stonden, en
     451      // 404'den zodra je op play drukte. Zie tracksTerug hieronder.
    362452    }
    363453
  • src/services/MigrationService.js

    rfbfd7a1 r6c4ff7e  
    248248 * moet stranden, ook als die bron "van onszelf" is.
    249249 */
    250 async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes }) {
    251   const r = await safeFetch(url, { headers: { accept: '*/*' } }).catch(() => null);
     250async function haalBijlage(url, { safeFetch, mediaRoot, fs, path, maxBytes, submap = 'migrated', headers = null }) {
     251  // Ondertekend als het moet. Gehoste audio zit achter dezelfde poort als de
     252  // rest van de bron, en een kale fetch krijgt daar een 403: de bron kan dan
     253  // niet zien dat wij de doel-actor van zijn Move zijn.
     254  const r = await safeFetch(url, { headers: headers || { accept: '*/*' } }).catch(() => null);
    252255  if (!r || !r.ok) return null;
    253256  const buf = Buffer.from(await r.arrayBuffer());
     
    261264  })();
    262265  const naam = `${crypto.randomUUID()}.${ext}`;
    263   const rel = `migrated/${naam}`;
    264   const abs = path.join(mediaRoot, 'migrated', naam);
     266  // Zonder submap komt het bestand in de root zelf: dat is wat gehoste audio
     267  // nodig heeft, want de speler zoekt AUDIO_ROOT + bestandsnaam en kijkt niet
     268  // in mappen eronder.
     269  const rel = submap ? `${submap}/${naam}` : naam;
     270  const abs = submap ? path.join(mediaRoot, submap, naam) : path.join(mediaRoot, naam);
    265271  fs.mkdirSync(path.dirname(abs), { recursive: true });
    266272  fs.writeFileSync(abs, buf);
     
    294300    // privacygrens en niet een weergavedetail.
    295301    noteVisibility = () => 'followers',
     302    audioRoot = null, signHeaders = null,
    296303  } = deps;
    297304  const zichtbaarheid = noteVisibility;
     
    329336  const rapport = {
    330337    bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
    331     blocks: 0, waarschuwingen: [],
     338    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [],
    332339  };
    333340
     
    441448    }
    442449    if (gezien >= max) rapport.waarschuwingen.push(`gestopt bij ${max} berichten, draai het nog eens voor de rest`);
     450
     451    // ── De muziekbibliotheek ──────────────────────────────────────
     452    //
     453    // Losse nummers staan niet in de outbox: die hangen aan de tracks-collectie
     454    // waar de actor via AS2 `streams` naar wijst. Zonder deze lus verhuist een
     455    // muzieksite zijn berichten en laat hij zijn bibliotheek achter.
     456    //
     457    // De bron geeft ons hier alles, niet alleen de fedi_open-nummers, omdat we
     458    // de doel-actor van zijn Move zijn (siteOpenTracks({alles})). Hetzelfde
     459    // geldt voor de bestanden zelf, die anders achter de gated audio-route
     460    // blijven.
     461    const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
     462    const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
     463    if (tracksUrl && safeFetch && fs && path && audioRoot) {
     464      const coll = await getJson(site.slug, tracksUrl);
     465      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
     466      for (const it of (Array.isArray(lijst) ? lijst : []).slice(0, max)) {
     467        const a = (it && typeof it.object === 'object' && it.object) ? it.object : it;
     468        if (!a || !a.id) continue;
     469        if (a.type && a.type !== 'Audio') continue;
     470        if (alGemigreerd(site.slug, a.id)) { rapport.overgeslagenTracks++; continue; }
     471        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));
     472        if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
     473        const g = await haalBijlage(String(bron), {
     474          safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
     475          headers: signHeaders ? signHeaders(site.slug, String(bron), '*/*') : null,
     476        }).catch(() => null);
     477        if (!g) {
     478          rapport.tracksMislukt++;
     479          rapport.waarschuwingen.push(`nummer niet opgehaald: ${a.name || bron}`);
     480          continue;                       // dezelfde regel als bij de zip: geen bestand, geen track
     481        }
     482        const trackId = crypto.randomUUID();
     483        const mediaId = crypto.randomUUID();
     484        try {
     485          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
     486            .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
     487          db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open)
     488                      VALUES (?,?,?,?,?,?,?,0)`)
     489            .run(trackId, site.id, a.name || 'zonder titel', a.artist || null, a.album || null,
     490              Number(a.duration) || null, mediaId);
     491          recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
     492          rapport.tracksBinnen++;
     493        } catch (e) {
     494          rapport.tracksMislukt++;
     495          rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
     496        }
     497      }
     498    } else if (tracksUrl) {
     499      rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
     500    }
    443501  } catch (e) {
    444502    // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
  • src/services/music/index.js

    rfbfd7a1 r6c4ff7e  
    4040 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
    4141 */
    42 export function siteOpenTracks(siteId) {
     42/**
     43 * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
     44 * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
     45 * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
     46 * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
     47 * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
     48 * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
     49 * moved_to er kwam te staan.
     50 */
     51export function siteOpenTracks(siteId, { alles = false } = {}) {
    4352  return db.prepare(
    4453    `SELECT ${TRACK_KOLOMMEN}
    4554     FROM audio_tracks t JOIN media m ON m.id = t.media_id
    46      WHERE t.site_id = ? AND t.fedi_open = 1
     55     WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
    4756     ORDER BY t.position, t.created_at, t.id`
    4857  ).all(siteId);
  • test/archive-export.test.js

    rfbfd7a1 r6c4ff7e  
    134134
    135135test('een gehoste track reist mee, met de gegevens die alleen in de database staan', () => {
    136   // [[track:]] verwijst naar een audio_tracks-rij met een media-rij eronder. Op
    137   // beta staan nul tracks, dus dit pad raakt daar geen echte data -- vandaar hier.
     136  // Sinds v2 gaat audio NIET meer als media-bijlage mee. Dat kon ook nooit:
     137  // gehoste audio staat buiten MEDIA_ROOT, de exporter rekende er met
     138  // path.relative een /media/../audio/x.mp3 van, en de importer weigerde dat
     139  // terecht. Resultaat op soundfabrics.nl: 13 tracks in de lijst, nul bestanden.
     140  // Nu heeft het archief een eigen audio/-gebied.
    138141  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
    139142  const WAV = Buffer.from('RIFF-nep-audio');
     
    147150  const uit = AX.buildArchive('me');
    148151  const o = lees(uit, 'posts/metaudio.json');
    149   const a = o.attachment[0];
    150   assert.equal(a['shaer:availability'], 'included');
    151   assert.equal(a.mediaType, 'audio/mpeg');
    152   assert.deepEqual(uit.files.get(a.url), WAV);
     152  const t = o['shaer:audio'][0];
    153153  assert.match(o.content, /\[\[track:tr1\]\]/, 'de shorthand blijft in de bron staan');
    154   const t = o['shaer:audio'][0];
    155154  assert.equal(t.name, 'Kanonnen');
    156155  assert.equal(t.artist, 'Robin');
    157156  assert.equal(t.license, 'CC BY');
    158157  assert.deepEqual(t.url, ['https://open.spotify.com/track/x']);
     158
     159  // Dit is de assertie die er echt toe doet: de BYTES zitten in het archief.
     160  assert.ok(String(t['shaer:media']).startsWith('audio/'), 'de track wijst naar het audio-gebied');
     161  assert.deepEqual(uit.files.get(t['shaer:media']), WAV, 'en daar staat het geluid ook echt');
     162
     163  // En hij staat in de bibliotheek, los van de post.
     164  const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8'));
     165  const rij = lib.orderedItems.find((x) => x.id === 'tr1');
     166  assert.equal(rij['shaer:availability'], 'included');
     167  assert.equal(rij['shaer:file'], t['shaer:media']);
     168  assert.equal(uit.counts.tracks, 1);
     169});
     170
     171test('de HELE bibliotheek gaat mee, ook wat in geen enkel bericht staat', () => {
     172  // De reden dat dit bestaat: op sound-fabrics.com stonden 140 tracks en gingen
     173  // er 14 mee, want alleen wat met [[track:]] in een bericht stond werd
     174  // geexporteerd. Een verhuizing die je bibliotheek achterlaat is er geen.
     175  fs.mkdirSync(path.join(MEDIA, 'audio'), { recursive: true });
     176  const B = Buffer.from('RIFF-los-nummer');
     177  fs.writeFileSync(path.join(MEDIA, 'audio', 'los.mp3'), B);
     178  db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
     179    .run('m9', 's1', 'los.mp3', 'audio/mpeg', B.length, path.join(MEDIA, 'audio', 'los.mp3'));
     180  db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('los','s1','Los nummer','m9')").run();
     181
     182  const uit = AX.buildArchive('me');
     183  const lib = JSON.parse(uit.files.get('tracks.json').toString('utf8'));
     184  const rij = lib.orderedItems.find((x) => x.id === 'los');
     185  assert.ok(rij, 'een track zonder bericht hoort er gewoon in');
     186  assert.deepEqual(uit.files.get(rij['shaer:file']), B);
     187});
     188
     189test('een track waarvan het bestand zoek is wordt gemeld, niet stil weggelaten', () => {
     190  db.prepare("INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES ('mz','s1','zoek.mp3','audio/mpeg',1,'/bestaat/niet/zoek.mp3')").run();
     191  db.prepare("INSERT INTO audio_tracks (id, site_id, title, media_id) VALUES ('zoek','s1','Zoek','mz')").run();
     192  const uit = AX.buildArchive('me');
     193  const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'zoek');
     194  assert.equal(rij['shaer:availability'], 'missing');
     195  assert.equal(rij['shaer:file'], undefined, 'geen verwijzing naar een bestand dat er niet is');
     196  assert.ok(uit.counts.audioMissing >= 1);
     197  assert.ok(uit.missing.some((m) => m.track === 'Zoek'), 'en de beller kan het aan de gebruiker melden');
     198});
     199
     200test('audio wordt gezocht zoals de SPELER hem zoekt, niet zoals de database hem onthoudt', async () => {
     201  // De stille moordenaar op sound-fabrics.com: na een dataverhuizing wees
     202  // storage_path voor 124 van de 139 tracks nog naar /srv/prutfolio/storage/audio.
     203  // De speler merkte er niets van, want die pakt AUDIO_ROOT + bestandsnaam en
     204  // negeert storage_path. De exporter las wel storage_path, vond niets, en liet
     205  // 124 nummers weg zonder dat iemand het zag.
     206  //
     207  // Deze test pint de VOLGORDE vast met een nep-fs, want dat is precies waar de
     208  // twee uit elkaar liepen. AUDIO_ROOT ligt bij import van paths.js al vast, dus
     209  // hem hier omzetten kan niet meer; de volgorde toetsen wel.
     210  const { resolveAudioPath, AUDIO_ROOT } = await import('../src/config/paths.js');
     211  const gevraagd = [];
     212  const nepFs = {
     213    statSync(p) {
     214      gevraagd.push(p);
     215      if (p === path.join(AUDIO_ROOT, 'x.mp3')) return { isFile: () => true };
     216      throw new Error('ENOENT');
     217    },
     218  };
     219  const uit = resolveAudioPath('/een/heel/oud/pad/x.mp3', nepFs);
     220  assert.equal(uit, path.join(AUDIO_ROOT, 'x.mp3'), 'de speler-plek wint');
     221  assert.equal(gevraagd[0], path.join(AUDIO_ROOT, 'x.mp3'), 'en wordt als EERSTE geprobeerd');
     222
     223  // Staat hij daar niet, dan telt het opgeslagen pad alsnog: installaties die
     224  // hun audio ergens anders hebben mogen niet stuk.
     225  const gevraagd2 = [];
     226  const nepFs2 = {
     227    statSync(p) {
     228      gevraagd2.push(p);
     229      if (p === path.resolve('/ergens/anders/y.mp3')) return { isFile: () => true };
     230      throw new Error('ENOENT');
     231    },
     232  };
     233  assert.equal(resolveAudioPath('/ergens/anders/y.mp3', nepFs2), path.resolve('/ergens/anders/y.mp3'));
     234  assert.equal(resolveAudioPath('', nepFs2), null);
    159235});
    160236
     
    227303  const m = AX.buildArchive('me').manifest;
    228304  assert.equal(m.origin, 'https://klonkt.test');
    229   assert.equal(m.formatVersion, 1);
     305  assert.equal(m.formatVersion, 2, 'v2 = audio zit er echt in');
    230306  assert.equal(m.actor, 'https://klonkt.test/ap/users/me');
    231307});
  • test/fep1580-migration.test.js

    rfbfd7a1 r6c4ff7e  
    1313import { test, beforeEach } from 'node:test';
    1414import assert from 'node:assert/strict';
     15import path from 'node:path';
    1516
    1617process.env.DATABASE_PATH = ':memory:';
     
    4748
    4849beforeEach(() => {
    49   for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers']) {
     50  for (const t of ['ap_migration', 'ap_moves', 'ap_blocks', 'posts', 'ap_followers', 'audio_tracks', 'media']) {
    5051    try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet in deze build */ }
    5152  }
     
    315316});
    316317
     318test('de muziekbibliotheek komt mee, ook wat niet fedi_open is', async () => {
     319  // Losse nummers staan NIET in de outbox: die hangen aan de tracks-collectie
     320  // waar de actor via AS2 `streams` naar wijst. Zonder deze tak verhuist een
     321  // muzieksite zijn berichten en laat hij zijn bibliotheek achter, en dat is
     322  // precies wat er op soundfabrics.nl gebeurde.
     323  const s = site({ aliases: [BRON] });
     324  const AUDIO = `${BRON}/audio/x.mp3`;
     325  const bytes = Buffer.from('ID3-nep-geluid');
     326  const kaart = new Map([
     327    [BRON, {
     328      id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`,
     329      streams: [`${BRON}/tracks`, `${BRON}/playlists`],
     330    }],
     331    [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
     332    [`${BRON}/tracks`, {
     333      type: 'OrderedCollection',
     334      orderedItems: [{
     335        id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Gesloten nummer', artist: 'Robo',
     336        url: [{ type: 'Link', href: AUDIO, mediaType: 'audio/mpeg' }],
     337      }],
     338    }],
     339  ]);
     340  const geschreven = new Map();
     341  let getekend = false;
     342  const r = await stil(() => Mig.ingestFromSource(s, {
     343    deps: {
     344      getJson: async (_slug, url) => kaart.get(url) || null,
     345      noteId: (b, id) => `${b}/ap/notes/${id}`,
     346      noteVisibility: AP.noteVisibility,
     347      audioRoot: '/nep/audio',
     348      // De handtekening is hier geen detail: de audio-route van de bron weigert
     349      // een kale fetch, want die kan niet zien dat wij de doel-actor zijn.
     350      signHeaders: () => { getekend = true; return { Signature: 'nep' }; },
     351      safeFetch: async (url, opts) => {
     352        assert.equal(url, AUDIO);
     353        assert.ok(opts.headers && opts.headers.Signature, 'de bytes worden ONDERTEKEND opgehaald');
     354        return { ok: true, arrayBuffer: async () => bytes, headers: { get: () => 'audio/mpeg' } };
     355      },
     356      fs: { mkdirSync() {}, writeFileSync: (p, b) => geschreven.set(p, b) },
     357      path,
     358    },
     359  }));
     360  assert.equal(r.tracksBinnen, 1);
     361  assert.equal(r.tracksMislukt, 0);
     362  assert.ok(getekend);
     363  assert.equal(geschreven.size, 1, 'het bestand wordt echt weggeschreven');
     364  const [pad] = [...geschreven.keys()];
     365  assert.ok(pad.startsWith('/nep/audio/'), 'audio hoort in AUDIO_ROOT, niet in de mediamap');
     366  assert.ok(!pad.slice('/nep/audio/'.length).includes('/'), 'en er direct in, want de speler zoekt op bestandsnaam');
     367  const t = db.prepare('SELECT title, artist FROM audio_tracks').get();
     368  assert.equal(t.title, 'Gesloten nummer');
     369});
     370
     371test('een nummer waarvan de bytes niet komen levert GEEN track op', async () => {
     372  // Dezelfde regel als bij de zip. Een nummer dat in de lijst staat en 404't is
     373  // erger dan een nummer dat ontbreekt.
     374  const s = site({ aliases: [BRON] });
     375  const kaart = new Map([
     376    [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
     377    [`${BRON}/outbox`, { type: 'OrderedCollection', orderedItems: [] }],
     378    [`${BRON}/tracks`, {
     379      type: 'OrderedCollection',
     380      orderedItems: [{ id: `${BRON}/tracks/t1`, type: 'Audio', name: 'Weg', url: `${BRON}/audio/weg.mp3` }],
     381    }],
     382  ]);
     383  const r = await stil(() => Mig.ingestFromSource(s, {
     384    deps: {
     385      getJson: async (_slug, url) => kaart.get(url) || null,
     386      noteId: (b, id) => `${b}/ap/notes/${id}`,
     387      noteVisibility: AP.noteVisibility,
     388      audioRoot: '/nep/audio',
     389      signHeaders: () => ({ Signature: 'nep' }),
     390      safeFetch: async () => ({ ok: false, status: 403 }),
     391      fs: { mkdirSync() {}, writeFileSync() { throw new Error('mag niet gebeuren'); } },
     392      path,
     393    },
     394  }));
     395  assert.equal(r.tracksBinnen, 0);
     396  assert.equal(r.tracksMislukt, 1);
     397  assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0);
     398});
     399
    317400test('een niet-publiek bericht komt wel mee maar niet in de publieke tabel', async () => {
    318401  const s = site({ aliases: [BRON] });
Note: See TracChangeset for help on using the changeset viewer.