karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__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.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,418 @@
1
+ """
2
+ Unit tests for TenantService.
3
+
4
+ Tests config loading from GCS, caching behavior, subdomain resolution,
5
+ and email domain validation.
6
+ """
7
+
8
+ import pytest
9
+ from datetime import datetime, timedelta
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ from backend.services.tenant_service import TenantService, get_tenant_service
13
+ from backend.models.tenant import TenantConfig, TenantPublicConfig
14
+
15
+
16
+ # Sample tenant config data that would come from GCS
17
+ SAMPLE_VOCALSTAR_CONFIG = {
18
+ "id": "vocalstar",
19
+ "name": "Vocal Star",
20
+ "subdomain": "vocalstar.nomadkaraoke.com",
21
+ "is_active": True,
22
+ "branding": {
23
+ "logo_url": "https://example.com/logo.png",
24
+ "logo_height": 50,
25
+ "primary_color": "#ffff00",
26
+ "secondary_color": "#006CF9",
27
+ "site_title": "Vocal Star Karaoke Generator",
28
+ },
29
+ "features": {
30
+ "audio_search": False,
31
+ "file_upload": True,
32
+ "youtube_url": False,
33
+ "youtube_upload": False,
34
+ "dropbox_upload": False,
35
+ "gdrive_upload": False,
36
+ "theme_selection": False,
37
+ },
38
+ "defaults": {
39
+ "theme_id": "vocalstar",
40
+ "locked_theme": "vocalstar",
41
+ "distribution_mode": "download_only",
42
+ },
43
+ "auth": {
44
+ "allowed_email_domains": ["vocal-star.com", "vocalstarmusic.com"],
45
+ "require_email_domain": True,
46
+ "sender_email": "vocalstar@nomadkaraoke.com",
47
+ },
48
+ }
49
+
50
+ SAMPLE_INACTIVE_CONFIG = {
51
+ "id": "inactive",
52
+ "name": "Inactive Tenant",
53
+ "subdomain": "inactive.nomadkaraoke.com",
54
+ "is_active": False,
55
+ "branding": {},
56
+ "features": {},
57
+ "defaults": {},
58
+ "auth": {},
59
+ }
60
+
61
+
62
+ class TestTenantService:
63
+ """Tests for TenantService class."""
64
+
65
+ @pytest.fixture
66
+ def mock_storage(self):
67
+ """Create a mock StorageService."""
68
+ storage = MagicMock()
69
+ storage.file_exists.return_value = False
70
+ storage.download_json.return_value = {}
71
+ storage.generate_signed_url.return_value = "https://signed-url.example.com"
72
+ return storage
73
+
74
+ @pytest.fixture
75
+ def tenant_service(self, mock_storage):
76
+ """Create a TenantService with mock storage."""
77
+ return TenantService(storage=mock_storage)
78
+
79
+ # Tests for get_tenant_config()
80
+ def test_get_tenant_config_success(self, tenant_service, mock_storage):
81
+ """Test successful config loading from GCS."""
82
+ mock_storage.file_exists.return_value = True
83
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
84
+
85
+ config = tenant_service.get_tenant_config("vocalstar")
86
+
87
+ assert config is not None
88
+ assert config.id == "vocalstar"
89
+ assert config.name == "Vocal Star"
90
+ assert config.features.audio_search is False
91
+ mock_storage.download_json.assert_called_once_with("tenants/vocalstar/config.json")
92
+
93
+ def test_get_tenant_config_not_found(self, tenant_service, mock_storage):
94
+ """Test returns None when tenant config doesn't exist."""
95
+ mock_storage.file_exists.return_value = False
96
+
97
+ config = tenant_service.get_tenant_config("nonexistent")
98
+
99
+ assert config is None
100
+ mock_storage.download_json.assert_not_called()
101
+
102
+ def test_get_tenant_config_gcs_error(self, tenant_service, mock_storage):
103
+ """Test handles GCS errors gracefully."""
104
+ mock_storage.file_exists.return_value = True
105
+ mock_storage.download_json.side_effect = Exception("GCS error")
106
+
107
+ config = tenant_service.get_tenant_config("vocalstar")
108
+
109
+ assert config is None
110
+
111
+ # Tests for caching behavior
112
+ def test_config_is_cached(self, tenant_service, mock_storage):
113
+ """Test that configs are cached after first load."""
114
+ mock_storage.file_exists.return_value = True
115
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
116
+
117
+ # First call loads from GCS
118
+ config1 = tenant_service.get_tenant_config("vocalstar")
119
+ # Second call should use cache
120
+ config2 = tenant_service.get_tenant_config("vocalstar")
121
+
122
+ assert config1 is config2
123
+ # download_json should only be called once
124
+ assert mock_storage.download_json.call_count == 1
125
+
126
+ def test_force_refresh_bypasses_cache(self, tenant_service, mock_storage):
127
+ """Test that force_refresh reloads from GCS."""
128
+ mock_storage.file_exists.return_value = True
129
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
130
+
131
+ # First call
132
+ tenant_service.get_tenant_config("vocalstar")
133
+ # Force refresh
134
+ tenant_service.get_tenant_config("vocalstar", force_refresh=True)
135
+
136
+ # download_json should be called twice
137
+ assert mock_storage.download_json.call_count == 2
138
+
139
+ def test_cache_expires_after_ttl(self, tenant_service, mock_storage):
140
+ """Test that cache expires after TTL."""
141
+ mock_storage.file_exists.return_value = True
142
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
143
+
144
+ # Load config
145
+ tenant_service.get_tenant_config("vocalstar")
146
+
147
+ # Simulate cache expiration by manipulating cache time
148
+ tenant_service._cache_times["vocalstar"] = datetime.now() - timedelta(seconds=400)
149
+
150
+ # Next call should reload
151
+ tenant_service.get_tenant_config("vocalstar")
152
+
153
+ assert mock_storage.download_json.call_count == 2
154
+
155
+ # Tests for get_tenant_by_subdomain()
156
+ def test_get_tenant_by_subdomain_cached(self, tenant_service, mock_storage):
157
+ """Test subdomain lookup uses cache."""
158
+ mock_storage.file_exists.return_value = True
159
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
160
+
161
+ # First load by ID to populate cache
162
+ tenant_service.get_tenant_config("vocalstar")
163
+
164
+ # Now lookup by subdomain should use cached mapping
165
+ config = tenant_service.get_tenant_by_subdomain("vocalstar.nomadkaraoke.com")
166
+
167
+ assert config is not None
168
+ assert config.id == "vocalstar"
169
+
170
+ def test_get_tenant_by_subdomain_fallback_resolution(self, tenant_service, mock_storage):
171
+ """Test subdomain resolution when not in cache."""
172
+ mock_storage.file_exists.return_value = True
173
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
174
+
175
+ # Lookup by subdomain without prior cache
176
+ config = tenant_service.get_tenant_by_subdomain("vocalstar.nomadkaraoke.com")
177
+
178
+ assert config is not None
179
+ assert config.id == "vocalstar"
180
+
181
+ def test_get_tenant_by_subdomain_not_found(self, tenant_service, mock_storage):
182
+ """Test returns None for unknown subdomain."""
183
+ mock_storage.file_exists.return_value = False
184
+
185
+ config = tenant_service.get_tenant_by_subdomain("unknown.nomadkaraoke.com")
186
+
187
+ assert config is None
188
+
189
+ def test_get_tenant_by_subdomain_case_insensitive(self, tenant_service, mock_storage):
190
+ """Test subdomain lookup is case insensitive."""
191
+ mock_storage.file_exists.return_value = True
192
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
193
+
194
+ config = tenant_service.get_tenant_by_subdomain("VOCALSTAR.NomadKaraoke.COM")
195
+
196
+ assert config is not None
197
+ assert config.id == "vocalstar"
198
+
199
+ # Tests for _resolve_subdomain_to_tenant_id()
200
+ def test_resolve_subdomain_3_parts(self, tenant_service, mock_storage):
201
+ """Test resolving tenant.nomadkaraoke.com pattern."""
202
+ mock_storage.file_exists.return_value = True
203
+
204
+ tenant_id = tenant_service._resolve_subdomain_to_tenant_id("vocalstar.nomadkaraoke.com")
205
+
206
+ assert tenant_id == "vocalstar"
207
+
208
+ def test_resolve_subdomain_4_parts(self, tenant_service, mock_storage):
209
+ """Test resolving tenant.gen.nomadkaraoke.com pattern."""
210
+ mock_storage.file_exists.return_value = True
211
+
212
+ tenant_id = tenant_service._resolve_subdomain_to_tenant_id("vocalstar.gen.nomadkaraoke.com")
213
+
214
+ assert tenant_id == "vocalstar"
215
+
216
+ def test_resolve_subdomain_too_short(self, tenant_service, mock_storage):
217
+ """Test returns None for subdomains with fewer than 3 parts."""
218
+ tenant_id = tenant_service._resolve_subdomain_to_tenant_id("nomadkaraoke.com")
219
+
220
+ assert tenant_id is None
221
+
222
+ def test_resolve_subdomain_tenant_not_exists(self, tenant_service, mock_storage):
223
+ """Test returns None when tenant config doesn't exist in GCS."""
224
+ mock_storage.file_exists.return_value = False
225
+
226
+ tenant_id = tenant_service._resolve_subdomain_to_tenant_id("nonexistent.nomadkaraoke.com")
227
+
228
+ assert tenant_id is None
229
+
230
+ # Tests for get_public_config()
231
+ def test_get_public_config_success(self, tenant_service, mock_storage):
232
+ """Test getting public config."""
233
+ mock_storage.file_exists.return_value = True
234
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
235
+
236
+ public_config = tenant_service.get_public_config("vocalstar")
237
+
238
+ assert public_config is not None
239
+ assert isinstance(public_config, TenantPublicConfig)
240
+ assert public_config.id == "vocalstar"
241
+
242
+ def test_get_public_config_not_found(self, tenant_service, mock_storage):
243
+ """Test returns None when tenant not found."""
244
+ mock_storage.file_exists.return_value = False
245
+
246
+ public_config = tenant_service.get_public_config("nonexistent")
247
+
248
+ assert public_config is None
249
+
250
+ def test_get_public_config_by_subdomain(self, tenant_service, mock_storage):
251
+ """Test getting public config by subdomain."""
252
+ mock_storage.file_exists.return_value = True
253
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
254
+
255
+ public_config = tenant_service.get_public_config_by_subdomain("vocalstar.nomadkaraoke.com")
256
+
257
+ assert public_config is not None
258
+ assert public_config.id == "vocalstar"
259
+
260
+ # Tests for tenant_exists()
261
+ def test_tenant_exists_true(self, tenant_service, mock_storage):
262
+ """Test returns True when config exists."""
263
+ mock_storage.file_exists.return_value = True
264
+
265
+ assert tenant_service.tenant_exists("vocalstar") is True
266
+ mock_storage.file_exists.assert_called_with("tenants/vocalstar/config.json")
267
+
268
+ def test_tenant_exists_false(self, tenant_service, mock_storage):
269
+ """Test returns False when config doesn't exist."""
270
+ mock_storage.file_exists.return_value = False
271
+
272
+ assert tenant_service.tenant_exists("nonexistent") is False
273
+
274
+ # Tests for is_email_allowed_for_tenant()
275
+ def test_is_email_allowed_for_tenant_valid(self, tenant_service, mock_storage):
276
+ """Test email allowed for valid domain."""
277
+ mock_storage.file_exists.return_value = True
278
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
279
+
280
+ assert tenant_service.is_email_allowed_for_tenant("vocalstar", "user@vocal-star.com") is True
281
+
282
+ def test_is_email_allowed_for_tenant_invalid(self, tenant_service, mock_storage):
283
+ """Test email not allowed for invalid domain."""
284
+ mock_storage.file_exists.return_value = True
285
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
286
+
287
+ assert tenant_service.is_email_allowed_for_tenant("vocalstar", "user@gmail.com") is False
288
+
289
+ def test_is_email_allowed_for_tenant_not_found(self, tenant_service, mock_storage):
290
+ """Test returns False when tenant not found."""
291
+ mock_storage.file_exists.return_value = False
292
+
293
+ assert tenant_service.is_email_allowed_for_tenant("nonexistent", "user@any.com") is False
294
+
295
+ # Tests for get_tenant_sender_email()
296
+ def test_get_tenant_sender_email_custom(self, tenant_service, mock_storage):
297
+ """Test returns custom sender email when configured."""
298
+ mock_storage.file_exists.return_value = True
299
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
300
+
301
+ sender = tenant_service.get_tenant_sender_email("vocalstar")
302
+
303
+ assert sender == "vocalstar@nomadkaraoke.com"
304
+
305
+ def test_get_tenant_sender_email_default(self, tenant_service, mock_storage):
306
+ """Test returns default sender when tenant not found."""
307
+ mock_storage.file_exists.return_value = False
308
+
309
+ sender = tenant_service.get_tenant_sender_email("nonexistent")
310
+
311
+ # Should match DEFAULT_SENDER_EMAIL constant (consistent with EmailService)
312
+ assert sender == "gen@nomadkaraoke.com"
313
+
314
+ # Tests for invalidate_cache()
315
+ def test_invalidate_cache_specific_tenant(self, tenant_service, mock_storage):
316
+ """Test invalidating cache for specific tenant."""
317
+ mock_storage.file_exists.return_value = True
318
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
319
+
320
+ # Load into cache
321
+ tenant_service.get_tenant_config("vocalstar")
322
+ assert "vocalstar" in tenant_service._config_cache
323
+
324
+ # Invalidate
325
+ tenant_service.invalidate_cache("vocalstar")
326
+
327
+ assert "vocalstar" not in tenant_service._config_cache
328
+ assert "vocalstar" not in tenant_service._cache_times
329
+
330
+ def test_invalidate_cache_specific_tenant_clears_subdomain_map(self, tenant_service, mock_storage):
331
+ """Test invalidating specific tenant also clears its subdomain map entry."""
332
+ mock_storage.file_exists.return_value = True
333
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
334
+
335
+ # Load into cache (this also populates subdomain map)
336
+ tenant_service.get_tenant_config("vocalstar")
337
+ assert "vocalstar.nomadkaraoke.com" in tenant_service._subdomain_map
338
+ assert tenant_service._subdomain_map["vocalstar.nomadkaraoke.com"] == "vocalstar"
339
+
340
+ # Invalidate specific tenant
341
+ tenant_service.invalidate_cache("vocalstar")
342
+
343
+ # Subdomain map entry should also be removed
344
+ assert "vocalstar.nomadkaraoke.com" not in tenant_service._subdomain_map
345
+
346
+ def test_invalidate_cache_all(self, tenant_service, mock_storage):
347
+ """Test invalidating all caches."""
348
+ mock_storage.file_exists.return_value = True
349
+ mock_storage.download_json.return_value = SAMPLE_VOCALSTAR_CONFIG
350
+
351
+ # Load into cache
352
+ tenant_service.get_tenant_config("vocalstar")
353
+
354
+ # Invalidate all
355
+ tenant_service.invalidate_cache()
356
+
357
+ assert len(tenant_service._config_cache) == 0
358
+ assert len(tenant_service._cache_times) == 0
359
+ assert len(tenant_service._subdomain_map) == 0
360
+
361
+ # Tests for get_asset_url()
362
+ def test_get_asset_url_success(self, tenant_service, mock_storage):
363
+ """Test getting signed URL for existing asset."""
364
+ mock_storage.file_exists.return_value = True
365
+
366
+ url = tenant_service.get_asset_url("vocalstar", "logo.png")
367
+
368
+ assert url == "https://signed-url.example.com"
369
+ mock_storage.file_exists.assert_called_with("tenants/vocalstar/logo.png")
370
+ mock_storage.generate_signed_url.assert_called_with("tenants/vocalstar/logo.png", expiration_minutes=60)
371
+
372
+ def test_get_asset_url_not_found(self, tenant_service, mock_storage):
373
+ """Test returns None when asset doesn't exist."""
374
+ mock_storage.file_exists.return_value = False
375
+
376
+ url = tenant_service.get_asset_url("vocalstar", "missing.png")
377
+
378
+ assert url is None
379
+ mock_storage.generate_signed_url.assert_not_called()
380
+
381
+
382
+ class TestGetTenantServiceSingleton:
383
+ """Tests for the get_tenant_service() singleton function."""
384
+
385
+ def test_returns_same_instance(self):
386
+ """Test that get_tenant_service returns singleton."""
387
+ # Reset the singleton
388
+ import backend.services.tenant_service as module
389
+ module._tenant_service = None
390
+
391
+ with patch.object(TenantService, '__init__', return_value=None):
392
+ service1 = get_tenant_service()
393
+ service2 = get_tenant_service()
394
+
395
+ assert service1 is service2
396
+
397
+ def test_thread_safe_initialization(self):
398
+ """Test that singleton initialization is thread-safe."""
399
+ import threading
400
+ import backend.services.tenant_service as module
401
+
402
+ # Reset the singleton
403
+ module._tenant_service = None
404
+
405
+ instances = []
406
+
407
+ def get_instance():
408
+ with patch.object(TenantService, '__init__', return_value=None):
409
+ instances.append(get_tenant_service())
410
+
411
+ threads = [threading.Thread(target=get_instance) for _ in range(10)]
412
+ for t in threads:
413
+ t.start()
414
+ for t in threads:
415
+ t.join()
416
+
417
+ # All instances should be the same
418
+ assert all(i is instances[0] for i in instances)
@@ -0,0 +1,27 @@
1
+ """
2
+ Utilities for identifying test data.
3
+
4
+ Test data is generated by automated E2E tests and should be filterable
5
+ in admin dashboards to show only real user data by default.
6
+ """
7
+
8
+ # Email domains used by automated testing frameworks
9
+ TEST_EMAIL_DOMAINS = [
10
+ "inbox.testmail.app", # Used by E2E happy path tests
11
+ ]
12
+
13
+
14
+ def is_test_email(email: str) -> bool:
15
+ """
16
+ Check if an email address belongs to automated test data.
17
+
18
+ Args:
19
+ email: Email address to check
20
+
21
+ Returns:
22
+ True if the email matches a test email domain pattern
23
+ """
24
+ if not email:
25
+ return False
26
+ email_lower = email.lower()
27
+ return any(email_lower.endswith(f"@{domain}") for domain in TEST_EMAIL_DOMAINS)
@@ -218,26 +218,36 @@ async def generate_screens(job_id: str) -> bool:
218
218
  def _validate_prerequisites(job) -> bool:
