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,1398 @@
1
+ """
2
+ Tests for audio search functionality (Batch 5).
3
+
4
+ Tests the audio search service, API routes, and job model fields.
5
+ """
6
+ import pytest
7
+ import os
8
+ from unittest.mock import Mock, patch, MagicMock
9
+ from datetime import datetime
10
+
11
+ from backend.models.job import Job, JobCreate, JobStatus, STATE_TRANSITIONS
12
+ from backend.services.audio_search_service import (
13
+ AudioSearchService,
14
+ AudioSearchResult,
15
+ AudioDownloadResult,
16
+ AudioSearchError,
17
+ NoResultsError,
18
+ DownloadError,
19
+ get_audio_search_service,
20
+ )
21
+
22
+
23
+ class TestJobModelAudioSearchFields:
24
+ """Test Job model has audio search fields."""
25
+
26
+ def test_job_has_audio_search_fields(self):
27
+ """Test Job model has audio search configuration fields."""
28
+ job = Job(
29
+ job_id="test123",
30
+ status=JobStatus.PENDING,
31
+ created_at=datetime.utcnow(),
32
+ updated_at=datetime.utcnow(),
33
+ artist="ABBA",
34
+ title="Waterloo",
35
+ audio_search_artist="ABBA",
36
+ audio_search_title="Waterloo",
37
+ auto_download=True,
38
+ )
39
+
40
+ assert job.audio_search_artist == "ABBA"
41
+ assert job.audio_search_title == "Waterloo"
42
+ assert job.auto_download is True
43
+
44
+ def test_job_audio_search_fields_default_values(self):
45
+ """Test default values for audio search fields."""
46
+ job = Job(
47
+ job_id="test123",
48
+ status=JobStatus.PENDING,
49
+ created_at=datetime.utcnow(),
50
+ updated_at=datetime.utcnow(),
51
+ )
52
+
53
+ assert job.audio_search_artist is None
54
+ assert job.audio_search_title is None
55
+ assert job.auto_download is False
56
+
57
+ def test_job_create_has_audio_search_fields(self):
58
+ """Test JobCreate model has audio search fields."""
59
+ job_create = JobCreate(
60
+ artist="ABBA",
61
+ title="Waterloo",
62
+ audio_search_artist="ABBA",
63
+ audio_search_title="Waterloo",
64
+ auto_download=True,
65
+ )
66
+
67
+ assert job_create.audio_search_artist == "ABBA"
68
+ assert job_create.audio_search_title == "Waterloo"
69
+ assert job_create.auto_download is True
70
+
71
+
72
+ class TestYouTubeFieldMapping:
73
+ """Test YouTube description field mapping between API and workers.
74
+
75
+ CRITICAL: This test class exists because we had a bug where:
76
+ - The audio_search API endpoint set `youtube_description`
77
+ - But video_worker.py reads `youtube_description_template`
78
+
79
+ These tests ensure both fields are properly set so YouTube uploads work.
80
+ """
81
+
82
+ def test_job_has_both_youtube_description_fields(self):
83
+ """Test Job model has both youtube_description and youtube_description_template."""
84
+ job = Job(
85
+ job_id="test123",
86
+ status=JobStatus.PENDING,
87
+ created_at=datetime.utcnow(),
88
+ updated_at=datetime.utcnow(),
89
+ artist="Test",
90
+ title="Song",
91
+ enable_youtube_upload=True,
92
+ youtube_description="This is a karaoke video",
93
+ youtube_description_template="This is a karaoke video",
94
+ )
95
+
96
+ # Both fields should exist and be set
97
+ assert job.youtube_description == "This is a karaoke video"
98
+ assert job.youtube_description_template == "This is a karaoke video"
99
+ assert job.enable_youtube_upload is True
100
+
101
+ def test_job_create_has_youtube_description_template(self):
102
+ """Test JobCreate model has youtube_description_template field."""
103
+ job_create = JobCreate(
104
+ artist="Test",
105
+ title="Song",
106
+ enable_youtube_upload=True,
107
+ youtube_description="This is a karaoke video",
108
+ youtube_description_template="This is a karaoke video",
109
+ )
110
+
111
+ assert job_create.youtube_description_template == "This is a karaoke video"
112
+
113
+ def test_video_worker_reads_youtube_description_template(self):
114
+ """Document what field video_worker expects.
115
+
116
+ This is a documentation test - if video_worker changes what field
117
+ it reads, this test should be updated to match.
118
+ """
119
+ # video_worker.py uses this pattern:
120
+ # if youtube_credentials and getattr(job, 'youtube_description_template', None):
121
+ # youtube_desc_path = os.path.join(temp_dir, "youtube_description.txt")
122
+ # with open(youtube_desc_path, 'w') as f:
123
+ # f.write(job.youtube_description_template)
124
+ #
125
+ # So the job MUST have youtube_description_template set for YouTube upload to work
126
+
127
+ job = Job(
128
+ job_id="test123",
129
+ status=JobStatus.PENDING,
130
+ created_at=datetime.utcnow(),
131
+ updated_at=datetime.utcnow(),
132
+ artist="Test",
133
+ title="Song",
134
+ enable_youtube_upload=True,
135
+ youtube_description_template="Template text",
136
+ )
137
+
138
+ # Simulate what video_worker does
139
+ template = getattr(job, 'youtube_description_template', None)
140
+ assert template is not None, "youtube_description_template must be set for YouTube upload"
141
+ assert template == "Template text"
142
+
143
+
144
+ class TestJobStatusAudioSearchStates:
145
+ """Test Job status includes audio search states."""
146
+
147
+ def test_audio_search_statuses_exist(self):
148
+ """Test audio search status values exist."""
149
+ assert hasattr(JobStatus, 'SEARCHING_AUDIO')
150
+ assert hasattr(JobStatus, 'AWAITING_AUDIO_SELECTION')
151
+ assert hasattr(JobStatus, 'DOWNLOADING_AUDIO')
152
+
153
+ assert JobStatus.SEARCHING_AUDIO == "searching_audio"
154
+ assert JobStatus.AWAITING_AUDIO_SELECTION == "awaiting_audio_selection"
155
+ assert JobStatus.DOWNLOADING_AUDIO == "downloading_audio"
156
+
157
+ def test_state_transitions_include_audio_search_flow(self):
158
+ """Test state transitions include audio search flow."""
159
+ # PENDING can go to SEARCHING_AUDIO
160
+ assert JobStatus.SEARCHING_AUDIO in STATE_TRANSITIONS[JobStatus.PENDING]
161
+
162
+ # SEARCHING_AUDIO can go to AWAITING_AUDIO_SELECTION or DOWNLOADING_AUDIO
163
+ assert JobStatus.AWAITING_AUDIO_SELECTION in STATE_TRANSITIONS[JobStatus.SEARCHING_AUDIO]
164
+ assert JobStatus.DOWNLOADING_AUDIO in STATE_TRANSITIONS[JobStatus.SEARCHING_AUDIO]
165
+
166
+ # AWAITING_AUDIO_SELECTION can go to DOWNLOADING_AUDIO
167
+ assert JobStatus.DOWNLOADING_AUDIO in STATE_TRANSITIONS[JobStatus.AWAITING_AUDIO_SELECTION]
168
+
169
+ # DOWNLOADING_AUDIO can go to DOWNLOADING
170
+ assert JobStatus.DOWNLOADING in STATE_TRANSITIONS[JobStatus.DOWNLOADING_AUDIO]
171
+
172
+
173
+ class TestAudioSearchResult:
174
+ """Test AudioSearchResult dataclass.
175
+
176
+ AudioSearchResult is now imported from karaoke_gen.audio_fetcher.
177
+ These tests verify the backend can use it correctly.
178
+ """
179
+
180
+ def test_create_audio_search_result(self):
181
+ """Test creating an AudioSearchResult."""
182
+ result = AudioSearchResult(
183
+ title="Waterloo",
184
+ artist="ABBA",
185
+ provider="YouTube",
186
+ url="https://youtube.com/watch?v=abc123",
187
+ duration=180,
188
+ quality="FLAC",
189
+ source_id="abc123",
190
+ index=0,
191
+ )
192
+
193
+ assert result.title == "Waterloo"
194
+ assert result.artist == "ABBA"
195
+ assert result.provider == "YouTube"
196
+ assert result.url == "https://youtube.com/watch?v=abc123"
197
+ assert result.duration == 180
198
+ assert result.quality == "FLAC"
199
+ assert result.source_id == "abc123"
200
+ assert result.index == 0
201
+
202
+ def test_to_dict(self):
203
+ """Test converting to dict for serialization."""
204
+ result = AudioSearchResult(
205
+ title="Waterloo",
206
+ artist="ABBA",
207
+ provider="YouTube",
208
+ url="https://youtube.com/watch?v=abc123",
209
+ index=0,
210
+ )
211
+
212
+ data = result.to_dict()
213
+
214
+ assert data['title'] == "Waterloo"
215
+ assert data['artist'] == "ABBA"
216
+ assert data['provider'] == "YouTube"
217
+ assert data['url'] == "https://youtube.com/watch?v=abc123"
218
+ assert data['index'] == 0
219
+ # raw_result should NOT be in serialized dict
220
+ assert 'raw_result' not in data
221
+
222
+ def test_from_dict(self):
223
+ """Test creating from dict."""
224
+ data = {
225
+ 'title': "Waterloo",
226
+ 'artist': "ABBA",
227
+ 'provider': "YouTube",
228
+ 'url': "https://youtube.com/watch?v=abc123",
229
+ 'duration': 180,
230
+ 'quality': "FLAC",
231
+ 'source_id': "abc123",
232
+ 'index': 0,
233
+ }
234
+
235
+ result = AudioSearchResult.from_dict(data)
236
+
237
+ assert result.title == "Waterloo"
238
+ assert result.artist == "ABBA"
239
+ assert result.provider == "YouTube"
240
+ assert result.index == 0
241
+ assert result.raw_result is None # Not set from dict
242
+
243
+
244
+ class TestAudioSearchServiceInit:
245
+ """Test AudioSearchService initialization."""
246
+
247
+ @patch.dict('os.environ', {'RED_API_KEY': '', 'RED_API_URL': '', 'OPS_API_KEY': '', 'OPS_API_URL': ''}, clear=False)
248
+ def test_init_with_no_keys(self):
249
+ """Test initialization without API keys."""
250
+ # Pass explicit None to override environment
251
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
252
+
253
+ # Service should have a FlacFetcher internally
254
+ assert service._fetcher is not None
255
+ assert service._cached_results == []
256
+
257
+ def test_init_with_keys(self):
258
+ """Test initialization with API keys and URLs."""
259
+ service = AudioSearchService(
260
+ red_api_key="test_red_key",
261
+ red_api_url="https://red.url",
262
+ ops_api_key="test_ops_key",
263
+ ops_api_url="https://ops.url",
264
+ )
265
+
266
+ # Keys and URLs are passed to FlacFetcher
267
+ assert service._fetcher._red_api_key == "test_red_key"
268
+ assert service._fetcher._red_api_url == "https://red.url"
269
+ assert service._fetcher._ops_api_key == "test_ops_key"
270
+ assert service._fetcher._ops_api_url == "https://ops.url"
271
+
272
+ def test_init_reads_from_environment(self):
273
+ """Test initialization reads keys from environment variables."""
274
+ # Just test that the service can be initialized
275
+ service = AudioSearchService()
276
+
277
+ # The service should have a FlacFetcher
278
+ assert service._fetcher is not None
279
+
280
+
281
+ class TestAudioSearchServiceSearch:
282
+ """Test AudioSearchService.search() method."""
283
+
284
+ def test_search_returns_results(self):
285
+ """Test search returns AudioSearchResult list."""
286
+ # Create mock result
287
+ mock_result = AudioSearchResult(
288
+ title="Waterloo",
289
+ artist="ABBA",
290
+ provider="YouTube",
291
+ url="https://youtube.com/watch?v=abc123",
292
+ duration=180,
293
+ quality="FLAC",
294
+ source_id="abc123",
295
+ index=0,
296
+ )
297
+
298
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
299
+ # IMPORTANT: Clear remote client to force local mode, otherwise real API calls are made
300
+ service._remote_client = None
301
+ service._fetcher = Mock()
302
+ service._fetcher.search.return_value = [mock_result]
303
+
304
+ results = service.search("ABBA", "Waterloo")
305
+
306
+ assert len(results) == 1
307
+ assert results[0].title == "Waterloo"
308
+ assert results[0].artist == "ABBA"
309
+ assert results[0].provider == "YouTube"
310
+ assert results[0].index == 0
311
+
312
+ def test_search_no_results_raises_error(self):
313
+ """Test search raises NoResultsError when no results."""
314
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
315
+ service._remote_client = None # Force local mode to use mocked fetcher
316
+ service._fetcher = Mock()
317
+ service._fetcher.search.side_effect = NoResultsError("No results found")
318
+
319
+ with pytest.raises(NoResultsError) as exc_info:
320
+ service.search("Unknown Artist", "Unknown Song")
321
+
322
+ assert "No results found" in str(exc_info.value)
323
+
324
+ def test_search_multiple_results(self):
325
+ """Test search returns multiple results with correct indices."""
326
+ mock_results = []
327
+ for i in range(3):
328
+ mock_results.append(AudioSearchResult(
329
+ title=f"Song {i}",
330
+ artist="Artist",
331
+ provider=["YouTube", "RED", "OPS"][i],
332
+ url=f"https://example.com/{i}",
333
+ duration=180 + i * 10,
334
+ quality=["320kbps", "FLAC", "FLAC"][i],
335
+ source_id=str(i),
336
+ index=i,
337
+ ))
338
+
339
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
340
+ service._remote_client = None # Force local mode to use mocked fetcher
341
+ service._fetcher = Mock()
342
+ service._fetcher.search.return_value = mock_results
343
+
344
+ results = service.search("Artist", "Song")
345
+
346
+ assert len(results) == 3
347
+ assert results[0].index == 0
348
+ assert results[1].index == 1
349
+ assert results[2].index == 2
350
+
351
+
352
+ class TestAudioSearchServiceSelectBest:
353
+ """Test AudioSearchService.select_best() method."""
354
+
355
+ def test_select_best_delegates_to_fetcher(self):
356
+ """Test select_best uses FlacFetcher's select_best."""
357
+ mock_results = [
358
+ AudioSearchResult(
359
+ title="Waterloo",
360
+ artist="ABBA",
361
+ provider="YouTube",
362
+ url="https://youtube.com/watch?v=abc123",
363
+ index=0,
364
+ ),
365
+ AudioSearchResult(
366
+ title="Waterloo",
367
+ artist="ABBA",
368
+ provider="RED",
369
+ url="https://example.com/456",
370
+ index=1,
371
+ ),
372
+ ]
373
+
374
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
375
+ service._fetcher = Mock()
376
+ service._fetcher.select_best.return_value = 1
377
+
378
+ best_index = service.select_best(mock_results)
379
+
380
+ assert best_index == 1
381
+ service._fetcher.select_best.assert_called_once_with(mock_results)
382
+
383
+ def test_select_best_empty_list_returns_zero(self):
384
+ """Test select_best returns 0 for empty list."""
385
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
386
+ service._fetcher = Mock()
387
+ service._fetcher.select_best.return_value = 0
388
+
389
+ best_index = service.select_best([])
390
+
391
+ assert best_index == 0
392
+
393
+
394
+ class TestAudioSearchServiceDownload:
395
+ """Test AudioSearchService.download() method."""
396
+
397
+ def test_download_without_search_raises_error(self):
398
+ """Test download without prior search raises error."""
399
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
400
+
401
+ with pytest.raises(DownloadError) as exc_info:
402
+ service.download(0, "/tmp")
403
+
404
+ assert "No cached result" in str(exc_info.value)
405
+
406
+ def test_download_after_search(self):
407
+ """Test download after search works."""
408
+ import tempfile
409
+
410
+ # Create a temp file to simulate downloaded file
411
+ temp_dir = tempfile.mkdtemp()
412
+ temp_file = os.path.join(temp_dir, "test.flac")
413
+ with open(temp_file, 'w') as f:
414
+ f.write("test")
415
+
416
+ mock_result = AudioSearchResult(
417
+ title="Waterloo",
418
+ artist="ABBA",
419
+ provider="YouTube",
420
+ url="https://youtube.com/watch?v=abc123",
421
+ duration=180,
422
+ quality="FLAC",
423
+ source_id="abc123",
424
+ index=0,
425
+ )
426
+
427
+ mock_fetch_result = AudioDownloadResult(
428
+ filepath=temp_file,
429
+ artist="ABBA",
430
+ title="Waterloo",
431
+ provider="YouTube",
432
+ duration=180,
433
+ quality="FLAC",
434
+ )
435
+
436
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
437
+ service._remote_client = None # Force local mode to use mocked fetcher
438
+ service._fetcher = Mock()
439
+ service._fetcher.search.return_value = [mock_result]
440
+ service._fetcher.download.return_value = mock_fetch_result
441
+
442
+ service.search("ABBA", "Waterloo")
443
+ result = service.download(0, temp_dir)
444
+
445
+ assert result.filepath == temp_file
446
+ assert result.artist == "ABBA"
447
+ assert result.title == "Waterloo"
448
+ assert result.provider == "YouTube"
449
+
450
+ # Cleanup
451
+ os.remove(temp_file)
452
+ os.rmdir(temp_dir)
453
+
454
+ def test_download_invalid_index_raises_error(self):
455
+ """Test download with invalid index raises error."""
456
+ mock_result = AudioSearchResult(
457
+ title="Waterloo",
458
+ artist="ABBA",
459
+ provider="YouTube",
460
+ url="https://youtube.com/watch?v=abc123",
461
+ index=0,
462
+ )
463
+
464
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
465
+ service._fetcher = Mock()
466
+ service._fetcher.search.return_value = [mock_result]
467
+ # Ensure no remote client so we test the local cache path
468
+ service._remote_client = None
469
+
470
+ service.search("ABBA", "Waterloo")
471
+
472
+ with pytest.raises(DownloadError) as exc_info:
473
+ service.download(99, "/tmp")
474
+
475
+ assert "No cached result for index 99" in str(exc_info.value)
476
+
477
+
478
+ class TestGetAudioSearchService:
479
+ """Test get_audio_search_service singleton."""
480
+
481
+ def test_get_audio_search_service_returns_instance(self):
482
+ """Test get_audio_search_service returns an instance."""
483
+ # Reset singleton
484
+ import backend.services.audio_search_service as module
485
+ module._audio_search_service = None
486
+
487
+ service = get_audio_search_service()
488
+
489
+ assert service is not None
490
+ assert isinstance(service, AudioSearchService)
491
+
492
+ def test_get_audio_search_service_singleton(self):
493
+ """Test get_audio_search_service returns same instance."""
494
+ service1 = get_audio_search_service()
495
+ service2 = get_audio_search_service()
496
+
497
+ assert service1 is service2
498
+
499
+
500
+ class TestRemoteDownloadPath:
501
+ """Test remote download functionality when flacfetch service is configured.
502
+
503
+ These tests verify the code path that uses the remote flacfetch service
504
+ for torrent downloads (RED/OPS providers). This would have caught
505
+ the 'extra_info' attribute error.
506
+
507
+ The tests directly set up the service state without calling search() to avoid
508
+ the complexity of mocking async remote search operations.
509
+ """
510
+
511
+ def test_download_uses_remote_for_red_provider(self):
512
+ """Test download routes to remote service for RED provider.
513
+
514
+ This test would have caught: 'AudioSearchResult' object has no attribute 'extra_info'
515
+ """
516
+ # Create a RED result (torrent source)
517
+ mock_result = AudioSearchResult(
518
+ title="Waterloo",
519
+ artist="ABBA",
520
+ provider="RED", # Torrent provider - should use remote
521
+ url="", # No URL for torrent sources
522
+ quality="FLAC 16bit CD",
523
+ seeders=50,
524
+ target_file="Waterloo.flac",
525
+ index=0,
526
+ )
527
+
528
+ # Create service with mocked remote client
529
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
530
+ service._fetcher = Mock()
531
+
532
+ # Directly set cached results (simulating after search)
533
+ service._cached_results = [mock_result]
534
+ service._remote_search_id = "remote_search_123" # Remote search was performed
535
+
536
+ # Mock the remote client
537
+ mock_remote_client = Mock()
538
+ service._remote_client = mock_remote_client
539
+
540
+ # Mock the remote download method
541
+ mock_download_result = AudioDownloadResult(
542
+ filepath="gs://bucket/uploads/job123/audio/Waterloo.flac",
543
+ artist="ABBA",
544
+ title="Waterloo",
545
+ provider="RED",
546
+ quality="FLAC 16bit CD",
547
+ )
548
+ service._download_remote = Mock(return_value=mock_download_result)
549
+
550
+ # Call download - should route to remote
551
+ result = service.download(0, "/tmp", gcs_path="uploads/job123/audio/")
552
+
553
+ # Verify remote download was called (includes search_id as last parameter)
554
+ service._download_remote.assert_called_once_with(0, "/tmp", None, "uploads/job123/audio/", "remote_search_123")
555
+ assert result.filepath == "gs://bucket/uploads/job123/audio/Waterloo.flac"
556
+
557
+ def test_download_uses_remote_for_ops_provider(self):
558
+ """Test download routes to remote service for OPS provider."""
559
+ mock_result = AudioSearchResult(
560
+ title="Waterloo",
561
+ artist="ABBA",
562
+ provider="OPS", # Torrent provider
563
+ url="",
564
+ quality="FLAC 16bit CD",
565
+ seeders=30,
566
+ index=0,
567
+ )
568
+
569
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
570
+ service._fetcher = Mock()
571
+
572
+ # Directly set cached results
573
+ service._cached_results = [mock_result]
574
+ service._remote_search_id = "remote_search_456"
575
+
576
+ # Mock remote client
577
+ mock_remote_client = Mock()
578
+ service._remote_client = mock_remote_client
579
+
580
+ # Mock remote download
581
+ mock_download_result = AudioDownloadResult(
582
+ filepath="gs://bucket/test.flac",
583
+ artist="ABBA",
584
+ title="Waterloo",
585
+ provider="OPS",
586
+ quality="FLAC",
587
+ )
588
+ service._download_remote = Mock(return_value=mock_download_result)
589
+
590
+ # Call download
591
+ result = service.download(0, "/tmp")
592
+
593
+ # Should use remote for OPS
594
+ service._download_remote.assert_called_once()
595
+
596
+ def test_download_uses_local_for_youtube_even_with_remote_client(self):
597
+ """Test YouTube downloads use local even when remote client is configured."""
598
+ mock_result = AudioSearchResult(
599
+ title="Waterloo",
600
+ artist="ABBA",
601
+ provider="YouTube", # NOT a torrent provider
602
+ url="https://youtube.com/watch?v=abc123",
603
+ quality="Opus 128kbps",
604
+ index=0,
605
+ )
606
+
607
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
608
+ service._fetcher = Mock()
609
+
610
+ # Directly set cached results
611
+ service._cached_results = [mock_result]
612
+ service._remote_search_id = "remote_search_789" # Remote search was performed
613
+
614
+ # Mock remote client (configured but shouldn't be used for YouTube)
615
+ mock_remote_client = Mock()
616
+ service._remote_client = mock_remote_client
617
+
618
+ # Mock local download
619
+ mock_fetch_result = AudioDownloadResult(
620
+ filepath="/tmp/test.opus",
621
+ artist="ABBA",
622
+ title="Waterloo",
623
+ provider="YouTube",
624
+ quality="Opus 128kbps",
625
+ )
626
+ service._fetcher.download.return_value = mock_fetch_result
627
+
628
+ # Mock _download_remote to ensure it's NOT called
629
+ service._download_remote = Mock()
630
+
631
+ # Call download
632
+ result = service.download(0, "/tmp")
633
+
634
+ # Should use local for YouTube
635
+ service._download_remote.assert_not_called()
636
+ service._fetcher.download.assert_called_once()
637
+
638
+ def test_download_uses_local_when_no_remote_client(self):
639
+ """Test download uses local when remote client is not configured."""
640
+ mock_result = AudioSearchResult(
641
+ title="Waterloo",
642
+ artist="ABBA",
643
+ provider="RED", # Would normally use remote
644
+ url="",
645
+ quality="FLAC",
646
+ index=0,
647
+ )
648
+
649
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
650
+ service._fetcher = Mock()
651
+
652
+ # Directly set cached results
653
+ service._cached_results = [mock_result]
654
+
655
+ # No remote client
656
+ service._remote_client = None
657
+ service._remote_search_id = None # No remote search
658
+
659
+ # Mock local download
660
+ mock_fetch_result = AudioDownloadResult(
661
+ filepath="/tmp/test.flac",
662
+ artist="ABBA",
663
+ title="Waterloo",
664
+ provider="RED",
665
+ quality="FLAC",
666
+ )
667
+ service._fetcher.download.return_value = mock_fetch_result
668
+
669
+ # Call download
670
+ result = service.download(0, "/tmp")
671
+
672
+ # Should use local even though it's RED
673
+ service._fetcher.download.assert_called_once()
674
+
675
+ def test_download_uses_local_when_no_remote_search_id(self):
676
+ """Test download uses local when search wasn't done remotely."""
677
+ mock_result = AudioSearchResult(
678
+ title="Waterloo",
679
+ artist="ABBA",
680
+ provider="RED",
681
+ url="",
682
+ quality="FLAC",
683
+ index=0,
684
+ )
685
+
686
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
687
+ service._fetcher = Mock()
688
+
689
+ # Directly set cached results
690
+ service._cached_results = [mock_result]
691
+
692
+ # Remote client configured but search was local (fallback scenario)
693
+ service._remote_client = Mock()
694
+ service._remote_search_id = None # Search was local, not remote
695
+
696
+ # Mock local download
697
+ mock_fetch_result = AudioDownloadResult(
698
+ filepath="/tmp/test.flac",
699
+ artist="ABBA",
700
+ title="Waterloo",
701
+ provider="RED",
702
+ quality="FLAC",
703
+ )
704
+ service._fetcher.download.return_value = mock_fetch_result
705
+
706
+ # Call download
707
+ result = service.download(0, "/tmp")
708
+
709
+ # Should use local since no remote search ID
710
+ service._fetcher.download.assert_called_once()
711
+
712
+ def test_torrent_provider_routing_checks_both_conditions(self):
713
+ """Test that torrent provider routing requires BOTH remote_search_id AND remote_client.
714
+
715
+ This test verifies the logical AND condition in the download routing.
716
+ """
717
+ mock_result = AudioSearchResult(
718
+ title="Waterloo",
719
+ artist="ABBA",
720
+ provider="RED",
721
+ url="",
722
+ quality="FLAC",
723
+ index=0,
724
+ )
725
+
726
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
727
+ service._fetcher = Mock()
728
+ service._cached_results = [mock_result]
729
+
730
+ # Test: remote_client set but no remote_search_id -> should use local
731
+ service._remote_client = Mock()
732
+ service._remote_search_id = None
733
+
734
+ mock_fetch_result = AudioDownloadResult(
735
+ filepath="/tmp/test.flac", artist="ABBA", title="Waterloo",
736
+ provider="RED", quality="FLAC",
737
+ )
738
+ service._fetcher.download.return_value = mock_fetch_result
739
+ service._download_remote = Mock()
740
+
741
+ service.download(0, "/tmp")
742
+ service._download_remote.assert_not_called()
743
+ service._fetcher.download.assert_called_once()
744
+
745
+ # Reset mocks
746
+ service._fetcher.download.reset_mock()
747
+ service._download_remote.reset_mock()
748
+
749
+ # Test: remote_search_id set but no remote_client -> should use local
750
+ service._remote_client = None
751
+ service._remote_search_id = "search_123"
752
+
753
+ service.download(0, "/tmp")
754
+ service._download_remote.assert_not_called()
755
+ service._fetcher.download.assert_called_once()
756
+
757
+ def test_download_does_not_access_nonexistent_attributes(self):
758
+ """Test that download() doesn't try to access nonexistent attributes.
759
+
760
+ This test would have caught the 'extra_info' attribute error:
761
+ AttributeError: 'AudioSearchResult' object has no attribute 'extra_info'
762
+
763
+ AudioSearchResult only has: title, artist, url, provider, duration,
764
+ quality, source_id, index, seeders, target_file, raw_result
765
+ """
766
+ mock_result = AudioSearchResult(
767
+ title="Waterloo",
768
+ artist="ABBA",
769
+ provider="RED",
770
+ url="",
771
+ quality="FLAC 16bit CD",
772
+ seeders=50,
773
+ index=0,
774
+ )
775
+
776
+ # Verify AudioSearchResult doesn't have extra_info
777
+ assert not hasattr(mock_result, 'extra_info')
778
+
779
+ service = AudioSearchService(red_api_key=None, red_api_url=None, ops_api_key=None, ops_api_url=None)
780
+ service._fetcher = Mock()
781
+ service._cached_results = [mock_result]
782
+ service._remote_search_id = "search_123"
783
+ service._remote_client = Mock()
784
+
785
+ # Mock _download_remote to avoid actual network call
786
+ mock_download_result = AudioDownloadResult(
787
+ filepath="/tmp/test.flac",
788
+ artist="ABBA",
789
+ title="Waterloo",
790
+ provider="RED",
791
+ quality="FLAC",
792
+ )
793
+ service._download_remote = Mock(return_value=mock_download_result)
794
+
795
+ # This should NOT raise AttributeError: 'AudioSearchResult' object has no attribute 'extra_info'
796
+ result = service.download(0, "/tmp")
797
+
798
+ # Should succeed and use remote download for RED provider
799
+ service._download_remote.assert_called_once()
800
+ assert result.filepath == "/tmp/test.flac"
801
+
802
+
803
+ class TestAudioSearchThemeSupport:
804
+ """Test theme support in audio search requests.
805
+
806
+ These tests verify that theme_id and color_overrides are properly:
807
+ 1. Accepted in AudioSearchRequest model
808
+ 2. Passed through to JobCreate
809
+ 3. Result in correct CDG/TXT defaults when theme is set
810
+ 4. Theme style is PREPARED (copied to job folder) when theme_id is set
811
+ """
812
+
813
+ def test_audio_search_request_accepts_theme_id(self):
814
+ """Test AudioSearchRequest model has theme_id field."""
815
+ from backend.api.routes.audio_search import AudioSearchRequest
816
+
817
+ request = AudioSearchRequest(
818
+ artist="Test Artist",
819
+ title="Test Song",
820
+ theme_id="nomad"
821
+ )
822
+
823
+ assert request.theme_id == "nomad"
824
+
825
+ def test_audio_search_request_accepts_color_overrides(self):
826
+ """Test AudioSearchRequest model has color_overrides field."""
827
+ from backend.api.routes.audio_search import AudioSearchRequest
828
+
829
+ request = AudioSearchRequest(
830
+ artist="Test Artist",
831
+ title="Test Song",
832
+ theme_id="nomad",
833
+ color_overrides={
834
+ "artist_color": "#ff0000",
835
+ "title_color": "#00ff00",
836
+ }
837
+ )
838
+
839
+ assert request.color_overrides["artist_color"] == "#ff0000"
840
+ assert request.color_overrides["title_color"] == "#00ff00"
841
+
842
+ def test_audio_search_request_theme_defaults_cdg_txt(self):
843
+ """Test that when theme_id is set, CDG/TXT defaults to enabled.
844
+
845
+ This is the key behavior: selecting a theme should automatically
846
+ enable CDG and TXT output formats.
847
+ """
848
+ from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
849
+
850
+ # When theme_id is set, enable_cdg/enable_txt should default to True
851
+ request = AudioSearchRequest(
852
+ artist="Test Artist",
853
+ title="Test Song",
854
+ theme_id="nomad"
855
+ # enable_cdg and enable_txt are None (not specified)
856
+ )
857
+
858
+ resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
859
+ request.theme_id, request.enable_cdg, request.enable_txt
860
+ )
861
+
862
+ assert resolved_cdg is True, "CDG should be enabled by default when theme is set"
863
+ assert resolved_txt is True, "TXT should be enabled by default when theme is set"
864
+
865
+ def test_audio_search_request_no_theme_no_cdg_txt(self):
866
+ """Test that without theme_id, CDG/TXT defaults to disabled."""
867
+ from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
868
+
869
+ request = AudioSearchRequest(
870
+ artist="Test Artist",
871
+ title="Test Song"
872
+ # No theme_id, no enable_cdg, no enable_txt
873
+ )
874
+
875
+ resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
876
+ request.theme_id, request.enable_cdg, request.enable_txt
877
+ )
878
+
879
+ assert resolved_cdg is False, "CDG should be disabled by default without theme"
880
+ assert resolved_txt is False, "TXT should be disabled by default without theme"
881
+
882
+ def test_explicit_cdg_txt_overrides_theme_default(self):
883
+ """Test that explicit enable_cdg/enable_txt values override theme defaults."""
884
+ from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
885
+
886
+ # Theme set (would default to True), but explicitly disabled
887
+ request = AudioSearchRequest(
888
+ artist="Test Artist",
889
+ title="Test Song",
890
+ theme_id="nomad",
891
+ enable_cdg=False,
892
+ enable_txt=False,
893
+ )
894
+
895
+ resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
896
+ request.theme_id, request.enable_cdg, request.enable_txt
897
+ )
898
+
899
+ assert resolved_cdg is False, "Explicit False should override theme default"
900
+ assert resolved_txt is False, "Explicit False should override theme default"
901
+
902
+ def test_explicit_cdg_txt_enables_without_theme(self):
903
+ """Test that explicit True enables CDG/TXT even without theme."""
904
+ from backend.api.routes.audio_search import AudioSearchRequest, _resolve_cdg_txt_defaults
905
+
906
+ # No theme (would default to False), but explicitly enabled
907
+ request = AudioSearchRequest(
908
+ artist="Test Artist",
909
+ title="Test Song",
910
+ enable_cdg=True,
911
+ enable_txt=True,
912
+ )
913
+
914
+ resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
915
+ request.theme_id, request.enable_cdg, request.enable_txt
916
+ )
917
+
918
+ assert resolved_cdg is True, "Explicit True should enable CDG without theme"
919
+ assert resolved_txt is True, "Explicit True should enable TXT without theme"
920
+
921
+ def test_job_create_receives_theme_from_audio_search(self):
922
+ """Test that JobCreate model can receive theme_id and color_overrides."""
923
+ job_create = JobCreate(
924
+ artist="Test Artist",
925
+ title="Test Song",
926
+ theme_id="nomad",
927
+ color_overrides={
928
+ "artist_color": "#ff0000",
929
+ "sung_lyrics_color": "#00ff00"
930
+ },
931
+ enable_cdg=True,
932
+ enable_txt=True,
933
+ )
934
+
935
+ assert job_create.theme_id == "nomad"
936
+ assert job_create.color_overrides["artist_color"] == "#ff0000"
937
+ assert job_create.color_overrides["sung_lyrics_color"] == "#00ff00"
938
+ assert job_create.enable_cdg is True
939
+ assert job_create.enable_txt is True
940
+
941
+ def test_job_model_stores_theme_configuration(self):
942
+ """Test Job model stores theme configuration correctly."""
943
+ job = Job(
944
+ job_id="test123",
945
+ status=JobStatus.PENDING,
946
+ created_at=datetime.utcnow(),
947
+ updated_at=datetime.utcnow(),
948
+ artist="Test Artist",
949
+ title="Test Song",
950
+ theme_id="nomad",
951
+ color_overrides={
952
+ "artist_color": "#ff0000"
953
+ },
954
+ enable_cdg=True,
955
+ enable_txt=True,
956
+ )
957
+
958
+ assert job.theme_id == "nomad"
959
+ assert job.color_overrides["artist_color"] == "#ff0000"
960
+ assert job.enable_cdg is True
961
+ assert job.enable_txt is True
962
+
963
+
964
+ class TestAudioSearchThemePreparation:
965
+ """Test that audio search endpoint properly prepares theme styles.
966
+
967
+ CRITICAL: This test class was added to catch the bug where audio_search.py
968
+ accepted theme_id but never called prepare_job_style(), resulting in jobs
969
+ with theme_id set but no style_params_gcs_path or style_assets.
970
+
971
+ The symptom: Preview videos show black background instead of themed background.
972
+ Job ID ffb0b8fa in production demonstrated this issue.
973
+ """
974
+
975
+ def test_audio_search_with_theme_calls_prepare_job_style(self):
976
+ """Test that creating a job via audio search with theme_id prepares the style.
977
+
978
+ CRITICAL: This test verifies that when a job is created via the audio-search
979
+ endpoint with a theme_id (and no custom style files), the code calls
980
+ prepare_job_style() to set:
981
+ 1. style_params_gcs_path (pointing to the copied style_params.json)
982
+ 2. style_assets (populated with asset mappings)
983
+
984
+ Without this, LyricsTranscriber won't have access to the theme's styles
985
+ and preview videos will have black backgrounds instead of themed ones.
986
+ """
987
+ # Import the endpoint module to access internal functions
988
+ from backend.api.routes import audio_search as audio_search_module
989
+
990
+ # Check if there's an import of prepare_job_style from theme_service
991
+ source_code_path = audio_search_module.__file__
992
+ with open(source_code_path, 'r') as f:
993
+ source_code = f.read()
994
+
995
+ # Check for either:
996
+ # 1. Import of _prepare_theme_for_job from file_upload
997
+ # 2. Import of prepare_job_style from theme_service
998
+ # 3. Inline call to theme_service.prepare_job_style
999
+ has_theme_prep_import = (
1000
+ '_prepare_theme_for_job' in source_code or
1001
+ 'prepare_job_style' in source_code
1002
+ )
1003
+
1004
+ assert has_theme_prep_import, (
1005
+ "audio_search.py does not import or call prepare_job_style() or _prepare_theme_for_job(). "
1006
+ "When theme_id is provided without custom style files, the endpoint MUST call "
1007
+ "prepare_job_style() to copy the theme's style_params.json to the job folder. "
1008
+ "Without this, LyricsTranscriber won't have style configuration and preview "
1009
+ "videos will have black backgrounds instead of themed ones."
1010
+ )
1011
+
1012
+ def test_audio_search_code_handles_theme_preparation(self):
1013
+ """Verify audio_search.py has theme preparation logic similar to file_upload.py.
1014
+
1015
+ This test compares audio_search.py with file_upload.py to ensure they have
1016
+ similar theme preparation patterns. file_upload.py correctly prepares themes,
1017
+ audio_search.py should do the same.
1018
+ """
1019
+ from backend.api.routes import audio_search as audio_search_module
1020
+ from backend.api.routes import file_upload as file_upload_module
1021
+
1022
+ # Read source code of both modules
1023
+ with open(audio_search_module.__file__, 'r') as f:
1024
+ audio_search_code = f.read()
1025
+
1026
+ with open(file_upload_module.__file__, 'r') as f:
1027
+ file_upload_code = f.read()
1028
+
1029
+ # file_upload.py has this pattern for theme preparation:
1030
+ # if body.theme_id and not has_style_params_upload:
1031
+ # style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(...)
1032
+ assert '_prepare_theme_for_job' in file_upload_code, \
1033
+ "file_upload.py should have _prepare_theme_for_job function"
1034
+
1035
+ # audio_search.py should have similar pattern
1036
+ # Look for either the function or a similar conditional check
1037
+ # STRICT CHECK: Look for actual theme preparation logic being CALLED, not just mentioned
1038
+ has_theme_preparation_call = (
1039
+ # Check for import or call of theme preparation function
1040
+ '_prepare_theme_for_job(' in audio_search_code or
1041
+ 'prepare_job_style(' in audio_search_code
1042
+ )
1043
+
1044
+ assert has_theme_preparation_call, (
1045
+ "audio_search.py does not CALL theme preparation logic. "
1046
+ "It accepts theme_id but never prepares the theme style like file_upload.py does. "
1047
+ "Add a call to _prepare_theme_for_job() when theme_id is set and no style files are uploaded."
1048
+ )
1049
+
1050
+
1051
+ class TestFlacfetchIntegration:
1052
+ """
1053
+ Integration tests that verify flacfetch imports work correctly.
1054
+
1055
+ These tests ensure the backend code is compatible with the installed
1056
+ flacfetch version and would catch issues like renamed classes.
1057
+
1058
+ Note: Some tests are marked with pytest.importorskip for flacfetch
1059
+ since it may not be installed in all test environments (e.g., CI).
1060
+ """
1061
+
1062
+ def test_flacfetch_imports_work(self):
1063
+ """Test that all flacfetch imports in audio_search_service work.
1064
+
1065
+ This test would have caught the YouTubeProvider -> YoutubeProvider rename.
1066
+ """
1067
+ flacfetch = pytest.importorskip("flacfetch")
1068
+
1069
+ # These imports match what karaoke_gen.audio_fetcher does
1070
+ from flacfetch.core.manager import FetchManager
1071
+ from flacfetch.providers.youtube import YoutubeProvider
1072
+ from flacfetch.core.models import TrackQuery
1073
+
1074
+ # Verify the classes exist and are callable
1075
+ assert FetchManager is not None
1076
+ assert YoutubeProvider is not None
1077
+ assert TrackQuery is not None
1078
+
1079
+ def test_karaoke_gen_audio_fetcher_imports_work(self):
1080
+ """Test that karaoke_gen.audio_fetcher imports work.
1081
+
1082
+ Since backend now imports from karaoke_gen, this is the critical test.
1083
+ This should work even without flacfetch installed (lazy import).
1084
+ """
1085
+ from karaoke_gen.audio_fetcher import (
1086
+ FlacFetcher,
1087
+ AudioSearchResult,
1088
+ AudioFetchResult,
1089
+ AudioFetcherError,
1090
+ NoResultsError,
1091
+ DownloadError,
1092
+ )
1093
+
1094
+ assert FlacFetcher is not None
1095
+ assert AudioSearchResult is not None
1096
+ assert AudioFetchResult is not None
1097
+ assert AudioFetcherError is not None
1098
+ assert NoResultsError is not None
1099
+ assert DownloadError is not None
1100
+
1101
+ def test_audio_search_service_can_initialize_fetcher(self):
1102
+ """Test AudioSearchService can initialize its FlacFetcher.
1103
+
1104
+ This verifies the actual initialization code path works.
1105
+ """
1106
+ pytest.importorskip("flacfetch")
1107
+
1108
+ service = AudioSearchService(
1109
+ red_api_key=None,
1110
+ red_api_url=None,
1111
+ ops_api_key=None,
1112
+ )
1113
+
1114
+ # Verify FlacFetcher was initialized
1115
+ assert service._fetcher is not None
1116
+
1117
+ # FlacFetcher can initialize its manager (tests actual flacfetch)
1118
+ manager = service._fetcher._get_manager()
1119
+ assert manager is not None
1120
+
1121
+ def test_shared_classes_are_same_as_karaoke_gen(self):
1122
+ """Verify backend uses the same classes as karaoke_gen."""
1123
+ from karaoke_gen.audio_fetcher import (
1124
+ AudioSearchResult as KGAudioSearchResult,
1125
+ AudioFetchResult as KGAudioFetchResult,
1126
+ NoResultsError as KGNoResultsError,
1127
+ DownloadError as KGDownloadError,
1128
+ )
1129
+
1130
+ # These should be the exact same classes, not copies
1131
+ assert AudioSearchResult is KGAudioSearchResult
1132
+ assert AudioDownloadResult is KGAudioFetchResult
1133
+ assert NoResultsError is KGNoResultsError
1134
+ assert DownloadError is KGDownloadError
1135
+
1136
+
1137
+ class TestAudioSearchApiRouteDownload:
1138
+ """
1139
+ Tests for _download_and_start_processing function in audio_search routes.
1140
+
1141
+ This tests the API layer's handling of the download flow, including:
1142
+ - Correct routing between local and remote downloads
1143
+ - GCS path handling for remote downloads
1144
+ - Error handling
1145
+ """
1146
+
1147
+ @pytest.fixture
1148
+ def mock_job_manager(self):
1149
+ """Mock the job manager singleton."""
1150
+ with patch('backend.api.routes.audio_search.job_manager') as mock:
1151
+ yield mock
1152
+
1153
+ @pytest.fixture
1154
+ def mock_storage_service(self):
1155
+ """Mock the storage service singleton."""
1156
+ with patch('backend.api.routes.audio_search.storage_service') as mock:
1157
+ yield mock
1158
+
1159
+ def test_remote_download_passes_gcs_path(self, mock_job_manager, mock_storage_service):
1160
+ """
1161
+ Test that remote torrent downloads include GCS path for direct upload.
1162
+
1163
+ This was the bug: we were calling download() without gcs_path for
1164
+ torrent sources, causing flacfetch VM to return a local path that
1165
+ Cloud Run couldn't access.
1166
+ """
1167
+ from backend.api.routes.audio_search import _download_and_start_processing
1168
+ import asyncio
1169
+
1170
+ # Setup mock job with RED search result
1171
+ mock_job = Mock()
1172
+ mock_job.state_data = {
1173
+ 'audio_search_results': [{
1174
+ 'title': 'Unwanted',
1175
+ 'artist': 'Avril Lavigne',
1176
+ 'provider': 'RED', # Torrent source
1177
+ 'quality': 'FLAC 16bit CD',
1178
+ }]
1179
+ }
1180
+ mock_job.audio_search_artist = 'Avril Lavigne'
1181
+ mock_job.audio_search_title = 'Unwanted'
1182
+ mock_job_manager.get_job.return_value = mock_job
1183
+
1184
+ # Create mock audio search service with remote client
1185
+ mock_audio_service = Mock()
1186
+ mock_audio_service.is_remote_enabled.return_value = True
1187
+
1188
+ # Mock download to return GCS path
1189
+ mock_download_result = Mock()
1190
+ mock_download_result.filepath = "gs://bucket/uploads/job123/audio/Avril Lavigne - Unwanted.flac"
1191
+ mock_audio_service.download.return_value = mock_download_result
1192
+
1193
+ # Create mock background tasks
1194
+ mock_bg_tasks = Mock()
1195
+
1196
+ # Run the async function
1197
+ loop = asyncio.new_event_loop()
1198
+ try:
1199
+ result = loop.run_until_complete(
1200
+ _download_and_start_processing(
1201
+ job_id="job123",
1202
+ selection_index=0,
1203
+ audio_search_service=mock_audio_service,
1204
+ background_tasks=mock_bg_tasks,
1205
+ )
1206
+ )
1207
+ finally:
1208
+ loop.close()
1209
+
1210
+ # CRITICAL: Verify download was called with gcs_path for remote torrent source
1211
+ mock_audio_service.download.assert_called_once()
1212
+ call_kwargs = mock_audio_service.download.call_args.kwargs
1213
+
1214
+ # The gcs_path should be set for remote torrent downloads
1215
+ assert 'gcs_path' in call_kwargs
1216
+ assert call_kwargs['gcs_path'] == "uploads/job123/audio/"
1217
+
1218
+ def test_local_youtube_download_does_not_pass_gcs_path(self, mock_job_manager, mock_storage_service):
1219
+ """Test that YouTube downloads don't use gcs_path (download locally, upload manually)."""
1220
+ from backend.api.routes.audio_search import _download_and_start_processing
1221
+ import asyncio
1222
+ import tempfile
1223
+
1224
+ # Setup mock job with YouTube search result
1225
+ mock_job = Mock()
1226
+ mock_job.state_data = {
1227
+ 'audio_search_results': [{
1228
+ 'title': 'Unwanted',
1229
+ 'artist': 'Avril Lavigne',
1230
+ 'provider': 'YouTube', # NOT a torrent source
1231
+ 'quality': 'Opus 128kbps',
1232
+ }]
1233
+ }
1234
+ mock_job.audio_search_artist = 'Avril Lavigne'
1235
+ mock_job.audio_search_title = 'Unwanted'
1236
+ mock_job_manager.get_job.return_value = mock_job
1237
+
1238
+ # Create mock audio search service with remote client (but YouTube doesn't use it)
1239
+ mock_audio_service = Mock()
1240
+ mock_audio_service.is_remote_enabled.return_value = True
1241
+
1242
+ # Create a temp file to simulate downloaded file
1243
+ temp_file = tempfile.NamedTemporaryFile(suffix='.opus', delete=False)
1244
+ temp_file.write(b'fake audio data')
1245
+ temp_file.close()
1246
+
1247
+ try:
1248
+ # Mock download to return local path
1249
+ mock_download_result = Mock()
1250
+ mock_download_result.filepath = temp_file.name
1251
+ mock_audio_service.download.return_value = mock_download_result
1252
+
1253
+ # Create mock background tasks
1254
+ mock_bg_tasks = Mock()
1255
+
1256
+ # Run the async function
1257
+ loop = asyncio.new_event_loop()
1258
+ try:
1259
+ result = loop.run_until_complete(
1260
+ _download_and_start_processing(
1261
+ job_id="job456",
1262
+ selection_index=0,
1263
+ audio_search_service=mock_audio_service,
1264
+ background_tasks=mock_bg_tasks,
1265
+ )
1266
+ )
1267
+ finally:
1268
+ loop.close()
1269
+
1270
+ # YouTube should NOT have gcs_path in kwargs
1271
+ mock_audio_service.download.assert_called_once()
1272
+ call_kwargs = mock_audio_service.download.call_args.kwargs
1273
+
1274
+ # For YouTube, gcs_path should NOT be set (or should be None)
1275
+ assert call_kwargs.get('gcs_path') is None
1276
+
1277
+ # Instead, storage service should be called to upload
1278
+ mock_storage_service.upload_fileobj.assert_called_once()
1279
+ finally:
1280
+ import os
1281
+ try:
1282
+ os.unlink(temp_file.name)
1283
+ except:
1284
+ pass
1285
+
1286
+ def test_handles_gcs_path_response_correctly(self, mock_job_manager, mock_storage_service):
1287
+ """Test that GCS path responses are parsed correctly."""
1288
+ from backend.api.routes.audio_search import _download_and_start_processing
1289
+ import asyncio
1290
+
1291
+ # Setup mock job with RED search result
1292
+ mock_job = Mock()
1293
+ mock_job.state_data = {
1294
+ 'audio_search_results': [{
1295
+ 'title': 'Test',
1296
+ 'artist': 'Test Artist',
1297
+ 'provider': 'RED',
1298
+ 'quality': 'FLAC',
1299
+ }]
1300
+ }
1301
+ mock_job_manager.get_job.return_value = mock_job
1302
+
1303
+ # Create mock audio search service
1304
+ mock_audio_service = Mock()
1305
+ mock_audio_service.is_remote_enabled.return_value = True
1306
+
1307
+ # Mock download to return full GCS path
1308
+ mock_download_result = Mock()
1309
+ mock_download_result.filepath = "gs://karaoke-gen-bucket/uploads/job789/audio/test.flac"
1310
+ mock_audio_service.download.return_value = mock_download_result
1311
+
1312
+ # Run the async function
1313
+ loop = asyncio.new_event_loop()
1314
+ try:
1315
+ result = loop.run_until_complete(
1316
+ _download_and_start_processing(
1317
+ job_id="job789",
1318
+ selection_index=0,
1319
+ audio_search_service=mock_audio_service,
1320
+ background_tasks=Mock(),
1321
+ )
1322
+ )
1323
+ finally:
1324
+ loop.close()
1325
+
1326
+ # Verify job was updated with correct GCS path (without gs://bucket/ prefix)
1327
+ update_calls = mock_job_manager.update_job.call_args_list
1328
+
1329
+ # Find the call that sets input_media_gcs_path
1330
+ gcs_path_set = False
1331
+ for call in update_calls:
1332
+ if 'input_media_gcs_path' in call.args[1]:
1333
+ gcs_path = call.args[1]['input_media_gcs_path']
1334
+ # The path stored should be the relative path, not the full gs:// URL
1335
+ assert gcs_path == "uploads/job789/audio/test.flac"
1336
+ gcs_path_set = True
1337
+ break
1338
+
1339
+ assert gcs_path_set, "input_media_gcs_path was not set in job update"
1340
+
1341
+ def test_remote_disabled_always_uses_local(self, mock_job_manager, mock_storage_service):
1342
+ """Test that when remote is disabled, even torrent sources use local download."""
1343
+ from backend.api.routes.audio_search import _download_and_start_processing
1344
+ import asyncio
1345
+ import tempfile
1346
+
1347
+ # Setup mock job with RED search result
1348
+ mock_job = Mock()
1349
+ mock_job.state_data = {
1350
+ 'audio_search_results': [{
1351
+ 'title': 'Test',
1352
+ 'artist': 'Test',
1353
+ 'provider': 'RED', # Torrent source, but remote is disabled
1354
+ 'quality': 'FLAC',
1355
+ }]
1356
+ }
1357
+ mock_job_manager.get_job.return_value = mock_job
1358
+
1359
+ # Create temp file
1360
+ temp_file = tempfile.NamedTemporaryFile(suffix='.flac', delete=False)
1361
+ temp_file.write(b'fake')
1362
+ temp_file.close()
1363
+
1364
+ try:
1365
+ # Create mock audio search service WITHOUT remote client
1366
+ mock_audio_service = Mock()
1367
+ mock_audio_service.is_remote_enabled.return_value = False # REMOTE DISABLED
1368
+
1369
+ mock_download_result = Mock()
1370
+ mock_download_result.filepath = temp_file.name
1371
+ mock_audio_service.download.return_value = mock_download_result
1372
+
1373
+ loop = asyncio.new_event_loop()
1374
+ try:
1375
+ result = loop.run_until_complete(
1376
+ _download_and_start_processing(
1377
+ job_id="job_no_remote",
1378
+ selection_index=0,
1379
+ audio_search_service=mock_audio_service,
1380
+ background_tasks=Mock(),
1381
+ )
1382
+ )
1383
+ finally:
1384
+ loop.close()
1385
+
1386
+ # When remote is disabled, gcs_path should NOT be passed
1387
+ mock_audio_service.download.assert_called_once()
1388
+ call_kwargs = mock_audio_service.download.call_args.kwargs
1389
+ assert call_kwargs.get('gcs_path') is None
1390
+
1391
+ # And storage should upload manually
1392
+ mock_storage_service.upload_fileobj.assert_called_once()
1393
+ finally:
1394
+ import os
1395
+ try:
1396
+ os.unlink(temp_file.name)
1397
+ except:
1398
+ pass