source: Klonkt/test/archive-audio.test.js

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

Een track zonder bestand is niet hetzelfde als een kapotte track

Robin, over het ene nummer dat niet meekwam: "ah, dat was een no audio 'audio'
voor alleen spotify en youtube links". Dat is een bestaand Klonkt-type, geen
ongelukje: geen gehost bestand, wel externe links, en buildNote maakt er een
embed-kaart van (trackEmbedLinks). Mijn regel "geen bestand, geen track" was te
grof en gooide hem weg.

"Geen bestand" en "niets om te tonen" zijn niet hetzelfde. De regel blijft
staan waar hij hoort: een track zonder bestand EN zonder links wordt nog steeds
geweigerd, want die kan niets.

Het archiefformaat heeft nu drie staten in plaats van twee: included, missing,
en linkOnly. Een link-only track telt dus ook niet meer als "ontbrekend", want
er ontbreekt niets.

EN EEN TWEEDE FOUT DIE HIERONDER LAG. De pull viste de audio-URL uit de
url-lijst met element NUL. buildTrackAudio zet daar een text/html-link naar de
post voor het bestand: wie blind de eerste pakt downloadt een HTML-pagina en
schrijft die weg als mp3. Nu wordt de link met mediaType audio/* gezocht, met
/audio/stream/ als terugval. Dat het bij Robin niet misging is geluk: zijn
tracks hadden geen host-post in die lijst.

Changed files:
src/services/ArchiveExportService.js

  • shaer:availability kent linkOnly; die telt niet als audioMissing

src/services/ArchiveImportService.js

  • link-only tracks komen mee, met media_id NULL zoals op de bron
  • de schrijftak kan een track zonder bestand aan

src/services/MigrationService.js

  • de pull vist de audio-link uit de lijst in plaats van element nul
  • een track met alleen externe links wordt gewoon aangemaakt

test/archive-audio.test.js

  • link-only reist mee; zonder bestand en zonder links blijft geweigerd

test/fep1580-migration.test.js

  • hetzelfde voor de ophaalknop, plus: de audio-link wordt gevist

remarks: Robins Youngstown-nummer komt hiermee alsnog over. Suite 1009 groen;
tegenproef op beide, en de eerste poging daarvan kwam niet aan (te slordige
sabotage), dus opnieuw gedaan met twee rode tests als bewijs.

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

  • Property mode set to 100644
File size: 15.0 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('ook een zip-import buigt links naar de bronpost om', () => {
237 // Robin: "het moet wel gebeuren bij migratie direct ook". Een zip-import is
238 // net zo goed een verhuizing, dus dezelfde regel.
239 leeg();
240 try { db.prepare('DELETE FROM posts').run(); } catch { /* leeg */ }
241 db.prepare(`INSERT INTO posts (id, site_id, author_id, slug, title, content, status, published_at)
242 VALUES ('pl1','s1','u1','tiktik','TikTik','<p><a href="https://oud.test/tiktik?fc=2">luister</a></p>','published','2026-01-01T10:00:00Z')`).run();
243 const uit = AX.buildArchive('me');
244
245 db.prepare('DELETE FROM posts').run();
246 // De import doet alsof het archief van oud.test komt; deze site is oud.test
247 // niet, dus de links moeten om.
248 const files = new Map(uit.files);
249 const man = JSON.parse(files.get('manifest.json').toString('utf8'));
250 man.origin = 'https://oud.test';
251 files.set('manifest.json', Buffer.from(JSON.stringify(man)));
252
253 const r = AI.importArchive(files, { slug: 'me', origin: 'https://nieuw.test' });
254 const c = db.prepare("SELECT content FROM posts WHERE slug = 'tiktik'").get().content;
255 assert.ok(c.includes('href="/tiktik?fc=2"'), `omgebogen naar hier, kreeg: ${c}`);
256 assert.ok(!c.includes('oud.test'), 'niets wijst meer naar de bron');
257 assert.equal(r.linksBijgetrokken, 1, 'en het verslag zegt het');
258});
259
260test('een track met alleen Spotify/YouTube-links reist gewoon mee', () => {
261 // Robins Youngstown-nummer. Klonkt kent link-only tracks: geen gehost
262 // bestand, wel externe links, en buildNote maakt daar een embed-kaart van.
263 // Mijn regel "geen bestand, geen track" gooide die weg, en dat was te grof:
264 // "geen bestand" en "niets om te tonen" zijn niet hetzelfde.
265 leeg();
266 db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, media_id, link_spotify, link_youtube)
267 VALUES ('lo','s1','Alleen links','Youngstown',NULL,
268 'https://open.spotify.com/track/abc','https://www.youtube.com/watch?v=xyz')`).run();
269
270 const uit = AX.buildArchive('me');
271 const rij = JSON.parse(uit.files.get('tracks.json').toString('utf8')).orderedItems.find((x) => x.id === 'lo');
272 assert.equal(rij['shaer:availability'], 'linkOnly', 'een eigen staat, niet "missing"');
273 assert.equal(uit.counts.audioMissing, 0, 'en hij telt niet als ontbrekend: er mist niets');
274 assert.deepEqual(rij.url, ['https://open.spotify.com/track/abc', 'https://www.youtube.com/watch?v=xyz']);
275
276 leeg();
277 const r = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
278 assert.equal(r.tracks, 1);
279 assert.equal(r.tracksMissing, 0);
280 const t = db.prepare("SELECT title, artist, media_id, link_spotify, link_youtube FROM audio_tracks WHERE id = 'lo'").get();
281 assert.ok(t, 'de track hoort er te staan');
282 assert.equal(t.media_id, null, 'zonder mediarij, precies zoals op de bron');
283 assert.equal(t.title, 'Alleen links');
284 assert.equal(t.link_spotify, 'https://open.spotify.com/track/abc');
285 assert.equal(t.link_youtube, 'https://www.youtube.com/watch?v=xyz');
286});
287
288test('een track zonder bestand EN zonder links blijft wel geweigerd', () => {
289 // De oorspronkelijke regel blijft staan waar hij hoort: dit is een track die
290 // niets kan tonen en niets kan afspelen.
291 leeg();
292 track('kapot', 'Kapot', { padInDb: '/weg/kapot.mp3', schrijf: false });
293 const uit = AX.buildArchive('me');
294 assert.equal(uit.counts.audioMissing, 1);
295 leeg();
296 const r = AI.importArchive(uit.files, { slug: 'me', origin: 'https://nieuw.test' });
297 assert.equal(r.tracks, 0);
298 assert.equal(r.tracksMissing, 1);
299 assert.equal(db.prepare('SELECT COUNT(*) n FROM audio_tracks').get().n, 0);
300});
301
302test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracBrowser for help on using the repository browser.