karaoke-gen 0.90.1__py3-none-any.whl → 0.96.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 (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,847 @@
1
+ """
2
+ Tests for Video Worker Orchestrator.
3
+
4
+ Tests cover:
5
+ - OrchestratorConfig and OrchestratorResult dataclasses
6
+ - VideoWorkerOrchestrator initialization
7
+ - Individual stage methods
8
+ - Full pipeline execution
9
+ - Error handling and recovery
10
+ """
11
+
12
+ import os
13
+ import tempfile
14
+ import pytest
15
+ from unittest.mock import MagicMock, patch, AsyncMock
16
+ from dataclasses import asdict
17
+
18
+ from backend.workers.video_worker_orchestrator import (
19
+ OrchestratorConfig,
20
+ OrchestratorResult,
21
+ VideoWorkerOrchestrator,
22
+ create_orchestrator_config_from_job,
23
+ )
24
+
25
+
26
+ class TestOrchestratorConfig:
27
+ """Test OrchestratorConfig dataclass."""
28
+
29
+ def test_required_fields(self):
30
+ """Test that required fields must be provided."""
31
+ config = OrchestratorConfig(
32
+ job_id="test-job",
33
+ artist="Test Artist",
34
+ title="Test Title",
35
+ title_video_path="/path/title.mov",
36
+ karaoke_video_path="/path/karaoke.mov",
37
+ instrumental_audio_path="/path/audio.flac",
38
+ )
39
+ assert config.job_id == "test-job"
40
+ assert config.artist == "Test Artist"
41
+ assert config.encoding_backend == "auto"
42
+
43
+ def test_default_values(self):
44
+ """Test default values for optional fields."""
45
+ config = OrchestratorConfig(
46
+ job_id="test-job",
47
+ artist="Test Artist",
48
+ title="Test Title",
49
+ title_video_path="/path/title.mov",
50
+ karaoke_video_path="/path/karaoke.mov",
51
+ instrumental_audio_path="/path/audio.flac",
52
+ )
53
+ assert config.enable_cdg is False
54
+ assert config.enable_txt is False
55
+ assert config.enable_youtube_upload is False
56
+ assert config.dry_run is False
57
+ assert config.non_interactive is True
58
+ assert config.end_video_path is None
59
+
60
+ def test_all_fields(self):
61
+ """Test all fields including optional ones."""
62
+ config = OrchestratorConfig(
63
+ job_id="test-job",
64
+ artist="Test Artist",
65
+ title="Test Title",
66
+ title_video_path="/path/title.mov",
67
+ karaoke_video_path="/path/karaoke.mov",
68
+ instrumental_audio_path="/path/audio.flac",
69
+ end_video_path="/path/end.mov",
70
+ enable_cdg=True,
71
+ enable_txt=True,
72
+ enable_youtube_upload=True,
73
+ brand_prefix="TEST",
74
+ discord_webhook_url="https://discord.com/api/webhooks/123/abc",
75
+ encoding_backend="gce",
76
+ )
77
+ assert config.enable_cdg is True
78
+ assert config.encoding_backend == "gce"
79
+ assert config.discord_webhook_url is not None
80
+
81
+
82
+ class TestOrchestratorResult:
83
+ """Test OrchestratorResult dataclass."""
84
+
85
+ def test_success_result(self):
86
+ """Test successful result."""
87
+ result = OrchestratorResult(
88
+ success=True,
89
+ final_video="/output/video.mp4",
90
+ brand_code="TEST-1234",
91
+ youtube_url="https://youtube.com/watch?v=test",
92
+ )
93
+ assert result.success is True
94
+ assert result.error_message is None
95
+ assert result.brand_code == "TEST-1234"
96
+
97
+ def test_failure_result(self):
98
+ """Test failure result."""
99
+ result = OrchestratorResult(
100
+ success=False,
101
+ error_message="Encoding failed",
102
+ )
103
+ assert result.success is False
104
+ assert result.error_message == "Encoding failed"
105
+ assert result.final_video is None
106
+
107
+ def test_all_output_files(self):
108
+ """Test all output file fields."""
109
+ result = OrchestratorResult(
110
+ success=True,
111
+ final_video="/output/lossless.mp4",
112
+ final_video_mkv="/output/lossless.mkv",
113
+ final_video_lossy="/output/lossy.mp4",
114
+ final_video_720p="/output/720p.mp4",
115
+ final_karaoke_cdg_zip="/output/cdg.zip",
116
+ final_karaoke_txt_zip="/output/txt.zip",
117
+ )
118
+ assert result.final_video == "/output/lossless.mp4"
119
+ assert result.final_video_mkv == "/output/lossless.mkv"
120
+
121
+
122
+ class TestVideoWorkerOrchestratorInit:
123
+ """Test VideoWorkerOrchestrator initialization."""
124
+
125
+ def test_init_with_config(self):
126
+ """Test initialization with config."""
127
+ config = OrchestratorConfig(
128
+ job_id="test-job",
129
+ artist="Test Artist",
130
+ title="Test Title",
131
+ title_video_path="/path/title.mov",
132
+ karaoke_video_path="/path/karaoke.mov",
133
+ instrumental_audio_path="/path/audio.flac",
134
+ )
135
+ orchestrator = VideoWorkerOrchestrator(config)
136
+
137
+ assert orchestrator.config == config
138
+ assert orchestrator.result.success is False
139
+
140
+ def test_init_with_job_manager(self):
141
+ """Test initialization with job manager."""
142
+ config = OrchestratorConfig(
143
+ job_id="test-job",
144
+ artist="Test Artist",
145
+ title="Test Title",
146
+ title_video_path="/path/title.mov",
147
+ karaoke_video_path="/path/karaoke.mov",
148
+ instrumental_audio_path="/path/audio.flac",
149
+ )
150
+ job_manager = MagicMock()
151
+ orchestrator = VideoWorkerOrchestrator(config, job_manager=job_manager)
152
+
153
+ assert orchestrator.job_manager == job_manager
154
+
155
+
156
+ class TestVideoWorkerOrchestratorServices:
157
+ """Test service lazy-loading."""
158
+
159
+ def test_get_encoding_backend(self):
160
+ """Test encoding backend lazy loading."""
161
+ config = OrchestratorConfig(
162
+ job_id="test-job",
163
+ artist="Test Artist",
164
+ title="Test Title",
165
+ title_video_path="/path/title.mov",
166
+ karaoke_video_path="/path/karaoke.mov",
167
+ instrumental_audio_path="/path/audio.flac",
168
+ encoding_backend="local",
169
+ )
170
+ orchestrator = VideoWorkerOrchestrator(config)
171
+
172
+ with patch("backend.services.encoding_interface.get_encoding_backend") as mock_get:
173
+ mock_backend = MagicMock()
174
+ mock_backend.name = "local"
175
+ mock_get.return_value = mock_backend
176
+
177
+ backend = orchestrator._get_encoding_backend()
178
+
179
+ assert backend == mock_backend
180
+ mock_get.assert_called_once()
181
+
182
+ def test_get_packaging_service(self):
183
+ """Test packaging service lazy loading."""
184
+ config = OrchestratorConfig(
185
+ job_id="test-job",
186
+ artist="Test Artist",
187
+ title="Test Title",
188
+ title_video_path="/path/title.mov",
189
+ karaoke_video_path="/path/karaoke.mov",
190
+ instrumental_audio_path="/path/audio.flac",
191
+ cdg_styles={"background_color": "black"},
192
+ )
193
+ orchestrator = VideoWorkerOrchestrator(config)
194
+
195
+ with patch("backend.services.packaging_service.PackagingService") as MockService:
196
+ mock_service = MagicMock()
197
+ MockService.return_value = mock_service
198
+
199
+ service = orchestrator._get_packaging_service()
200
+
201
+ assert service == mock_service
202
+ MockService.assert_called_once()
203
+
204
+ def test_get_youtube_service(self):
205
+ """Test YouTube service lazy loading."""
206
+ config = OrchestratorConfig(
207
+ job_id="test-job",
208
+ artist="Test Artist",
209
+ title="Test Title",
210
+ title_video_path="/path/title.mov",
211
+ karaoke_video_path="/path/karaoke.mov",
212
+ instrumental_audio_path="/path/audio.flac",
213
+ youtube_credentials={"token": "test"},
214
+ )
215
+ orchestrator = VideoWorkerOrchestrator(config)
216
+
217
+ with patch("backend.services.youtube_upload_service.YouTubeUploadService") as MockService:
218
+ mock_service = MagicMock()
219
+ MockService.return_value = mock_service
220
+
221
+ service = orchestrator._get_youtube_service()
222
+
223
+ assert service == mock_service
224
+ MockService.assert_called_once()
225
+
226
+ def test_get_discord_service(self):
227
+ """Test Discord service lazy loading."""
228
+ config = OrchestratorConfig(
229
+ job_id="test-job",
230
+ artist="Test Artist",
231
+ title="Test Title",
232
+ title_video_path="/path/title.mov",
233
+ karaoke_video_path="/path/karaoke.mov",
234
+ instrumental_audio_path="/path/audio.flac",
235
+ discord_webhook_url="https://discord.com/api/webhooks/123/abc",
236
+ )
237
+ orchestrator = VideoWorkerOrchestrator(config)
238
+
239
+ with patch("backend.services.discord_service.DiscordNotificationService") as MockService:
240
+ mock_service = MagicMock()
241
+ MockService.return_value = mock_service
242
+
243
+ service = orchestrator._get_discord_service()
244
+
245
+ assert service == mock_service
246
+ MockService.assert_called_once()
247
+
248
+
249
+ class TestVideoWorkerOrchestratorPackaging:
250
+ """Test packaging stage."""
251
+
252
+ @pytest.mark.asyncio
253
+ async def test_run_packaging_no_lrc(self):
254
+ """Test packaging stage with no LRC file."""
255
+ config = OrchestratorConfig(
256
+ job_id="test-job",
257
+ artist="Test Artist",
258
+ title="Test Title",
259
+ title_video_path="/path/title.mov",
260
+ karaoke_video_path="/path/karaoke.mov",
261
+ instrumental_audio_path="/path/audio.flac",
262
+ enable_cdg=True,
263
+ lrc_file_path=None,
264
+ )
265
+ orchestrator = VideoWorkerOrchestrator(config)
266
+
267
+ # Should not raise, just skip
268
+ await orchestrator._run_packaging()
269
+
270
+ assert orchestrator.result.final_karaoke_cdg_zip is None
271
+
272
+ @pytest.mark.asyncio
273
+ async def test_run_packaging_cdg(self):
274
+ """Test CDG packaging."""
275
+ with tempfile.TemporaryDirectory() as temp_dir:
276
+ lrc_file = os.path.join(temp_dir, "test.lrc")
277
+ audio_file = os.path.join(temp_dir, "test.flac")
278
+
279
+ # Create dummy files
280
+ with open(lrc_file, "w") as f:
281
+ f.write("[00:00.00]Test lyrics")
282
+ with open(audio_file, "w") as f:
283
+ f.write("dummy audio")
284
+
285
+ config = OrchestratorConfig(
286
+ job_id="test-job",
287
+ artist="Test Artist",
288
+ title="Test Title",
289
+ title_video_path="/path/title.mov",
290
+ karaoke_video_path="/path/karaoke.mov",
291
+ instrumental_audio_path=audio_file,
292
+ lrc_file_path=lrc_file,
293
+ output_dir=temp_dir,
294
+ enable_cdg=True,
295
+ cdg_styles={"background_color": "black"},
296
+ )
297
+ orchestrator = VideoWorkerOrchestrator(config)
298
+
299
+ with patch.object(orchestrator, "_get_packaging_service") as mock_get:
300
+ mock_service = MagicMock()
301
+ mock_service.create_cdg_package.return_value = (
302
+ f"{temp_dir}/cdg.zip",
303
+ f"{temp_dir}/test.mp3",
304
+ f"{temp_dir}/test.cdg",
305
+ )
306
+ mock_get.return_value = mock_service
307
+
308
+ await orchestrator._run_packaging()
309
+
310
+ mock_service.create_cdg_package.assert_called_once()
311
+ assert orchestrator.result.final_karaoke_cdg_zip == f"{temp_dir}/cdg.zip"
312
+
313
+
314
+ class TestVideoWorkerOrchestratorEncoding:
315
+ """Test encoding stage."""
316
+
317
+ @pytest.mark.asyncio
318
+ async def test_run_encoding_success(self):
319
+ """Test successful encoding."""
320
+ config = OrchestratorConfig(
321
+ job_id="test-job",
322
+ artist="Test Artist",
323
+ title="Test Title",
324
+ title_video_path="/path/title.mov",
325
+ karaoke_video_path="/path/karaoke.mov",
326
+ instrumental_audio_path="/path/audio.flac",
327
+ output_dir="/output",
328
+ )
329
+ orchestrator = VideoWorkerOrchestrator(config)
330
+
331
+ with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
332
+ from backend.services.encoding_interface import EncodingOutput
333
+
334
+ mock_backend = MagicMock()
335
+ mock_backend.name = "local"
336
+ mock_backend.encode = AsyncMock(return_value=EncodingOutput(
337
+ success=True,
338
+ lossless_4k_mp4_path="/output/lossless.mp4",
339
+ lossy_4k_mp4_path="/output/lossy.mp4",
340
+ lossy_720p_mp4_path="/output/720p.mp4",
341
+ lossless_mkv_path="/output/lossless.mkv",
342
+ encoding_time_seconds=120.5,
343
+ encoding_backend="local",
344
+ ))
345
+ mock_get.return_value = mock_backend
346
+
347
+ await orchestrator._run_encoding()
348
+
349
+ mock_backend.encode.assert_called_once()
350
+ assert orchestrator.result.final_video == "/output/lossless.mp4"
351
+ assert orchestrator.result.final_video_720p == "/output/720p.mp4"
352
+ assert orchestrator.result.encoding_time_seconds == 120.5
353
+
354
+ def test_encoding_input_gcs_paths_pattern(self):
355
+ """Test that EncodingInput.options contains proper GCS paths structure.
356
+
357
+ This test verifies the GCS path construction pattern that GCE encoding
358
+ requires. It tests the structure without running _run_encoding().
359
+
360
+ This would have caught: 'GCE encoding requires input_gcs_path and
361
+ output_gcs_path in options' error when the orchestrator didn't pass
362
+ the required paths for GCE encoding.
363
+ """
364
+ from backend.services.encoding_interface import EncodingInput
365
+
366
+ # Test that EncodingInput can hold GCS paths in options
367
+ # This mirrors what the orchestrator should build
368
+ job_id = "test-job-123"
369
+ bucket = "test-bucket"
370
+ input_gcs_path = f"gs://{bucket}/jobs/{job_id}/"
371
+ output_gcs_path = f"gs://{bucket}/jobs/{job_id}/finals/"
372
+
373
+ encoding_input = EncodingInput(
374
+ title_video_path="/path/title.mov",
375
+ karaoke_video_path="/path/karaoke.mov",
376
+ instrumental_audio_path="/path/audio.flac",
377
+ artist="Test Artist",
378
+ title="Test Title",
379
+ brand_code="TEST-001",
380
+ output_dir="/output",
381
+ options={
382
+ "input_gcs_path": input_gcs_path,
383
+ "output_gcs_path": output_gcs_path,
384
+ },
385
+ )
386
+
387
+ # Verify the structure that GCEEncodingBackend expects
388
+ assert "input_gcs_path" in encoding_input.options, \
389
+ "EncodingInput.options must include input_gcs_path for GCE encoding"
390
+ assert "output_gcs_path" in encoding_input.options, \
391
+ "EncodingInput.options must include output_gcs_path for GCE encoding"
392
+ # Verify path format
393
+ assert encoding_input.options["input_gcs_path"].startswith("gs://")
394
+ assert encoding_input.options["output_gcs_path"].startswith("gs://")
395
+ assert job_id in encoding_input.options["input_gcs_path"]
396
+ assert job_id in encoding_input.options["output_gcs_path"]
397
+
398
+ @pytest.mark.asyncio
399
+ async def test_run_encoding_failure(self):
400
+ """Test encoding failure."""
401
+ config = OrchestratorConfig(
402
+ job_id="test-job",
403
+ artist="Test Artist",
404
+ title="Test Title",
405
+ title_video_path="/path/title.mov",
406
+ karaoke_video_path="/path/karaoke.mov",
407
+ instrumental_audio_path="/path/audio.flac",
408
+ )
409
+ orchestrator = VideoWorkerOrchestrator(config)
410
+
411
+ with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
412
+ from backend.services.encoding_interface import EncodingOutput
413
+
414
+ mock_backend = MagicMock()
415
+ mock_backend.name = "local"
416
+ mock_backend.encode = AsyncMock(return_value=EncodingOutput(
417
+ success=False,
418
+ error_message="FFmpeg failed",
419
+ encoding_backend="local",
420
+ ))
421
+ mock_get.return_value = mock_backend
422
+
423
+ with pytest.raises(Exception) as exc_info:
424
+ await orchestrator._run_encoding()
425
+
426
+ assert "Encoding failed" in str(exc_info.value)
427
+
428
+ @pytest.mark.asyncio
429
+ async def test_run_encoding_gce_downloads_files(self):
430
+ """Test that GCE encoding downloads files from GCS to local directory.
431
+
432
+ This test verifies the fix for YouTube upload failure when using GCE encoding.
433
+ GCE encoding returns GCS blob paths, which need to be downloaded locally
434
+ before YouTube upload can access them.
435
+ """
436
+ with tempfile.TemporaryDirectory() as temp_dir:
437
+ config = OrchestratorConfig(
438
+ job_id="test-job",
439
+ artist="Test Artist",
440
+ title="Test Title",
441
+ title_video_path=os.path.join(temp_dir, "title.mov"),
442
+ karaoke_video_path=os.path.join(temp_dir, "karaoke.mov"),
443
+ instrumental_audio_path=os.path.join(temp_dir, "audio.flac"),
444
+ output_dir=temp_dir,
445
+ )
446
+
447
+ # Mock storage service
448
+ mock_storage = MagicMock()
449
+ mock_storage.download_file = MagicMock()
450
+
451
+ orchestrator = VideoWorkerOrchestrator(
452
+ config,
453
+ storage=mock_storage,
454
+ )
455
+
456
+ with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
457
+ from backend.services.encoding_interface import EncodingOutput
458
+
459
+ # GCE backend returns GCS blob paths (not local paths)
460
+ mock_backend = MagicMock()
461
+ mock_backend.name = "gce" # Important: must be "gce" to trigger download
462
+ mock_backend.encode = AsyncMock(return_value=EncodingOutput(
463
+ success=True,
464
+ lossless_4k_mp4_path="jobs/test-job/finals/output_4k_lossless.mp4",
465
+ lossy_4k_mp4_path="jobs/test-job/finals/output_4k_lossy.mp4",
466
+ lossy_720p_mp4_path="jobs/test-job/finals/output_720p.mp4",
467
+ lossless_mkv_path="jobs/test-job/finals/output_4k.mkv",
468
+ encoding_time_seconds=60.0,
469
+ encoding_backend="gce",
470
+ ))
471
+ mock_get.return_value = mock_backend
472
+
473
+ await orchestrator._run_encoding()
474
+
475
+ # Verify download_file was called for each output file
476
+ assert mock_storage.download_file.call_count == 4
477
+
478
+ # Verify the result paths were updated to local paths
479
+ assert orchestrator.result.final_video.startswith(temp_dir)
480
+ assert orchestrator.result.final_video_mkv.startswith(temp_dir)
481
+ assert orchestrator.result.final_video_lossy.startswith(temp_dir)
482
+ assert orchestrator.result.final_video_720p.startswith(temp_dir)
483
+
484
+ @pytest.mark.asyncio
485
+ async def test_run_encoding_local_does_not_download(self):
486
+ """Test that local encoding does NOT trigger GCS download.
487
+
488
+ Local encoding produces files directly in the output directory,
489
+ so no download is needed.
490
+ """
491
+ with tempfile.TemporaryDirectory() as temp_dir:
492
+ config = OrchestratorConfig(
493
+ job_id="test-job",
494
+ artist="Test Artist",
495
+ title="Test Title",
496
+ title_video_path=os.path.join(temp_dir, "title.mov"),
497
+ karaoke_video_path=os.path.join(temp_dir, "karaoke.mov"),
498
+ instrumental_audio_path=os.path.join(temp_dir, "audio.flac"),
499
+ output_dir=temp_dir,
500
+ )
501
+
502
+ # Mock storage service
503
+ mock_storage = MagicMock()
504
+ mock_storage.download_file = MagicMock()
505
+
506
+ orchestrator = VideoWorkerOrchestrator(
507
+ config,
508
+ storage=mock_storage,
509
+ )
510
+
511
+ with patch.object(orchestrator, "_get_encoding_backend") as mock_get:
512
+ from backend.services.encoding_interface import EncodingOutput
513
+
514
+ # Local backend returns local paths directly
515
+ mock_backend = MagicMock()
516
+ mock_backend.name = "local" # Local backend
517
+ mock_backend.encode = AsyncMock(return_value=EncodingOutput(
518
+ success=True,
519
+ lossless_4k_mp4_path=os.path.join(temp_dir, "lossless.mp4"),
520
+ lossy_4k_mp4_path=os.path.join(temp_dir, "lossy.mp4"),
521
+ lossy_720p_mp4_path=os.path.join(temp_dir, "720p.mp4"),
522
+ lossless_mkv_path=os.path.join(temp_dir, "lossless.mkv"),
523
+ encoding_time_seconds=120.0,
524
+ encoding_backend="local",
525
+ ))
526
+ mock_get.return_value = mock_backend
527
+
528
+ await orchestrator._run_encoding()
529
+
530
+ # Verify download_file was NOT called for local encoding
531
+ mock_storage.download_file.assert_not_called()
532
+
533
+
534
+ class TestVideoWorkerOrchestratorOrganization:
535
+ """Test organization stage."""
536
+
537
+ @pytest.mark.asyncio
538
+ async def test_run_organization_keep_brand_code(self):
539
+ """Test organization with existing brand code."""
540
+ config = OrchestratorConfig(
541
+ job_id="test-job",
542
+ artist="Test Artist",
543
+ title="Test Title",
544
+ title_video_path="/path/title.mov",
545
+ karaoke_video_path="/path/karaoke.mov",
546
+ instrumental_audio_path="/path/audio.flac",
547
+ keep_brand_code="NOMAD-9999",
548
+ )
549
+ orchestrator = VideoWorkerOrchestrator(config)
550
+
551
+ await orchestrator._run_organization()
552
+
553
+ assert orchestrator.result.brand_code == "NOMAD-9999"
554
+
555
+ @pytest.mark.asyncio
556
+ async def test_run_organization_generate_brand_code(self):
557
+ """Test brand code generation from Dropbox."""
558
+ config = OrchestratorConfig(
559
+ job_id="test-job",
560
+ artist="Test Artist",
561
+ title="Test Title",
562
+ title_video_path="/path/title.mov",
563
+ karaoke_video_path="/path/karaoke.mov",
564
+ instrumental_audio_path="/path/audio.flac",
565
+ dropbox_path="/Karaoke/Tracks",
566
+ brand_prefix="TEST",
567
+ )
568
+ orchestrator = VideoWorkerOrchestrator(config)
569
+
570
+ with patch("backend.services.dropbox_service.get_dropbox_service") as mock_get:
571
+ mock_dropbox = MagicMock()
572
+ mock_dropbox.is_configured = True
573
+ mock_dropbox.get_next_brand_code.return_value = "TEST-0001"
574
+ mock_get.return_value = mock_dropbox
575
+
576
+ await orchestrator._run_organization()
577
+
578
+ assert orchestrator.result.brand_code == "TEST-0001"
579
+
580
+
581
+ class TestVideoWorkerOrchestratorDistribution:
582
+ """Test distribution stage."""
583
+
584
+ @pytest.mark.asyncio
585
+ async def test_upload_to_youtube(self):
586
+ """Test YouTube upload."""
587
+ with tempfile.TemporaryDirectory() as temp_dir:
588
+ video_file = os.path.join(temp_dir, "test.mp4")
589
+ with open(video_file, "w") as f:
590
+ f.write("dummy video")
591
+
592
+ config = OrchestratorConfig(
593
+ job_id="test-job",
594
+ artist="Test Artist",
595
+ title="Test Title",
596
+ title_video_path="/path/title.mov",
597
+ karaoke_video_path="/path/karaoke.mov",
598
+ instrumental_audio_path="/path/audio.flac",
599
+ enable_youtube_upload=True,
600
+ youtube_credentials={"token": "test"},
601
+ )
602
+ orchestrator = VideoWorkerOrchestrator(config)
603
+ orchestrator.result.final_video_lossy = video_file
604
+
605
+ with patch.object(orchestrator, "_get_youtube_service") as mock_get:
606
+ mock_service = MagicMock()
607
+ mock_service.upload_video.return_value = (
608
+ "video123",
609
+ "https://youtube.com/watch?v=video123"
610
+ )
611
+ mock_get.return_value = mock_service
612
+
613
+ await orchestrator._upload_to_youtube()
614
+
615
+ mock_service.upload_video.assert_called_once()
616
+ assert orchestrator.result.youtube_url == "https://youtube.com/watch?v=video123"
617
+
618
+ @pytest.mark.asyncio
619
+ async def test_upload_to_dropbox(self):
620
+ """Test Dropbox upload."""
621
+ with tempfile.TemporaryDirectory() as temp_dir:
622
+ config = OrchestratorConfig(
623
+ job_id="test-job",
624
+ artist="Test Artist",
625
+ title="Test Title",
626
+ title_video_path="/path/title.mov",
627
+ karaoke_video_path="/path/karaoke.mov",
628
+ instrumental_audio_path="/path/audio.flac",
629
+ output_dir=temp_dir,
630
+ dropbox_path="/Karaoke/Tracks",
631
+ brand_prefix="TEST",
632
+ )
633
+ orchestrator = VideoWorkerOrchestrator(config)
634
+ orchestrator.result.brand_code = "TEST-0001"
635
+
636
+ with patch("backend.services.dropbox_service.get_dropbox_service") as mock_get:
637
+ mock_dropbox = MagicMock()
638
+ mock_dropbox.is_configured = True
639
+ mock_dropbox.create_shared_link.return_value = "https://dropbox.com/link"
640
+ mock_get.return_value = mock_dropbox
641
+
642
+ await orchestrator._upload_to_dropbox()
643
+
644
+ mock_dropbox.upload_folder.assert_called_once()
645
+ assert orchestrator.result.dropbox_link == "https://dropbox.com/link"
646
+
647
+
648
+ class TestVideoWorkerOrchestratorNotifications:
649
+ """Test notifications stage."""
650
+
651
+ @pytest.mark.asyncio
652
+ async def test_run_notifications_with_youtube_url(self):
653
+ """Test Discord notification with YouTube URL."""
654
+ config = OrchestratorConfig(
655
+ job_id="test-job",
656
+ artist="Test Artist",
657
+ title="Test Title",
658
+ title_video_path="/path/title.mov",
659
+ karaoke_video_path="/path/karaoke.mov",
660
+ instrumental_audio_path="/path/audio.flac",
661
+ discord_webhook_url="https://discord.com/api/webhooks/123/abc",
662
+ )
663
+ orchestrator = VideoWorkerOrchestrator(config)
664
+ orchestrator.result.youtube_url = "https://youtube.com/watch?v=test"
665
+
666
+ with patch.object(orchestrator, "_get_discord_service") as mock_get:
667
+ mock_service = MagicMock()
668
+ mock_service.post_video_notification.return_value = True
669
+ mock_get.return_value = mock_service
670
+
671
+ await orchestrator._run_notifications()
672
+
673
+ mock_service.post_video_notification.assert_called_once_with(
674
+ "https://youtube.com/watch?v=test"
675
+ )
676
+
677
+ @pytest.mark.asyncio
678
+ async def test_run_notifications_no_youtube_url(self):
679
+ """Test notification skipped without YouTube URL."""
680
+ config = OrchestratorConfig(
681
+ job_id="test-job",
682
+ artist="Test Artist",
683
+ title="Test Title",
684
+ title_video_path="/path/title.mov",
685
+ karaoke_video_path="/path/karaoke.mov",
686
+ instrumental_audio_path="/path/audio.flac",
687
+ discord_webhook_url="https://discord.com/api/webhooks/123/abc",
688
+ )
689
+ orchestrator = VideoWorkerOrchestrator(config)
690
+ # No youtube_url set
691
+
692
+ with patch.object(orchestrator, "_get_discord_service") as mock_get:
693
+ mock_service = MagicMock()
694
+ mock_get.return_value = mock_service
695
+
696
+ await orchestrator._run_notifications()
697
+
698
+ mock_service.post_video_notification.assert_not_called()
699
+
700
+
701
+ class TestVideoWorkerOrchestratorFullPipeline:
702
+ """Test full pipeline execution."""
703
+
704
+ @pytest.mark.asyncio
705
+ async def test_run_full_pipeline_success(self):
706
+ """Test successful full pipeline."""
707
+ config = OrchestratorConfig(
708
+ job_id="test-job",
709
+ artist="Test Artist",
710
+ title="Test Title",
711
+ title_video_path="/path/title.mov",
712
+ karaoke_video_path="/path/karaoke.mov",
713
+ instrumental_audio_path="/path/audio.flac",
714
+ enable_cdg=True, # Enable to trigger packaging stage
715
+ )
716
+ orchestrator = VideoWorkerOrchestrator(config)
717
+
718
+ # Mock all stages
719
+ with patch.object(orchestrator, "_run_packaging", new_callable=AsyncMock) as mock_packaging, \
720
+ patch.object(orchestrator, "_run_encoding", new_callable=AsyncMock) as mock_encoding, \
721
+ patch.object(orchestrator, "_run_organization", new_callable=AsyncMock) as mock_org, \
722
+ patch.object(orchestrator, "_run_distribution", new_callable=AsyncMock) as mock_dist, \
723
+ patch.object(orchestrator, "_run_notifications", new_callable=AsyncMock) as mock_notify:
724
+
725
+ result = await orchestrator.run()
726
+
727
+ assert result.success is True
728
+ mock_packaging.assert_called_once()
729
+ mock_encoding.assert_called_once()
730
+ mock_org.assert_called_once()
731
+ mock_dist.assert_called_once()
732
+ mock_notify.assert_called_once()
733
+
734
+ @pytest.mark.asyncio
735
+ async def test_run_pipeline_skips_packaging_when_disabled(self):
736
+ """Test pipeline skips packaging when CDG/TXT disabled."""
737
+ config = OrchestratorConfig(
738
+ job_id="test-job",
739
+ artist="Test Artist",
740
+ title="Test Title",
741
+ title_video_path="/path/title.mov",
742
+ karaoke_video_path="/path/karaoke.mov",
743
+ instrumental_audio_path="/path/audio.flac",
744
+ enable_cdg=False,
745
+ enable_txt=False,
746
+ )
747
+ orchestrator = VideoWorkerOrchestrator(config)
748
+
749
+ # Mock all stages
750
+ with patch.object(orchestrator, "_run_packaging", new_callable=AsyncMock) as mock_packaging, \
751
+ patch.object(orchestrator, "_run_encoding", new_callable=AsyncMock) as mock_encoding, \
752
+ patch.object(orchestrator, "_run_organization", new_callable=AsyncMock), \
753
+ patch.object(orchestrator, "_run_distribution", new_callable=AsyncMock), \
754
+ patch.object(orchestrator, "_run_notifications", new_callable=AsyncMock):
755
+
756
+ result = await orchestrator.run()
757
+
758
+ assert result.success is True
759
+ mock_packaging.assert_not_called()
760
+ mock_encoding.assert_called_once()
761
+
762
+ @pytest.mark.asyncio
763
+ async def test_run_full_pipeline_encoding_failure(self):
764
+ """Test pipeline failure during encoding."""
765
+ config = OrchestratorConfig(
766
+ job_id="test-job",
767
+ artist="Test Artist",
768
+ title="Test Title",
769
+ title_video_path="/path/title.mov",
770
+ karaoke_video_path="/path/karaoke.mov",
771
+ instrumental_audio_path="/path/audio.flac",
772
+ )
773
+ orchestrator = VideoWorkerOrchestrator(config)
774
+
775
+ with patch.object(orchestrator, "_run_packaging", new_callable=AsyncMock), \
776
+ patch.object(orchestrator, "_run_encoding", new_callable=AsyncMock) as mock_encoding, \
777
+ patch.object(orchestrator, "_run_organization", new_callable=AsyncMock) as mock_org:
778
+
779
+ mock_encoding.side_effect = Exception("Encoding failed")
780
+
781
+ result = await orchestrator.run()
782
+
783
+ assert result.success is False
784
+ assert "Encoding failed" in result.error_message
785
+ mock_org.assert_not_called()
786
+
787
+
788
+ class TestCreateOrchestratorConfigFromJob:
789
+ """Test helper function for creating config from job."""
790
+
791
+ def test_create_config_from_job(self):
792
+ """Test creating config from a job object."""
793
+ job = MagicMock()
794
+ job.job_id = "test-123"
795
+ job.artist = "Test Artist"
796
+ job.title = "Test Title"
797
+ job.state_data = {"instrumental_selection": "clean"}
798
+ job.enable_cdg = True
799
+ job.enable_txt = False
800
+ job.enable_youtube_upload = True
801
+ job.brand_prefix = "NOMAD"
802
+ job.discord_webhook_url = "https://discord.com/api/webhooks/123/abc"
803
+ job.youtube_description_template = "Test description"
804
+ job.dropbox_path = "/Karaoke"
805
+ job.gdrive_folder_id = None
806
+ job.keep_brand_code = None
807
+ job.existing_instrumental_gcs_path = None
808
+
809
+ config = create_orchestrator_config_from_job(
810
+ job=job,
811
+ temp_dir="/tmp/test",
812
+ youtube_credentials={"token": "test"},
813
+ cdg_styles={"background": "black"},
814
+ )
815
+
816
+ assert config.job_id == "test-123"
817
+ assert config.artist == "Test Artist"
818
+ assert config.title == "Test Title"
819
+ assert config.enable_cdg is True
820
+ assert config.enable_youtube_upload is True
821
+ assert config.title_video_path == "/tmp/test/Test Artist - Test Title (Title).mov"
822
+ assert config.instrumental_audio_path == "/tmp/test/Test Artist - Test Title (Instrumental Clean).flac"
823
+
824
+ def test_create_config_from_job_with_existing_instrumental(self):
825
+ """Test config with user-provided instrumental."""
826
+ job = MagicMock()
827
+ job.job_id = "test-123"
828
+ job.artist = "Test Artist"
829
+ job.title = "Test Title"
830
+ job.state_data = {"instrumental_selection": "custom"}
831
+ job.enable_cdg = False
832
+ job.enable_txt = False
833
+ job.enable_youtube_upload = False
834
+ job.brand_prefix = None
835
+ job.discord_webhook_url = None
836
+ job.youtube_description_template = None
837
+ job.dropbox_path = None
838
+ job.gdrive_folder_id = None
839
+ job.keep_brand_code = None
840
+ job.existing_instrumental_gcs_path = "gs://bucket/instrumental.mp3"
841
+
842
+ config = create_orchestrator_config_from_job(
843
+ job=job,
844
+ temp_dir="/tmp/test",
845
+ )
846
+
847
+ assert config.instrumental_audio_path == "/tmp/test/Test Artist - Test Title (Instrumental User).mp3"