karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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 (197) 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 +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -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 +502 -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 +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,412 @@
1
+ """
2
+ Tests for Encoding Interface.
3
+
4
+ Tests cover:
5
+ - EncodingInput and EncodingOutput dataclasses
6
+ - LocalEncodingBackend implementation
7
+ - Backend factory function
8
+ """
9
+
10
+ import pytest
11
+ from unittest.mock import MagicMock, patch, AsyncMock
12
+
13
+ from backend.services.encoding_interface import (
14
+ EncodingInput,
15
+ EncodingOutput,
16
+ EncodingBackend,
17
+ LocalEncodingBackend,
18
+ GCEEncodingBackend,
19
+ get_encoding_backend,
20
+ )
21
+
22
+
23
+ class TestEncodingInput:
24
+ """Test EncodingInput dataclass."""
25
+
26
+ def test_required_fields(self):
27
+ """Test that required fields must be provided."""
28
+ input_config = EncodingInput(
29
+ title_video_path="/path/title.mov",
30
+ karaoke_video_path="/path/karaoke.mov",
31
+ instrumental_audio_path="/path/audio.flac",
32
+ )
33
+ assert input_config.title_video_path == "/path/title.mov"
34
+ assert input_config.end_video_path is None
35
+
36
+ def test_all_fields(self):
37
+ """Test all fields including optional ones."""
38
+ input_config = EncodingInput(
39
+ title_video_path="/path/title.mov",
40
+ karaoke_video_path="/path/karaoke.mov",
41
+ instrumental_audio_path="/path/audio.flac",
42
+ end_video_path="/path/end.mov",
43
+ artist="Test Artist",
44
+ title="Test Title",
45
+ brand_code="NOMAD-1234",
46
+ output_dir="/output",
47
+ options={"quality": "high"}
48
+ )
49
+ assert input_config.artist == "Test Artist"
50
+ assert input_config.brand_code == "NOMAD-1234"
51
+ assert input_config.options["quality"] == "high"
52
+
53
+
54
+ class TestEncodingOutput:
55
+ """Test EncodingOutput dataclass."""
56
+
57
+ def test_success_output(self):
58
+ """Test successful output."""
59
+ output = EncodingOutput(
60
+ success=True,
61
+ lossless_4k_mp4_path="/output/video.mp4",
62
+ encoding_backend="local"
63
+ )
64
+ assert output.success is True
65
+ assert output.error_message is None
66
+
67
+ def test_failure_output(self):
68
+ """Test failure output."""
69
+ output = EncodingOutput(
70
+ success=False,
71
+ error_message="Encoding failed",
72
+ encoding_backend="gce"
73
+ )
74
+ assert output.success is False
75
+ assert output.error_message == "Encoding failed"
76
+
77
+ def test_output_files_dict(self):
78
+ """Test output_files dictionary."""
79
+ output = EncodingOutput(
80
+ success=True,
81
+ output_files={
82
+ "lossless_4k_mp4": "/output/lossless.mp4",
83
+ "720p_mp4": "/output/720p.mp4"
84
+ }
85
+ )
86
+ assert "lossless_4k_mp4" in output.output_files
87
+ assert output.output_files["720p_mp4"] == "/output/720p.mp4"
88
+
89
+
90
+ class TestLocalEncodingBackend:
91
+ """Test LocalEncodingBackend implementation."""
92
+
93
+ def test_name(self):
94
+ """Test backend name."""
95
+ backend = LocalEncodingBackend()
96
+ assert backend.name == "local"
97
+
98
+ def test_init_with_dry_run(self):
99
+ """Test initialization with dry run."""
100
+ backend = LocalEncodingBackend(dry_run=True)
101
+ assert backend.dry_run is True
102
+
103
+ @pytest.mark.asyncio
104
+ @patch("subprocess.run")
105
+ async def test_is_available_success(self, mock_run):
106
+ """Test availability check when FFmpeg is installed."""
107
+ mock_run.return_value = MagicMock(returncode=0)
108
+
109
+ backend = LocalEncodingBackend()
110
+ available = await backend.is_available()
111
+
112
+ assert available is True
113
+
114
+ @pytest.mark.asyncio
115
+ @patch("subprocess.run")
116
+ async def test_is_available_not_found(self, mock_run):
117
+ """Test availability check when FFmpeg is not installed."""
118
+ mock_run.side_effect = FileNotFoundError()
119
+
120
+ backend = LocalEncodingBackend()
121
+ available = await backend.is_available()
122
+
123
+ assert available is False
124
+
125
+ @pytest.mark.asyncio
126
+ @patch.object(LocalEncodingBackend, "is_available")
127
+ @patch.object(LocalEncodingBackend, "_get_service")
128
+ async def test_get_status(self, mock_get_service, mock_is_available):
129
+ """Test status retrieval."""
130
+ mock_is_available.return_value = True
131
+ mock_service = MagicMock()
132
+ mock_service.hwaccel_available = True
133
+ mock_service.video_encoder = "h264_nvenc"
134
+ mock_get_service.return_value = mock_service
135
+
136
+ backend = LocalEncodingBackend()
137
+ status = await backend.get_status()
138
+
139
+ assert status["backend"] == "local"
140
+ assert status["available"] is True
141
+
142
+ @pytest.mark.asyncio
143
+ @patch("asyncio.to_thread")
144
+ @patch.object(LocalEncodingBackend, "_get_service")
145
+ async def test_encode_success(self, mock_get_service, mock_to_thread):
146
+ """Test successful encoding."""
147
+ from backend.services.local_encoding_service import EncodingResult
148
+
149
+ mock_result = EncodingResult(
150
+ success=True,
151
+ output_files={"key": "/path/file.mp4"}
152
+ )
153
+ mock_to_thread.return_value = mock_result
154
+
155
+ mock_service = MagicMock()
156
+ mock_get_service.return_value = mock_service
157
+
158
+ backend = LocalEncodingBackend()
159
+ input_config = EncodingInput(
160
+ title_video_path="/input/title.mov",
161
+ karaoke_video_path="/input/karaoke.mov",
162
+ instrumental_audio_path="/input/audio.flac",
163
+ artist="Test Artist",
164
+ title="Test Title",
165
+ output_dir="/output"
166
+ )
167
+
168
+ output = await backend.encode(input_config)
169
+
170
+ assert output.success is True
171
+ assert output.encoding_backend == "local"
172
+ assert output.encoding_time_seconds is not None
173
+
174
+ @pytest.mark.asyncio
175
+ @patch("asyncio.to_thread")
176
+ @patch.object(LocalEncodingBackend, "_get_service")
177
+ async def test_encode_failure(self, mock_get_service, mock_to_thread):
178
+ """Test encoding failure."""
179
+ from backend.services.local_encoding_service import EncodingResult
180
+
181
+ mock_result = EncodingResult(
182
+ success=False,
183
+ output_files={},
184
+ error="FFmpeg failed"
185
+ )
186
+ mock_to_thread.return_value = mock_result
187
+
188
+ mock_service = MagicMock()
189
+ mock_get_service.return_value = mock_service
190
+
191
+ backend = LocalEncodingBackend()
192
+ input_config = EncodingInput(
193
+ title_video_path="/input/title.mov",
194
+ karaoke_video_path="/input/karaoke.mov",
195
+ instrumental_audio_path="/input/audio.flac",
196
+ artist="Test",
197
+ title="Test",
198
+ )
199
+
200
+ output = await backend.encode(input_config)
201
+
202
+ assert output.success is False
203
+ assert output.error_message == "FFmpeg failed"
204
+
205
+
206
+ class TestGCEEncodingBackend:
207
+ """Test GCEEncodingBackend implementation."""
208
+
209
+ def test_name(self):
210
+ """Test backend name."""
211
+ backend = GCEEncodingBackend()
212
+ assert backend.name == "gce"
213
+
214
+ @patch.object(GCEEncodingBackend, "_get_service")
215
+ @pytest.mark.asyncio
216
+ async def test_is_available_enabled(self, mock_get_service):
217
+ """Test availability when GCE is enabled."""
218
+ mock_service = MagicMock()
219
+ mock_service.is_enabled = True
220
+ mock_get_service.return_value = mock_service
221
+
222
+ backend = GCEEncodingBackend()
223
+ available = await backend.is_available()
224
+
225
+ assert available is True
226
+
227
+ @patch.object(GCEEncodingBackend, "_get_service")
228
+ @pytest.mark.asyncio
229
+ async def test_is_available_disabled(self, mock_get_service):
230
+ """Test availability when GCE is disabled."""
231
+ mock_service = MagicMock()
232
+ mock_service.is_enabled = False
233
+ mock_get_service.return_value = mock_service
234
+
235
+ backend = GCEEncodingBackend()
236
+ available = await backend.is_available()
237
+
238
+ assert available is False
239
+
240
+ @patch.object(GCEEncodingBackend, "_get_service")
241
+ @pytest.mark.asyncio
242
+ async def test_encode_missing_gcs_paths(self, mock_get_service):
243
+ """Test encoding fails without GCS paths."""
244
+ backend = GCEEncodingBackend()
245
+ input_config = EncodingInput(
246
+ title_video_path="/input/title.mov",
247
+ karaoke_video_path="/input/karaoke.mov",
248
+ instrumental_audio_path="/input/audio.flac",
249
+ # Missing GCS paths in options
250
+ )
251
+
252
+ output = await backend.encode(input_config)
253
+
254
+ assert output.success is False
255
+ assert "gcs_path" in output.error_message.lower()
256
+
257
+ @patch.object(GCEEncodingBackend, "_get_service")
258
+ @pytest.mark.asyncio
259
+ async def test_encode_success(self, mock_get_service):
260
+ """Test successful GCE encoding."""
261
+ mock_service = MagicMock()
262
+ mock_service.encode_videos = AsyncMock(return_value={
263
+ "status": "complete",
264
+ "output_files": {
265
+ "mp4_4k_lossless": "gs://bucket/output/lossless.mp4",
266
+ "mp4_720p": "gs://bucket/output/720p.mp4",
267
+ }
268
+ })
269
+ mock_get_service.return_value = mock_service
270
+
271
+ backend = GCEEncodingBackend()
272
+ input_config = EncodingInput(
273
+ title_video_path="/input/title.mov",
274
+ karaoke_video_path="/input/karaoke.mov",
275
+ instrumental_audio_path="/input/audio.flac",
276
+ artist="Test Artist",
277
+ title="Test Title",
278
+ options={
279
+ "job_id": "test-job",
280
+ "input_gcs_path": "gs://bucket/input/",
281
+ "output_gcs_path": "gs://bucket/output/",
282
+ }
283
+ )
284
+
285
+ output = await backend.encode(input_config)
286
+
287
+ assert output.success is True
288
+ assert output.encoding_backend == "gce"
289
+ mock_service.encode_videos.assert_called_once()
290
+
291
+ @patch.object(GCEEncodingBackend, "_get_service")
292
+ @pytest.mark.asyncio
293
+ async def test_encode_failure(self, mock_get_service):
294
+ """Test GCE encoding failure handling."""
295
+ mock_service = MagicMock()
296
+ mock_service.encode_videos = AsyncMock(side_effect=Exception("GCE worker error"))
297
+ mock_get_service.return_value = mock_service
298
+
299
+ backend = GCEEncodingBackend()
300
+ input_config = EncodingInput(
301
+ title_video_path="/input/title.mov",
302
+ karaoke_video_path="/input/karaoke.mov",
303
+ instrumental_audio_path="/input/audio.flac",
304
+ options={
305
+ "job_id": "test-job",
306
+ "input_gcs_path": "gs://bucket/input/",
307
+ "output_gcs_path": "gs://bucket/output/",
308
+ }
309
+ )
310
+
311
+ output = await backend.encode(input_config)
312
+
313
+ assert output.success is False
314
+ assert "GCE worker error" in output.error_message
315
+
316
+ @patch.object(GCEEncodingBackend, "_get_service")
317
+ @pytest.mark.asyncio
318
+ async def test_encode_handles_list_result(self, mock_get_service):
319
+ """Test GCE encoding handles list response gracefully.
320
+
321
+ This would have caught: 'list' object has no attribute 'get' error
322
+ when GCE worker returns a list instead of a dict.
323
+ """
324
+ mock_service = MagicMock()
325
+ # Simulate GCE worker returning a list instead of dict
326
+ mock_service.encode_videos = AsyncMock(return_value=[
327
+ {"output_files": {"mp4_4k_lossless": "gs://bucket/output/lossless.mp4"}}
328
+ ])
329
+ mock_get_service.return_value = mock_service
330
+
331
+ backend = GCEEncodingBackend()
332
+ input_config = EncodingInput(
333
+ title_video_path="/input/title.mov",
334
+ karaoke_video_path="/input/karaoke.mov",
335
+ instrumental_audio_path="/input/audio.flac",
336
+ options={
337
+ "job_id": "test-job",
338
+ "input_gcs_path": "gs://bucket/input/",
339
+ "output_gcs_path": "gs://bucket/output/",
340
+ }
341
+ )
342
+
343
+ # This should not raise an error
344
+ output = await backend.encode(input_config)
345
+
346
+ # Should still succeed by extracting from the list
347
+ assert output.success is True
348
+ assert output.lossless_4k_mp4_path == "gs://bucket/output/lossless.mp4"
349
+
350
+
351
+ class TestGetEncodingBackend:
352
+ """Test encoding backend factory function."""
353
+
354
+ def test_get_local_backend(self):
355
+ """Test getting local backend."""
356
+ backend = get_encoding_backend("local")
357
+ assert isinstance(backend, LocalEncodingBackend)
358
+ assert backend.name == "local"
359
+
360
+ @patch.object(GCEEncodingBackend, "_get_service")
361
+ def test_get_auto_backend_gce_disabled(self, mock_get_service):
362
+ """Test getting auto backend falls back to local when GCE disabled."""
363
+ mock_service = MagicMock()
364
+ mock_service.is_enabled = False
365
+ mock_get_service.return_value = mock_service
366
+
367
+ backend = get_encoding_backend("auto")
368
+ assert isinstance(backend, LocalEncodingBackend)
369
+
370
+ def test_get_local_backend_with_options(self):
371
+ """Test getting local backend with options."""
372
+ backend = get_encoding_backend("local", dry_run=True)
373
+ assert backend.dry_run is True
374
+
375
+ def test_get_gce_backend(self):
376
+ """Test getting GCE backend."""
377
+ backend = get_encoding_backend("gce")
378
+ assert isinstance(backend, GCEEncodingBackend)
379
+ assert backend.name == "gce"
380
+
381
+ def test_get_unknown_backend_raises(self):
382
+ """Test that unknown backend raises ValueError."""
383
+ with pytest.raises(ValueError) as exc_info:
384
+ get_encoding_backend("unknown")
385
+ assert "Unknown encoding backend type" in str(exc_info.value)
386
+
387
+ def test_get_gce_backend_with_options(self):
388
+ """Test getting GCE backend with common options like dry_run.
389
+
390
+ This ensures all backends accept the same kwargs, preventing
391
+ errors when get_encoding_backend() passes **kwargs to different backends.
392
+ """
393
+ # This would have caught: GCEEncodingBackend.__init__() got an unexpected keyword argument 'dry_run'
394
+ backend = get_encoding_backend("gce", dry_run=True)
395
+ assert isinstance(backend, GCEEncodingBackend)
396
+ assert backend.dry_run is True
397
+
398
+ @patch.object(GCEEncodingBackend, "_get_service")
399
+ def test_all_backends_accept_dry_run(self, mock_get_service):
400
+ """Test that all backend types accept dry_run parameter.
401
+
402
+ This is an integration test to ensure the factory function
403
+ can pass dry_run to any backend without TypeError.
404
+ """
405
+ mock_service = MagicMock()
406
+ mock_service.is_enabled = False
407
+ mock_get_service.return_value = mock_service
408
+
409
+ for backend_type in ["local", "gce", "auto"]:
410
+ # This should not raise TypeError
411
+ backend = get_encoding_backend(backend_type, dry_run=True)
412
+ assert backend is not None