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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {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
|