219
219
  """
220
220
  Validate that both audio and lyrics processing are complete.
221
-
221
+
222
222
  Single Responsibility: Validation logic separated from main flow.
223
-
223
+
224
224
  Args:
225
225
  job: Job object
226
-
226
+
227
227
  Returns:
228
228
  True if prerequisites met, False otherwise
229
229
  """
230
+ # SAFETY NET: Enforce theme requirement at processing time
231
+ # This catches any jobs that somehow bypassed JobManager.create_job() validation
232
+ if not job.theme_id:
233
+ logger.error(
234
+ f"Job {job.job_id}: CRITICAL - No theme_id configured. "
235
+ "All jobs must have a theme to generate styled videos. "
236
+ "This job should have been rejected at creation time."
237
+ )
238
+ return False
239
+
230
240
  audio_complete = job.state_data.get('audio_complete', False)
231
241
  lyrics_complete = job.state_data.get('lyrics_complete', False)
232
-
242
+
233
243
  if not audio_complete:
234
244
  logger.error(f"Job {job.job_id}: Audio processing not complete")
235
245
  return False
236
-
246
+
237
247
  if not lyrics_complete:
238
248
  logger.error(f"Job {job.job_id}: Lyrics processing not complete")
239
249
  return False
240
-
250
+
241
251
  if not job.artist or not job.title:
242
252
  logger.error(f"Job {job.job_id}: Missing artist or title")
243
253
  return False
@@ -831,9 +831,12 @@ async def _handle_native_distribution(
831
831
  # Don't fail the job - distribution is optional
832
832
 
833
833
  # Upload to Google Drive using native API
834
+ # Skip if orchestrator already uploaded (gdrive_files already populated)
835
+ # This prevents duplicate uploads when using the orchestrator path
834
836
  gdrive_folder_id = getattr(job, 'gdrive_folder_id', None)
835
-
836
- if gdrive_folder_id:
837
+ existing_gdrive_files = result.get('gdrive_files')
838
+
839
+ if gdrive_folder_id and not existing_gdrive_files:
837
840
  try:
838
841
  from backend.services.gdrive_service import get_gdrive_service
839
842
 
@@ -869,7 +872,9 @@ async def _handle_native_distribution(
869
872
  except Exception as e:
870
873
  job_log.error(f"Native Google Drive upload failed: {e}", exc_info=True)
871
874
  # Don't fail the job - distribution is optional
872
-
875
+ elif existing_gdrive_files:
876
+ job_log.info(f"Skipping Google Drive upload - orchestrator already uploaded {len(existing_gdrive_files)} files")
877
+
873
878
  # Update job state_data with brand code and links
874
879
  if brand_code or result.get('dropbox_link') or result.get('gdrive_files'):
875
880
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: karaoke-gen
3
- Version: 0.96.0
3
+ Version: 0.101.0
4
4
  Summary: Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens.
5
5
  License: MIT
6
6
  License-File: LICENSE