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,1116 @@
1
+ """
2
+ Unit tests for backend workers.
3
+
4
+ These tests mock external dependencies and test worker logic in isolation.
5
+ This includes the functions that would have caught bugs like the
6
+ UnboundLocalError in upload_lyrics_results.
7
+ """
8
+ import pytest
9
+ import os
10
+ import json
11
+ import tempfile
12
+ from datetime import datetime, UTC
13
+ from unittest.mock import MagicMock, AsyncMock, patch, mock_open
14
+ from pathlib import Path
15
+
16
+ from backend.models.job import Job, JobStatus
17
+
18
+
19
+ class TestAudioWorker:
20
+ """Tests for audio_worker.py functions."""
21
+
22
+ @pytest.fixture
23
+ def mock_job(self):
24
+ """Create a mock job for testing."""
25
+ return Job(
26
+ job_id="test123",
27
+ status=JobStatus.PENDING,
28
+ created_at=datetime.now(UTC),
29
+ updated_at=datetime.now(UTC),
30
+ artist="Test Artist",
31
+ title="Test Song",
32
+ input_media_gcs_path="uploads/test123/song.flac"
33
+ )
34
+
35
+ @pytest.fixture
36
+ def mock_job_manager(self, mock_job):
37
+ """Create a mock JobManager."""
38
+ manager = MagicMock()
39
+ manager.get_job.return_value = mock_job
40
+ manager.update_job_status.return_value = None
41
+ manager.update_file_url.return_value = None
42
+ manager.update_state_data.return_value = None
43
+ manager.mark_job_failed.return_value = None
44
+ return manager
45
+
46
+ @pytest.fixture
47
+ def mock_storage(self):
48
+ """Create a mock StorageService."""
49
+ storage = MagicMock()
50
+ storage.download_file.return_value = "/tmp/test/song.flac"
51
+ storage.upload_file.return_value = "gs://bucket/path"
52
+ return storage
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_download_audio_from_gcs(self, mock_job_manager, mock_storage, mock_job):
56
+ """Test downloading audio from GCS for uploaded files."""
57
+ with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
58
+ patch('backend.workers.audio_worker.StorageService', return_value=mock_storage):
59
+
60
+ from backend.workers.audio_worker import download_audio
61
+
62
+ with tempfile.TemporaryDirectory() as temp_dir:
63
+ result = await download_audio("test123", temp_dir, mock_storage, mock_job)
64
+
65
+ # Should have called download_file with the GCS path
66
+ mock_storage.download_file.assert_called()
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_upload_separation_results_handles_clean_stems(self, mock_job_manager, mock_storage):
70
+ """Test that upload_separation_results handles clean stems correctly."""
71
+ from backend.workers.audio_worker import upload_separation_results
72
+
73
+ with tempfile.TemporaryDirectory() as temp_dir:
74
+ # Create actual test files
75
+ inst_path = os.path.join(temp_dir, "instrumental.flac")
76
+ vocals_path = os.path.join(temp_dir, "vocals.flac")
77
+ with open(inst_path, 'wb') as f:
78
+ f.write(b'fake audio data')
79
+ with open(vocals_path, 'wb') as f:
80
+ f.write(b'fake audio data')
81
+
82
+ # Create mock separation result matching AudioProcessor output format
83
+ separation_result = {
84
+ "clean_instrumental": {
85
+ "instrumental": inst_path,
86
+ "vocals": vocals_path
87
+ },
88
+ "other_stems": {},
89
+ "backing_vocals": {},
90
+ "combined_instrumentals": {}
91
+ }
92
+
93
+ await upload_separation_results("test123", separation_result, mock_storage, mock_job_manager)
94
+
95
+ # Should have uploaded files
96
+ assert mock_storage.upload_file.called
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_upload_separation_results_handles_other_stems_as_dict(self, mock_job_manager, mock_storage):
100
+ """Test that upload_separation_results handles other_stems when values are dicts (bug fix)."""
101
+ with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
102
+ patch('backend.workers.audio_worker.StorageService', return_value=mock_storage):
103
+
104
+ from backend.workers.audio_worker import upload_separation_results
105
+
106
+ # This is the structure that caused the original bug - values are dicts, not strings
107
+ separation_result = {
108
+ "clean": {},
109
+ "other_stems": {
110
+ "bass": {"path": "/tmp/test/bass.flac", "other_key": "value"},
111
+ "drums": "/tmp/test/drums.flac" # String path
112
+ },
113
+ "backing_vocals": {},
114
+ "combined_instrumentals": {}
115
+ }
116
+
117
+ # Mock os.path.exists
118
+ with patch('os.path.exists', return_value=True):
119
+ # This should NOT raise TypeError: stat: path should be string...
120
+ await upload_separation_results("test123", separation_result, mock_storage, mock_job_manager)
121
+
122
+ @pytest.mark.asyncio
123
+ async def test_process_audio_separation_updates_status_on_failure(self, mock_job_manager, mock_storage):
124
+ """Test that process_audio_separation marks job as failed on error."""
125
+ mock_job_manager.get_job.return_value = Job(
126
+ job_id="test123",
127
+ status=JobStatus.PENDING,
128
+ created_at=datetime.now(UTC),
129
+ updated_at=datetime.now(UTC),
130
+ artist="Test",
131
+ title="Test"
132
+ )
133
+
134
+ with patch('backend.workers.audio_worker.JobManager', return_value=mock_job_manager), \
135
+ patch('backend.workers.audio_worker.StorageService', return_value=mock_storage), \
136
+ patch('backend.workers.audio_worker.download_audio', side_effect=Exception("Download failed")):
137
+
138
+ from backend.workers.audio_worker import process_audio_separation
139
+
140
+ await process_audio_separation("test123")
141
+
142
+ # Should have marked job as failed
143
+ mock_job_manager.mark_job_failed.assert_called()
144
+
145
+
146
+ class TestLyricsWorker:
147
+ """Tests for lyrics_worker.py functions."""
148
+
149
+ @pytest.fixture
150
+ def mock_job(self):
151
+ """Create a mock job for testing."""
152
+ return Job(
153
+ job_id="test123",
154
+ status=JobStatus.PENDING,
155
+ created_at=datetime.now(UTC),
156
+ updated_at=datetime.now(UTC),
157
+ artist="ABBA",
158
+ title="Waterloo",
159
+ input_media_gcs_path="uploads/test123/song.flac"
160
+ )
161
+
162
+ @pytest.fixture
163
+ def mock_job_manager(self, mock_job):
164
+ """Create a mock JobManager."""
165
+ manager = MagicMock()
166
+ manager.get_job.return_value = mock_job
167
+ manager.update_job_status.return_value = None
168
+ manager.update_file_url.return_value = None
169
+ manager.update_state_data.return_value = None
170
+ manager.mark_job_failed.return_value = None
171
+ return manager
172
+
173
+ @pytest.fixture
174
+ def mock_storage(self):
175
+ """Create a mock StorageService."""
176
+ storage = MagicMock()
177
+ storage.download_file.return_value = "/tmp/test/song.flac"
178
+ storage.upload_file.return_value = "gs://bucket/path"
179
+ return storage
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_upload_lyrics_results_requires_job(self, mock_job_manager, mock_storage, mock_job):
183
+ """Test that upload_lyrics_results correctly fetches job for artist/title.
184
+
185
+ This test would have caught the UnboundLocalError bug where job was used
186
+ before being defined.
187
+ """
188
+ with patch('backend.workers.lyrics_worker.JobManager', return_value=mock_job_manager), \
189
+ patch('backend.workers.lyrics_worker.StorageService', return_value=mock_storage):
190
+
191
+ from backend.workers.lyrics_worker import upload_lyrics_results
192
+
193
+ with tempfile.TemporaryDirectory() as temp_dir:
194
+ # Create mock lyrics directory and files
195
+ lyrics_dir = os.path.join(temp_dir, "lyrics")
196
+ os.makedirs(lyrics_dir)
197
+
198
+ # Create test LRC file
199
+ lrc_path = os.path.join(lyrics_dir, "ABBA - Waterloo (Karaoke).lrc")
200
+ with open(lrc_path, 'w') as f:
201
+ f.write("[00:00.00]Test lyrics\n")
202
+
203
+ # Create corrections JSON
204
+ corrections_path = os.path.join(lyrics_dir, "ABBA - Waterloo (Lyrics Corrections).json")
205
+ with open(corrections_path, 'w') as f:
206
+ json.dump({"lines": [], "corrections": []}, f)
207
+
208
+ transcription_result = {
209
+ "lrc_filepath": lrc_path,
210
+ "corrections_filepath": corrections_path
211
+ }
212
+
213
+ # This should NOT raise UnboundLocalError
214
+ await upload_lyrics_results(
215
+ "test123",
216
+ temp_dir,
217
+ transcription_result,
218
+ mock_storage,
219
+ mock_job_manager
220
+ )
221
+
222
+ # Verify job was fetched
223
+ mock_job_manager.get_job.assert_called_with("test123")
224
+
225
+ @pytest.mark.asyncio
226
+ async def test_upload_lyrics_results_uploads_lrc_file(self, mock_job_manager, mock_storage, mock_job):
227
+ """Test that LRC file is uploaded correctly."""
228
+ from backend.workers.lyrics_worker import upload_lyrics_results
229
+ import json
230
+
231
+ with tempfile.TemporaryDirectory() as temp_dir:
232
+ lyrics_dir = os.path.join(temp_dir, "lyrics")
233
+ os.makedirs(lyrics_dir)
234
+
235
+ lrc_path = os.path.join(lyrics_dir, "test.lrc")
236
+ with open(lrc_path, 'w') as f:
237
+ f.write("[00:00.00]Test\n")
238
+
239
+ # Create required corrections.json file
240
+ corrections_path = os.path.join(lyrics_dir, "corrections.json")
241
+ with open(corrections_path, 'w') as f:
242
+ json.dump({"corrected_segments": []}, f)
243
+
244
+ transcription_result = {"lrc_filepath": lrc_path}
245
+
246
+ await upload_lyrics_results(
247
+ "test123", temp_dir, transcription_result,
248
+ mock_storage, mock_job_manager
249
+ )
250
+
251
+ # Should have uploaded the LRC file
252
+ mock_storage.upload_file.assert_called()
253
+ mock_job_manager.update_file_url.assert_called()
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_upload_lyrics_results_handles_missing_files(self, mock_job_manager, mock_storage, mock_job):
257
+ """Test graceful handling when optional files are missing."""
258
+ from backend.workers.lyrics_worker import upload_lyrics_results
259
+ import json
260
+
261
+ with tempfile.TemporaryDirectory() as temp_dir:
262
+ lyrics_dir = os.path.join(temp_dir, "lyrics")
263
+ os.makedirs(lyrics_dir)
264
+
265
+ # Only create LRC file and required corrections.json, no other files
266
+ lrc_path = os.path.join(lyrics_dir, "test.lrc")
267
+ with open(lrc_path, 'w') as f:
268
+ f.write("[00:00.00]Test\n")
269
+
270
+ # Create required corrections.json file
271
+ corrections_path = os.path.join(lyrics_dir, "corrections.json")
272
+ with open(corrections_path, 'w') as f:
273
+ json.dump({"corrected_segments": []}, f)
274
+
275
+ transcription_result = {"lrc_filepath": lrc_path}
276
+
277
+ # Should not raise exception for missing optional files
278
+ await upload_lyrics_results(
279
+ "test123", temp_dir, transcription_result,
280
+ mock_storage, mock_job_manager
281
+ )
282
+
283
+ @pytest.mark.asyncio
284
+ async def test_upload_lyrics_results_uses_artist_title_from_job(self, mock_job_manager, mock_storage, mock_job):
285
+ """Test that upload_lyrics_results correctly uses job.artist and job.title.
286
+
287
+ This test specifically validates the bug fix where job was not defined
288
+ when accessing job.artist and job.title for reference file lookups.
289
+ """
290
+ from backend.workers.lyrics_worker import upload_lyrics_results
291
+ import json
292
+
293
+ with tempfile.TemporaryDirectory() as temp_dir:
294
+ lyrics_dir = os.path.join(temp_dir, "lyrics")
295
+ os.makedirs(lyrics_dir)
296
+
297
+ # Create LRC file
298
+ lrc_path = os.path.join(lyrics_dir, "test.lrc")
299
+ with open(lrc_path, 'w') as f:
300
+ f.write("[00:00.00]Test\n")
301
+
302
+ # Create required corrections.json file
303
+ corrections_path = os.path.join(lyrics_dir, "corrections.json")
304
+ with open(corrections_path, 'w') as f:
305
+ json.dump({"corrected_segments": []}, f)
306
+
307
+ # Create a reference lyrics file using the job's artist/title
308
+ ref_path = os.path.join(lyrics_dir, f"{mock_job.artist} - {mock_job.title} (Lyrics Genius).txt")
309
+ with open(ref_path, 'w') as f:
310
+ f.write("Reference lyrics content\n")
311
+
312
+ # Create uncorrected transcription file using job's artist/title
313
+ uncorrected_path = os.path.join(lyrics_dir, f"{mock_job.artist} - {mock_job.title} (Lyrics Uncorrected).txt")
314
+ with open(uncorrected_path, 'w') as f:
315
+ f.write("Uncorrected transcription\n")
316
+
317
+ transcription_result = {"lrc_filepath": lrc_path}
318
+
319
+ # This should NOT raise UnboundLocalError for 'job'
320
+ await upload_lyrics_results(
321
+ "test123", temp_dir, transcription_result,
322
+ mock_storage, mock_job_manager
323
+ )
324
+
325
+ # Verify job was fetched to get artist/title
326
+ mock_job_manager.get_job.assert_called_with("test123")
327
+
328
+ # Verify files were uploaded (the reference and uncorrected files exist)
329
+ upload_calls = mock_storage.upload_file.call_args_list
330
+ assert len(upload_calls) >= 2 # LRC + at least one reference or uncorrected
331
+
332
+ @pytest.mark.asyncio
333
+ async def test_process_lyrics_transcription_marks_failed_on_error(self, mock_job_manager, mock_storage):
334
+ """Test that process_lyrics_transcription marks job as failed on error."""
335
+ mock_job_manager.get_job.return_value = Job(
336
+ job_id="test123",
337
+ status=JobStatus.PENDING,
338
+ created_at=datetime.now(UTC),
339
+ updated_at=datetime.now(UTC),
340
+ artist="Test",
341
+ title="Test",
342
+ input_media_gcs_path="uploads/test123/song.flac"
343
+ )
344
+
345
+ with patch('backend.workers.lyrics_worker.JobManager', return_value=mock_job_manager), \
346
+ patch('backend.workers.lyrics_worker.StorageService', return_value=mock_storage), \
347
+ patch('backend.workers.lyrics_worker.download_audio', side_effect=Exception("Download failed")):
348
+
349
+ from backend.workers.lyrics_worker import process_lyrics_transcription
350
+
351
+ await process_lyrics_transcription("test123")
352
+
353
+ mock_job_manager.mark_job_failed.assert_called()
354
+
355
+
356
+ class TestLyricsWorkerConfiguration:
357
+ """Tests for lyrics worker configuration parameters."""
358
+
359
+ def test_create_lyrics_processor_with_defaults(self):
360
+ """Test creating LyricsProcessor with default parameters."""
361
+ with patch('backend.workers.lyrics_worker.LyricsProcessor') as mock_processor:
362
+ from backend.workers.lyrics_worker import create_lyrics_processor
363
+
364
+ result = create_lyrics_processor()
365
+
366
+ mock_processor.assert_called_once()
367
+ call_kwargs = mock_processor.call_args[1]
368
+ assert call_kwargs['lyrics_file'] is None
369
+ assert call_kwargs['subtitle_offset_ms'] == 0
370
+
371
+ def test_create_lyrics_processor_with_lyrics_file(self):
372
+ """Test creating LyricsProcessor with custom lyrics file."""
373
+ with patch('backend.workers.lyrics_worker.LyricsProcessor') as mock_processor:
374
+ from backend.workers.lyrics_worker import create_lyrics_processor
375
+
376
+ result = create_lyrics_processor(
377
+ lyrics_file="/path/to/lyrics.txt",
378
+ subtitle_offset_ms=0
379
+ )
380
+
381
+ mock_processor.assert_called_once()
382
+ call_kwargs = mock_processor.call_args[1]
383
+ assert call_kwargs['lyrics_file'] == "/path/to/lyrics.txt"
384
+
385
+ def test_create_lyrics_processor_with_subtitle_offset(self):
386
+ """Test creating LyricsProcessor with subtitle offset."""
387
+ with patch('backend.workers.lyrics_worker.LyricsProcessor') as mock_processor:
388
+ from backend.workers.lyrics_worker import create_lyrics_processor
389
+
390
+ result = create_lyrics_processor(
391
+ subtitle_offset_ms=500
392
+ )
393
+
394
+ mock_processor.assert_called_once()
395
+ call_kwargs = mock_processor.call_args[1]
396
+ assert call_kwargs['subtitle_offset_ms'] == 500
397
+
398
+
399
+ class TestLyricsOverrideParameters:
400
+ """Tests for lyrics artist/title override functionality."""
401
+
402
+ @pytest.fixture
403
+ def mock_job_with_overrides(self):
404
+ """Create a mock job with lyrics override fields."""
405
+ return Job(
406
+ job_id="test123",
407
+ status=JobStatus.PENDING,
408
+ created_at=datetime.now(UTC),
409
+ updated_at=datetime.now(UTC),
410
+ artist="Beatles, The",
411
+ title="Hey Jude - 2009 Remaster",
412
+ lyrics_artist="The Beatles",
413
+ lyrics_title="Hey Jude",
414
+ subtitle_offset_ms=250,
415
+ input_media_gcs_path="uploads/test123/song.flac"
416
+ )
417
+
418
+ def test_job_uses_lyrics_artist_override(self, mock_job_with_overrides):
419
+ """Test that job uses lyrics_artist when searching for lyrics."""
420
+ job = mock_job_with_overrides
421
+
422
+ # Use override if present, else fall back to main artist
423
+ lyrics_search_artist = job.lyrics_artist or job.artist
424
+
425
+ assert lyrics_search_artist == "The Beatles"
426
+ assert lyrics_search_artist != job.artist # Override is different
427
+
428
+ def test_job_uses_lyrics_title_override(self, mock_job_with_overrides):
429
+ """Test that job uses lyrics_title when searching for lyrics."""
430
+ job = mock_job_with_overrides
431
+
432
+ # Use override if present, else fall back to main title
433
+ lyrics_search_title = job.lyrics_title or job.title
434
+
435
+ assert lyrics_search_title == "Hey Jude"
436
+ assert lyrics_search_title != job.title # Override is different
437
+
438
+ def test_job_falls_back_when_no_override(self):
439
+ """Test that job falls back to main artist/title when no override."""
440
+ job = Job(
441
+ job_id="test123",
442
+ status=JobStatus.PENDING,
443
+ created_at=datetime.now(UTC),
444
+ updated_at=datetime.now(UTC),
445
+ artist="Test Artist",
446
+ title="Test Song",
447
+ input_media_gcs_path="uploads/test123/song.flac"
448
+ )
449
+
450
+ # When override is None, use main values
451
+ lyrics_search_artist = job.lyrics_artist or job.artist
452
+ lyrics_search_title = job.lyrics_title or job.title
453
+
454
+ assert lyrics_search_artist == "Test Artist"
455
+ assert lyrics_search_title == "Test Song"
456
+
457
+
458
+ class TestScreensWorker:
459
+ """Tests for screens_worker.py functions.
460
+
461
+ Note: The main process function is tested indirectly via integration tests.
462
+ These unit tests focus on helper functions and module structure.
463
+ """
464
+
465
+ def test_screens_worker_module_imports(self):
466
+ """Test screens worker module can be imported."""
467
+ from backend.workers import screens_worker
468
+ assert hasattr(screens_worker, 'logger')
469
+
470
+
471
+ class TestVideoWorker:
472
+ """Tests for video_worker.py functions.
473
+
474
+ Note: The main process function is tested indirectly via integration tests.
475
+ These unit tests focus on helper functions and module structure.
476
+ """
477
+
478
+ def test_video_worker_module_imports(self):
479
+ """Test video worker module can be imported."""
480
+ from backend.workers import video_worker
481
+ assert hasattr(video_worker, 'logger')
482
+
483
+
484
+ class TestRenderVideoWorkerConfiguration:
485
+ """Tests for render_video_worker subtitle_offset_ms support."""
486
+
487
+ @pytest.fixture
488
+ def mock_job_with_offset(self):
489
+ """Create a mock job with subtitle offset."""
490
+ return Job(
491
+ job_id="test123",
492
+ status=JobStatus.RENDERING_VIDEO,
493
+ created_at=datetime.now(UTC),
494
+ updated_at=datetime.now(UTC),
495
+ artist="Test Artist",
496
+ title="Test Song",
497
+ subtitle_offset_ms=500,
498
+ input_media_gcs_path="uploads/test123/song.flac"
499
+ )
500
+
501
+ def test_job_has_subtitle_offset_ms(self, mock_job_with_offset):
502
+ """Test that job has subtitle_offset_ms field."""
503
+ assert mock_job_with_offset.subtitle_offset_ms == 500
504
+
505
+ def test_subtitle_offset_from_job(self, mock_job_with_offset):
506
+ """Test extracting subtitle offset from job with getattr."""
507
+ job = mock_job_with_offset
508
+
509
+ # This mirrors the logic in render_video_worker.py
510
+ subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
511
+
512
+ assert subtitle_offset == 500
513
+
514
+ def test_subtitle_offset_default_zero(self):
515
+ """Test that subtitle offset defaults to 0 when not set."""
516
+ job = Job(
517
+ job_id="test123",
518
+ status=JobStatus.RENDERING_VIDEO,
519
+ created_at=datetime.now(UTC),
520
+ updated_at=datetime.now(UTC),
521
+ artist="Test Artist",
522
+ title="Test Song",
523
+ input_media_gcs_path="uploads/test123/song.flac"
524
+ )
525
+
526
+ subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
527
+
528
+ assert subtitle_offset == 0
529
+
530
+ def test_subtitle_offset_negative_value(self):
531
+ """Test that subtitle offset can be negative (advance subtitles)."""
532
+ job = Job(
533
+ job_id="test123",
534
+ status=JobStatus.RENDERING_VIDEO,
535
+ created_at=datetime.now(UTC),
536
+ updated_at=datetime.now(UTC),
537
+ artist="Test Artist",
538
+ title="Test Song",
539
+ subtitle_offset_ms=-250, # Negative = advance subtitles
540
+ input_media_gcs_path="uploads/test123/song.flac"
541
+ )
542
+
543
+ subtitle_offset = getattr(job, 'subtitle_offset_ms', 0) or 0
544
+
545
+ # Negative values should be preserved (not converted to 0)
546
+ assert subtitle_offset == -250
547
+
548
+
549
+ class TestRenderVideoWorkerCountdownPadding:
550
+ """Tests for render_video_worker countdown detection and audio padding.
551
+
552
+ This ensures that when corrections with countdown timestamps are loaded,
553
+ the audio is padded to match. This prevents video desynchronization.
554
+ """
555
+
556
+ def test_countdown_processor_import(self):
557
+ """Test that render_video_worker imports CountdownProcessor."""
558
+ from backend.workers.render_video_worker import CountdownProcessor
559
+ assert CountdownProcessor is not None
560
+
561
+ def test_countdown_processor_has_process_method(self):
562
+ """Test that CountdownProcessor has the process method (main API)."""
563
+ from lyrics_transcriber.output.countdown_processor import CountdownProcessor
564
+
565
+ countdown_processor = CountdownProcessor(cache_dir="/tmp")
566
+ assert hasattr(countdown_processor, 'process')
567
+ assert callable(countdown_processor.process)
568
+
569
+ def test_countdown_processor_constants(self):
570
+ """Test that CountdownProcessor has expected constants."""
571
+ from lyrics_transcriber.output.countdown_processor import CountdownProcessor
572
+
573
+ # Verify the constants exist (these control countdown behavior)
574
+ assert hasattr(CountdownProcessor, 'COUNTDOWN_TEXT')
575
+ assert hasattr(CountdownProcessor, 'COUNTDOWN_PADDING_SECONDS')
576
+ assert hasattr(CountdownProcessor, 'COUNTDOWN_THRESHOLD_SECONDS')
577
+
578
+
579
+ class TestDownloadHelpers:
580
+ """Tests for download helper functions in workers."""
581
+
582
+ @pytest.mark.asyncio
583
+ async def test_download_audio_handles_uploaded_file(self):
584
+ """Test download_audio handles jobs with uploaded file."""
585
+ mock_storage = MagicMock()
586
+ mock_storage.download_file.return_value = "/tmp/downloaded.flac"
587
+
588
+ mock_job = Job(
589
+ job_id="test123",
590
+ status=JobStatus.PENDING,
591
+ created_at=datetime.now(UTC),
592
+ updated_at=datetime.now(UTC),
593
+ artist="Test",
594
+ title="Test",
595
+ input_media_gcs_path="uploads/test123/song.flac" # Uploaded file
596
+ )
597
+
598
+ from backend.workers.audio_worker import download_audio
599
+
600
+ with tempfile.TemporaryDirectory() as temp_dir:
601
+ result = await download_audio("test123", temp_dir, mock_storage, mock_job)
602
+
603
+ # Should have downloaded from GCS
604
+ mock_storage.download_file.assert_called()
605
+
606
+
607
+ class TestAudioWorkerModelConfiguration:
608
+ """Tests for audio worker model configuration parameters."""
609
+
610
+ def test_create_audio_processor_with_defaults(self):
611
+ """Test creating AudioProcessor with default models."""
612
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
613
+ from backend.workers.audio_worker import create_audio_processor
614
+
615
+ with tempfile.TemporaryDirectory() as temp_dir:
616
+ result = create_audio_processor(temp_dir)
617
+
618
+ mock_processor.assert_called_once()
619
+ call_kwargs = mock_processor.call_args[1]
620
+
621
+ # Should use default models
622
+ assert call_kwargs['clean_instrumental_model'] == "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
623
+ assert call_kwargs['backing_vocals_models'] == ["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"]
624
+ assert call_kwargs['other_stems_models'] == ["htdemucs_6s.yaml"]
625
+
626
+ def test_create_audio_processor_with_custom_clean_model(self):
627
+ """Test creating AudioProcessor with custom clean instrumental model."""
628
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
629
+ from backend.workers.audio_worker import create_audio_processor
630
+
631
+ with tempfile.TemporaryDirectory() as temp_dir:
632
+ result = create_audio_processor(
633
+ temp_dir,
634
+ clean_instrumental_model="custom_clean_model.ckpt"
635
+ )
636
+
637
+ mock_processor.assert_called_once()
638
+ call_kwargs = mock_processor.call_args[1]
639
+
640
+ # Should use custom clean model
641
+ assert call_kwargs['clean_instrumental_model'] == "custom_clean_model.ckpt"
642
+ # Other models should still be defaults
643
+ assert call_kwargs['backing_vocals_models'] == ["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"]
644
+
645
+ def test_create_audio_processor_with_custom_backing_models(self):
646
+ """Test creating AudioProcessor with custom backing vocals models."""
647
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
648
+ from backend.workers.audio_worker import create_audio_processor
649
+
650
+ with tempfile.TemporaryDirectory() as temp_dir:
651
+ result = create_audio_processor(
652
+ temp_dir,
653
+ backing_vocals_models=["custom_bv1.ckpt", "custom_bv2.ckpt"]
654
+ )
655
+
656
+ mock_processor.assert_called_once()
657
+ call_kwargs = mock_processor.call_args[1]
658
+
659
+ # Should use custom backing vocals models
660
+ assert call_kwargs['backing_vocals_models'] == ["custom_bv1.ckpt", "custom_bv2.ckpt"]
661
+
662
+ def test_create_audio_processor_with_custom_other_stems_models(self):
663
+ """Test creating AudioProcessor with custom other stems models."""
664
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
665
+ from backend.workers.audio_worker import create_audio_processor
666
+
667
+ with tempfile.TemporaryDirectory() as temp_dir:
668
+ result = create_audio_processor(
669
+ temp_dir,
670
+ other_stems_models=["custom_demucs.yaml"]
671
+ )
672
+
673
+ mock_processor.assert_called_once()
674
+ call_kwargs = mock_processor.call_args[1]
675
+
676
+ # Should use custom other stems models
677
+ assert call_kwargs['other_stems_models'] == ["custom_demucs.yaml"]
678
+
679
+ def test_create_audio_processor_with_all_custom_models(self):
680
+ """Test creating AudioProcessor with all custom models."""
681
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
682
+ from backend.workers.audio_worker import create_audio_processor
683
+
684
+ with tempfile.TemporaryDirectory() as temp_dir:
685
+ result = create_audio_processor(
686
+ temp_dir,
687
+ clean_instrumental_model="custom_clean.ckpt",
688
+ backing_vocals_models=["custom_bv.ckpt"],
689
+ other_stems_models=["custom_stems.yaml"]
690
+ )
691
+
692
+ mock_processor.assert_called_once()
693
+ call_kwargs = mock_processor.call_args[1]
694
+
695
+ # All models should be custom
696
+ assert call_kwargs['clean_instrumental_model'] == "custom_clean.ckpt"
697
+ assert call_kwargs['backing_vocals_models'] == ["custom_bv.ckpt"]
698
+ assert call_kwargs['other_stems_models'] == ["custom_stems.yaml"]
699
+
700
+ def test_job_model_fields_are_passed_to_processor(self):
701
+ """Test that job model fields can be passed to create_audio_processor."""
702
+ mock_job = Job(
703
+ job_id="test123",
704
+ status=JobStatus.PENDING,
705
+ created_at=datetime.now(UTC),
706
+ updated_at=datetime.now(UTC),
707
+ artist="Test",
708
+ title="Test",
709
+ clean_instrumental_model="job_clean.ckpt",
710
+ backing_vocals_models=["job_bv.ckpt"],
711
+ other_stems_models=["job_stems.yaml"]
712
+ )
713
+
714
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
715
+ from backend.workers.audio_worker import create_audio_processor
716
+
717
+ with tempfile.TemporaryDirectory() as temp_dir:
718
+ # Simulate passing job model fields to create_audio_processor
719
+ result = create_audio_processor(
720
+ temp_dir,
721
+ clean_instrumental_model=mock_job.clean_instrumental_model,
722
+ backing_vocals_models=mock_job.backing_vocals_models,
723
+ other_stems_models=mock_job.other_stems_models
724
+ )
725
+
726
+ mock_processor.assert_called_once()
727
+ call_kwargs = mock_processor.call_args[1]
728
+
729
+ # Models from job should be used
730
+ assert call_kwargs['clean_instrumental_model'] == "job_clean.ckpt"
731
+ assert call_kwargs['backing_vocals_models'] == ["job_bv.ckpt"]
732
+ assert call_kwargs['other_stems_models'] == ["job_stems.yaml"]
733
+
734
+ def test_none_model_values_use_defaults(self):
735
+ """Test that None model values fall back to defaults."""
736
+ mock_job = Job(
737
+ job_id="test123",
738
+ status=JobStatus.PENDING,
739
+ created_at=datetime.now(UTC),
740
+ updated_at=datetime.now(UTC),
741
+ artist="Test",
742
+ title="Test",
743
+ clean_instrumental_model=None, # Not specified
744
+ backing_vocals_models=None, # Not specified
745
+ other_stems_models=None # Not specified
746
+ )
747
+
748
+ with patch('backend.workers.audio_worker.AudioProcessor') as mock_processor:
749
+ from backend.workers.audio_worker import create_audio_processor
750
+
751
+ with tempfile.TemporaryDirectory() as temp_dir:
752
+ result = create_audio_processor(
753
+ temp_dir,
754
+ clean_instrumental_model=mock_job.clean_instrumental_model,
755
+ backing_vocals_models=mock_job.backing_vocals_models,
756
+ other_stems_models=mock_job.other_stems_models
757
+ )
758
+
759
+ mock_processor.assert_called_once()
760
+ call_kwargs = mock_processor.call_args[1]
761
+
762
+ # Should fall back to defaults
763
+ assert call_kwargs['clean_instrumental_model'] == "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
764
+ assert call_kwargs['backing_vocals_models'] == ["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"]
765
+ assert call_kwargs['other_stems_models'] == ["htdemucs_6s.yaml"]
766
+
767
+
768
+ class TestDownloadFromUrl:
769
+ """Tests for download_from_url function - URL-based audio download."""
770
+
771
+ def test_download_from_url_function_exists(self):
772
+ """Test that download_from_url function exists in audio_worker."""
773
+ from backend.workers.audio_worker import download_from_url
774
+ assert callable(download_from_url)
775
+
776
+ def test_download_from_url_signature(self):
777
+ """Test that download_from_url has the expected signature."""
778
+ import inspect
779
+ from backend.workers.audio_worker import download_from_url
780
+
781
+ sig = inspect.signature(download_from_url)
782
+ params = list(sig.parameters.keys())
783
+
784
+ # Should have these parameters
785
+ assert 'url' in params
786
+ assert 'temp_dir' in params
787
+ assert 'artist' in params
788
+ assert 'title' in params
789
+ assert 'job_manager' in params
790
+ assert 'job_id' in params
791
+
792
+ def test_download_from_url_is_async(self):
793
+ """Test that download_from_url is an async function."""
794
+ import inspect
795
+ from backend.workers.audio_worker import download_from_url
796
+
797
+ assert inspect.iscoroutinefunction(download_from_url)
798
+
799
+ @pytest.mark.asyncio
800
+ async def test_download_from_url_returns_none_for_invalid_url(self):
801
+ """Test that download_from_url handles errors gracefully."""
802
+ from backend.workers.audio_worker import download_from_url
803
+
804
+ with tempfile.TemporaryDirectory() as temp_dir:
805
+ # This should fail gracefully (no yt-dlp in test env or invalid URL)
806
+ result = await download_from_url(
807
+ url='not-a-valid-url',
808
+ temp_dir=temp_dir,
809
+ artist='Test',
810
+ title='Test'
811
+ )
812
+
813
+ # Should return None on error (graceful failure)
814
+ assert result is None
815
+
816
+
817
+ class TestDownloadAudioWithUrl:
818
+ """Tests for download_audio function with URL-based jobs."""
819
+
820
+ @pytest.fixture
821
+ def mock_job_with_url(self):
822
+ """Create a mock job with a URL."""
823
+ return Job(
824
+ job_id="test123",
825
+ status=JobStatus.PROCESSING,
826
+ created_at=datetime.now(UTC),
827
+ updated_at=datetime.now(UTC),
828
+ artist="Test Artist",
829
+ title="Test Song",
830
+ url="https://www.youtube.com/watch?v=test123"
831
+ )
832
+
833
+ @pytest.fixture
834
+ def mock_job_manager(self, mock_job_with_url):
835
+ """Create a mock JobManager."""
836
+ manager = MagicMock()
837
+ manager.get_job.return_value = mock_job_with_url
838
+ manager.update_job.return_value = None
839
+ return manager
840
+
841
+ @pytest.fixture
842
+ def mock_storage(self):
843
+ """Create a mock StorageService."""
844
+ storage = MagicMock()
845
+ return storage
846
+
847
+ @pytest.mark.asyncio
848
+ async def test_download_audio_from_url(self, mock_job_with_url, mock_job_manager, mock_storage):
849
+ """Test download_audio routes to URL download when job has URL."""
850
+ with patch('backend.workers.audio_worker.download_from_url', return_value='/tmp/test.wav') as mock_download:
851
+ from backend.workers.audio_worker import download_audio
852
+
853
+ with tempfile.TemporaryDirectory() as temp_dir:
854
+ result = await download_audio(
855
+ "test123",
856
+ temp_dir,
857
+ mock_storage,
858
+ mock_job_with_url,
859
+ job_manager_instance=mock_job_manager
860
+ )
861
+
862
+ # Should have called download_from_url
863
+ mock_download.assert_called_once()
864
+ # Check positional args
865
+ args = mock_download.call_args[0]
866
+ assert args[0] == "https://www.youtube.com/watch?v=test123" # url
867
+ assert args[1] == temp_dir # temp_dir
868
+ assert args[2] == "Test Artist" # artist
869
+ assert args[3] == "Test Song" # title
870
+
871
+ @pytest.mark.asyncio
872
+ async def test_download_audio_from_gcs_when_no_url(self, mock_job_manager, mock_storage):
873
+ """Test download_audio downloads from GCS when job has no URL."""
874
+ mock_job_no_url = Job(
875
+ job_id="test123",
876
+ status=JobStatus.PROCESSING,
877
+ created_at=datetime.now(UTC),
878
+ updated_at=datetime.now(UTC),
879
+ artist="Test Artist",
880
+ title="Test Song",
881
+ input_media_gcs_path="uploads/test123/song.flac"
882
+ )
883
+
884
+ with patch('backend.workers.audio_worker.download_from_url') as mock_url_download:
885
+ from backend.workers.audio_worker import download_audio
886
+
887
+ with tempfile.TemporaryDirectory() as temp_dir:
888
+ result = await download_audio(
889
+ "test123",
890
+ temp_dir,
891
+ mock_storage,
892
+ mock_job_no_url,
893
+ job_manager_instance=mock_job_manager
894
+ )
895
+
896
+ # Should NOT have called download_from_url
897
+ mock_url_download.assert_not_called()
898
+
899
+ # Should have called storage.download_file
900
+ mock_storage.download_file.assert_called()
901
+
902
+
903
+ class TestBackingVocalsAnalysis:
904
+ """Tests for backing vocals analysis in render_video_worker."""
905
+
906
+ def test_analyze_backing_vocals_function_exists(self):
907
+ """Test that _analyze_backing_vocals function exists in render_video_worker."""
908
+ from backend.workers.render_video_worker import _analyze_backing_vocals
909
+ assert callable(_analyze_backing_vocals)
910
+
911
+ def test_analyze_backing_vocals_is_async(self):
912
+ """Test that _analyze_backing_vocals is an async function."""
913
+ import inspect
914
+ from backend.workers.render_video_worker import _analyze_backing_vocals
915
+ assert inspect.iscoroutinefunction(_analyze_backing_vocals)
916
+
917
+ def test_render_video_worker_imports_analysis_service(self):
918
+ """Test that render_video_worker can import AudioAnalysisService."""
919
+ # This verifies the import path is correct
920
+ from backend.services.audio_analysis_service import AudioAnalysisService
921
+ assert AudioAnalysisService is not None
922
+
923
+ @pytest.mark.asyncio
924
+ async def test_analyze_backing_vocals_handles_missing_job(self):
925
+ """Test that analysis returns early when job not found (no error stored)."""
926
+ mock_job_manager = MagicMock()
927
+ mock_job_manager.get_job.return_value = None # No job found
928
+ mock_job_manager.update_state_data = MagicMock()
929
+
930
+ mock_storage = MagicMock()
931
+ mock_logger = MagicMock()
932
+
933
+ from backend.workers.render_video_worker import _analyze_backing_vocals
934
+
935
+ # Should not raise when job not found, just log warning and return
936
+ await _analyze_backing_vocals(
937
+ "nonexistent", mock_job_manager, mock_storage, mock_logger
938
+ )
939
+
940
+ # Should log warning but not update state (early return)
941
+ mock_logger.warning.assert_called()
942
+ # No state_data update since we return early
943
+ mock_job_manager.update_state_data.assert_not_called()
944
+
945
+ @pytest.mark.asyncio
946
+ async def test_analyze_backing_vocals_handles_missing_stems(self):
947
+ """Test that analysis returns early when stems not found (no error stored)."""
948
+ mock_job = MagicMock()
949
+ mock_job.file_urls = {} # No stems
950
+
951
+ mock_job_manager = MagicMock()
952
+ mock_job_manager.get_job.return_value = mock_job
953
+ mock_job_manager.update_state_data = MagicMock()
954
+
955
+ mock_storage = MagicMock()
956
+ mock_logger = MagicMock()
957
+
958
+ from backend.workers.render_video_worker import _analyze_backing_vocals
959
+
960
+ # Should not raise when stems not found, just log warning and return
961
+ await _analyze_backing_vocals(
962
+ "test123", mock_job_manager, mock_storage, mock_logger
963
+ )
964
+
965
+ # Should log warning but not update state (early return)
966
+ mock_logger.warning.assert_called()
967
+ # No state_data update since we return early
968
+ mock_job_manager.update_state_data.assert_not_called()
969
+
970
+ def test_analysis_service_can_be_instantiated(self):
971
+ """Test that AudioAnalysisService can be instantiated."""
972
+ from backend.services.audio_analysis_service import AudioAnalysisService
973
+
974
+ mock_storage = MagicMock()
975
+ service = AudioAnalysisService(storage_service=mock_storage)
976
+
977
+ assert service is not None
978
+ assert service.storage_service == mock_storage
979
+
980
+
981
+ class TestModelNamesStorage:
982
+ """Tests for model names storage in audio_worker.
983
+
984
+ These tests verify that model names are stored in job state_data
985
+ for use by video_worker in distribution directory preparation.
986
+ """
987
+
988
+ def test_effective_model_names_defaults(self):
989
+ """Test that default model names are used when not specified on job."""
990
+ from backend.workers.audio_worker import (
991
+ DEFAULT_CLEAN_MODEL,
992
+ DEFAULT_BACKING_MODELS,
993
+ DEFAULT_OTHER_MODELS,
994
+ )
995
+
996
+ # Simulate the logic from process_audio_separation
997
+ job_clean_model = None
998
+ job_backing_models = None
999
+ job_other_models = None
1000
+
1001
+ effective_model_names = {
1002
+ 'clean_instrumental_model': job_clean_model or DEFAULT_CLEAN_MODEL,
1003
+ 'backing_vocals_models': job_backing_models or DEFAULT_BACKING_MODELS,
1004
+ 'other_stems_models': job_other_models or DEFAULT_OTHER_MODELS,
1005
+ }
1006
+
1007
+ assert effective_model_names['clean_instrumental_model'] == DEFAULT_CLEAN_MODEL
1008
+ assert effective_model_names['backing_vocals_models'] == DEFAULT_BACKING_MODELS
1009
+ assert effective_model_names['other_stems_models'] == DEFAULT_OTHER_MODELS
1010
+
1011
+ def test_effective_model_names_custom(self):
1012
+ """Test that custom model names override defaults."""
1013
+ from backend.workers.audio_worker import (
1014
+ DEFAULT_CLEAN_MODEL,
1015
+ DEFAULT_BACKING_MODELS,
1016
+ DEFAULT_OTHER_MODELS,
1017
+ )
1018
+
1019
+ custom_clean = "custom_clean_model.ckpt"
1020
+ custom_backing = ["custom_backing.ckpt"]
1021
+ custom_other = ["custom_demucs.yaml"]
1022
+
1023
+ # Simulate the logic from process_audio_separation
1024
+ effective_model_names = {
1025
+ 'clean_instrumental_model': custom_clean or DEFAULT_CLEAN_MODEL,
1026
+ 'backing_vocals_models': custom_backing or DEFAULT_BACKING_MODELS,
1027
+ 'other_stems_models': custom_other or DEFAULT_OTHER_MODELS,
1028
+ }
1029
+
1030
+ assert effective_model_names['clean_instrumental_model'] == custom_clean
1031
+ assert effective_model_names['backing_vocals_models'] == custom_backing
1032
+ assert effective_model_names['other_stems_models'] == custom_other
1033
+
1034
+
1035
+ class TestDistributionDirectoryPreparation:
1036
+ """Tests for _prepare_distribution_directory in video_worker.
1037
+
1038
+ These tests verify that the distribution directory is prepared with:
1039
+ - stems/ subfolder containing all audio stems with model names
1040
+ - lyrics/ subfolder containing intermediate lyrics files
1041
+ - Properly named instrumentals at root level
1042
+ """
1043
+
1044
+ def test_distribution_directory_creates_stems_folder(self, tmp_path):
1045
+ """Test that stems directory is created."""
1046
+ stems_dir = tmp_path / "stems"
1047
+ stems_dir.mkdir()
1048
+
1049
+ assert stems_dir.exists()
1050
+ assert stems_dir.is_dir()
1051
+
1052
+ def test_distribution_directory_creates_lyrics_folder(self, tmp_path):
1053
+ """Test that lyrics directory is created."""
1054
+ lyrics_dir = tmp_path / "lyrics"
1055
+ lyrics_dir.mkdir()
1056
+
1057
+ assert lyrics_dir.exists()
1058
+ assert lyrics_dir.is_dir()
1059
+
1060
+ def test_instrumental_naming_with_model(self):
1061
+ """Test that instrumental files are named with model names."""
1062
+ base_name = "Artist - Song"
1063
+ clean_model = "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
1064
+ backing_model = "mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"
1065
+
1066
+ clean_instrumental_name = f"{base_name} (Instrumental {clean_model}).flac"
1067
+ backing_instrumental_name = f"{base_name} (Instrumental +BV {backing_model}).flac"
1068
+
1069
+ # Verify expected format
1070
+ assert "model_bs_roformer" in clean_instrumental_name
1071
+ assert "+BV" in backing_instrumental_name
1072
+ assert backing_model in backing_instrumental_name
1073
+
1074
+ def test_stem_naming_convention(self):
1075
+ """Test that stems are named with proper model suffixes."""
1076
+ base_name = "Artist - Song"
1077
+ clean_model = "model_bs_roformer_ep_317_sdr_12.9755.ckpt"
1078
+ backing_model = "mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"
1079
+ other_model = "htdemucs_6s.yaml"
1080
+
1081
+ # Expected stem filenames
1082
+ expected_stems = {
1083
+ 'vocals_clean': f"{base_name} (Vocals {clean_model}).flac",
1084
+ 'lead_vocals': f"{base_name} (Lead Vocals {backing_model}).flac",
1085
+ 'backing_vocals': f"{base_name} (Backing Vocals {backing_model}).flac",
1086
+ 'bass': f"{base_name} (Bass {other_model}).flac",
1087
+ 'drums': f"{base_name} (Drums {other_model}).flac",
1088
+ }
1089
+
1090
+ # Verify naming convention
1091
+ for _key, name in expected_stems.items():
1092
+ assert ".flac" in name
1093
+ assert base_name in name
1094
+
1095
+ def test_simplified_instrumental_cleanup(self, tmp_path):
1096
+ """Test that simplified instrumental names are cleaned up."""
1097
+ base_name = "Artist - Song"
1098
+
1099
+ # Create simplified-named files (as created by _setup_working_directory)
1100
+ simplified_clean = tmp_path / f"{base_name} (Instrumental Clean).flac"
1101
+ simplified_backing = tmp_path / f"{base_name} (Instrumental Backing).flac"
1102
+ simplified_clean.write_text("fake")
1103
+ simplified_backing.write_text("fake")
1104
+
1105
+ # Verify they exist
1106
+ assert simplified_clean.exists()
1107
+ assert simplified_backing.exists()
1108
+
1109
+ # Simulate cleanup
1110
+ simplified_clean.unlink()
1111
+ simplified_backing.unlink()
1112
+
1113
+ # Verify they're removed
1114
+ assert not simplified_clean.exists()
1115
+ assert not simplified_backing.exists()
1116
+