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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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'));
Note: See TracChangeset for help on using the changeset viewer.