Ignore:
Timestamp:
06/30/2026 01:50:00 AM (2 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
084d1c0
Parents:
80797d7
Message:

feat(music): per-track "share on the fediverse" — federate the file as a native AS2 Audio attachment

A per-track opt-in (default off) so an OPEN track's audio file is federated as a real AS2 Audio
attachment and served ungated → it plays inline in EVERY fediverse client, incl. the official
Mastodon apps (which only play native media, not external player cards). Gated tracks (default)
keep the file hidden + web-player-only. This is the spec-canonical way to federate audio; the
gated path stays the deliberate anti-steal choice.

  • src/config/database.js — audio_tracks.fedi_open column (default 0)
  • src/routes/audio.js — /audio/stream serves fedi_open tracks ungated so remote servers can fetch them
  • src/services/ActivityPubService.js (buildNote) — fedi_open tracks → AS2 Audio attachments (the file URL)
  • src/routes/admin-audio.js — POST /:id/fedi-open toggle (god-only) + fedi_open in the track query
  • src/views/pages/admin-audio.ejs — per-track share toggle next to the download toggle
  • src/services/i18n.js — aaud.fedi_on/off labels (nl/en/de)

Co-Authored-By: Claude <noreply@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    r80797d7 rf2eacca  
    225225    for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim());
    226226  } catch { /* non-fatal */ }
     227  // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
     228  // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
     229  // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
     230  // later body mutation can't affect it.
     231  const openAudio = [];
     232  if (hadAudio) {
     233    const seenA = new Set();
     234    const addRow = (r) => {
     235      const fn = r.filename || (r.storage_path || '').split('/').pop();
     236      if (!fn || seenA.has(fn)) return; seenA.add(fn);
     237      openAudio.push({ type: 'Document', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' });
     238    };
     239    const SEL = 'SELECT t.title, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
     240    try {
     241      for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
     242      for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
     243      for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
     244    } catch { /* non-fatal */ }
     245  }
    227246  body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
    228247  // External embeds ([[embed:url]]) → emit the bare URL as a link so Mastodon
     
    263282    .filter((u) => { if (seen.has(u)) return false; seen.add(u); return true; })
    264283    .map((u) => ({ type: 'Document', mediaType: mediaType(u), url: u }));
     284  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
    265285
    266286  const note = {
Note: See TracChangeset for help on using the changeset viewer.