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');
   }
 
