karaoke-gen 0.103.1__py3-none-any.whl → 0.105.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ """
2
+ Tests for Push Notification API routes.
3
+
4
+ Tests the /api/push/* endpoints for subscription management.
5
+ """
6
+ import pytest
7
+ from unittest.mock import Mock, patch, AsyncMock
8
+ from fastapi.testclient import TestClient
9
+ from dataclasses import dataclass
10
+ from typing import Optional
11
+
12
+ from backend.main import app
13
+ from backend.api.dependencies import require_auth, require_admin
14
+ from backend.services.auth_service import UserType, AuthResult
15
+
16
+
17
+ @pytest.fixture
18
+ def client():
19
+ """Create test client."""
20
+ return TestClient(app)
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_auth_result():
25
+ """Create a mock AuthResult for regular user."""
26
+ return AuthResult(
27
+ is_valid=True,
28
+ user_type=UserType.UNLIMITED,
29
+ remaining_uses=-1,
30
+ message="Valid",
31
+ user_email="test@example.com",
32
+ is_admin=False,
33
+ )
34
+
35
+
36
+ @pytest.fixture
37
+ def mock_admin_auth_result():
38
+ """Create a mock AuthResult for admin user."""
39
+ return AuthResult(
40
+ is_valid=True,
41
+ user_type=UserType.ADMIN,
42
+ remaining_uses=-1,
43
+ message="Valid",
44
+ user_email="admin@nomadkaraoke.com",
45
+ is_admin=True,
46
+ )
47
+
48
+
49
+ class TestGetVapidPublicKey:
50
+ """Tests for GET /api/push/vapid-public-key."""
51
+
52
+ def test_returns_disabled_when_feature_off(self, client):
53
+ """Returns enabled=false when push notifications disabled."""
54
+ with patch('backend.api.routes.push.get_settings') as mock_settings:
55
+ mock_settings.return_value.enable_push_notifications = False
56
+
57
+ response = client.get("/api/push/vapid-public-key")
58
+
59
+ assert response.status_code == 200
60
+ data = response.json()
61
+ assert data["enabled"] is False
62
+ assert data["vapid_public_key"] is None
63
+
64
+ def test_returns_key_when_enabled(self, client):
65
+ """Returns public key when push notifications enabled."""
66
+ with patch('backend.api.routes.push.get_settings') as mock_settings, \
67
+ patch('backend.api.routes.push.get_push_notification_service') as mock_service:
68
+ mock_settings.return_value.enable_push_notifications = True
69
+ mock_service.return_value.get_public_key.return_value = "test-public-key-123"
70
+
71
+ response = client.get("/api/push/vapid-public-key")
72
+
73
+ assert response.status_code == 200
74
+ data = response.json()
75
+ assert data["enabled"] is True
76
+ assert data["vapid_public_key"] == "test-public-key-123"
77
+
78
+
79
+ class TestSubscribe:
80
+ """Tests for POST /api/push/subscribe."""
81
+
82
+ def test_requires_authentication(self, client):
83
+ """Returns 401 when not authenticated."""
84
+ # Clear any auth overrides to test real auth
85
+ app.dependency_overrides.clear()
86
+
87
+ response = client.post(
88
+ "/api/push/subscribe",
89
+ json={
90
+ "endpoint": "https://push.example.com/endpoint",
91
+ "keys": {"p256dh": "key1", "auth": "key2"}
92
+ }
93
+ )
94
+
95
+ assert response.status_code == 401
96
+
97
+ def test_returns_503_when_disabled(self, client, mock_auth_result):
98
+ """Returns 503 when push notifications disabled."""
99
+ async def override_auth():
100
+ return mock_auth_result
101
+
102
+ app.dependency_overrides[require_auth] = override_auth
103
+
104
+ try:
105
+ with patch('backend.api.routes.push.get_settings') as mock_settings:
106
+ mock_settings.return_value.enable_push_notifications = False
107
+
108
+ response = client.post(
109
+ "/api/push/subscribe",
110
+ json={
111
+ "endpoint": "https://push.example.com/endpoint",
112
+ "keys": {"p256dh": "key1", "auth": "key2"}
113
+ }
114
+ )
115
+
116
+ assert response.status_code == 503
117
+ assert "not enabled" in response.json()["detail"].lower()
118
+ finally:
119
+ app.dependency_overrides.clear()
120
+
121
+ def test_validates_required_keys(self, client, mock_auth_result):
122
+ """Returns 400 when keys missing."""
123
+ async def override_auth():
124
+ return mock_auth_result
125
+
126
+ app.dependency_overrides[require_auth] = override_auth
127
+
128
+ try:
129
+ with patch('backend.api.routes.push.get_settings') as mock_settings:
130
+ mock_settings.return_value.enable_push_notifications = True
131
+
132
+ response = client.post(
133
+ "/api/push/subscribe",
134
+ json={
135
+ "endpoint": "https://push.example.com/endpoint",
136
+ "keys": {"p256dh": "key1"} # Missing auth
137
+ }
138
+ )
139
+
140
+ assert response.status_code == 400
141
+ assert "missing" in response.json()["detail"].lower()
142
+ finally:
143
+ app.dependency_overrides.clear()
144
+
145
+ def test_successful_subscription(self, client, mock_auth_result):
146
+ """Successfully subscribes user to push notifications."""
147
+ async def override_auth():
148
+ return mock_auth_result
149
+
150
+ app.dependency_overrides[require_auth] = override_auth
151
+
152
+ try:
153
+ with patch('backend.api.routes.push.get_settings') as mock_settings, \
154
+ patch('backend.api.routes.push.get_push_notification_service') as mock_service:
155
+ mock_settings.return_value.enable_push_notifications = True
156
+ mock_service.return_value.add_subscription = AsyncMock(return_value=True)
157
+
158
+ response = client.post(
159
+ "/api/push/subscribe",
160
+ json={
161
+ "endpoint": "https://push.example.com/endpoint",
162
+ "keys": {"p256dh": "key1", "auth": "key2"},
163
+ "device_name": "Test Device"
164
+ }
165
+ )
166
+
167
+ assert response.status_code == 200
168
+ data = response.json()
169
+ assert data["status"] == "success"
170
+ mock_service.return_value.add_subscription.assert_called_once_with(
171
+ user_email="test@example.com",
172
+ endpoint="https://push.example.com/endpoint",
173
+ keys={"p256dh": "key1", "auth": "key2"},
174
+ device_name="Test Device"
175
+ )
176
+ finally:
177
+ app.dependency_overrides.clear()
178
+
179
+
180
+ class TestUnsubscribe:
181
+ """Tests for POST /api/push/unsubscribe."""
182
+
183
+ def test_requires_authentication(self, client):
184
+ """Returns 401 when not authenticated."""
185
+ app.dependency_overrides.clear()
186
+
187
+ response = client.post(
188
+ "/api/push/unsubscribe",
189
+ json={"endpoint": "https://push.example.com/endpoint"}
190
+ )
191
+
192
+ assert response.status_code == 401
193
+
194
+ def test_successful_unsubscribe(self, client, mock_auth_result):
195
+ """Successfully unsubscribes from push notifications."""
196
+ async def override_auth():
197
+ return mock_auth_result
198
+
199
+ app.dependency_overrides[require_auth] = override_auth
200
+
201
+ try:
202
+ with patch('backend.api.routes.push.get_push_notification_service') as mock_service:
203
+ mock_service.return_value.remove_subscription = AsyncMock(return_value=True)
204
+
205
+ response = client.post(
206
+ "/api/push/unsubscribe",
207
+ json={"endpoint": "https://push.example.com/endpoint"}
208
+ )
209
+
210
+ assert response.status_code == 200
211
+ data = response.json()
212
+ assert data["status"] == "success"
213
+ mock_service.return_value.remove_subscription.assert_called_once()
214
+ finally:
215
+ app.dependency_overrides.clear()
216
+
217
+ def test_unsubscribe_not_found_succeeds(self, client, mock_auth_result):
218
+ """Returns success even when subscription not found."""
219
+ async def override_auth():
220
+ return mock_auth_result
221
+
222
+ app.dependency_overrides[require_auth] = override_auth
223
+
224
+ try:
225
+ with patch('backend.api.routes.push.get_push_notification_service') as mock_service:
226
+ mock_service.return_value.remove_subscription = AsyncMock(return_value=False)
227
+
228
+ response = client.post(
229
+ "/api/push/unsubscribe",
230
+ json={"endpoint": "https://push.example.com/unknown"}
231
+ )
232
+
233
+ assert response.status_code == 200
234
+ data = response.json()
235
+ assert data["status"] == "success"
236
+ finally:
237
+ app.dependency_overrides.clear()
238
+
239
+
240
+ class TestListSubscriptions:
241
+ """Tests for GET /api/push/subscriptions."""
242
+
243
+ def test_requires_authentication(self, client):
244
+ """Returns 401 when not authenticated."""
245
+ app.dependency_overrides.clear()
246
+
247
+ response = client.get("/api/push/subscriptions")
248
+ assert response.status_code == 401
249
+
250
+ def test_returns_user_subscriptions(self, client, mock_auth_result):
251
+ """Returns list of user's subscriptions."""
252
+ async def override_auth():
253
+ return mock_auth_result
254
+
255
+ app.dependency_overrides[require_auth] = override_auth
256
+
257
+ try:
258
+ with patch('backend.api.routes.push.get_push_notification_service') as mock_service:
259
+ mock_service.return_value.list_subscriptions = AsyncMock(return_value=[
260
+ {
261
+ "endpoint": "https://push.example.com/endpoint1",
262
+ "device_name": "Device 1",
263
+ "created_at": "2024-01-01T00:00:00Z",
264
+ "last_used_at": None
265
+ },
266
+ {
267
+ "endpoint": "https://push.example.com/endpoint2",
268
+ "device_name": "Device 2",
269
+ "created_at": "2024-01-02T00:00:00Z",
270
+ "last_used_at": "2024-01-03T00:00:00Z"
271
+ }
272
+ ])
273
+
274
+ response = client.get("/api/push/subscriptions")
275
+
276
+ assert response.status_code == 200
277
+ data = response.json()
278
+ assert data["count"] == 2
279
+ assert len(data["subscriptions"]) == 2
280
+ assert data["subscriptions"][0]["device_name"] == "Device 1"
281
+ finally:
282
+ app.dependency_overrides.clear()
283
+
284
+
285
+ class TestTestNotification:
286
+ """Tests for POST /api/push/test."""
287
+
288
+ def test_requires_admin(self, client, mock_auth_result):
289
+ """Returns 403 when not admin."""
290
+ # Use a non-admin auth result
291
+ async def override_admin():
292
+ from fastapi import HTTPException
293
+ raise HTTPException(status_code=403, detail="Admin access required")
294
+
295
+ app.dependency_overrides[require_admin] = override_admin
296
+
297
+ try:
298
+ response = client.post(
299
+ "/api/push/test",
300
+ json={"title": "Test", "body": "Test message"}
301
+ )
302
+
303
+ assert response.status_code == 403
304
+ finally:
305
+ app.dependency_overrides.clear()
306
+
307
+ def test_returns_503_when_disabled(self, client, mock_admin_auth_result):
308
+ """Returns 503 when push notifications disabled."""
309
+ async def override_admin():
310
+ return mock_admin_auth_result
311
+
312
+ app.dependency_overrides[require_admin] = override_admin
313
+
314
+ try:
315
+ with patch('backend.api.routes.push.get_settings') as mock_settings:
316
+ mock_settings.return_value.enable_push_notifications = False
317
+
318
+ response = client.post(
319
+ "/api/push/test",
320
+ json={"title": "Test", "body": "Test message"}
321
+ )
322
+
323
+ assert response.status_code == 503
324
+ finally:
325
+ app.dependency_overrides.clear()
326
+
327
+ def test_sends_test_notification(self, client, mock_admin_auth_result):
328
+ """Successfully sends test notification to admin."""
329
+ async def override_admin():
330
+ return mock_admin_auth_result
331
+
332
+ app.dependency_overrides[require_admin] = override_admin
333
+
334
+ try:
335
+ with patch('backend.api.routes.push.get_settings') as mock_settings, \
336
+ patch('backend.api.routes.push.get_push_notification_service') as mock_service:
337
+ mock_settings.return_value.enable_push_notifications = True
338
+ mock_service.return_value.send_push = AsyncMock(return_value=2)
339
+
340
+ response = client.post(
341
+ "/api/push/test",
342
+ json={"title": "Test Title", "body": "Test Body"}
343
+ )
344
+
345
+ assert response.status_code == 200
346
+ data = response.json()
347
+ assert data["status"] == "success"
348
+ assert data["sent_count"] == 2
349
+ mock_service.return_value.send_push.assert_called_once_with(
350
+ user_email="admin@nomadkaraoke.com",
351
+ title="Test Title",
352
+ body="Test Body",
353
+ url="/app/",
354
+ tag="test"
355
+ )
356
+ finally:
357
+ app.dependency_overrides.clear()
@@ -0,0 +1,205 @@
1
+ """
2
+ Tests for StripeService.
3
+
4
+ Tests cover:
5
+ - Made-for-you checkout session URL generation
6
+ - Credit purchase checkout session URL generation
7
+ - Package validation
8
+ """
9
+
10
+ import pytest
11
+ from unittest.mock import Mock, patch, MagicMock
12
+ import os
13
+
14
+
15
+ class TestStripeServiceUrls:
16
+ """Tests for checkout session URL generation."""
17
+
18
+ @pytest.fixture
19
+ def stripe_service(self):
20
+ """Create a StripeService instance with mocked Stripe."""
21
+ # Set required env vars
22
+ with patch.dict(os.environ, {
23
+ 'STRIPE_SECRET_KEY': 'sk_test_fake',
24
+ 'FRONTEND_URL': 'https://gen.nomadkaraoke.com',
25
+ }):
26
+ # Import here to pick up env vars
27
+ from backend.services.stripe_service import StripeService
28
+ service = StripeService()
29
+ return service
30
+
31
+ def test_made_for_you_success_url_uses_frontend_url(self, stripe_service):
32
+ """Test that made-for-you checkout uses frontend_url, not hardcoded domain."""
33
+ # Mock stripe.checkout.Session.create to capture the params
34
+ with patch('stripe.checkout.Session.create') as mock_create:
35
+ mock_session = MagicMock()
36
+ mock_session.id = 'cs_test_123'
37
+ mock_session.url = 'https://checkout.stripe.com/test'
38
+ mock_create.return_value = mock_session
39
+
40
+ success, url, message = stripe_service.create_made_for_you_checkout_session(
41
+ customer_email='customer@example.com',
42
+ artist='Test Artist',
43
+ title='Test Song',
44
+ )
45
+
46
+ # Verify the call was made
47
+ assert mock_create.called
48
+ call_kwargs = mock_create.call_args[1]
49
+
50
+ # Verify success_url uses frontend_url and correct path
51
+ success_url = call_kwargs['success_url']
52
+ assert success_url.startswith('https://gen.nomadkaraoke.com')
53
+ assert '/order/success' in success_url
54
+ assert 'session_id=' in success_url
55
+
56
+ def test_made_for_you_success_url_not_hardcoded_to_marketing_site(self, stripe_service):
57
+ """Test that success_url is NOT the old hardcoded marketing site URL."""
58
+ with patch('stripe.checkout.Session.create') as mock_create:
59
+ mock_session = MagicMock()
60
+ mock_session.id = 'cs_test_123'
61
+ mock_session.url = 'https://checkout.stripe.com/test'
62
+ mock_create.return_value = mock_session
63
+
64
+ stripe_service.create_made_for_you_checkout_session(
65
+ customer_email='customer@example.com',
66
+ artist='Test Artist',
67
+ title='Test Song',
68
+ )
69
+
70
+ call_kwargs = mock_create.call_args[1]
71
+ success_url = call_kwargs['success_url']
72
+
73
+ # The bug was: success_url = "https://nomadkaraoke.com/order/success/"
74
+ # (note: nomadkaraoke.com WITHOUT the 'gen.' prefix - that's the marketing site)
75
+ # This should NOT be the case anymore - should use gen.nomadkaraoke.com
76
+ assert not success_url.startswith('https://nomadkaraoke.com/')
77
+ # Should use the frontend URL (gen.nomadkaraoke.com)
78
+ assert success_url.startswith('https://gen.nomadkaraoke.com')
79
+
80
+ def test_made_for_you_respects_custom_frontend_url(self):
81
+ """Test that made-for-you checkout uses custom FRONTEND_URL if set."""
82
+ with patch.dict(os.environ, {
83
+ 'STRIPE_SECRET_KEY': 'sk_test_fake',
84
+ 'FRONTEND_URL': 'https://custom.example.com',
85
+ }):
86
+ from backend.services.stripe_service import StripeService
87
+ service = StripeService()
88
+
89
+ with patch('stripe.checkout.Session.create') as mock_create:
90
+ mock_session = MagicMock()
91
+ mock_session.id = 'cs_test_123'
92
+ mock_session.url = 'https://checkout.stripe.com/test'
93
+ mock_create.return_value = mock_session
94
+
95
+ service.create_made_for_you_checkout_session(
96
+ customer_email='customer@example.com',
97
+ artist='Test Artist',
98
+ title='Test Song',
99
+ )
100
+
101
+ call_kwargs = mock_create.call_args[1]
102
+ success_url = call_kwargs['success_url']
103
+ assert success_url.startswith('https://custom.example.com')
104
+
105
+ def test_credit_purchase_success_url_uses_payment_success(self, stripe_service):
106
+ """Test that credit purchase checkout uses /payment/success path."""
107
+ with patch('stripe.checkout.Session.create') as mock_create:
108
+ mock_session = MagicMock()
109
+ mock_session.id = 'cs_test_123'
110
+ mock_session.url = 'https://checkout.stripe.com/test'
111
+ mock_create.return_value = mock_session
112
+
113
+ success, url, message = stripe_service.create_checkout_session(
114
+ package_id='1_credit',
115
+ user_email='user@example.com',
116
+ )
117
+
118
+ call_kwargs = mock_create.call_args[1]
119
+ success_url = call_kwargs['success_url']
120
+
121
+ # Credit purchases go to /payment/success
122
+ assert '/payment/success' in success_url
123
+ assert success_url.startswith('https://gen.nomadkaraoke.com')
124
+
125
+ def test_made_for_you_uses_order_success_path(self, stripe_service):
126
+ """Test that made-for-you uses /order/success path (not /payment/success)."""
127
+ with patch('stripe.checkout.Session.create') as mock_create:
128
+ mock_session = MagicMock()
129
+ mock_session.id = 'cs_test_123'
130
+ mock_session.url = 'https://checkout.stripe.com/test'
131
+ mock_create.return_value = mock_session
132
+
133
+ stripe_service.create_made_for_you_checkout_session(
134
+ customer_email='customer@example.com',
135
+ artist='Test Artist',
136
+ title='Test Song',
137
+ )
138
+
139
+ call_kwargs = mock_create.call_args[1]
140
+ success_url = call_kwargs['success_url']
141
+
142
+ # Made-for-you orders go to /order/success (different messaging)
143
+ assert '/order/success' in success_url
144
+ # Should NOT go to /payment/success
145
+ assert '/payment/success' not in success_url
146
+
147
+
148
+ class TestStripeServiceMetadata:
149
+ """Tests for checkout session metadata."""
150
+
151
+ @pytest.fixture
152
+ def stripe_service(self):
153
+ """Create a StripeService instance with mocked Stripe."""
154
+ with patch.dict(os.environ, {
155
+ 'STRIPE_SECRET_KEY': 'sk_test_fake',
156
+ 'FRONTEND_URL': 'https://gen.nomadkaraoke.com',
157
+ }):
158
+ from backend.services.stripe_service import StripeService
159
+ return StripeService()
160
+
161
+ def test_made_for_you_includes_order_type_metadata(self, stripe_service):
162
+ """Test that made-for-you checkout includes order_type in metadata."""
163
+ with patch('stripe.checkout.Session.create') as mock_create:
164
+ mock_session = MagicMock()
165
+ mock_session.id = 'cs_test_123'
166
+ mock_session.url = 'https://checkout.stripe.com/test'
167
+ mock_create.return_value = mock_session
168
+
169
+ stripe_service.create_made_for_you_checkout_session(
170
+ customer_email='customer@example.com',
171
+ artist='Test Artist',
172
+ title='Test Song',
173
+ notes='Special request',
174
+ )
175
+
176
+ call_kwargs = mock_create.call_args[1]
177
+ metadata = call_kwargs['metadata']
178
+
179
+ assert metadata['order_type'] == 'made_for_you'
180
+ assert metadata['customer_email'] == 'customer@example.com'
181
+ assert metadata['artist'] == 'Test Artist'
182
+ assert metadata['title'] == 'Test Song'
183
+ assert metadata['notes'] == 'Special request'
184
+
185
+ def test_made_for_you_truncates_long_notes(self, stripe_service):
186
+ """Test that notes longer than 500 chars are truncated."""
187
+ with patch('stripe.checkout.Session.create') as mock_create:
188
+ mock_session = MagicMock()
189
+ mock_session.id = 'cs_test_123'
190
+ mock_session.url = 'https://checkout.stripe.com/test'
191
+ mock_create.return_value = mock_session
192
+
193
+ long_notes = 'x' * 600
194
+
195
+ stripe_service.create_made_for_you_checkout_session(
196
+ customer_email='customer@example.com',
197
+ artist='Test Artist',
198
+ title='Test Song',
199
+ notes=long_notes,
200
+ )
201
+
202
+ call_kwargs = mock_create.call_args[1]
203
+ metadata = call_kwargs['metadata']
204
+
205
+ assert len(metadata['notes']) == 500
@@ -479,6 +479,22 @@ class VideoWorkerOrchestrator:
479
479
  if self.config.gdrive_folder_id:
