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,632 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the flacfetch remote HTTP API client.
|
|
3
|
+
|
|
4
|
+
This module tests FlacfetchClient which handles communication with the
|
|
5
|
+
dedicated flacfetch VM for torrent/audio downloads.
|
|
6
|
+
|
|
7
|
+
These tests use mocked HTTP responses to verify:
|
|
8
|
+
- Request formatting (headers, payloads)
|
|
9
|
+
- Response parsing
|
|
10
|
+
- Error handling
|
|
11
|
+
- State management (singleton pattern)
|
|
12
|
+
"""
|
|
13
|
+
import pytest
|
|
14
|
+
from unittest.mock import AsyncMock, Mock, patch, MagicMock
|
|
15
|
+
import asyncio
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from backend.services.flacfetch_client import (
|
|
20
|
+
FlacfetchClient,
|
|
21
|
+
FlacfetchServiceError,
|
|
22
|
+
get_flacfetch_client,
|
|
23
|
+
reset_flacfetch_client,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TestFlacfetchClientInit:
|
|
28
|
+
"""Test FlacfetchClient initialization."""
|
|
29
|
+
|
|
30
|
+
def test_init_with_required_params(self):
|
|
31
|
+
"""Test client initializes with base_url and api_key."""
|
|
32
|
+
client = FlacfetchClient(
|
|
33
|
+
base_url="http://localhost:8080",
|
|
34
|
+
api_key="test-key-123",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert client.base_url == "http://localhost:8080"
|
|
38
|
+
assert client.api_key == "test-key-123"
|
|
39
|
+
assert client.timeout == 60 # Default timeout
|
|
40
|
+
|
|
41
|
+
def test_init_strips_trailing_slash(self):
|
|
42
|
+
"""Test base_url trailing slash is stripped."""
|
|
43
|
+
client = FlacfetchClient(
|
|
44
|
+
base_url="http://localhost:8080/",
|
|
45
|
+
api_key="test-key",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert client.base_url == "http://localhost:8080"
|
|
49
|
+
|
|
50
|
+
def test_init_with_custom_timeout(self):
|
|
51
|
+
"""Test custom timeout is set."""
|
|
52
|
+
client = FlacfetchClient(
|
|
53
|
+
base_url="http://localhost:8080",
|
|
54
|
+
api_key="test-key",
|
|
55
|
+
timeout=120,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert client.timeout == 120
|
|
59
|
+
|
|
60
|
+
def test_headers_include_api_key(self):
|
|
61
|
+
"""Test _headers() includes X-API-Key."""
|
|
62
|
+
client = FlacfetchClient(
|
|
63
|
+
base_url="http://localhost:8080",
|
|
64
|
+
api_key="secret-key-456",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
headers = client._headers()
|
|
68
|
+
|
|
69
|
+
assert headers["X-API-Key"] == "secret-key-456"
|
|
70
|
+
assert headers["Content-Type"] == "application/json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestFlacfetchClientHealthCheck:
|
|
74
|
+
"""Test health_check() method."""
|
|
75
|
+
|
|
76
|
+
@pytest.mark.asyncio
|
|
77
|
+
async def test_health_check_success(self):
|
|
78
|
+
"""Test successful health check."""
|
|
79
|
+
client = FlacfetchClient(
|
|
80
|
+
base_url="http://localhost:8080",
|
|
81
|
+
api_key="test-key",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
mock_response = Mock()
|
|
85
|
+
mock_response.json.return_value = {
|
|
86
|
+
"status": "healthy",
|
|
87
|
+
"transmission": {"available": True, "version": "4.0.5"},
|
|
88
|
+
"disk": {"free_gb": 15.5},
|
|
89
|
+
}
|
|
90
|
+
mock_response.raise_for_status = Mock()
|
|
91
|
+
|
|
92
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
93
|
+
mock_client = AsyncMock()
|
|
94
|
+
mock_client.get = AsyncMock(return_value=mock_response)
|
|
95
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
96
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
97
|
+
mock_client_class.return_value = mock_client
|
|
98
|
+
|
|
99
|
+
result = await client.health_check()
|
|
100
|
+
|
|
101
|
+
assert result["status"] == "healthy"
|
|
102
|
+
assert result["transmission"]["available"] is True
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_health_check_degraded_status(self):
|
|
106
|
+
"""Test health check with degraded status still succeeds."""
|
|
107
|
+
client = FlacfetchClient(
|
|
108
|
+
base_url="http://localhost:8080",
|
|
109
|
+
api_key="test-key",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
mock_response = Mock()
|
|
113
|
+
mock_response.json.return_value = {"status": "degraded"}
|
|
114
|
+
mock_response.raise_for_status = Mock()
|
|
115
|
+
|
|
116
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
117
|
+
mock_client = AsyncMock()
|
|
118
|
+
mock_client.get = AsyncMock(return_value=mock_response)
|
|
119
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
120
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
121
|
+
mock_client_class.return_value = mock_client
|
|
122
|
+
|
|
123
|
+
result = await client.health_check()
|
|
124
|
+
|
|
125
|
+
assert result["status"] == "degraded"
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_health_check_unhealthy_raises_error(self):
|
|
129
|
+
"""Test health check with unhealthy status raises error."""
|
|
130
|
+
client = FlacfetchClient(
|
|
131
|
+
base_url="http://localhost:8080",
|
|
132
|
+
api_key="test-key",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
mock_response = Mock()
|
|
136
|
+
mock_response.json.return_value = {"status": "unhealthy", "error": "Transmission down"}
|
|
137
|
+
mock_response.raise_for_status = Mock()
|
|
138
|
+
|
|
139
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
140
|
+
mock_client = AsyncMock()
|
|
141
|
+
mock_client.get = AsyncMock(return_value=mock_response)
|
|
142
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
143
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
144
|
+
mock_client_class.return_value = mock_client
|
|
145
|
+
|
|
146
|
+
with pytest.raises(FlacfetchServiceError) as exc_info:
|
|
147
|
+
await client.health_check()
|
|
148
|
+
|
|
149
|
+
assert "unhealthy" in str(exc_info.value)
|
|
150
|
+
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_health_check_network_error(self):
|
|
153
|
+
"""Test health check handles network errors."""
|
|
154
|
+
client = FlacfetchClient(
|
|
155
|
+
base_url="http://localhost:8080",
|
|
156
|
+
api_key="test-key",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
160
|
+
mock_client = AsyncMock()
|
|
161
|
+
mock_client.get = AsyncMock(side_effect=httpx.RequestError("Connection refused"))
|
|
162
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
163
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
164
|
+
mock_client_class.return_value = mock_client
|
|
165
|
+
|
|
166
|
+
with pytest.raises(FlacfetchServiceError) as exc_info:
|
|
167
|
+
await client.health_check()
|
|
168
|
+
|
|
169
|
+
assert "Cannot reach" in str(exc_info.value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestFlacfetchClientSearch:
|
|
173
|
+
"""Test search() method."""
|
|
174
|
+
|
|
175
|
+
@pytest.mark.asyncio
|
|
176
|
+
async def test_search_success(self):
|
|
177
|
+
"""Test successful search returns results."""
|
|
178
|
+
client = FlacfetchClient(
|
|
179
|
+
base_url="http://localhost:8080",
|
|
180
|
+
api_key="test-key",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
mock_response = Mock()
|
|
184
|
+
mock_response.status_code = 200
|
|
185
|
+
mock_response.json.return_value = {
|
|
186
|
+
"search_id": "search_abc123",
|
|
187
|
+
"results": [
|
|
188
|
+
{
|
|
189
|
+
"title": "Waterloo",
|
|
190
|
+
"artist": "ABBA",
|
|
191
|
+
"provider": "RED",
|
|
192
|
+
"quality": "FLAC 16bit CD",
|
|
193
|
+
"seeders": 50,
|
|
194
|
+
"index": 0,
|
|
195
|
+
}
|
|
196
|
+
],
|
|
197
|
+
"results_count": 1,
|
|
198
|
+
}
|
|
199
|
+
mock_response.raise_for_status = Mock()
|
|
200
|
+
|
|
201
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
202
|
+
mock_client = AsyncMock()
|
|
203
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
204
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
205
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
206
|
+
mock_client_class.return_value = mock_client
|
|
207
|
+
|
|
208
|
+
result = await client.search("ABBA", "Waterloo")
|
|
209
|
+
|
|
210
|
+
assert result["search_id"] == "search_abc123"
|
|
211
|
+
assert len(result["results"]) == 1
|
|
212
|
+
assert result["results"][0]["title"] == "Waterloo"
|
|
213
|
+
|
|
214
|
+
# Verify request was made correctly
|
|
215
|
+
mock_client.post.assert_called_once()
|
|
216
|
+
call_args = mock_client.post.call_args
|
|
217
|
+
assert call_args.kwargs["json"] == {"artist": "ABBA", "title": "Waterloo"}
|
|
218
|
+
|
|
219
|
+
@pytest.mark.asyncio
|
|
220
|
+
async def test_search_no_results_returns_empty(self):
|
|
221
|
+
"""Test search with no results returns empty list."""
|
|
222
|
+
client = FlacfetchClient(
|
|
223
|
+
base_url="http://localhost:8080",
|
|
224
|
+
api_key="test-key",
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
mock_response = Mock()
|
|
228
|
+
mock_response.status_code = 404
|
|
229
|
+
|
|
230
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
231
|
+
mock_client = AsyncMock()
|
|
232
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
233
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
234
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
235
|
+
mock_client_class.return_value = mock_client
|
|
236
|
+
|
|
237
|
+
result = await client.search("Unknown", "Artist")
|
|
238
|
+
|
|
239
|
+
assert result["results"] == []
|
|
240
|
+
assert result["search_id"] is None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestFlacfetchClientDownload:
|
|
244
|
+
"""Test download() method."""
|
|
245
|
+
|
|
246
|
+
@pytest.mark.asyncio
|
|
247
|
+
async def test_download_starts_successfully(self):
|
|
248
|
+
"""Test download() returns download_id."""
|
|
249
|
+
client = FlacfetchClient(
|
|
250
|
+
base_url="http://localhost:8080",
|
|
251
|
+
api_key="test-key",
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
mock_response = Mock()
|
|
255
|
+
mock_response.json.return_value = {
|
|
256
|
+
"download_id": "dl_xyz789",
|
|
257
|
+
"status": "queued",
|
|
258
|
+
}
|
|
259
|
+
mock_response.raise_for_status = Mock()
|
|
260
|
+
|
|
261
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
262
|
+
mock_client = AsyncMock()
|
|
263
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
264
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
265
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
266
|
+
mock_client_class.return_value = mock_client
|
|
267
|
+
|
|
268
|
+
download_id = await client.download(
|
|
269
|
+
search_id="search_abc123",
|
|
270
|
+
result_index=0,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
assert download_id == "dl_xyz789"
|
|
274
|
+
|
|
275
|
+
@pytest.mark.asyncio
|
|
276
|
+
async def test_download_with_gcs_path(self):
|
|
277
|
+
"""Test download() includes GCS upload parameters."""
|
|
278
|
+
client = FlacfetchClient(
|
|
279
|
+
base_url="http://localhost:8080",
|
|
280
|
+
api_key="test-key",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
mock_response = Mock()
|
|
284
|
+
mock_response.json.return_value = {"download_id": "dl_xyz789"}
|
|
285
|
+
mock_response.raise_for_status = Mock()
|
|
286
|
+
|
|
287
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
288
|
+
mock_client = AsyncMock()
|
|
289
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
290
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
291
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
292
|
+
mock_client_class.return_value = mock_client
|
|
293
|
+
|
|
294
|
+
await client.download(
|
|
295
|
+
search_id="search_abc123",
|
|
296
|
+
result_index=0,
|
|
297
|
+
gcs_path="uploads/job123/audio/",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Verify GCS params were included
|
|
301
|
+
call_args = mock_client.post.call_args
|
|
302
|
+
payload = call_args.kwargs["json"]
|
|
303
|
+
assert payload["upload_to_gcs"] is True
|
|
304
|
+
assert payload["gcs_path"] == "uploads/job123/audio/"
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_download_with_custom_filename(self):
|
|
308
|
+
"""Test download() with custom output filename."""
|
|
309
|
+
client = FlacfetchClient(
|
|
310
|
+
base_url="http://localhost:8080",
|
|
311
|
+
api_key="test-key",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
mock_response = Mock()
|
|
315
|
+
mock_response.json.return_value = {"download_id": "dl_xyz789"}
|
|
316
|
+
mock_response.raise_for_status = Mock()
|
|
317
|
+
|
|
318
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
319
|
+
mock_client = AsyncMock()
|
|
320
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
321
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
322
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
323
|
+
mock_client_class.return_value = mock_client
|
|
324
|
+
|
|
325
|
+
await client.download(
|
|
326
|
+
search_id="search_abc123",
|
|
327
|
+
result_index=0,
|
|
328
|
+
output_filename="ABBA - Waterloo",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
call_args = mock_client.post.call_args
|
|
332
|
+
payload = call_args.kwargs["json"]
|
|
333
|
+
assert payload["output_filename"] == "ABBA - Waterloo"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class TestFlacfetchClientGetDownloadStatus:
|
|
337
|
+
"""Test get_download_status() method."""
|
|
338
|
+
|
|
339
|
+
@pytest.mark.asyncio
|
|
340
|
+
async def test_get_download_status_success(self):
|
|
341
|
+
"""Test getting download status."""
|
|
342
|
+
client = FlacfetchClient(
|
|
343
|
+
base_url="http://localhost:8080",
|
|
344
|
+
api_key="test-key",
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
mock_response = Mock()
|
|
348
|
+
mock_response.json.return_value = {
|
|
349
|
+
"download_id": "dl_xyz789",
|
|
350
|
+
"status": "downloading",
|
|
351
|
+
"progress": 45.5,
|
|
352
|
+
"download_speed_kbps": 1250.0,
|
|
353
|
+
"peers": 5,
|
|
354
|
+
}
|
|
355
|
+
mock_response.raise_for_status = Mock()
|
|
356
|
+
|
|
357
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
358
|
+
mock_client = AsyncMock()
|
|
359
|
+
mock_client.get = AsyncMock(return_value=mock_response)
|
|
360
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
361
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
362
|
+
mock_client_class.return_value = mock_client
|
|
363
|
+
|
|
364
|
+
status = await client.get_download_status("dl_xyz789")
|
|
365
|
+
|
|
366
|
+
assert status["status"] == "downloading"
|
|
367
|
+
assert status["progress"] == 45.5
|
|
368
|
+
|
|
369
|
+
@pytest.mark.asyncio
|
|
370
|
+
async def test_get_download_status_not_found(self):
|
|
371
|
+
"""Test getting status for non-existent download."""
|
|
372
|
+
client = FlacfetchClient(
|
|
373
|
+
base_url="http://localhost:8080",
|
|
374
|
+
api_key="test-key",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
mock_response = Mock()
|
|
378
|
+
mock_response.status_code = 404
|
|
379
|
+
mock_response.raise_for_status = Mock(
|
|
380
|
+
side_effect=httpx.HTTPStatusError("Not found", request=Mock(), response=mock_response)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
with patch("httpx.AsyncClient") as mock_client_class:
|
|
384
|
+
mock_client = AsyncMock()
|
|
385
|
+
mock_client.get = AsyncMock(return_value=mock_response)
|
|
386
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
387
|
+
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
388
|
+
mock_client_class.return_value = mock_client
|
|
389
|
+
|
|
390
|
+
with pytest.raises(FlacfetchServiceError) as exc_info:
|
|
391
|
+
await client.get_download_status("nonexistent")
|
|
392
|
+
|
|
393
|
+
assert "not found" in str(exc_info.value).lower()
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
class TestFlacfetchClientWaitForDownload:
|
|
397
|
+
"""Test wait_for_download() method."""
|
|
398
|
+
|
|
399
|
+
@pytest.mark.asyncio
|
|
400
|
+
async def test_wait_for_download_completes(self):
|
|
401
|
+
"""Test waiting for download to complete."""
|
|
402
|
+
client = FlacfetchClient(
|
|
403
|
+
base_url="http://localhost:8080",
|
|
404
|
+
api_key="test-key",
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Mock get_download_status to return complete status
|
|
408
|
+
async def mock_get_status(download_id):
|
|
409
|
+
return {
|
|
410
|
+
"download_id": download_id,
|
|
411
|
+
"status": "complete",
|
|
412
|
+
"gcs_path": "gs://bucket/uploads/test.flac",
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
client.get_download_status = mock_get_status
|
|
416
|
+
|
|
417
|
+
result = await client.wait_for_download("dl_xyz789", timeout=10)
|
|
418
|
+
|
|
419
|
+
assert result["status"] == "complete"
|
|
420
|
+
assert result["gcs_path"] == "gs://bucket/uploads/test.flac"
|
|
421
|
+
|
|
422
|
+
@pytest.mark.asyncio
|
|
423
|
+
async def test_wait_for_download_seeding_status_completes(self):
|
|
424
|
+
"""Test that 'seeding' status is treated as complete."""
|
|
425
|
+
client = FlacfetchClient(
|
|
426
|
+
base_url="http://localhost:8080",
|
|
427
|
+
api_key="test-key",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
async def mock_get_status(download_id):
|
|
431
|
+
return {
|
|
432
|
+
"download_id": download_id,
|
|
433
|
+
"status": "seeding",
|
|
434
|
+
"output_path": "/downloads/test.flac",
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
client.get_download_status = mock_get_status
|
|
438
|
+
|
|
439
|
+
result = await client.wait_for_download("dl_xyz789", timeout=10)
|
|
440
|
+
|
|
441
|
+
assert result["status"] == "seeding"
|
|
442
|
+
|
|
443
|
+
@pytest.mark.asyncio
|
|
444
|
+
async def test_wait_for_download_failed(self):
|
|
445
|
+
"""Test wait raises error when download fails."""
|
|
446
|
+
client = FlacfetchClient(
|
|
447
|
+
base_url="http://localhost:8080",
|
|
448
|
+
api_key="test-key",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
async def mock_get_status(download_id):
|
|
452
|
+
return {
|
|
453
|
+
"download_id": download_id,
|
|
454
|
+
"status": "failed",
|
|
455
|
+
"error": "Torrent download failed: no peers",
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
client.get_download_status = mock_get_status
|
|
459
|
+
|
|
460
|
+
with pytest.raises(FlacfetchServiceError) as exc_info:
|
|
461
|
+
await client.wait_for_download("dl_xyz789", timeout=10)
|
|
462
|
+
|
|
463
|
+
assert "failed" in str(exc_info.value).lower()
|
|
464
|
+
assert "no peers" in str(exc_info.value)
|
|
465
|
+
|
|
466
|
+
@pytest.mark.asyncio
|
|
467
|
+
async def test_wait_for_download_cancelled(self):
|
|
468
|
+
"""Test wait raises error when download is cancelled."""
|
|
469
|
+
client = FlacfetchClient(
|
|
470
|
+
base_url="http://localhost:8080",
|
|
471
|
+
api_key="test-key",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
async def mock_get_status(download_id):
|
|
475
|
+
return {
|
|
476
|
+
"download_id": download_id,
|
|
477
|
+
"status": "cancelled",
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
client.get_download_status = mock_get_status
|
|
481
|
+
|
|
482
|
+
with pytest.raises(FlacfetchServiceError) as exc_info:
|
|
483
|
+
await client.wait_for_download("dl_xyz789", timeout=10)
|
|
484
|
+
|
|
485
|
+
assert "cancelled" in str(exc_info.value).lower()
|
|
486
|
+
|
|
487
|
+
@pytest.mark.asyncio
|
|
488
|
+
async def test_wait_for_download_timeout(self):
|
|
489
|
+
"""Test wait raises error on timeout."""
|
|
490
|
+
client = FlacfetchClient(
|
|
491
|
+
base_url="http://localhost:8080",
|
|
492
|
+
api_key="test-key",
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
call_count = 0
|
|
496
|
+
async def mock_get_status(download_id):
|
|
497
|
+
nonlocal call_count
|
|
498
|
+
call_count += 1
|
|
499
|
+
return {
|
|
500
|
+
"download_id": download_id,
|
|
501
|
+
"status": "downloading",
|
|
502
|
+
"progress": 10.0 * call_count,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
client.get_download_status = mock_get_status
|
|
506
|
+
|
|
507
|
+
with pytest.raises(FlacfetchServiceError) as exc_info:
|
|
508
|
+
# Very short timeout with short poll interval
|
|
509
|
+
await client.wait_for_download("dl_xyz789", timeout=0.2, poll_interval=0.1)
|
|
510
|
+
|
|
511
|
+
assert "timed out" in str(exc_info.value).lower()
|
|
512
|
+
|
|
513
|
+
@pytest.mark.asyncio
|
|
514
|
+
async def test_wait_for_download_calls_progress_callback(self):
|
|
515
|
+
"""Test progress callback is called during wait."""
|
|
516
|
+
client = FlacfetchClient(
|
|
517
|
+
base_url="http://localhost:8080",
|
|
518
|
+
api_key="test-key",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
call_count = 0
|
|
522
|
+
async def mock_get_status(download_id):
|
|
523
|
+
nonlocal call_count
|
|
524
|
+
call_count += 1
|
|
525
|
+
if call_count >= 3:
|
|
526
|
+
return {"status": "complete", "gcs_path": "gs://test"}
|
|
527
|
+
return {"status": "downloading", "progress": 33.0 * call_count}
|
|
528
|
+
|
|
529
|
+
client.get_download_status = mock_get_status
|
|
530
|
+
|
|
531
|
+
progress_updates = []
|
|
532
|
+
def progress_callback(status):
|
|
533
|
+
progress_updates.append(status)
|
|
534
|
+
|
|
535
|
+
await client.wait_for_download(
|
|
536
|
+
"dl_xyz789",
|
|
537
|
+
timeout=10,
|
|
538
|
+
poll_interval=0.01,
|
|
539
|
+
progress_callback=progress_callback,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Should have received progress updates
|
|
543
|
+
assert len(progress_updates) >= 2
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class TestGetFlacfetchClient:
|
|
547
|
+
"""Test get_flacfetch_client() singleton factory."""
|
|
548
|
+
|
|
549
|
+
def test_returns_none_when_not_configured(self):
|
|
550
|
+
"""Test returns None when FLACFETCH_API_URL not set."""
|
|
551
|
+
reset_flacfetch_client()
|
|
552
|
+
|
|
553
|
+
with patch("backend.config.get_settings") as mock_settings:
|
|
554
|
+
mock_settings.return_value = Mock(
|
|
555
|
+
flacfetch_api_url=None,
|
|
556
|
+
flacfetch_api_key=None,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
client = get_flacfetch_client()
|
|
560
|
+
|
|
561
|
+
assert client is None
|
|
562
|
+
|
|
563
|
+
def test_returns_none_when_key_missing(self):
|
|
564
|
+
"""Test returns None when API key not set."""
|
|
565
|
+
reset_flacfetch_client()
|
|
566
|
+
|
|
567
|
+
with patch("backend.config.get_settings") as mock_settings:
|
|
568
|
+
mock_settings.return_value = Mock(
|
|
569
|
+
flacfetch_api_url="http://localhost:8080",
|
|
570
|
+
flacfetch_api_key=None,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
client = get_flacfetch_client()
|
|
574
|
+
|
|
575
|
+
assert client is None
|
|
576
|
+
|
|
577
|
+
def test_returns_client_when_configured(self):
|
|
578
|
+
"""Test returns FlacfetchClient when both URL and key are set."""
|
|
579
|
+
reset_flacfetch_client()
|
|
580
|
+
|
|
581
|
+
with patch("backend.config.get_settings") as mock_settings:
|
|
582
|
+
mock_settings.return_value = Mock(
|
|
583
|
+
flacfetch_api_url="http://10.0.0.5:8080",
|
|
584
|
+
flacfetch_api_key="secret-key",
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
client = get_flacfetch_client()
|
|
588
|
+
|
|
589
|
+
assert client is not None
|
|
590
|
+
assert isinstance(client, FlacfetchClient)
|
|
591
|
+
assert client.base_url == "http://10.0.0.5:8080"
|
|
592
|
+
assert client.api_key == "secret-key"
|
|
593
|
+
|
|
594
|
+
def test_returns_same_instance(self):
|
|
595
|
+
"""Test singleton returns same instance."""
|
|
596
|
+
reset_flacfetch_client()
|
|
597
|
+
|
|
598
|
+
with patch("backend.config.get_settings") as mock_settings:
|
|
599
|
+
mock_settings.return_value = Mock(
|
|
600
|
+
flacfetch_api_url="http://localhost:8080",
|
|
601
|
+
flacfetch_api_key="test-key",
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
client1 = get_flacfetch_client()
|
|
605
|
+
client2 = get_flacfetch_client()
|
|
606
|
+
|
|
607
|
+
assert client1 is client2
|
|
608
|
+
|
|
609
|
+
def test_reset_clears_singleton(self):
|
|
610
|
+
"""Test reset_flacfetch_client() clears the singleton."""
|
|
611
|
+
reset_flacfetch_client()
|
|
612
|
+
|
|
613
|
+
with patch("backend.config.get_settings") as mock_settings:
|
|
614
|
+
mock_settings.return_value = Mock(
|
|
615
|
+
flacfetch_api_url="http://localhost:8080",
|
|
616
|
+
flacfetch_api_key="test-key",
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
client1 = get_flacfetch_client()
|
|
620
|
+
reset_flacfetch_client()
|
|
621
|
+
|
|
622
|
+
# Change the settings
|
|
623
|
+
mock_settings.return_value = Mock(
|
|
624
|
+
flacfetch_api_url="http://other:8080",
|
|
625
|
+
flacfetch_api_key="other-key",
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
client2 = get_flacfetch_client()
|
|
629
|
+
|
|
630
|
+
assert client1 is not client2
|
|
631
|
+
assert client2.base_url == "http://other:8080"
|
|
632
|
+
|