Changeset 21522ae in Klonkt


Ignore:
Timestamp:
05/20/2026 10:14:01 PM (4 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
353c39c
Parents:
46f23fd
git-author:
Robin Genis <roboburr@…> (05/20/2026 10:13:26 PM)
git-committer:
Robin Genis <roboburr@…> (05/20/2026 10:14:01 PM)
Message:

audio: Spotify-style blob playback + same-origin gate (fix playback loop)

Root cause of the "next-loops-but-never-plays after 4-5 songs" bug: every
track URL was HMAC-signed once at page-render time with a 10-min TTL. A whole
queue shared that single deadline, so tracks further down expired mid-session
-> /audio/stream returned 403 -> audio 'error' -> auto-skip -> next track also
expired -> infinite loop. The 3-strike guard never fired because the eager
'play' event reset the counter before each 403 landed.

Removed the expiring-token system entirely and replaced it with two
non-expiring layers:

  • Client fetch()es track bytes and plays from a blob: object URL (no shareable URL, no "save audio as"); blobs revoked to avoid leaks; loadSeq guards fast prev/next; pre-seed is metadata-only (no auto-download).
  • Server gates /audio/stream to same-origin browser fetches (X-Audio-Player header or Sec-Fetch-Site): blocks address-bar paste, hotlinks, curl.

Also: reset error counter on real 'playing' event (not eager 'play') so the
3-strike auto-skip-stop actually works; fix admin play-state detection to
compare logical currentTrack().url instead of the now-blob: audio.src; bump
audio-player.js cache-buster v5.

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

Location:
src
Files:
10 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/audio-player.js

    r46f23fd r21522ae  
    158158  let isPlaying = false;
    159159  let albumName = '';
     160  // Blob playback state. We fetch each track's bytes and play from a blob:
     161  // object URL — no plain media URL is ever exposed to the page. currentObjectUrl
     162  // is revoked when we move on, so we don't leak one Blob per track in memory.
     163  let currentObjectUrl = null;
     164  // Monotonic load token: a fast prev/next can fire several loads before an
     165  // earlier fetch resolves. Only the latest load may set audio.src.
     166  let loadSeq = 0;
    160167
    161168  // Hide initially
     
    177184  }
    178185
    179   function loadTrack(index) {
     186  // Fetch the track bytes and hand back a blob: object URL. The X-Audio-Player
     187  // header + same-origin credentials get us past the stream route's access gate.
     188  async function fetchAsObjectUrl(url) {
     189    const r = await fetch(url, {
     190      credentials: 'same-origin',
     191      headers: { 'X-Audio-Player': '1' },
     192    });
     193    if (!r.ok) throw new Error('HTTP ' + r.status);
     194    const blob = await r.blob();
     195    return URL.createObjectURL(blob);
     196  }
     197
     198  // metaOnly: show the track in the UI but DON'T download its bytes yet.
     199  // Used by the site pre-seed so opening a page doesn't auto-download audio;
     200  // the blob is fetched lazily on the first play().
     201  function loadTrack(index, autoplay, metaOnly) {
    180202    if (!queue[index]) {
    181203      console.warn('[pcms-audio] loadTrack: no track at index', index);
     
    188210      return;
    189211    }
    190     console.log('[pcms-audio] loading', t.title, t.url);
    191     // Schone overgang: pause + reset voorkomt state-corruption van het
    192     // audio-element na meerdere src-changes (bug die continuous playback
    193     // brak na 3-4 tracks). audio.load() forceert reset van internal state.
    194     try { audio.pause(); } catch (e) {}
    195     audio.src = t.url;
    196     try { audio.load(); } catch (e) {}
     212    console.log('[pcms-audio] loading', t.title, t.url, metaOnly ? '(meta only)' : '');
     213    // Metadata + chrome update synchronously so the UI reacts instantly while
     214    // the bytes download.
    197215    titleEl.textContent  = t.title  || 'Untitled';
    198216    artistEl.textContent = t.artist || '';
     
    205223    document.body.classList.add('has-audio-player');
    206224    renderQueue();
     225
     226    if (metaOnly) return;
     227
     228    const mySeq = ++loadSeq;
     229    root.classList.add('audio-loading');
     230    fetchAsObjectUrl(t.url).then((objUrl) => {
     231      if (mySeq !== loadSeq) { URL.revokeObjectURL(objUrl); return; }  // superseded
     232      root.classList.remove('audio-loading');
     233      // Free the previous track's blob — otherwise every track leaks a copy.
     234      if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
     235      currentObjectUrl = objUrl;
     236      // Schone overgang: pause + load forceert reset van internal state na
     237      // meerdere src-changes (voorkomt state-corruption van het audio-element).
     238      try { audio.pause(); } catch (e) {}
     239      audio.src = objUrl;
     240      try { audio.load(); } catch (e) {}
     241      if (autoplay) play();
     242    }).catch((err) => {
     243      if (mySeq !== loadSeq) return;  // superseded — ignore stale failure
     244      root.classList.remove('audio-loading');
     245      console.error('[pcms-audio] track fetch failed', err);
     246      // Treat a failed download like a playback error: bump the counter and
     247      // auto-skip, but stop after 3 in a row so we never loop forever.
     248      consecutiveErrors++;
     249      if (consecutiveErrors < 3 && queue.length > 1) setTimeout(next, 400);
     250    });
    207251  }
    208252
     
    211255    albumName = (opts && opts.albumName) || '';
    212256    if (!queue.length) return;
    213     loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0);
    214     play();
     257    loadTrack(typeof startIdx === 'number' ? Math.max(0, Math.min(startIdx, queue.length - 1)) : 0, true);
    215258  }
    216259
    217260  function play() {
    218     if (!audio.src) return;
     261    if (!audio.src) {
     262      // Nothing fetched yet (pre-seed showed metadata only, or a load is still
     263      // in flight). Kick off the blob load for the current track and autoplay.
     264      if (queue[currentIndex]) loadTrack(currentIndex, true);
     265      return;
     266    }
    219267    const p = audio.play();
    220268    if (p && typeof p.catch === 'function') {
     
    236284  function next() {
    237285    if (!queue.length) return;
    238     loadTrack((currentIndex + 1) % queue.length);
    239     play();
     286    loadTrack((currentIndex + 1) % queue.length, true);
    240287  }
    241288  function prev() {
    242289    if (!queue.length) return;
    243     loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1);
    244     play();
     290    loadTrack(currentIndex === 0 ? queue.length - 1 : currentIndex - 1, true);
    245291  }
    246292  function close() {
     
    251297    queue = [];
    252298    albumName = '';
     299    loadSeq++;  // cancel any in-flight load
     300    if (currentObjectUrl) { try { URL.revokeObjectURL(currentObjectUrl); } catch (e) {} }
     301    currentObjectUrl = null;
     302    try { audio.removeAttribute('src'); audio.load(); } catch (e) {}
    253303  }
    254304
     
    268318        const idx = parseInt(li.dataset.idx, 10);
    269319        if (!isNaN(idx) && idx !== currentIndex) {
    270           loadTrack(idx);
    271           play();
     320          loadTrack(idx, true);
    272321        }
    273322      });
     
    288337  audio.addEventListener('play',  () => {
    289338    isPlaying = true;
    290     consecutiveErrors = 0;  // reset bij succesvolle play
    291339    root.classList.add('is-playing');
    292340    root.classList.remove('audio-needs-tap');  // verstop tap-hint
    293341  });
     342  // Reset de error-teller pas bij ECHTE playback-start (`playing`), niet bij
     343  // het eager `play`-event. `play` vuurt vóór een eventuele netwerk-/decode-
     344  // fout, dus resetten daar zou de 3-strikes-stop nooit laten triggeren bij
     345  // een kapotte track → infinite "next"-loop. `playing` vuurt alleen als er
     346  // daadwerkelijk audio speelt.
     347  audio.addEventListener('playing', () => { consecutiveErrors = 0; });
    294348  audio.addEventListener('pause', () => { isPlaying = false; root.classList.remove('is-playing'); });
    295349  audio.addEventListener('ended', next);
     
    539593      cover:  t.cover_url || t.cover || '',
    540594    }));
    541     if (queue.length) loadTrack(0);
     595    if (queue.length) loadTrack(0, false, true);  // metadata only — fetch on first play
    542596  }
    543597})();
  • src/routes/admin-audio.js

    r46f23fd r21522ae  
    2020import { requireGod } from '../middleware/auth.js';
    2121import { transcodeToMp3 } from '../services/AudioTranscoder.js';
    22 import { signUrl } from '../services/AudioStreamService.js';
     22import { audioUrl } from '../services/AudioStreamService.js';
    2323
    2424const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    8080  `).all(site.id);
    8181
    82   // Sign each track's stream URL so admins can preview audio inline.
    83   // Short TTL (default 10 min from AudioStreamService) means the URL on
    84   // the page expires if it sits open too long; a refresh re-signs.
     82  // Build each track's stream URL so admins can preview audio inline.
    8583  const tracks = rows.map(t => ({
    8684    ...t,
    87     stream_url: t.filename ? signUrl(t.filename).url : null,
     85    stream_url: t.filename ? audioUrl(t.filename) : null,
    8886  }));
    8987
     
    335333  `).get(req.params.id, site.id);
    336334  if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
    337   // Sign the stream URL so the modal can render an inline preview player.
    338   const stream_url = t.filename ? signUrl(t.filename).url : null;
     335  // Stream URL so the modal can render an inline preview player.
     336  const stream_url = t.filename ? audioUrl(t.filename) : null;
    339337  res.json({ ok: true, track: { ...t, stream_url } });
    340338});
  • src/routes/admin-playlists.js

    r46f23fd r21522ae  
    112112  if (!site) return res.status(404).json({ error: 'Site required' });
    113113
    114   // Editor needs the raw track-id list (not signed URLs) — pass no signUrl.
     114  // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
    115115  const playlist = PlaylistService.get(site.id, req.params.id, null);
    116116  if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
  • src/routes/audio.js

    r46f23fd r21522ae  
    11/**
    2  * Audio streaming routes — v9-style signed URL + byte-range support.
     2 * Audio streaming routes — byte-range streaming.
    33 *
    4  * Files live in storage/media/audio/ and are NOT served by the static
    5  * /media handler — every fetch must go through this verified route.
     4 * Files live in storage/audio/ and are NOT served by the static /media
     5 * handler — every fetch goes through this route, which adds byte-range
     6 * support so HTML5 <audio> can seek.
    67 *
    7  * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
    8  *   Verifies the token. If valid, streams the file with byte-range support
    9  *   so HTML5 <audio> can seek. Anything invalid returns 403.
     8 * GET /audio/stream/:filename
     9 *   Streams the file with byte-range support.
     10 *
     11 * ANTI-THEFT (Spotify-flavoured, step 1 — 2026-05-20):
     12 *   The player never exposes this URL to the user — it fetch()es the bytes
     13 *   and plays from a blob: object URL (no shareable link, no "save audio as").
     14 *   This route additionally refuses anything that isn't a same-origin browser
     15 *   fetch, so the raw URL can't be pasted into the address bar, hotlinked from
     16 *   another site, or pulled with curl/yt-dlp.
     17 *
     18 *   A request is allowed when EITHER:
     19 *     - it carries the X-Audio-Player header (our fetch sets it), OR
     20 *     - Sec-Fetch-Site is same-origin/same-site (covers the admin <audio>
     21 *       preview, which can't set custom headers).
     22 *   Address-bar paste sends Sec-Fetch-Site: none; hotlinks send cross-site;
     23 *   curl/yt-dlp send neither signal → all rejected.
    1024 */
    1125
     
    1428import path from 'path';
    1529import { fileURLToPath } from 'url';
    16 import { verifyToken } from '../services/AudioStreamService.js';
    1730
    1831const __dirname = path.dirname(fileURLToPath(import.meta.url));
    1932// Audio files live OUTSIDE storage/media — the public /media static handler
    20 // cannot reach them. Every fetch must go through this signed route.
     33// cannot reach them. Every fetch must go through this gated route.
    2134const AUDIO_DIR = path.resolve(
    2235  process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
     
    3952};
    4053
     54// Access gate: allow only same-origin browser fetches / media loads.
     55function isAllowedAudioRequest(req) {
     56  if (req.get('X-Audio-Player') === '1') return true;  // our blob fetch
     57  const site = req.get('Sec-Fetch-Site');              // set by modern browsers
     58  return site === 'same-origin' || site === 'same-site';
     59}
     60
    4161router.get('/stream/:filename', (req, res) => {
    4262  const { filename } = req.params;
    43   const { t, exp } = req.query;
     63
     64  if (!isAllowedAudioRequest(req)) {
     65    return res.status(403).send('Direct access not allowed');
     66  }
    4467
    4568  // Sanity: no path traversal, no slashes
    4669  if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
    4770    return res.status(400).send('Bad filename');
    48   }
    49 
    50   if (!verifyToken(filename, t, exp)) {
    51     return res.status(403).send('Invalid or expired token');
    5271  }
    5372
  • src/routes/posts.js

    r46f23fd r21522ae  
    1313import AudioEmbedService from '../services/AudioEmbedService.js';
    1414import PlaylistService from '../services/PlaylistService.js';
    15 import { signUrl } from '../services/AudioStreamService.js';
     15import { audioUrl } from '../services/AudioStreamService.js';
    1616
    1717const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    419419          artist: r.artist,
    420420          cover: r.cover_url,
    421           url: signUrl(r.filename).url,
     421          url: audioUrl(r.filename),
    422422        };
    423423      });
     
    439439        if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
    440440        byAlbum.get(r.album).push({
    441           url: signUrl(r.filename).url,
     441          url: audioUrl(r.filename),
    442442          title: r.title || 'Untitled',
    443443          artist: r.artist || '',
     
    464464      const isAdmin = req.session?.user?.role === 'god';
    465465      html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
    466         return PlaylistService.get(site.id, id, signUrl);
     466        return PlaylistService.get(site.id, id, audioUrl);
    467467      }, { isAdmin });
    468468    }
  • src/services/AudioStreamService.js

    r46f23fd r21522ae  
    11/**
    2  * AudioStreamService — Signed audio streaming, v9-style.
     2 * AudioStreamService — builds URLs for the audio streaming route.
    33 *
    4  * The src of <audio> is /audio/stream/:filename?t=HMAC&exp=TIMESTAMP.
    5  * HMAC = SHA256(filename|exp|AUDIO_SECRET).
    6  *
    7  * Defeats hotlinking, scrapers, casual URL sharing — not state actors.
    8  * Token TTL: 10 minutes (long enough for a track, short enough that a
    9  * shared link expires before anyone can use it).
    10  *
    11  * AUDIO_SECRET comes from env. If missing on first boot, generate one
    12  * and persist to storage/.audio-secret so it survives restarts.
     4 * ┌─ ANTI-THEFT MODEL (Spotify-flavoured, step 1 — 2026-05-20) ─────────────┐
     5 * │ audioUrl() returns a plain /audio/stream/<filename> path. There is NO   │
     6 * │ signed/expiring token in the URL — that earlier design baked a single   │
     7 * │ 10-min deadline into a whole queue at render time, so later tracks'     │
     8 * │ tokens expired mid-session and the player looped "next" forever.        │
     9 * │                                                                          │
     10 * │ Protection now lives in two NON-expiring layers, so it can't cause that │
     11 * │ failure again:                                                           │
     12 * │   1. Client (audio-player.js) fetch()es the bytes and plays from a      │
     13 * │      blob: object URL — no shareable link, no "save audio as".          │
     14 * │   2. Server (routes/audio.js) gates /audio/stream to same-origin        │
     15 * │      browser fetches — blocks address-bar paste, hotlinks, curl/yt-dlp. │
     16 * │                                                                          │
     17 * │ FUTURE STEPS (deliberate, tested one at a time):                         │
     18 * │   - step 2: MSE chunked/progressive streaming (true Spotify feel)        │
     19 * │   - step 3: per-session short-lived token in a header, minted JIT        │
     20 * │   - step 4: light byte obfuscation (XOR/key) on the wire                 │
     21 * └──────────────────────────────────────────────────────────────────────┘
    1322 */
    1423
    15 import crypto from 'crypto';
    16 import fs from 'fs';
    17 import path from 'path';
    18 import { fileURLToPath } from 'url';
    19 
    20 const __dirname = path.dirname(fileURLToPath(import.meta.url));
    21 const SECRET_FILE = path.join(__dirname, '..', '..', 'storage', '.audio-secret');
    22 
    23 export const TOKEN_TTL_SECONDS = 600;
    24 
    25 let cachedSecret = null;
    26 
    27 function loadOrGenerateSecret() {
    28   if (cachedSecret) return cachedSecret;
    29 
    30   // 1. Env wins
    31   if (process.env.AUDIO_SECRET && process.env.AUDIO_SECRET.length >= 32) {
    32     cachedSecret = process.env.AUDIO_SECRET;
    33     return cachedSecret;
    34   }
    35 
    36   // 2. Persisted file
    37   try {
    38     const fromDisk = fs.readFileSync(SECRET_FILE, 'utf-8').trim();
    39     if (fromDisk.length >= 32) {
    40       cachedSecret = fromDisk;
    41       return cachedSecret;
    42     }
    43   } catch (e) { /* file missing — generate */ }
    44 
    45   // 3. Generate + persist
    46   const generated = crypto.randomBytes(32).toString('hex');
    47   try {
    48     fs.mkdirSync(path.dirname(SECRET_FILE), { recursive: true });
    49     fs.writeFileSync(SECRET_FILE, generated, { mode: 0o600 });
    50     console.log('AudioStreamService: generated new audio secret at', SECRET_FILE);
    51   } catch (e) {
    52     console.error('AudioStreamService: could not persist audio secret:', e.message);
    53   }
    54   cachedSecret = generated;
    55   return cachedSecret;
     24/**
     25 * Build the public stream URL for an audio filename.
     26 * Returns null for a falsy filename so callers can guard playability.
     27 */
     28export function audioUrl(filename) {
     29  if (!filename) return null;
     30  return `/audio/stream/${encodeURIComponent(filename)}`;
    5631}
    5732
    58 function makeHmac(filename, exp) {
    59   const secret = loadOrGenerateSecret();
    60   return crypto
    61     .createHmac('sha256', secret)
    62     .update(`${filename}|${exp}`)
    63     .digest('hex');
    64 }
    65 
    66 /**
    67  * Sign a filename → returns { url, exp, t } so callers can build the URL.
    68  * The full URL is /audio/stream/<filename>?t=<t>&exp=<exp>.
    69  */
    70 export function signUrl(filename, ttlSeconds = TOKEN_TTL_SECONDS) {
    71   const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
    72   const t = makeHmac(filename, exp);
    73   const safe = encodeURIComponent(filename);
    74   return {
    75     url: `/audio/stream/${safe}?t=${t}&exp=${exp}`,
    76     exp,
    77     t,
    78   };
    79 }
    80 
    81 /**
    82  * Verify a token for a filename. Returns true iff exp is in the future
    83  * AND the HMAC matches.
    84  */
    85 export function verifyToken(filename, t, exp) {
    86   if (!filename || !t || !exp) return false;
    87   const expNum = Number(exp);
    88   if (!Number.isFinite(expNum)) return false;
    89   if (expNum < Math.floor(Date.now() / 1000)) return false;
    90 
    91   const expected = makeHmac(filename, expNum);
    92   // timingSafeEqual requires equal-length buffers
    93   try {
    94     const a = Buffer.from(t, 'hex');
    95     const b = Buffer.from(expected, 'hex');
    96     if (a.length !== b.length) return false;
    97     return crypto.timingSafeEqual(a, b);
    98   } catch (e) {
    99     return false;
    100   }
    101 }
    102 
    103 /**
    104  * Force-rotate the secret. Invalidates all outstanding tokens.
    105  */
    106 export function rotateSecret() {
    107   cachedSecret = null;
    108   try { fs.unlinkSync(SECRET_FILE); } catch (e) {}
    109   return loadOrGenerateSecret();
    110 }
    111 
    112 export default { signUrl, verifyToken, rotateSecret, TOKEN_TTL_SECONDS };
     33export default { audioUrl };
  • src/services/PlaylistService.js

    r46f23fd r21522ae  
    7777   * Returns null if the playlist doesn't exist.
    7878   *
    79    * `signUrl` is an optional callback that takes a media filename and returns
    80    * a (possibly signed) URL. If not provided, tracks come back with no `url`
    81    * and the caller has to resolve them. The render pipeline in posts.js
    82    * always passes signUrl.
    83    */
    84   static get(siteId, id, signUrl) {
     79   * `urlFor` is an optional callback that takes a media filename and returns
     80   * its stream URL. If not provided, tracks come back with no `url` and the
     81   * caller has to resolve them. The render pipeline in posts.js always passes
     82   * urlFor.
     83   */
     84  static get(siteId, id, urlFor) {
    8585    id = this.normalizeId(id);
    8686    if (!id) return null;
     
    117117          cover: t.cover_url || p.cover_url || '',
    118118          duration: t.duration || 0,
    119           url: signUrl ? signUrl(t.filename).url : null,
     119          url: urlFor ? urlFor(t.filename) : null,
    120120        })),
    121121    };
  • src/views/pages/admin-audio.ejs

    r46f23fd r21522ae  
    583583    function resyncAll() {
    584584      const audio = document.getElementById('audio-element');
     585      const player = window.pcmsAudioPlayer;
    585586      const playing = audio && !audio.paused && !audio.ended;
    586       const currentSrc = audio ? audio.src : '';
     587      // audio.src is now a blob: URL (Spotify-style playback), so compare
     588      // against the player's logical track URL, not the element src.
     589      const cur = player && player.currentTrack();
     590      const curUrl = cur ? cur.url : '';
    587591      buttons.forEach(b => {
    588         const isThisOne = playing && currentSrc.endsWith(b.dataset.streamUrl);
     592        const isThisOne = playing && curUrl === b.dataset.streamUrl;
    589593        setIcon(b, isThisOne);
    590594      });
     
    617621        };
    618622
    619         // If this exact track is already playing, toggle pause/play instead
    620         // of restarting from zero.
    621         const audio = document.getElementById('audio-element');
    622         if (audio && audio.src.endsWith(url)) {
    623           if (audio.paused) player.play();
    624           else              player.pause();
     623        // If this exact track is already current, toggle pause/play instead
     624        // of restarting from zero. Compare logical URLs (audio.src is a blob:).
     625        const cur = player.currentTrack();
     626        if (cur && cur.url === url) {
     627          if (player.isPlaying()) player.pause();
     628          else                    player.play();
    625629          return;
    626630        }
  • src/views/partials/track-editor.ejs

    r46f23fd r21522ae  
    445445      };
    446446      const isOurTrack = () => {
    447         const audio = document.getElementById('audio-element');
    448         return audio && track.stream_url && audio.src.endsWith(track.stream_url);
     447        // audio.src is a blob: URL (Spotify-style playback) — compare against
     448        // the player's logical current-track URL instead.
     449        const player = window.pcmsAudioPlayer;
     450        const cur = player && player.currentTrack();
     451        return !!(cur && track.stream_url && cur.url === track.stream_url);
    449452      };
    450453      const resync = () => {
  • src/views/shell.ejs

    r46f23fd r21522ae  
    277277     ?v=N — cache-buster: bump bij elke audio-player.js wijziging zodat
    278278     Cloudflare (max-age=1y) niet de oude versie blijft serveren. -->
    279 <script src="/assets/js/audio-player.js?v=3"></script>
     279<script src="/assets/js/audio-player.js?v=5"></script>
    280280<% if (site && site.enable_audio_player && audioTracks && audioTracks.length > 0) { %>
    281281  <script>window.PCMS_SITE_TRACKS = <%- JSON.stringify(audioTracks) %>;</script>
Note: See TracChangeset for help on using the changeset viewer.