480
480
  await self._upload_to_gdrive()
481
481
 
482
+ # Clear outputs_deleted_at if set (job was re-processed after output deletion)
483
+ # Only clear if we actually uploaded something
484
+ uploads_happened = (
485
+ self.result.youtube_url or
486
+ self.result.dropbox_link or
487
+ self.result.gdrive_files
488
+ )
489
+ if uploads_happened and self.job_manager:
490
+ job = self.job_manager.get_job(self.config.job_id)
491
+ if job and job.outputs_deleted_at:
492
+ self.job_manager.update_job(self.config.job_id, {
493
+ "outputs_deleted_at": None,
494
+ "outputs_deleted_by": None,
495
+ })
496
+ self.job_log.info("Cleared outputs_deleted_at flag (job was re-processed)")
497
+
482
498
  async def _upload_to_youtube(self):
483
499
  """Upload video to YouTube."""
484
500
  self.job_log.info("Uploading to YouTube")
@@ -750,18 +750,44 @@
750
750
  const urlParams = new URLSearchParams(window.location.search);
751
751
  const encodedBaseApiUrl = urlParams.get('baseApiUrl');
752
752
  const instrumentalToken = urlParams.get('instrumentalToken');
753
-
753
+
754
+ // Check localStorage for full auth token (logged-in users)
755
+ // This takes priority over instrumentalToken which can expire
756
+ const fullAuthToken = localStorage.getItem('karaoke_access_token');
757
+
754
758
  // Determine API base URL - cloud mode uses provided URL, local mode uses default
