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,431 @@
1
+ """
2
+ API endpoint tests for instrumental review functionality.
3
+
4
+ Tests for:
5
+ - GET /api/jobs/{job_id}/instrumental-analysis
6
+ - GET /api/jobs/{job_id}/audio-stream/{stem_type}
7
+ - POST /api/jobs/{job_id}/create-custom-instrumental
8
+ - GET /api/jobs/{job_id}/waveform-data
9
+ """
10
+
11
+ import pytest
12
+ from datetime import datetime
13
+ from unittest.mock import MagicMock, patch, AsyncMock
14
+
15
+ from backend.models.job import Job, JobStatus
16
+
17
+
18
+ @pytest.fixture
19
+ def mock_job_manager():
20
+ """Create a mock job manager."""
21
+ with patch('backend.api.routes.jobs.job_manager') as mock:
22
+ yield mock
23
+
24
+
25
+ @pytest.fixture
26
+ def mock_storage_service():
27
+ """Create a mock storage service."""
28
+ with patch('backend.api.routes.jobs.StorageService') as mock:
29
+ mock_instance = MagicMock()
30
+ mock_instance.generate_signed_url.return_value = "https://storage.googleapis.com/signed-url"
31
+ mock.return_value = mock_instance
32
+ yield mock_instance
33
+
34
+
35
+ @pytest.fixture
36
+ def sample_job():
37
+ """Create a sample job for testing."""
38
+ return Job(
39
+ job_id="test-job-123",
40
+ artist="Test Artist",
41
+ title="Test Song",
42
+ status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
43
+ created_at=datetime.now(),
44
+ updated_at=datetime.now(),
45
+ file_urls={
46
+ "stems": {
47
+ "instrumental_clean": "jobs/test/stems/instrumental_clean.flac",
48
+ "backing_vocals": "jobs/test/stems/backing_vocals.flac",
49
+ "instrumental_with_backing": "jobs/test/stems/instrumental_with_backing.flac",
50
+ },
51
+ "analysis": {
52
+ "backing_vocals_waveform": "jobs/test/analysis/waveform.png",
53
+ },
54
+ },
55
+ state_data={
56
+ "backing_vocals_analysis": {
57
+ "has_audible_content": True,
58
+ "total_duration_seconds": 180.0,
59
+ "audible_segments": [
60
+ {
61
+ "start_seconds": 10.0,
62
+ "end_seconds": 20.0,
63
+ "duration_seconds": 10.0,
64
+ "avg_amplitude_db": -25.0,
65
+ },
66
+ {
67
+ "start_seconds": 60.0,
68
+ "end_seconds": 80.0,
69
+ "duration_seconds": 20.0,
70
+ "avg_amplitude_db": -30.0,
71
+ },
72
+ ],
73
+ "recommended_selection": "review_needed",
74
+ "total_audible_duration_seconds": 30.0,
75
+ "audible_percentage": 16.67,
76
+ "silence_threshold_db": -40.0,
77
+ }
78
+ },
79
+ )
80
+
81
+
82
+ @pytest.fixture
83
+ def silent_job():
84
+ """Create a job with silent backing vocals."""
85
+ return Job(
86
+ job_id="test-job-silent",
87
+ artist="Test Artist",
88
+ title="Test Song",
89
+ status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
90
+ created_at=datetime.now(),
91
+ updated_at=datetime.now(),
92
+ file_urls={
93
+ "stems": {
94
+ "instrumental_clean": "jobs/test/stems/instrumental_clean.flac",
95
+ "backing_vocals": "jobs/test/stems/backing_vocals.flac",
96
+ "instrumental_with_backing": "jobs/test/stems/instrumental_with_backing.flac",
97
+ },
98
+ "analysis": {
99
+ "backing_vocals_waveform": "jobs/test/analysis/waveform.png",
100
+ },
101
+ },
102
+ state_data={
103
+ "backing_vocals_analysis": {
104
+ "has_audible_content": False,
105
+ "total_duration_seconds": 180.0,
106
+ "audible_segments": [],
107
+ "recommended_selection": "clean",
108
+ "total_audible_duration_seconds": 0.0,
109
+ "audible_percentage": 0.0,
110
+ "silence_threshold_db": -40.0,
111
+ }
112
+ },
113
+ )
114
+
115
+
116
+ class TestGetInstrumentalAnalysis:
117
+ """Tests for GET /api/jobs/{job_id}/instrumental-analysis."""
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_get_analysis_returns_data(self, mock_job_manager, mock_storage_service, sample_job):
121
+ """GET /instrumental-analysis should return analysis data."""
122
+ mock_job_manager.get_job.return_value = sample_job
123
+
124
+ from backend.api.routes.jobs import get_instrumental_analysis
125
+
126
+ result = await get_instrumental_analysis("test-job-123")
127
+
128
+ assert result["job_id"] == "test-job-123"
129
+ assert result["artist"] == "Test Artist"
130
+ assert result["title"] == "Test Song"
131
+ assert result["analysis"]["has_audible_content"] is True
132
+ assert len(result["analysis"]["audible_segments"]) == 2
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_get_analysis_includes_audio_urls(self, mock_job_manager, mock_storage_service, sample_job):
136
+ """GET /instrumental-analysis should include audio URLs."""
137
+ mock_job_manager.get_job.return_value = sample_job
138
+
139
+ from backend.api.routes.jobs import get_instrumental_analysis
140
+
141
+ result = await get_instrumental_analysis("test-job-123")
142
+
143
+ assert "audio_urls" in result
144
+ assert "clean_instrumental" in result["audio_urls"]
145
+ assert "backing_vocals" in result["audio_urls"]
146
+ assert "with_backing" in result["audio_urls"]
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_get_analysis_includes_waveform_url(self, mock_job_manager, sample_job):
150
+ """GET /instrumental-analysis should include waveform URL."""
151
+ mock_job_manager.get_job.return_value = sample_job
152
+
153
+ # Need to mock StorageService at module level for the signed URL
154
+ with patch('backend.api.routes.jobs.StorageService') as mock_storage_cls:
155
+ mock_storage = MagicMock()
156
+ mock_storage.generate_signed_url.return_value = "https://storage.googleapis.com/signed-url"
157
+ mock_storage_cls.return_value = mock_storage
158
+
159
+ from backend.api.routes.jobs import get_instrumental_analysis
160
+
161
+ result = await get_instrumental_analysis("test-job-123")
162
+
163
+ assert "waveform_url" in result
164
+ # Waveform URL should be generated from the file_urls
165
+ assert result["waveform_url"] == "https://storage.googleapis.com/signed-url"
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_get_analysis_silent_audio(self, mock_job_manager, mock_storage_service, silent_job):
169
+ """GET /instrumental-analysis should correctly report silent audio."""
170
+ mock_job_manager.get_job.return_value = silent_job
171
+
172
+ from backend.api.routes.jobs import get_instrumental_analysis
173
+
174
+ result = await get_instrumental_analysis("test-job-silent")
175
+
176
+ assert result["analysis"]["has_audible_content"] is False
177
+ assert result["analysis"]["recommended_selection"] == "clean"
178
+ assert len(result["analysis"]["audible_segments"]) == 0
179
+
180
+ @pytest.mark.asyncio
181
+ async def test_get_analysis_job_not_found(self, mock_job_manager):
182
+ """GET /instrumental-analysis should return 404 for non-existent job."""
183
+ mock_job_manager.get_job.return_value = None
184
+
185
+ from backend.api.routes.jobs import get_instrumental_analysis
186
+ from fastapi import HTTPException
187
+
188
+ with pytest.raises(HTTPException) as exc_info:
189
+ await get_instrumental_analysis("non-existent")
190
+
191
+ assert exc_info.value.status_code == 404
192
+
193
+ @pytest.mark.asyncio
194
+ async def test_get_analysis_wrong_status(self, mock_job_manager, sample_job):
195
+ """GET /instrumental-analysis should return 400 for wrong job status."""
196
+ sample_job.status = JobStatus.PENDING
197
+ mock_job_manager.get_job.return_value = sample_job
198
+
199
+ from backend.api.routes.jobs import get_instrumental_analysis
200
+ from fastapi import HTTPException
201
+
202
+ with pytest.raises(HTTPException) as exc_info:
203
+ await get_instrumental_analysis("test-job-123")
204
+
205
+ assert exc_info.value.status_code == 400
206
+
207
+
208
+ class TestAudioStream:
209
+ """Tests for GET /api/jobs/{job_id}/audio-stream/{stem_type}."""
210
+
211
+ def test_stream_backing_vocals_valid(self, mock_job_manager, sample_job):
212
+ """Should return redirect for backing vocals stream."""
213
+ mock_job_manager.get_job.return_value = sample_job
214
+
215
+ from backend.api.routes.jobs import stream_audio
216
+
217
+ # This will try to stream from GCS which we can't test easily
218
+ # Instead, verify the function exists and has correct signature
219
+ assert callable(stream_audio)
220
+
221
+ @pytest.mark.asyncio
222
+ async def test_stream_invalid_stem_type(self, mock_job_manager, sample_job):
223
+ """Should return 400 for invalid stem type."""
224
+ mock_job_manager.get_job.return_value = sample_job
225
+
226
+ from backend.api.routes.jobs import stream_audio
227
+ from fastapi import HTTPException
228
+
229
+ with pytest.raises(HTTPException) as exc_info:
230
+ await stream_audio("test-job-123", "invalid_stem")
231
+
232
+ assert exc_info.value.status_code == 400
233
+
234
+ @pytest.mark.asyncio
235
+ async def test_stream_job_not_found(self, mock_job_manager):
236
+ """Should return 404 for non-existent job."""
237
+ mock_job_manager.get_job.return_value = None
238
+
239
+ from backend.api.routes.jobs import stream_audio
240
+ from fastapi import HTTPException
241
+
242
+ with pytest.raises(HTTPException) as exc_info:
243
+ await stream_audio("non-existent", "backing_vocals")
244
+
245
+ assert exc_info.value.status_code == 404
246
+
247
+
248
+ class TestCreateCustomInstrumental:
249
+ """Tests for POST /api/jobs/{job_id}/create-custom-instrumental."""
250
+
251
+ @pytest.fixture
252
+ def mock_audio_editing_service(self):
253
+ """Mock the audio editing service."""
254
+ with patch('backend.api.routes.jobs.AudioEditingService') as mock:
255
+ mock_instance = MagicMock()
256
+ mock_instance.create_custom_instrumental.return_value = MagicMock(
257
+ output_path="jobs/test/stems/custom_instrumental.flac",
258
+ mute_regions_applied=[
259
+ MagicMock(start_seconds=10.0, end_seconds=20.0),
260
+ ],
261
+ total_muted_duration_seconds=10.0,
262
+ output_duration_seconds=180.0,
263
+ )
264
+ mock.return_value = mock_instance
265
+ yield mock_instance
266
+
267
+ def test_create_custom_validates_mute_regions(self):
268
+ """Should validate mute regions at model level."""
269
+ from backend.models.requests import CreateCustomInstrumentalRequest, MuteRegionRequest
270
+ from pydantic import ValidationError
271
+
272
+ # Empty mute regions should fail at model validation
273
+ with pytest.raises(ValidationError) as exc_info:
274
+ CreateCustomInstrumentalRequest(mute_regions=[])
275
+
276
+ assert "At least one mute region is required" in str(exc_info.value)
277
+
278
+ @pytest.mark.asyncio
279
+ async def test_create_custom_job_not_found(self, mock_job_manager):
280
+ """Should return 404 for non-existent job."""
281
+ mock_job_manager.get_job.return_value = None
282
+
283
+ from backend.api.routes.jobs import create_custom_instrumental
284
+ from backend.models.requests import CreateCustomInstrumentalRequest, MuteRegionRequest
285
+ from fastapi import HTTPException
286
+
287
+ request = CreateCustomInstrumentalRequest(
288
+ mute_regions=[MuteRegionRequest(start_seconds=10.0, end_seconds=20.0)]
289
+ )
290
+
291
+ with pytest.raises(HTTPException) as exc_info:
292
+ await create_custom_instrumental("non-existent", request)
293
+
294
+ assert exc_info.value.status_code == 404
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_create_custom_wrong_status(self, mock_job_manager, sample_job):
298
+ """Should return 400 for wrong job status."""
299
+ sample_job.status = JobStatus.PENDING
300
+ mock_job_manager.get_job.return_value = sample_job
301
+
302
+ from backend.api.routes.jobs import create_custom_instrumental
303
+ from backend.models.requests import CreateCustomInstrumentalRequest, MuteRegionRequest
304
+ from fastapi import HTTPException
305
+
306
+ request = CreateCustomInstrumentalRequest(
307
+ mute_regions=[MuteRegionRequest(start_seconds=10.0, end_seconds=20.0)]
308
+ )
309
+
310
+ with pytest.raises(HTTPException) as exc_info:
311
+ await create_custom_instrumental("test-job-123", request)
312
+
313
+ assert exc_info.value.status_code == 400
314
+
315
+
316
+ class TestGetWaveformData:
317
+ """Tests for GET /api/jobs/{job_id}/waveform-data."""
318
+
319
+ @pytest.mark.asyncio
320
+ async def test_get_waveform_data_job_not_found(self, mock_job_manager):
321
+ """Should return 404 for non-existent job."""
322
+ mock_job_manager.get_job.return_value = None
323
+
324
+ from backend.api.routes.jobs import get_waveform_data
325
+ from fastapi import HTTPException
326
+
327
+ with pytest.raises(HTTPException) as exc_info:
328
+ await get_waveform_data("non-existent")
329
+
330
+ assert exc_info.value.status_code == 404
331
+
332
+ @pytest.mark.asyncio
333
+ async def test_get_waveform_data_wrong_status(self, mock_job_manager, sample_job):
334
+ """Should return 400 for wrong job status."""
335
+ sample_job.status = JobStatus.PENDING
336
+ mock_job_manager.get_job.return_value = sample_job
337
+
338
+ from backend.api.routes.jobs import get_waveform_data
339
+ from fastapi import HTTPException
340
+
341
+ with pytest.raises(HTTPException) as exc_info:
342
+ await get_waveform_data("test-job-123")
343
+
344
+ assert exc_info.value.status_code == 400
345
+
346
+ def test_waveform_data_endpoint_exists(self):
347
+ """Verify the endpoint function exists and is callable."""
348
+ from backend.api.routes.jobs import get_waveform_data
349
+
350
+ assert callable(get_waveform_data)
351
+ # Verify it's an async function
352
+ import inspect
353
+ assert inspect.iscoroutinefunction(get_waveform_data)
354
+
355
+
356
+ class TestRequestModels:
357
+ """Tests for API request models."""
358
+
359
+ def test_mute_region_request_valid(self):
360
+ """MuteRegionRequest should accept valid values."""
361
+ from backend.models.requests import MuteRegionRequest
362
+
363
+ region = MuteRegionRequest(start_seconds=10.0, end_seconds=20.0)
364
+
365
+ assert region.start_seconds == 10.0
366
+ assert region.end_seconds == 20.0
367
+
368
+ def test_mute_region_request_invalid_start(self):
369
+ """MuteRegionRequest should reject negative start."""
370
+ from backend.models.requests import MuteRegionRequest
371
+ from pydantic import ValidationError
372
+
373
+ with pytest.raises(ValidationError):
374
+ MuteRegionRequest(start_seconds=-1.0, end_seconds=20.0)
375
+
376
+ def test_mute_region_request_invalid_order(self):
377
+ """MuteRegionRequest should reject end before start."""
378
+ from backend.models.requests import MuteRegionRequest
379
+ from pydantic import ValidationError
380
+
381
+ with pytest.raises(ValidationError):
382
+ MuteRegionRequest(start_seconds=20.0, end_seconds=10.0)
383
+
384
+ def test_create_custom_instrumental_request_valid(self):
385
+ """CreateCustomInstrumentalRequest should accept valid mute regions."""
386
+ from backend.models.requests import (
387
+ CreateCustomInstrumentalRequest,
388
+ MuteRegionRequest,
389
+ )
390
+
391
+ request = CreateCustomInstrumentalRequest(
392
+ mute_regions=[
393
+ MuteRegionRequest(start_seconds=10.0, end_seconds=20.0),
394
+ MuteRegionRequest(start_seconds=60.0, end_seconds=80.0),
395
+ ]
396
+ )
397
+
398
+ assert len(request.mute_regions) == 2
399
+
400
+
401
+ class TestInstrumentalSelectionExtension:
402
+ """Tests for extended instrumental selection (including 'custom')."""
403
+
404
+ def test_instrumental_selection_accepts_custom(self):
405
+ """InstrumentalSelection should accept 'custom' as valid selection."""
406
+ from backend.models.requests import InstrumentalSelection
407
+
408
+ selection = InstrumentalSelection(selection="custom")
409
+ assert selection.selection == "custom"
410
+
411
+ def test_instrumental_selection_accepts_clean(self):
412
+ """InstrumentalSelection should accept 'clean' as valid selection."""
413
+ from backend.models.requests import InstrumentalSelection
414
+
415
+ selection = InstrumentalSelection(selection="clean")
416
+ assert selection.selection == "clean"
417
+
418
+ def test_instrumental_selection_accepts_with_backing(self):
419
+ """InstrumentalSelection should accept 'with_backing' as valid selection."""
420
+ from backend.models.requests import InstrumentalSelection
421
+
422
+ selection = InstrumentalSelection(selection="with_backing")
423
+ assert selection.selection == "with_backing"
424
+
425
+ def test_instrumental_selection_rejects_invalid(self):
426
+ """InstrumentalSelection should reject invalid values."""
427
+ from backend.models.requests import InstrumentalSelection
428
+ from pydantic import ValidationError
429
+
430
+ with pytest.raises(ValidationError):
431
+ InstrumentalSelection(selection="invalid_option")