karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,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
+