755
- const API_BASE = encodedBaseApiUrl
759
+ const API_BASE = encodedBaseApiUrl
756
760
  ? decodeURIComponent(encodedBaseApiUrl)
757
761
  : '/api/jobs/local';
758
-
759
- // Helper to add token to URL if available
762
+
763
+ // Helper to add auth token to URL
764
+ // Priority: fullAuthToken (doesn't expire) > instrumentalToken (expires after 48h)
765
+ // This is essential for audio elements which can't use Bearer headers
760
766
  function addTokenToUrl(url) {
767
+ // Prefer full auth token - it doesn't have the short expiry that magic link tokens have
768
+ if (fullAuthToken) {
769
+ const separator = url.includes('?') ? '&' : '?';
770
+ return `${url}${separator}token=${encodeURIComponent(fullAuthToken)}`;
771
+ }
772
+ // Fall back to instrumental token for non-logged-in users
761
773
  if (!instrumentalToken) return url;
762
774
  const separator = url.includes('?') ? '&' : '?';
763
775
  return `${url}${separator}instrumental_token=${encodeURIComponent(instrumentalToken)}`;
764
776
  }
777
+
778
+ // Auth-aware fetch helper
779
+ // Priority: fullAuthToken (Bearer header) > instrumentalToken (query param)
780
+ function authFetch(url, options = {}) {
781
+ if (fullAuthToken) {
782
+ // Use Bearer auth for logged-in users
783
+ const headers = new Headers(options.headers || {});
784
+ headers.set('Authorization', `Bearer ${fullAuthToken}`);
785
+ return fetch(url, { ...options, headers });
786
+ } else {
787
+ // Fall back to instrumental token in URL
788
+ return fetch(addTokenToUrl(url), options);
789
+ }
790
+ }
765
791
 
