Changeset f704237 in Klonkt


Ignore:
Timestamp:
08/14/2026 02:35:06 AM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
47f1183
Parents:
fc664ce
Message:

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

Files:
5 edited

Legend:

Unmodified
Added
Removed
  • src/services/ArchiveExportService.js

    rfc664ce rf704237  
    228228      } catch { naam = null; }           // onleesbaar telt als ontbrekend, niet als stilte
    229229    }
    230     if (!naam) {
     230    // Een LINK-ONLY track is geen kapotte track. Klonkt kent dat type: geen
     231    // gehost bestand, wel een Spotify- of YouTube-link, en buildNote maakt er
     232    // een embed-kaart van (zie trackEmbedLinks). Die tellen dus niet als
     233    // ontbrekend, anders meldt de export een probleem dat er niet is.
     234    const alleenLinks = !t.media_id && [t.link_spotify, t.link_youtube, t.link_soundcloud].some(Boolean);
     235    if (!naam && !alleenLinks) {
    231236      tellingen.audioMissing += 1;
    232237      ontbrekend.push({ track: t.title || t.id, url: t.storage_path || '(geen mediarij)' });
    233238    }
     239    if (alleenLinks) tellingen.audioLinks = (tellingen.audioLinks || 0) + 1;
    234240    items.push({
    235241      id: t.id, name: t.title || '', artist: t.artist || undefined, album: t.album || undefined,
     
    247253      // stond. Stil weglaten zou een leugen zijn, en de importer moet hierop
    248254      // kunnen weigeren in plaats van een track zonder bestand aan te maken.
    249       'shaer:availability': naam ? 'included' : 'missing',
     255      // Drie staten in plaats van twee: erbij, weg, of bewust zonder bestand.
     256      'shaer:availability': naam ? 'included' : (alleenLinks ? 'linkOnly' : 'missing'),
    250257      'shaer:originalPath': naam ? undefined : (t.storage_path || undefined),
    251258      url: [t.link_spotify, t.link_youtube, t.link_soundcloud].filter(Boolean),
  • src/services/ArchiveImportService.js

    rfc664ce rf704237  
    166166    const bestand = t['shaer:file'];
    167167    const bytes = bestand ? files.get(bestand) : null;
    168     if (!bytes || !bytes.length) {
     168    // "Geen bestand" en "niets om te tonen" zijn niet hetzelfde. Een LINK-ONLY
     169    // track heeft nooit een bestand gehad: hij bestaat uit een Spotify- of
     170    // YouTube-link en Klonkt maakt daar een embed-kaart van. Die hoort gewoon
     171    // mee. Mijn eerste regel gooide hem weg, en dat kostte Robin een nummer
     172    // (Youngstown) dat op de oude site prima werkte.
     173    const links = Array.isArray(t.url) ? t.url.filter(Boolean) : [];
     174    const alleenLinks = t['shaer:availability'] === 'linkOnly' || (!bestand && links.length > 0);
     175    if ((!bytes || !bytes.length) && !alleenLinks) {
    169176      rapport.tracksMissing += 1;
    170177      rapport.waarschuwingen.push(`${t.name || id}: geluidsbestand zit niet in het archief, track niet aangemaakt`);
    171       continue;                       // de hele regel van deze functie
     178      continue;
     179    }
     180    if (alleenLinks) {
     181      // Geen bestand om weg te schrijven, geen mediarij: alleen de track zelf.
     182      werk.push({ soort: 'track', id, t, naam: null, bytes: null, doel: null, hoes: hoesTerug(files, t['shaer:coverFile'], werk) });
     183      rapport.tracks += 1;
     184      rapport.tracksLinks = (rapport.tracksLinks || 0) + 1;
     185      continue;
    172186    }
    173187    // Naam op de schijf: de hash uit het archief, met zijn extensie. De speler
     
    256270    posts: 0, overgeslagen: 0, overschreven: 0,
    257271    replies: 0, media: 0, mediaMissing: 0, gemist: [], waarschuwingen: [],
    258     tracks: 0, tracksMissing: 0, playlists: 0, linksBijgetrokken: 0,
     272    tracks: 0, tracksMissing: 0, tracksLinks: 0, playlists: 0, linksBijgetrokken: 0,
    259273  };
    260274
     
    417431        // rolt de hele transactie terug: liever geen import dan een track zonder
    418432        // geluid, want dat is precies de val waar dit uit voortkomt.
    419         fs.mkdirSync(path.dirname(s.doel), { recursive: true });
    420         fs.writeFileSync(s.doel, s.bytes);
    421         const mediaId = randomUUID();
    422         db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
    423           .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
     433        //
     434        // Een link-only track heeft geen bestand en dus ook geen mediarij; die
     435        // krijgt media_id NULL, precies zoals op de bron.
     436        let mediaId = null;
     437        if (s.doel && s.bytes) {
     438          fs.mkdirSync(path.dirname(s.doel), { recursive: true });
     439          fs.writeFileSync(s.doel, s.bytes);
     440          mediaId = randomUUID();
     441          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
     442            .run(mediaId, site.id, s.naam, s.t['shaer:mediaType'] || 'audio/mpeg', s.bytes.length, s.doel);
     443        }
    424444        const link = (k) => (s.t.url || []).find((u) => String(u).includes(k)) || null;
    425445        db.prepare(`INSERT OR REPLACE INTO audio_tracks
  • src/services/MigrationService.js

    rfc664ce rf704237  
    512512  const rapport = {
    513513    bron: bronActor.id, posts: 0, overgeslagen: 0, opnieuw: 0, postsBijgewerkt: 0, media: 0, mediaMislukt: 0,
    514     blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, overgeslagenTracks: 0,
     514    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, tracksBijgewerkt: 0, tracksLinks: 0, overgeslagenTracks: 0,
    515515    playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
    516516  };
     
    769769          }
    770770        }
    771         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));
    772         if (!bron || !/^https?:\/\//i.test(String(bron))) { rapport.tracksMislukt++; continue; }
     771        // Uit de url-lijst de LINK NAAR HET BESTAND vissen, niet zomaar de eerste:
     772        // buildTrackAudio zet er ook een text/html-link naar de post voor. En een
     773        // LINK-ONLY track (alleen Spotify of YouTube, nooit een gehost bestand)
     774        // heeft er helemaal geen. Die hoort gewoon mee, met media_id NULL.
     775        // Weggooien kostte Robin een nummer dat het op de oude site prima deed.
     776        const urls = [].concat(a.url || []).map((u) => (typeof u === 'string' ? { href: u } : u)).filter((u) => u && u.href);
     777        const bestandLink = urls.find((u) => /^audio\//i.test(String(u.mediaType || '')))
     778          || urls.find((u) => /\/audio\/stream\//.test(String(u.href)));
     779        const externe = urls.map((u) => String(u.href)).filter((h) => /spotify|youtube|youtu\.be|soundcloud|bandcamp/i.test(h));
     780        const bron = bestandLink && bestandLink.href;
     781        if (!bron || !/^https?:\/\//i.test(String(bron))) {
     782          if (externe.length) {
     783            const trackIdL = ruwId(a.id) || crypto.randomUUID();
     784            try {
     785              db.prepare(`INSERT OR REPLACE INTO audio_tracks
     786                  (id, site_id, title, artist, media_id, link_spotify, link_youtube, link_soundcloud, fedi_open)
     787                VALUES (?,?,?,?,NULL,?,?,?,0)`)
     788                .run(trackIdL, site.id, a.name || 'zonder titel', a.summary || a.artist || null,
     789                  externe.find((u) => /spotify/i.test(u)) || null,
     790                  externe.find((u) => /youtube|youtu\.be/i.test(u)) || null,
     791                  externe.find((u) => /soundcloud/i.test(u)) || null);
     792              recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackIdL}`, sourceActor: bronActor.id, isPublic: false });
     793              trackKaart.set(String(a.id), trackIdL);
     794              ruwKaart.set(ruwId(a.id), trackIdL);
     795              rapport.tracksLinks++;
     796              rapport.tracksBinnen++;
     797            } catch (e) {
     798              rapport.tracksMislukt++;
     799              rapport.waarschuwingen.push(`nummer niet opgeslagen: ${a.name || a.id} (${e && e.message})`);
     800            }
     801            continue;
     802          }
     803          rapport.tracksMislukt++;
     804          continue;
     805        }
    773806        const g = await haalBijlage(String(bron), {
    774807          safeFetch, mediaRoot: audioRoot, fs, path, maxBytes, submap: '',
  • test/archive-audio.test.js

    rfc664ce rf704237  
    258258});
    259259
     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
    260302test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
  • test/fep1580-migration.test.js

    rfc664ce rf704237  
    369369});
    370370
     371test('een link-only nummer komt ook via de ophaalknop mee', async () => {
     372  // Dezelfde regel als bij de zip. Over AP heeft zo'n track geen audio-link in
     373  // zijn url-lijst, alleen de externe. De vorige versie viste blind de eerste
     374  // url en strandde daarop.
     375  const s = site({ aliases: [BRON] });
     376  const kaart = new Map([
     377    [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
     378    [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 0, first: `${BRON}/outbox?page=1` }],
     379    [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [] }],
     380    [`${BRON}/tracks`, { type: 'OrderedCollection', orderedItems: [{
     381      id: `${BRON}/tracks/lo`, type: 'Audio', name: 'Alleen links', summary: 'Youngstown',
     382      url: [
     383        { type: 'Link', href: 'https://oud.example/eenpost', mediaType: 'text/html' },
     384        { type: 'Link', href: 'https://open.spotify.com/track/abc' },
     385        { type: 'Link', href: 'https://www.youtube.com/watch?v=xyz' },
     386      ],
     387    }] }],
     388  ]);
     389  const gehaald = [];
     390  const r = await stil(() => Mig.ingestFromSource(s, { deps: {
     391    getJson: async (_s, url) => kaart.get(url) || null,
     392    noteId: (b, id) => `${b}/ap/notes/${id}`,
     393    noteVisibility: AP.noteVisibility,
     394    audioRoot: '/nep/audio', mediaRoot: '/nep/media',
     395    signHeaders: () => ({ Signature: 'nep' }),
     396    safeFetch: async (u) => { gehaald.push(u); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }; },
     397    fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
     398    path,
     399  } }));
     400  assert.equal(r.tracksBinnen, 1);
     401  assert.equal(r.tracksMislukt, 0);
     402  assert.equal(r.tracksLinks, 1);
     403  assert.deepEqual(gehaald, [], 'er valt niets te downloaden, en dat hoort ook niet geprobeerd');
     404  const t = db.prepare("SELECT title, artist, media_id, link_spotify, link_youtube FROM audio_tracks WHERE id = 'lo'").get();
     405  assert.ok(t, 'de track staat er');
     406  assert.equal(t.media_id, null);
     407  assert.equal(t.artist, 'Youngstown', 'de artiest komt uit summary');
     408  assert.equal(t.link_spotify, 'https://open.spotify.com/track/abc');
     409  assert.equal(t.link_youtube, 'https://www.youtube.com/watch?v=xyz');
     410});
     411
     412test('de audio-link wordt uit de lijst GEVIST, niet blind de eerste gepakt', async () => {
     413  // buildTrackAudio zet een text/html-link naar de post VOOR het bestand. Wie
     414  // element nul pakt downloadt een HTML-pagina en noemt dat een mp3.
     415  const s = site({ aliases: [BRON] });
     416  const MP3 = 'https://oud.example/audio/stream/x.mp3';
     417  const kaart = new Map([
     418    [BRON, { id: BRON, type: 'Person', movedTo: IK, outbox: `${BRON}/outbox`, streams: [`${BRON}/tracks`] }],
     419    [`${BRON}/outbox`, { type: 'OrderedCollection', totalItems: 0, first: `${BRON}/outbox?page=1` }],
     420    [`${BRON}/outbox?page=1`, { type: 'OrderedCollectionPage', orderedItems: [] }],
     421    [`${BRON}/tracks`, { type: 'OrderedCollection', orderedItems: [{
     422      id: `${BRON}/tracks/m1`, type: 'Audio', name: 'Met bestand',
     423      url: [
     424        { type: 'Link', href: 'https://oud.example/depost', mediaType: 'text/html' },
     425        { type: 'Link', href: MP3, mediaType: 'audio/mpeg' },
     426      ],
     427    }] }],
     428  ]);
     429  const gehaald = [];
     430  await stil(() => Mig.ingestFromSource(s, { deps: {
     431    getJson: async (_s, url) => kaart.get(url) || null,
     432    noteId: (b, id) => `${b}/ap/notes/${id}`,
     433    noteVisibility: AP.noteVisibility,
     434    audioRoot: '/nep/audio', mediaRoot: '/nep/media',
     435    signHeaders: () => ({ Signature: 'nep' }),
     436    safeFetch: async (u) => { gehaald.push(u); return { ok: true, arrayBuffer: async () => Buffer.from('x'), headers: { get: () => 'audio/mpeg' } }; },
     437    fs: { mkdirSync() {}, writeFileSync() {}, statSync() { throw new Error('ENOENT'); } },
     438    path,
     439  } }));
     440  assert.deepEqual(gehaald, [MP3], 'het bestand, niet de postpagina');
     441});
     442
    371443test('een nummer waarvan de bytes niet komen levert GEEN track op', async () => {
    372444  // Dezelfde regel als bij de zip. Een nummer dat in de lijst staat en 404't is
Note: See TracChangeset for help on using the changeset viewer.