Changeset 98acb97 in Klonkt


Ignore:
Timestamp:
08/14/2026 12:03:55 AM (4 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
eb071a7
Parents:
9529d7b
Message:

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

Files:
6 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r9529d7b r98acb97  
    11651165});
    11661166
    1167 router.get('/ap/users/:slug/playlists/:id', (req, res) => {
     1167router.get('/ap/users/:slug/playlists/:id', async (req, res) => {
    11681168  const site = publicSite(req.params.slug);
    11691169  if (!site) return res.status(404).end();
     
    11711171    .get(req.params.id, site.id);
    11721172  if (!pl) return res.status(404).end();
    1173   AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id)));
     1173  // De doel-actor van een verhuizing krijgt de VOLLEDIGE plaat, niet alleen de
     1174  // nummers die voor de fediverse opengezet zijn (FEP-1580).
     1175  const alles = await magAlles(req, site.slug);
     1176  AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id, { alles })));
    11741177});
    11751178
  • src/services/ArchiveExportService.js

    r9529d7b r98acb97  
    182182 * @returns {Map<string,string>} trackId -> pad in het archief
    183183 */
    184 function audioBibliotheek(site, bestanden, tellingen, ontbrekend) {
     184/**
     185 * Een hoes in het archief leggen.
     186 *
     187 * cover_url reisde wel mee als STRING en het bestand niet, dus kwam een track
     188 * aan met een verwijzing naar een plaatje dat er niet was. Precies dezelfde
     189 * fout als bij de audio zelf, een laag hoger: een verwijzing zonder bytes.
     190 *
     191 * @returns {string|null} het pad in het archief, of null
     192 */
     193function hoesToevoegen(url, origin, bestanden, tellingen) {
     194  const schijf = localMediaPath(url, origin);
     195  if (!schijf) return null;
     196  let bytes = null;
     197  try { bytes = fs.readFileSync(schijf); } catch { return null; }
     198  const hash = sha256(bytes);
     199  const naam = `media/${hash}${extOf(url) ? `.${extOf(url)}` : ''}`;
     200  if (!bestanden.has(naam)) { bestanden.set(naam, bytes); tellingen.media += 1; }
     201  return naam;
     202}
     203
     204function audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend) {
    185205  const kaart = new Map();
    186206  let tracks = [];
     
    217237      credit: t.credit || undefined, license: t.license || undefined,
    218238      'shaer:coverUrl': t.cover_url || undefined,
     239      // De BYTES van de hoes, niet alleen de verwijzing.
     240      'shaer:coverFile': hoesToevoegen(t.cover_url, origin, bestanden, tellingen) || undefined,
    219241      'shaer:downloadable': t.downloadable ? 1 : 0,
    220242      'shaer:fediOpen': t.fedi_open ? 1 : 0,
     
    243265        id: p.id, name: p.title || '', artist: p.artist || undefined, year: p.year || undefined,
    244266        'shaer:kind': p.kind || undefined, 'shaer:coverUrl': p.cover_url || undefined,
     267        'shaer:coverFile': hoesToevoegen(p.cover_url, origin, bestanden, tellingen) || undefined,
    245268        'shaer:tracks': db.prepare('SELECT track_id, position FROM playlist_tracks WHERE playlist_id = ? ORDER BY position')
    246269          .all(p.id).map((r) => ({ id: r.track_id, position: r.position })),
     
    459482  // De audiobibliotheek EERST. De posts verwijzen ernaar met [[track:]], dus de
    460483  // kaart moet klaar zijn voor de eerste post gebouwd wordt.
    461   const audioKaart = audioBibliotheek(site, bestanden, tellingen, ontbrekend);
     484  const audioKaart = audioBibliotheek(site, origin, bestanden, tellingen, ontbrekend);
    462485
    463486  // Vaste volgorde: eerst op publicatiedatum, dan op id. Zonder tweede sleutel
  • src/services/ArchiveImportService.js

    r9529d7b r98acb97  
    135135 * (en bij een droogloop dus niet, maar het verslag klopt wel)
    136136 */
     137/**
     138 * Een hoes uit het archief terugzetten. Geeft het nieuwe /media-pad terug, of
     139 * null als het bestand er niet in zat: dan liever GEEN cover_url dan een
     140 * verwijzing naar niets.
     141 */
     142function hoesTerug(files, bestand, werk) {
     143  if (!bestand) return null;
     144  const bytes = files.get(bestand);
     145  if (!bytes || !bytes.length) return null;
     146  const naam = path.basename(String(bestand));
     147  if (!naam || naam.includes('/') || naam.includes('\\') || naam.startsWith('.')) return null;
     148  const urlPad = `/media/archief/${naam}`;
     149  const doel = veiligMediaPad(urlPad);
     150  if (!doel) return null;
     151  werk.push({ soort: 'media', doel, bytes });
     152  return urlPad;
     153}
     154
    137155function tracksTerug(files, site, rapport) {
    138156  const buf = files.get('tracks.json');
     
    160178      continue;
    161179    }
    162     werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam });
     180    const hoes = hoesTerug(files, t['shaer:coverFile'], werk);
     181    werk.push({ soort: 'track', doel: path.join(path.resolve(AUDIO_ROOT), naam), bytes, id, t, naam, hoes });
    163182    rapport.tracks += 1;
    164183  }
     
    182201    const kwijt = (p['shaer:tracks'] || []).length - items.length;
    183202    if (kwijt) rapport.waarschuwingen.push(`playlist ${p.name || id}: ${kwijt} nummer(s) ontbreken en zijn eruit gelaten`);
    184     werk.push({ soort: 'playlist', p, id, items });
     203    const hoes = hoesTerug(files, p['shaer:coverFile'], werk);
     204    werk.push({ soort: 'playlist', p, id, items, hoes });
    185205    rapport.playlists += 1;
    186206  }
     
    395415          .run(s.id, site.id, s.t.name || 'zonder titel', s.t.artist || null, s.t.album || null,
    396416            s.t.duration || null, mediaId, s.t.position ?? null, s.t.credit || null, s.t.license || null,
    397             s.t['shaer:coverUrl'] || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
     417            s.hoes || null, s.t['shaer:downloadable'] ? 1 : 0, s.t['shaer:fediOpen'] ? 1 : 0,
    398418            link('spotify'), link('youtube'), link('soundcloud'));
    399419        continue;
     
    403423                    VALUES (?,?,?,?,?,?,?)`)
    404424          .run(s.id, site.id, s.p.name || 'zonder titel', s.p.artist || null, s.p.year || null,
    405             s.p['shaer:coverUrl'] || null, s.p['shaer:kind'] || null);
     425            s.hoes || null, s.p['shaer:kind'] || null);
    406426        db.prepare('DELETE FROM playlist_tracks WHERE playlist_id = ?').run(s.id);
    407427        const insPT = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
  • src/services/MigrationService.js

    r9529d7b r98acb97  
    219219const AFBEELDING = /^image\//i;
    220220
     221/** AS2 geeft de duur als ISO-8601 ("PT212S"), de database wil seconden. */
     222function duurSeconden(v) {
     223  if (v == null) return null;
     224  if (typeof v === 'number') return Math.round(v) || null;
     225  const m = /^P(?:.*?T)?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?$/.exec(String(v));
     226  if (!m) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : null; }
     227  const sec = (Number(m[1]) || 0) * 3600 + (Number(m[2]) || 0) * 60 + (Number(m[3]) || 0);
     228  return sec > 0 ? Math.round(sec) : null;
     229}
     230
    221231/**
    222232 * De titel terugwinnen uit de content.
     
    336346  const rapport = {
    337347    bron: bronActor.id, posts: 0, overgeslagen: 0, media: 0, mediaMislukt: 0,
    338     blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0, waarschuwingen: [],
     348    blocks: 0, tracksBinnen: 0, tracksMislukt: 0, overgeslagenTracks: 0,
     349    playlistsBinnen: 0, playlistsMislukt: 0, waarschuwingen: [],
    339350  };
    340351
     
    459470    // geldt voor de bestanden zelf, die anders achter de gated audio-route
    460471    // blijven.
     472    const trackKaart = new Map();   // bron-URI van een nummer -> ons nieuwe id
    461473    const streams = [].concat(bronActor.streams || []).filter((u) => typeof u === 'string');
    462474    const tracksUrl = streams.find((u) => /\/tracks\/?$/.test(u));
     
    480492          continue;                       // dezelfde regel als bij de zip: geen bestand, geen track
    481493        }
     494        // De hoes. Die reisde als URL wel mee en als bestand niet, dus kwam een
     495        // nummer aan met een verwijzing naar een plaatje dat er niet is.
     496        let hoes = null;
     497        const hoesUrl = (a.icon && (a.icon.url || a.icon)) || (a.image && (a.image.url || a.image)) || null;
     498        if (hoesUrl && /^https?:\/\//i.test(String(hoesUrl))) {
     499          const h = await haalBijlage(String(hoesUrl), {
     500            safeFetch, mediaRoot, fs, path, maxBytes,
     501            headers: signHeaders ? signHeaders(site.slug, String(hoesUrl), '*/*') : null,
     502          }).catch(() => null);
     503          if (h) { hoes = h.url; rapport.media++; }
     504          else rapport.waarschuwingen.push(`hoes niet opgehaald: ${a.name || hoesUrl}`);
     505        }
    482506        const trackId = crypto.randomUUID();
    483507        const mediaId = crypto.randomUUID();
     
    485509          db.prepare('INSERT INTO media (id, site_id, filename, mime_type, size, storage_path) VALUES (?,?,?,?,?,?)')
    486510            .run(mediaId, site.id, g.filename, g.mediaType, g.size, g.storage_path);
    487           db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, fedi_open)
    488                       VALUES (?,?,?,?,?,?,?,0)`)
    489             .run(trackId, site.id, a.name || 'zonder titel', a.artist || null, a.album || null,
    490               Number(a.duration) || null, mediaId);
     511          db.prepare(`INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, media_id, cover_url, fedi_open)
     512                      VALUES (?,?,?,?,?,?,?,?,0)`)
     513            .run(trackId, site.id, a.name || 'zonder titel', a.summary || a.artist || null, a.album || null,
     514              duurSeconden(a.duration), mediaId, hoes);
    491515          recordMigrated(site.slug, { origin: a.id, target: `${me}/ap/tracks/${trackId}`, sourceActor: bronActor.id, isPublic: false });
     516          trackKaart.set(String(a.id), trackId);
    492517          rapport.tracksBinnen++;
    493518        } catch (e) {
     
    499524      rapport.waarschuwingen.push('muziekbibliotheek overgeslagen: geen audiomap meegegeven');
    500525    }
     526
     527    // ── De playlists ──────────────────────────────────────────────
     528    //
     529    // Los van de nummers, want de VOLGORDE is de playlist. Die staat nergens
     530    // anders: haal je alleen de tracks op, dan heb je wel alle muziek en geen
     531    // enkele plaat. De bron geeft ons de volledige lijst omdat we de doel-actor
     532    // zijn; anders zaten er alleen de opengezette nummers in en kreeg je een
     533    // plaat met gaten.
     534    const plUrl = streams.find((u) => /\/playlists\/?$/.test(u));
     535    if (plUrl && trackKaart.size) {
     536      const coll = await getJson(site.slug, plUrl);
     537      const lijst = (coll && (coll.orderedItems || coll.items)) || [];
     538      for (const p of (Array.isArray(lijst) ? lijst : []).slice(0, 200)) {
     539        const uri = typeof p === 'string' ? p : (p && p.id);
     540        if (!uri) continue;
     541        const plc = typeof p === 'object' && (p.orderedItems || p.items) ? p : await getJson(site.slug, uri);
     542        if (!plc) { rapport.playlistsMislukt++; continue; }
     543        const nummers = (plc.orderedItems || plc.items || [])
     544          .map((x) => (x && typeof x === 'object' ? x.id : x))
     545          .map((id) => trackKaart.get(String(id)))
     546          .filter(Boolean);
     547        if (!nummers.length) {
     548          rapport.waarschuwingen.push(`playlist ${plc.name || uri}: geen van de nummers is aangekomen, overgeslagen`);
     549          continue;
     550        }
     551        const plId = crypto.randomUUID();
     552        try {
     553          db.prepare('INSERT INTO playlists (id, site_id, title, artist, year, kind) VALUES (?,?,?,?,?,?)')
     554            .run(plId, site.id, plc.name || 'zonder titel', plc.attributedTo && plc.artist || plc.artist || null,
     555              plc.year || null, plc['shaer:kind'] || null);
     556          const ins = db.prepare('INSERT OR IGNORE INTO playlist_tracks (playlist_id, track_id, position) VALUES (?,?,?)');
     557          nummers.forEach((tid, i) => ins.run(plId, tid, i));
     558          rapport.playlistsBinnen++;
     559          const kwijt = (plc.orderedItems || plc.items || []).length - nummers.length;
     560          if (kwijt > 0) rapport.waarschuwingen.push(`playlist ${plc.name || uri}: ${kwijt} nummer(s) ontbraken en zijn eruit gelaten`);
     561        } catch (e) {
     562          rapport.playlistsMislukt++;
     563          rapport.waarschuwingen.push(`playlist niet opgeslagen: ${plc.name || uri} (${e && e.message})`);
     564        }
     565      }
     566    }
    501567  } catch (e) {
    502568    // 9-bij-mislukking: de vlag blijft OPEN staan. Derden blijven dan kijken,
  • src/services/music/index.js

    r9529d7b r98acb97  
    2222     m.filename, m.storage_path, m.mime_type, m.size`;
    2323
    24 export function playlistOpenTracks(playlistId) {
     24/**
     25 * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
     26 * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
     27 * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
     28 */
     29export function playlistOpenTracks(playlistId, { alles = false } = {}) {
    2530  return db.prepare(
    2631    `SELECT ${TRACK_KOLOMMEN}
     
    2833     JOIN audio_tracks t ON t.id = pt.track_id
    2934     JOIN media m ON m.id = t.media_id
    30      WHERE pt.playlist_id = ? AND t.fedi_open = 1
     35     WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
    3136     ORDER BY pt.position`
    3237  ).all(playlistId);
  • test/archive-audio.test.js

    r9529d7b r98acb97  
    188188});
    189189
     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
    190236test.after(() => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* niets */ } });
Note: See TracChangeset for help on using the changeset viewer.