766
792
  // HTML escape helper to prevent XSS
767
793
  function escapeHtml(str) {
@@ -780,8 +806,8 @@
780
806
  async function init() {
781
807
  try {
782
808
  const [analysisRes, waveformRes] = await Promise.all([
783
- fetch(addTokenToUrl(`${API_BASE}/instrumental-analysis`)),
784
- fetch(addTokenToUrl(`${API_BASE}/waveform-data?num_points=1000`))
809
+ authFetch(`${API_BASE}/instrumental-analysis`),
810
+ authFetch(`${API_BASE}/waveform-data?num_points=1000`)
785
811
  ]);
786
812
 
787
813
  if (!analysisRes.ok) throw new Error('Failed to load analysis');
@@ -1484,7 +1510,7 @@
1484
1510
  const formData = new FormData();
1485
1511
  formData.append('file', file);
1486
1512
 
1487
- const response = await fetch(addTokenToUrl(`${API_BASE}/upload-instrumental`), {
1513
+ const response = await authFetch(`${API_BASE}/upload-instrumental`, {
1488
1514
  method: 'POST',
1489
1515
  body: formData
1490
1516
  });
@@ -1543,7 +1569,7 @@
1543
1569
  }
1544
1570
 
1545
1571
  try {
1546
- const response = await fetch(addTokenToUrl(`${API_BASE}/create-custom-instrumental`), {
1572
+ const response = await authFetch(`${API_BASE}/create-custom-instrumental`, {
1547
1573
  method: 'POST',
1548
1574
  headers: { 'Content-Type': 'application/json' },
1549
1575
  body: JSON.stringify({ mute_regions: muteRegions })
@@ -1593,7 +1619,7 @@
1593
1619
  }
1594
1620
 
1595
1621
  try {
1596
- const response = await fetch(addTokenToUrl(`${API_BASE}/select-instrumental`), {
1622
+ const response = await authFetch(`${API_BASE}/select-instrumental`, {
1597
1623
  method: 'POST',
1598
1624
  headers: { 'Content-Type': 'application/json' },
1599
1625
  body: JSON.stringify({ selection: selectedOption })