source: Klonkt/test/archive-audio.test.js@ 98acb97

main
Last change on this file since 98acb97 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: 11.4 KB
Line 
1// Audio overleeft een verhuizing. Formaat v2.
2//
3// Dit bestand bestaat door wat er op soundfabrics.nl gebeurde: 36 berichten
4// kwamen goed over, 14 van de 140 nummers stonden er, en 13 daarvan speelden
5// niet af. Drie oorzaken, alle drie hier vastgelegd:
6//
7// 1. Gehoste audio leeft BUITEN MEDIA_ROOT (eigen gated route). Het archief
8// droeg alleen bestanden onder media/, dus de exporter rekende er een
9// /media/../audio/x.mp3 van en de importer weigerde dat pad. Terecht: het
10// wijst buiten de mediamap. Gevolg was wel dat audio nooit kon aankomen.
11// 2. De importer maakte een audio_tracks-rij aan ZONDER te kijken of het
12// bestand er was. Een nummer dat in de lijst staat en 404't bij play is
13// erger dan een nummer dat ontbreekt: het ziet eruit alsof het gelukt is,
14// dus je gooit je oude instantie weg.
15// 3. Alleen audio die met [[track:]] in een bericht stond ging mee. Losse
16// nummers en playlists bleven achter (126 respectievelijk 11 stuks).
17//
18// Een verhuizing die je bibliotheek achterlaat is geen verhuizing.
19
20import { test } from 'node:test';
21import assert from 'node:assert/strict';
22import fs from 'fs';
23import os from 'os';
24import path from 'path';
25
26const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio-'));
27const MEDIA = path.join(TMP, 'media');
28const AUDIO = path.join(TMP, 'audio');
29fs.mkdirSync(MEDIA, { recursive: true });
30fs.mkdirSync(AUDIO, { recursive: true });
31
32process.env.DATABASE_PATH = ':memory:';
33process.env.PUBLIC_BASE_URL = 'https://oud.test';
34process.env.MEDIA_PATH = MEDIA;
35process.env.AUDIO_PATH = AUDIO; // MOET voor de import van paths.js staan
36
37const dbMod = await import('../src/config/database.js');
38const db = dbMod.default;
39{ const stil = console.log; console.log = () => {}; try { dbMod.initializeDatabase(); } finally { console.log = stil; } }
40const AX = await import('../src/services/ArchiveExportService.js');
41const AI = await import('../src/services/ArchiveImportService.js');
42const { AUDIO_ROOT } = await import('../src/config/paths.js');
43
44db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
45 .run('u1', 'u1', 'u1@test', 'x', 'god');
46db.prepare('INSERT INTO sites (id, slug, title, owner_id) VALUES (?,?,?,?)').run('s1', 'me', 'Mijn site', 'u1');
47
48const BYTES = (s) => Buffer.from(`RIFF${s}`.padEnd(64, '.'));
49
50/** Schone lei: tabellen leeg en de audiomap leeg. Tests lekken anders in elkaar. */
51function leeg() {
52 for (const t of ['playlist_tracks', 'playlists', 'audio_tracks', 'media']) {
53 try { db.prepare(`DELETE FROM ${t}`).run(); } catch { /* tabel bestaat niet */ }
54 }
55 for (const f of fs.readdirSync(AUDIO)) { try { fs.unlinkSync(path.join(AUDIO, f)); } catch { /* niets */ } }
56}
57
58/** Een nummer met zijn bestand op de plek waar de SPELER het zoekt. */
59function track(id, titel, { padInDb = null, schrijf = true } = {}) {
60 const naam = `${id}.mp3`;
61 const echt = path.join(AUDIO, naam);
62 if (schrijf) fs.writeFileSync(echt, BYTES(id));
63 db.prepare('INSERT OR REPLACE INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
64 .run(`m-${id}`, 's1', naam, 'audio/mpeg', 1, padInDb || echt);
65 db.prepare('INSERT OR REPLACE INTO audio_tracks (id, site_id, title, artist, media_id) VALUES (?,?,?,?,?)')
66 .run(id, 's1', titel, 'Robin', `m-${id}`);
67 return echt;
68}
69
70test('een nummer met een VEROUDERD pad in de database gaat gewoon mee', () => {
71 leeg();
72 // Dit is de stille moordenaar. Op sound-fabrics.com wees storage_path voor
73 // 124 van de 139 nummers nog naar /srv/prutfolio/storage/audio, van voor een
74 // dataverhuizing. De speler merkte niets (die zoekt op bestandsnaam in
75 // AUDIO_ROOT), de exporter vond niets en liet ze weg. Zonder klacht.
76 track('oudpad', 'Verouderd pad', { padInDb: '/srv/ergens/anders/oudpad.mp3' });
77 const uit = AX.buildArchive('me');
78 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'oudpad');
79 assert.equal(rij['shaer:availability'], 'included',
80 'de exporter hoort te zoeken zoals de speler zoekt, niet zoals de database onthoudt');
81 assert.deepEqual(uit.files.get(rij['shaer:file']), BYTES('oudpad'));
82});
83
84test('de hele rondgang: exporteren, importeren, en het geluid staat er', () => {
85 leeg();
86 track('a', 'Nummer A');
87 track('b', 'Nummer B');
88 const uit = AX.buildArchive('me');
89 assert.equal(uit.counts.tracks, 2);
90
91 // Een LEGE tweede installatie, met zijn eigen mappen.
92 const TMP2 = fs.mkdtempSync(path.join(os.tmpdir(), 'klonkt-audio2-'));
93 const doelAudio = path.join(TMP2, 'audio');
94 fs.mkdirSync(doelAudio, { recursive: true });
95
96 // AUDIO_ROOT ligt vast bij import, dus we importeren in DEZELFDE map en
97 // controleren daarna dat het bestand er staat onder de naam uit het archief.
98 leeg();
99 assert.equal(fs.readdirSync(AUDIO).length, 0, 'schone lei');
100
101 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
102 assert.equal(rapport.tracks, 2, 'beide nummers komen terug');
103 assert.equal(rapport.tracksMissing, 0);
104
105 const rijen = db.prepare('SELECT t.id, t.title, m.storage_path, m.size FROM audio_tracks t JOIN media m ON m.id = t.media_id ORDER BY t.id').all();
106 assert.equal(rijen.length, 2);
107 for (const r of rijen) {
108 assert.ok(fs.existsSync(r.storage_path), `${r.title}: het bestand hoort er ECHT te staan`);
109 assert.ok(r.size > 0, 'en de grootte klopt, geen 0 zoals bij de kapotte import');
110 assert.ok(r.storage_path.startsWith(path.resolve(AUDIO_ROOT) + path.sep),
111 'audio hoort in AUDIO_ROOT, niet onder de publieke mediamap');
112 }
113 fs.rmSync(TMP2, { recursive: true, force: true });
114});
115
116test('een nummer zonder bestand wordt NIET aangemaakt', () => {
117 // De kern van de klacht: 13 nummers die bestonden en niet speelden. Liever
118 // eerlijk weg dan zichtbaar kapot.
119 leeg();
120 track('zoek', 'Zoekgeraakt', { padInDb: '/bestaat/echt/niet.mp3', schrijf: false });
121 const uit = AX.buildArchive('me');
122 assert.equal(uit.counts.audioMissing, 1);
123
124 leeg();
125 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
126 assert.equal(rapport.tracks, 0);
127 assert.equal(rapport.tracksMissing, 1);
128 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0,
129 'geen rij zonder geluid: dat is precies de val die soundfabrics.nl opleverde');
130 assert.ok(rapport.waarschuwingen.some((w) => /Zoekgeraakt/.test(w)),
131 'en de gebruiker hoort te weten welk nummer er niet is');
132});
133
134test('playlists komen mee, met hun volgorde', () => {
135 leeg();
136
137 track('p1', 'Eerste');
138 track('p2', 'Tweede');
139 track('p3', 'Derde');
140 db.prepare("INSERT INTO playlists (id, site_id, title, artist, kind) VALUES ('pl1','s1','Mijn plaat','Robin','album')").run();
141 // Bewust NIET op alfabet: de volgorde is de hele playlist.
142 db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl1','p3',0),('pl1','p1',1),('pl1','p2',2)").run();
143
144 const uit = AX.buildArchive('me');
145 assert.equal(uit.counts.playlists, 1);
146
147 leeg();
148
149 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
150 assert.equal(rapport.playlists, 1);
151 const pl = db.prepare("SELECT * FROM playlists WHERE id = 'pl1'").get();
152 assert.equal(pl.title, 'Mijn plaat');
153 assert.equal(pl.kind, 'album');
154 const volgorde = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl1' ORDER BY position").all().map((r) => r.track_id);
155 assert.deepEqual(volgorde, ['p3', 'p1', 'p2'], 'de volgorde IS de playlist');
156});
157
158test('een playlist verwijst nooit naar een nummer dat niet aankwam', () => {
159 // Anders staat er een plaat met gaten erin die je niet kunt afspelen, en dat
160 // is weer dezelfde soort halve waarheid.
161 leeg();
162
163 track('heel', 'Heel');
164 track('stuk', 'Stuk', { padInDb: '/weg/stuk.mp3', schrijf: false });
165 db.prepare("INSERT INTO playlists (id, site_id, title) VALUES ('pl2','s1','Half')").run();
166 db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('pl2','heel',0),('pl2','stuk',1)").run();
167
168 const uit = AX.buildArchive('me');
169 leeg();
170
171 const rapport = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
172 const items = db.prepare("SELECT track_id FROM playlist_tracks WHERE playlist_id = 'pl2'").all().map((r) => r.track_id);
173 assert.deepEqual(items, ['heel']);
174 assert.ok(rapport.waarschuwingen.some((w) => /Half/.test(w)), 'en het wordt gemeld');
175});
176
177test('een droogloop telt de nummers maar schrijft niets', () => {
178 leeg();
179 track('d1', 'Droog');
180 const uit = AX.buildArchive('me');
181
182 leeg();
183
184 const droog = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test', dryRun: true });
185 assert.equal(droog.tracks, 1, 'het verslag hoort te zeggen hoeveel nummers er zouden komen');
186 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0, 'maar er staat niets');
187 assert.equal(fs.readdirSync(AUDIO).length, 0, 'en er is geen bestand geschreven');
188});
189
190
191test('de hoes van een nummer reist mee als BESTAND, niet als losse verwijzing', () => {
192 // Robin na de tweede ronde: "de audio tracks hadden images, die zijn niet
193 // meegegaan". cover_url ging wel mee als string en het bestand niet, dus kwam
194 // een nummer aan met een verwijzing naar een plaatje dat er niet was.
195 // Dezelfde fout als bij de audio zelf, een laag hoger.
196 leeg();
197 const hoesDir = path.join(MEDIA, 'hoes');
198 fs.mkdirSync(hoesDir, { recursive: true });
199 const PNG = Buffer.from('nep-png-bytes');
200 fs.writeFileSync(path.join(hoesDir, 'a.png'), PNG);
201 track('h1', 'Met hoes');
202 db.prepare("UPDATE audio_tracks SET cover_url = '/media/hoes/a.png' WHERE id = 'h1'").run();
203 db.prepare("INSERT INTO playlists (id, site_id, title, cover_url) VALUES ('plh','s1','Plaat','/media/hoes/a.png')").run();
204 db.prepare("INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES ('plh','h1',0)").run();
205
206 const uit = AX.buildArchive('me');
207 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems[0];
208 assert.ok(rij['shaer:coverFile'], 'de hoes hoort een plek in het archief te hebben');
209 assert.deepEqual(uit.files.get(rij['shaer:coverFile']), PNG, 'met de echte bytes erin');
210 const pl = JSON.parse(uit.files.get('playlists.json').toString('utf8')).orderedItems[0];
211 assert.ok(pl['shaer:coverFile'], 'en de hoes van de plaat ook');
212
213 leeg();
214 AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
215 const t = db.prepare("SELECT cover_url FROM audio_tracks WHERE id = 'h1'").get();
216 assert.ok(t.cover_url, 'na de import wijst het nummer naar een hoes');
217 const opSchijf = path.join(MEDIA, t.cover_url.replace(/^\/media\//, ''));
218 assert.deepEqual(fs.readFileSync(opSchijf), PNG, 'en die staat er ook echt');
219});
220
221test('een hoes die niet in het archief zit levert GEEN kapotte verwijzing op', () => {
222 // Liever geen hoes dan een <img> die 404't. Dezelfde regel als bij de tracks.
223 leeg();
224 track('h2', 'Hoes zoek');
225 db.prepare("UPDATE audio_tracks SET cover_url = '/media/bestaat/niet.png' WHERE id = 'h2'").run();
226 const uit = AX.buildArchive('me');
227 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems[0];
228 assert.equal(rij['shaer:coverFile'], undefined);
229
230 leeg();
231 AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
232 assert.equal(db.prepare("SELECT cover_url FROM audio_tracks WHERE id = 'h2'").get().cover_url, null,
233 'geen verwijzing naar een plaatje dat er niet is');
234});
235
236test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.