karaoke-gen 0.86.7__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 (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,567 @@
1
+ """
2
+ Tests for LocalPreviewEncodingService.
3
+
4
+ Tests cover:
5
+ - Service initialization
6
+ - Hardware acceleration detection (NVENC)
7
+ - FFmpeg filter path escaping
8
+ - ASS filter building
9
+ - Preview FFmpeg command construction
10
+ - Preview encoding execution
11
+ - Error handling
12
+ """
13
+
14
+ import os
15
+ import tempfile
16
+ import pytest
17
+ from unittest.mock import MagicMock, patch
18
+
19
+ from backend.services.local_preview_encoding_service import (
20
+ LocalPreviewEncodingService,
21
+ PreviewEncodingConfig,
22
+ PreviewEncodingResult,
23
+ get_local_preview_encoding_service,
24
+ )
25
+
26
+
27
+ class TestLocalPreviewEncodingServiceInit:
28
+ """Test service initialization."""
29
+
30
+ def test_init_default_values(self):
31
+ """Test default initialization."""
32
+ service = LocalPreviewEncodingService()
33
+ assert service._nvenc_available is None # Lazy detection
34
+ assert service._video_encoder is None # Lazy detection
35
+ assert service._hwaccel_flags is None # Lazy detection
36
+
37
+ def test_init_with_custom_logger(self):
38
+ """Test initialization with custom logger."""
39
+ import logging
40
+ custom_logger = logging.getLogger("test")
41
+ service = LocalPreviewEncodingService(logger=custom_logger)
42
+ assert service.logger is custom_logger
43
+
44
+
45
+ class TestLocalPreviewEncodingServiceHWAccel:
46
+ """Test hardware acceleration detection."""
47
+
48
+ @patch("subprocess.run")
49
+ def test_detect_nvenc_available(self, mock_run):
50
+ """Test NVENC detection when available."""
51
+ mock_run.return_value = MagicMock(returncode=0)
52
+
53
+ service = LocalPreviewEncodingService()
54
+ result = service._detect_nvenc_support()
55
+
56
+ assert result is True
57
+
58
+ @patch("subprocess.run")
59
+ def test_detect_nvenc_not_available(self, mock_run):
60
+ """Test fallback when NVENC not available."""
61
+ import subprocess
62
+ mock_run.side_effect = subprocess.CalledProcessError(1, "test")
63
+
64
+ service = LocalPreviewEncodingService()
65
+ result = service._detect_nvenc_support()
66
+
67
+ assert result is False
68
+
69
+ @patch("subprocess.run")
70
+ def test_detect_nvenc_timeout(self, mock_run):
71
+ """Test NVENC detection timeout handling."""
72
+ import subprocess
73
+ mock_run.side_effect = subprocess.TimeoutExpired("test", 30)
74
+
75
+ service = LocalPreviewEncodingService()
76
+ result = service._detect_nvenc_support()
77
+
78
+ assert result is False
79
+
80
+ @patch.object(LocalPreviewEncodingService, "_detect_nvenc_support")
81
+ def test_configure_nvenc_available(self, mock_detect):
82
+ """Test configuration when NVENC is available."""
83
+ mock_detect.return_value = True
84
+
85
+ service = LocalPreviewEncodingService()
86
+ service._configure_hardware_acceleration()
87
+
88
+ assert service._nvenc_available is True
89
+ assert service._video_encoder == "h264_nvenc"
90
+ assert "-hwaccel" in service._hwaccel_flags
91
+
92
+ @patch.object(LocalPreviewEncodingService, "_detect_nvenc_support")
93
+ def test_configure_no_nvenc(self, mock_detect):
94
+ """Test configuration when NVENC not available."""
95
+ mock_detect.return_value = False
96
+
97
+ service = LocalPreviewEncodingService()
98
+ service._configure_hardware_acceleration()
99
+
100
+ assert service._nvenc_available is False
101
+ assert service._video_encoder == "libx264"
102
+ assert service._hwaccel_flags == []
103
+
104
+
105
+ class TestLocalPreviewEncodingServiceProperties:
106
+ """Test property accessors that trigger lazy detection."""
107
+
108
+ @patch.object(LocalPreviewEncodingService, "_configure_hardware_acceleration")
109
+ def test_nvenc_available_triggers_detection(self, mock_config):
110
+ """Test that nvenc_available property triggers detection."""
111
+ service = LocalPreviewEncodingService()
112
+ service._nvenc_available = None
113
+
114
+ # Access property
115
+ _ = service.nvenc_available
116
+
117
+ mock_config.assert_called_once()
118
+
119
+ @patch.object(LocalPreviewEncodingService, "_configure_hardware_acceleration")
120
+ def test_video_encoder_triggers_detection(self, mock_config):
121
+ """Test that video_encoder property triggers detection."""
122
+ service = LocalPreviewEncodingService()
123
+ service._video_encoder = None
124
+
125
+ # Access property
126
+ _ = service.video_encoder
127
+
128
+ mock_config.assert_called_once()
129
+
130
+ @patch.object(LocalPreviewEncodingService, "_configure_hardware_acceleration")
131
+ def test_hwaccel_flags_triggers_detection(self, mock_config):
132
+ """Test that hwaccel_flags property triggers detection."""
133
+ service = LocalPreviewEncodingService()
134
+ service._hwaccel_flags = None
135
+
136
+ # Access property
137
+ _ = service.hwaccel_flags
138
+
139
+ mock_config.assert_called_once()
140
+
141
+
142
+ class TestLocalPreviewEncodingServicePathEscaping:
143
+ """Test FFmpeg filter path escaping."""
144
+
145
+ def test_escape_simple_path(self):
146
+ """Test escaping a simple path."""
147
+ service = LocalPreviewEncodingService()
148
+ result = service._escape_ffmpeg_filter_path("/simple/path.ass")
149
+ assert result == "/simple/path.ass"
150
+
151
+ def test_escape_path_with_spaces(self):
152
+ """Test escaping path with spaces."""
153
+ service = LocalPreviewEncodingService()
154
+ result = service._escape_ffmpeg_filter_path("/path/with spaces/file.ass")
155
+ assert "\\ " in result # Spaces should be escaped
156
+
157
+ def test_escape_path_with_apostrophe(self):
158
+ """Test escaping path with apostrophe (common in song titles)."""
159
+ service = LocalPreviewEncodingService()
160
+ result = service._escape_ffmpeg_filter_path("/path/I'm With You/file.ass")
161
+ assert "\\\\\\'" in result # Apostrophe should be triple-backslash escaped
162
+
163
+ def test_escape_path_with_special_chars(self):
164
+ """Test escaping path with FFmpeg special characters."""
165
+ service = LocalPreviewEncodingService()
166
+ result = service._escape_ffmpeg_filter_path("/path:with[special];chars,here.ass")
167
+ # Should escape :,[];
168
+ assert "\\:" in result
169
+ assert "\\[" in result
170
+ assert "\\]" in result
171
+ assert "\\;" in result
172
+ assert "\\," in result
173
+
174
+ def test_escape_path_with_backslashes(self):
175
+ """Test escaping path with existing backslashes."""
176
+ service = LocalPreviewEncodingService()
177
+ result = service._escape_ffmpeg_filter_path("/path\\with\\backslashes.ass")
178
+ assert "\\\\" in result
179
+
180
+
181
+ class TestLocalPreviewEncodingServiceASSFilter:
182
+ """Test ASS filter building."""
183
+
184
+ def test_build_ass_filter_simple(self):
185
+ """Test building ASS filter without font."""
186
+ service = LocalPreviewEncodingService()
187
+ result = service._build_ass_filter("/path/to/file.ass")
188
+ assert result.startswith("ass=")
189
+ assert "fontsdir" not in result
190
+
191
+ def test_build_ass_filter_with_font(self):
192
+ """Test building ASS filter with custom font."""
193
+ with tempfile.TemporaryDirectory() as tmpdir:
194
+ font_path = os.path.join(tmpdir, "custom.ttf")
195
+ with open(font_path, "w") as f:
196
+ f.write("fake font")
197
+
198
+ service = LocalPreviewEncodingService()
199
+ result = service._build_ass_filter("/path/to/file.ass", font_path)
200
+
201
+ assert "fontsdir=" in result
202
+
203
+ def test_build_ass_filter_nonexistent_font(self):
204
+ """Test building ASS filter with nonexistent font file."""
205
+ service = LocalPreviewEncodingService()
206
+ result = service._build_ass_filter("/path/to/file.ass", "/nonexistent/font.ttf")
207
+ # Should not include fontsdir for nonexistent font
208
+ assert "fontsdir" not in result
209
+
210
+
211
+ class TestLocalPreviewEncodingServiceFFmpegCommand:
212
+ """Test FFmpeg command building."""
213
+
214
+ def test_build_command_solid_background_libx264(self):
215
+ """Test command building with solid background and libx264."""
216
+ service = LocalPreviewEncodingService()
217
+ service._nvenc_available = False
218
+ service._video_encoder = "libx264"
219
+ service._hwaccel_flags = []
220
+
221
+ config = PreviewEncodingConfig(
222
+ ass_path="/path/to/subs.ass",
223
+ audio_path="/path/to/audio.flac",
224
+ output_path="/path/to/output.mp4",
225
+ background_color="black"
226
+ )
227
+
228
+ cmd = service._build_preview_ffmpeg_command(config)
229
+
230
+ assert "ffmpeg" in cmd
231
+ assert "-r" in cmd and "24" in cmd # Frame rate
232
+ assert "-c:v" in cmd
233
+ assert "libx264" in cmd
234
+ assert "-preset" in cmd and "superfast" in cmd
235
+ assert "-crf" in cmd and "28" in cmd
236
+ assert "color=c=black" in " ".join(cmd)
237
+
238
+ def test_build_command_with_nvenc(self):
239
+ """Test command building with NVENC hardware acceleration."""
240
+ service = LocalPreviewEncodingService()
241
+ service._nvenc_available = True
242
+ service._video_encoder = "h264_nvenc"
243
+ service._hwaccel_flags = ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
244
+
245
+ config = PreviewEncodingConfig(
246
+ ass_path="/path/to/subs.ass",
247
+ audio_path="/path/to/audio.flac",
248
+ output_path="/path/to/output.mp4"
249
+ )
250
+
251
+ cmd = service._build_preview_ffmpeg_command(config)
252
+
253
+ assert "h264_nvenc" in cmd
254
+ assert "-hwaccel" in cmd
255
+ assert "-preset" in cmd and "p1" in cmd # Fastest NVENC preset
256
+
257
+ def test_build_command_with_background_image(self):
258
+ """Test command building with background image."""
259
+ with tempfile.TemporaryDirectory() as tmpdir:
260
+ bg_path = os.path.join(tmpdir, "background.png")
261
+ with open(bg_path, "wb") as f:
262
+ f.write(b"fake png")
263
+
264
+ service = LocalPreviewEncodingService()
265
+ service._nvenc_available = False
266
+ service._video_encoder = "libx264"
267
+ service._hwaccel_flags = []
268
+
269
+ config = PreviewEncodingConfig(
270
+ ass_path="/path/to/subs.ass",
271
+ audio_path="/path/to/audio.flac",
272
+ output_path="/path/to/output.mp4",
273
+ background_image_path=bg_path
274
+ )
275
+
276
+ cmd = service._build_preview_ffmpeg_command(config)
277
+
278
+ assert "-loop" in cmd
279
+ assert "1" in cmd
280
+ assert bg_path in cmd
281
+ # Should have scale filter for background image
282
+ vf_index = cmd.index("-vf")
283
+ vf_value = cmd[vf_index + 1]
284
+ assert "scale=" in vf_value
285
+ assert "pad=" in vf_value
286
+
287
+ def test_build_command_resolution(self):
288
+ """Test that command uses correct preview resolution."""
289
+ service = LocalPreviewEncodingService()
290
+ service._nvenc_available = False
291
+ service._video_encoder = "libx264"
292
+ service._hwaccel_flags = []
293
+
294
+ config = PreviewEncodingConfig(
295
+ ass_path="/path/to/subs.ass",
296
+ audio_path="/path/to/audio.flac",
297
+ output_path="/path/to/output.mp4"
298
+ )
299
+
300
+ cmd = service._build_preview_ffmpeg_command(config)
301
+ cmd_str = " ".join(cmd)
302
+
303
+ # Resolution should be 480x270
304
+ assert "480" in cmd_str
305
+ assert "270" in cmd_str
306
+
307
+ def test_build_command_audio_settings(self):
308
+ """Test that command uses correct audio settings."""
309
+ service = LocalPreviewEncodingService()
310
+ service._nvenc_available = False
311
+ service._video_encoder = "libx264"
312
+ service._hwaccel_flags = []
313
+
314
+ config = PreviewEncodingConfig(
315
+ ass_path="/path/to/subs.ass",
316
+ audio_path="/path/to/audio.flac",
317
+ output_path="/path/to/output.mp4"
318
+ )
319
+
320
+ cmd = service._build_preview_ffmpeg_command(config)
321
+
322
+ assert "-c:a" in cmd
323
+ assert "aac" in cmd
324
+ assert "-b:a" in cmd
325
+ assert "96k" in cmd
326
+
327
+
328
+ class TestLocalPreviewEncodingServiceEncode:
329
+ """Test preview encoding execution."""
330
+
331
+ @patch("subprocess.run")
332
+ def test_encode_preview_success(self, mock_run):
333
+ """Test successful preview encoding."""
334
+ mock_run.return_value = MagicMock(returncode=0, stderr="")
335
+
336
+ with tempfile.TemporaryDirectory() as tmpdir:
337
+ ass_path = os.path.join(tmpdir, "subs.ass")
338
+ audio_path = os.path.join(tmpdir, "audio.flac")
339
+ output_path = os.path.join(tmpdir, "output.mp4")
340
+
341
+ # Create input files
342
+ with open(ass_path, "w") as f:
343
+ f.write("[Script Info]\n")
344
+ with open(audio_path, "wb") as f:
345
+ f.write(b"fake audio")
346
+
347
+ service = LocalPreviewEncodingService()
348
+ service._nvenc_available = False
349
+ service._video_encoder = "libx264"
350
+ service._hwaccel_flags = []
351
+
352
+ config = PreviewEncodingConfig(
353
+ ass_path=ass_path,
354
+ audio_path=audio_path,
355
+ output_path=output_path
356
+ )
357
+
358
+ result = service.encode_preview(config)
359
+
360
+ assert result.success is True
361
+ assert result.output_path == output_path
362
+ assert result.error is None
363
+ mock_run.assert_called_once()
364
+
365
+ @patch("subprocess.run")
366
+ def test_encode_preview_ffmpeg_failure(self, mock_run):
367
+ """Test preview encoding with FFmpeg failure."""
368
+ mock_run.return_value = MagicMock(returncode=1, stderr="FFmpeg error message")
369
+
370
+ with tempfile.TemporaryDirectory() as tmpdir:
371
+ ass_path = os.path.join(tmpdir, "subs.ass")
372
+ audio_path = os.path.join(tmpdir, "audio.flac")
373
+ output_path = os.path.join(tmpdir, "output.mp4")
374
+
375
+ with open(ass_path, "w") as f:
376
+ f.write("[Script Info]\n")
377
+ with open(audio_path, "wb") as f:
378
+ f.write(b"fake audio")
379
+
380
+ service = LocalPreviewEncodingService()
381
+ service._nvenc_available = False
382
+ service._video_encoder = "libx264"
383
+ service._hwaccel_flags = []
384
+
385
+ config = PreviewEncodingConfig(
386
+ ass_path=ass_path,
387
+ audio_path=audio_path,
388
+ output_path=output_path
389
+ )
390
+
391
+ result = service.encode_preview(config)
392
+
393
+ assert result.success is False
394
+ assert "FFmpeg preview encoding failed" in result.error
395
+
396
+ def test_encode_preview_missing_ass_file(self):
397
+ """Test preview encoding with missing ASS file."""
398
+ with tempfile.TemporaryDirectory() as tmpdir:
399
+ audio_path = os.path.join(tmpdir, "audio.flac")
400
+ output_path = os.path.join(tmpdir, "output.mp4")
401
+
402
+ with open(audio_path, "wb") as f:
403
+ f.write(b"fake audio")
404
+
405
+ service = LocalPreviewEncodingService()
406
+
407
+ config = PreviewEncodingConfig(
408
+ ass_path="/nonexistent/subs.ass",
409
+ audio_path=audio_path,
410
+ output_path=output_path
411
+ )
412
+
413
+ result = service.encode_preview(config)
414
+
415
+ assert result.success is False
416
+ assert "not found" in result.error
417
+
418
+ def test_encode_preview_missing_audio_file(self):
419
+ """Test preview encoding with missing audio file."""
420
+ with tempfile.TemporaryDirectory() as tmpdir:
421
+ ass_path = os.path.join(tmpdir, "subs.ass")
422
+ output_path = os.path.join(tmpdir, "output.mp4")
423
+
424
+ with open(ass_path, "w") as f:
425
+ f.write("[Script Info]\n")
426
+
427
+ service = LocalPreviewEncodingService()
428
+
429
+ config = PreviewEncodingConfig(
430
+ ass_path=ass_path,
431
+ audio_path="/nonexistent/audio.flac",
432
+ output_path=output_path
433
+ )
434
+
435
+ result = service.encode_preview(config)
436
+
437
+ assert result.success is False
438
+ assert "not found" in result.error
439
+
440
+ @patch("subprocess.run")
441
+ def test_encode_preview_timeout(self, mock_run):
442
+ """Test preview encoding timeout."""
443
+ import subprocess
444
+ mock_run.side_effect = subprocess.TimeoutExpired("ffmpeg", 300)
445
+
446
+ with tempfile.TemporaryDirectory() as tmpdir:
447
+ ass_path = os.path.join(tmpdir, "subs.ass")
448
+ audio_path = os.path.join(tmpdir, "audio.flac")
449
+ output_path = os.path.join(tmpdir, "output.mp4")
450
+
451
+ with open(ass_path, "w") as f:
452
+ f.write("[Script Info]\n")
453
+ with open(audio_path, "wb") as f:
454
+ f.write(b"fake audio")
455
+
456
+ service = LocalPreviewEncodingService()
457
+ service._nvenc_available = False
458
+ service._video_encoder = "libx264"
459
+ service._hwaccel_flags = []
460
+
461
+ config = PreviewEncodingConfig(
462
+ ass_path=ass_path,
463
+ audio_path=audio_path,
464
+ output_path=output_path
465
+ )
466
+
467
+ result = service.encode_preview(config)
468
+
469
+ assert result.success is False
470
+ assert "timed out" in result.error
471
+
472
+
473
+ class TestPreviewEncodingConfig:
474
+ """Test PreviewEncodingConfig dataclass."""
475
+
476
+ def test_config_required_fields(self):
477
+ """Test that required fields must be provided."""
478
+ config = PreviewEncodingConfig(
479
+ ass_path="/path/to/subs.ass",
480
+ audio_path="/path/to/audio.flac",
481
+ output_path="/path/to/output.mp4"
482
+ )
483
+ assert config.ass_path == "/path/to/subs.ass"
484
+ assert config.background_color == "black" # Default
485
+ assert config.background_image_path is None # Optional
486
+
487
+ def test_config_all_fields(self):
488
+ """Test config with all fields."""
489
+ config = PreviewEncodingConfig(
490
+ ass_path="/path/to/subs.ass",
491
+ audio_path="/path/to/audio.flac",
492
+ output_path="/path/to/output.mp4",
493
+ background_image_path="/path/to/bg.png",
494
+ background_color="red",
495
+ font_path="/path/to/font.ttf"
496
+ )
497
+ assert config.background_image_path == "/path/to/bg.png"
498
+ assert config.background_color == "red"
499
+ assert config.font_path == "/path/to/font.ttf"
500
+
501
+
502
+ class TestPreviewEncodingResult:
503
+ """Test PreviewEncodingResult dataclass."""
504
+
505
+ def test_result_success(self):
506
+ """Test successful result."""
507
+ result = PreviewEncodingResult(
508
+ success=True,
509
+ output_path="/path/to/output.mp4"
510
+ )
511
+ assert result.success is True
512
+ assert result.error is None
513
+ assert result.output_path == "/path/to/output.mp4"
514
+
515
+ def test_result_failure(self):
516
+ """Test failure result."""
517
+ result = PreviewEncodingResult(
518
+ success=False,
519
+ error="Something went wrong"
520
+ )
521
+ assert result.success is False
522
+ assert result.error == "Something went wrong"
523
+ assert result.output_path is None
524
+
525
+
526
+ class TestGetLocalPreviewEncodingService:
527
+ """Test factory function."""
528
+
529
+ def test_get_service_creates_instance(self):
530
+ """Test that factory function creates a new instance."""
531
+ import backend.services.local_preview_encoding_service as module
532
+ module._local_preview_encoding_service = None
533
+
534
+ service = get_local_preview_encoding_service()
535
+
536
+ assert service is not None
537
+ assert isinstance(service, LocalPreviewEncodingService)
538
+
539
+ def test_get_service_returns_singleton(self):
540
+ """Test that factory function returns the same instance."""
541
+ import backend.services.local_preview_encoding_service as module
542
+ module._local_preview_encoding_service = None
543
+
544
+ service1 = get_local_preview_encoding_service()
545
+ service2 = get_local_preview_encoding_service()
546
+
547
+ assert service1 is service2
548
+
549
+
550
+ class TestPreviewEncodingConstants:
551
+ """Test that encoding constants match expected values."""
552
+
553
+ def test_preview_resolution(self):
554
+ """Test preview resolution constants."""
555
+ service = LocalPreviewEncodingService()
556
+ assert service.PREVIEW_WIDTH == 480
557
+ assert service.PREVIEW_HEIGHT == 270
558
+
559
+ def test_preview_fps(self):
560
+ """Test preview frame rate constant."""
561
+ service = LocalPreviewEncodingService()
562
+ assert service.PREVIEW_FPS == 24
563
+
564
+ def test_preview_audio_bitrate(self):
565
+ """Test preview audio bitrate constant."""
566
+ service = LocalPreviewEncodingService()
567
+ assert service.PREVIEW_AUDIO_BITRATE == "96k"
@@ -0,0 +1,87 @@
1
+ """
2
+ Unit tests for backend main.py and config.py.
3
+
4
+ These tests cover the FastAPI app initialization and configuration.
5
+ """
6
+ import pytest
7
+ import os
8
+ from unittest.mock import MagicMock, patch
9
+
10
+
11
+ class TestConfig:
12
+ """Tests for config.py."""
13
+
14
+ def test_settings_from_environment(self):
15
+ """Test settings can be loaded from environment variables."""
16
+ with patch.dict(os.environ, {
17
+ 'GCS_BUCKET_NAME': 'test-bucket',
18
+ 'FIRESTORE_COLLECTION': 'test-jobs',
19
+ 'ADMIN_TOKENS': 'token1,token2',
20
+ 'ENVIRONMENT': 'testing'
21
+ }):
22
+ from backend.config import Settings
23
+ settings = Settings()
24
+ assert settings.gcs_bucket_name == 'test-bucket'
25
+ assert settings.firestore_collection == 'test-jobs'
26
+ # admin_tokens is a string, comma-separated
27
+ assert 'token1' in settings.admin_tokens
28
+ assert 'token2' in settings.admin_tokens
29
+
30
+ def test_admin_tokens_is_comma_separated_string(self):
31
+ """Test admin tokens stored as comma-separated string."""
32
+ with patch.dict(os.environ, {
33
+ 'ADMIN_TOKENS': 'token1,token2,token3'
34
+ }):
35
+ from backend.config import Settings
36
+ settings = Settings()
37
+ # admin_tokens is stored as a string
38
+ assert settings.admin_tokens == 'token1,token2,token3'
39
+
40
+ def test_default_environment(self):
41
+ """Test default environment is development."""
42
+ with patch.dict(os.environ, {}, clear=True):
43
+ from backend.config import Settings
44
+ settings = Settings()
45
+ # Accept 'test' as well since pytest may set ENVIRONMENT=test
46
+ assert settings.environment in ['development', 'production', 'testing', 'test']
47
+
48
+
49
+ class TestMain:
50
+ """Tests for main.py FastAPI app."""
51
+
52
+ @pytest.fixture
53
+ def app(self):
54
+ """Create FastAPI app with mocked services."""
55
+ mock_creds = MagicMock()
56
+ mock_creds.universe_domain = 'googleapis.com'
57
+ with patch('backend.services.firestore_service.firestore'), \
58
+ patch('backend.services.storage_service.storage'), \
59
+ patch('google.auth.default', return_value=(mock_creds, 'test-project')):
60
+ from backend.main import app
61
+ return app
62
+
63
+ def test_app_has_api_routes(self, app):
64
+ """Test app includes API routes."""
65
+ routes = [route.path for route in app.routes]
66
+ assert any('/api' in route or '/jobs' in route for route in routes)
67
+
68
+ def test_app_has_cors_middleware(self, app):
69
+ """Test app has CORS middleware configured."""
70
+ # Check that middleware is configured (not necessarily CORS specific)
71
+ assert len(app.user_middleware) >= 0 # App initializes middleware
72
+
73
+ def test_app_title(self, app):
74
+ """Test app has expected title."""
75
+ assert 'karaoke' in app.title.lower() or app.title
76
+
77
+
78
+ class TestDependencies:
79
+ """Tests for dependencies.py."""
80
+
81
+ def test_dependencies_module_imports(self):
82
+ """Test dependencies module can be imported and has auth functions."""
83
+ from backend.api import dependencies
84
+ assert hasattr(dependencies, 'require_auth')
85
+ assert hasattr(dependencies, 'require_admin')
86
+ assert hasattr(dependencies, 'optional_auth')
87
+