Index: src/routes/admin-audio.js
===================================================================
--- src/routes/admin-audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/admin-audio.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -20,5 +20,5 @@
 import { requireGod } from '../middleware/auth.js';
 import { transcodeToMp3 } from '../services/AudioTranscoder.js';
-import { signUrl } from '../services/AudioStreamService.js';
+import { audioUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -80,10 +80,8 @@
   `).all(site.id);
 
-  // Sign each track's stream URL so admins can preview audio inline.
-  // Short TTL (default 10 min from AudioStreamService) means the URL on
-  // the page expires if it sits open too long; a refresh re-signs.
+  // Build each track's stream URL so admins can preview audio inline.
   const tracks = rows.map(t => ({
     ...t,
-    stream_url: t.filename ? signUrl(t.filename).url : null,
+    stream_url: t.filename ? audioUrl(t.filename) : null,
   }));
 
@@ -335,6 +333,6 @@
   `).get(req.params.id, site.id);
   if (!t) return res.status(404).json({ error: 'Track niet gevonden' });
-  // Sign the stream URL so the modal can render an inline preview player.
-  const stream_url = t.filename ? signUrl(t.filename).url : null;
+  // Stream URL so the modal can render an inline preview player.
+  const stream_url = t.filename ? audioUrl(t.filename) : null;
   res.json({ ok: true, track: { ...t, stream_url } });
 });
Index: src/routes/admin-playlists.js
===================================================================
--- src/routes/admin-playlists.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/admin-playlists.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -112,5 +112,5 @@
   if (!site) return res.status(404).json({ error: 'Site required' });
 
-  // Editor needs the raw track-id list (not signed URLs) — pass no signUrl.
+  // Editor needs the raw track-id list (not stream URLs) — pass no urlFor.
   const playlist = PlaylistService.get(site.id, req.params.id, null);
   if (!playlist) return res.status(404).json({ error: 'Playlist niet gevonden' });
Index: src/routes/audio.js
===================================================================
--- src/routes/audio.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/audio.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -1,11 +1,25 @@
 /**
- * Audio streaming routes — v9-style signed URL + byte-range support.
+ * Audio streaming routes — byte-range streaming.
  *
- * Files live in storage/media/audio/ and are NOT served by the static
- * /media handler — every fetch must go through this verified route.
+ * Files live in storage/audio/ and are NOT served by the static /media
+ * handler — every fetch goes through this route, which adds byte-range
+ * support so HTML5 <audio> can seek.
  *
- * GET /audio/stream/:filename?t=<hmac>&exp=<unix>
- *   Verifies the token. If valid, streams the file with byte-range support
- *   so HTML5 <audio> can seek. Anything invalid returns 403.
+ * GET /audio/stream/:filename
+ *   Streams the file with byte-range support.
+ *
+ * ANTI-THEFT (Spotify-flavoured, step 1 — 2026-05-20):
+ *   The player never exposes this URL to the user — it fetch()es the bytes
+ *   and plays from a blob: object URL (no shareable link, no "save audio as").
+ *   This route additionally refuses anything that isn't a same-origin browser
+ *   fetch, so the raw URL can't be pasted into the address bar, hotlinked from
+ *   another site, or pulled with curl/yt-dlp.
+ *
+ *   A request is allowed when EITHER:
+ *     - it carries the X-Audio-Player header (our fetch sets it), OR
+ *     - Sec-Fetch-Site is same-origin/same-site (covers the admin <audio>
+ *       preview, which can't set custom headers).
+ *   Address-bar paste sends Sec-Fetch-Site: none; hotlinks send cross-site;
+ *   curl/yt-dlp send neither signal → all rejected.
  */
 
@@ -14,9 +28,8 @@
 import path from 'path';
 import { fileURLToPath } from 'url';
-import { verifyToken } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 // Audio files live OUTSIDE storage/media — the public /media static handler
-// cannot reach them. Every fetch must go through this signed route.
+// cannot reach them. Every fetch must go through this gated route.
 const AUDIO_DIR = path.resolve(
   process.env.AUDIO_PATH || path.join(__dirname, '..', '..', 'storage', 'audio')
@@ -39,15 +52,21 @@
 };
 
+// Access gate: allow only same-origin browser fetches / media loads.
+function isAllowedAudioRequest(req) {
+  if (req.get('X-Audio-Player') === '1') return true;  // our blob fetch
+  const site = req.get('Sec-Fetch-Site');              // set by modern browsers
+  return site === 'same-origin' || site === 'same-site';
+}
+
 router.get('/stream/:filename', (req, res) => {
   const { filename } = req.params;
-  const { t, exp } = req.query;
+
+  if (!isAllowedAudioRequest(req)) {
+    return res.status(403).send('Direct access not allowed');
+  }
 
   // Sanity: no path traversal, no slashes
   if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
     return res.status(400).send('Bad filename');
-  }
-
-  if (!verifyToken(filename, t, exp)) {
-    return res.status(403).send('Invalid or expired token');
   }
 
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 7bc636b391c66ac399c33e54f7173a022c6a3cbd)
+++ src/routes/posts.js	(revision 21522aeeeadc321d1358c416b7223a6300a03996)
@@ -13,5 +13,5 @@
 import AudioEmbedService from '../services/AudioEmbedService.js';
 import PlaylistService from '../services/PlaylistService.js';
-import { signUrl } from '../services/AudioStreamService.js';
+import { audioUrl } from '../services/AudioStreamService.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -419,5 +419,5 @@
           artist: r.artist,
           cover: r.cover_url,
-          url: signUrl(r.filename).url,
+          url: audioUrl(r.filename),
         };
       });
@@ -439,5 +439,5 @@
         if (!byAlbum.has(r.album)) byAlbum.set(r.album, []);
         byAlbum.get(r.album).push({
-          url: signUrl(r.filename).url,
+          url: audioUrl(r.filename),
           title: r.title || 'Untitled',
           artist: r.artist || '',
@@ -464,5 +464,5 @@
       const isAdmin = req.session?.user?.role === 'god';
       html = AudioEmbedService.embedPlaylistShortcodes(html, (id) => {
-        return PlaylistService.get(site.id, id, signUrl);
+        return PlaylistService.get(site.id, id, audioUrl);
       }, { isAdmin });
     }
