Changeset 6c4ff7e in Klonkt for test


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@…>

Location:
test
Files:
1 added
2 edited

Legend:

Unmodified
Added
Removed
  • 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.