karaoke-gen 0.86.7__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 (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,702 @@
1
+ """
2
+ Audio search service for finding and downloading audio files.
3
+
4
+ This service integrates with karaoke_gen.audio_fetcher (which wraps flacfetch)
5
+ to search for audio from various sources (YouTube, music trackers, etc.)
6
+ and download the selected audio file.
7
+
8
+ This is a thin wrapper that adds backend-specific functionality:
9
+ - Caching raw results for API-based selection flow
10
+ - Firestore-compatible serialization
11
+ - Singleton pattern for service lifecycle
12
+ - Remote flacfetch service integration for torrent downloads
13
+
14
+ When FLACFETCH_API_URL is configured, this service uses the remote flacfetch
15
+ HTTP API for search and download operations. This is required for torrent
16
+ downloads since Cloud Run doesn't support BitTorrent peer connections.
17
+ """
18
+ import asyncio
19
+ import logging
20
+ import os
21
+ from typing import List, Optional
22
+
23
+ import nest_asyncio
24
+
25
+ # Import shared classes from karaoke_gen.audio_fetcher - single source of truth
26
+ # Note: Import directly from audio_fetcher module, not karaoke_gen.__init__
27
+ # to avoid pulling in heavier dependencies like lyrics_transcriber
28
+ from karaoke_gen.audio_fetcher import (
29
+ FlacFetcher,
30
+ AudioSearchResult,
31
+ AudioFetchResult,
32
+ AudioFetcherError,
33
+ NoResultsError,
34
+ DownloadError,
35
+ )
36
+
37
+ from .flacfetch_client import get_flacfetch_client, FlacfetchServiceError
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ # Re-export exception under backend's naming convention for compatibility
43
+ AudioSearchError = AudioFetcherError
44
+
45
+
46
+ # Also alias AudioFetchResult as AudioDownloadResult for backwards compatibility
47
+ AudioDownloadResult = AudioFetchResult
48
+
49
+
50
+ class AudioSearchService:
51
+ """
52
+ Service for searching and downloading audio files.
53
+
54
+ This is a thin wrapper around karaoke_gen.audio_fetcher.FlacFetcher that adds
55
+ backend-specific functionality:
56
+ - Caches raw results for API-based selection (client picks by index)
57
+ - Provides Firestore-compatible serialization
58
+ - Manages service lifecycle as singleton
59
+ - Remote flacfetch service integration for torrent downloads
60
+
61
+ When FLACFETCH_API_URL is configured, this service uses the remote flacfetch
62
+ HTTP API for search and download operations. This enables torrent downloads
63
+ which are not possible in Cloud Run due to network restrictions.
64
+
65
+ The actual flacfetch integration is in karaoke_gen.audio_fetcher.FlacFetcher,
66
+ which is shared between local CLI and cloud backend.
67
+ """
68
+
69
+ # Sentinel value to indicate "use environment variable"
70
+ _USE_ENV = object()
71
+
72
+ def __init__(
73
+ self,
74
+ red_api_key: Optional[str] = _USE_ENV,
75
+ red_api_url: Optional[str] = _USE_ENV,
76
+ ops_api_key: Optional[str] = _USE_ENV,
77
+ ops_api_url: Optional[str] = _USE_ENV,
78
+ ):
79
+ """
80
+ Initialize the audio search service.
81
+
82
+ Args:
83
+ red_api_key: API key for RED tracker (optional, uses env if not provided)
84
+ red_api_url: Base URL for RED tracker API (optional, uses env if not provided)
85
+ ops_api_key: API key for OPS tracker (optional, uses env if not provided)
86
+ ops_api_url: Base URL for OPS tracker API (optional, uses env if not provided)
87
+ """
88
+ # Use environment variables if not explicitly provided
89
+ if red_api_key is self._USE_ENV:
90
+ red_api_key = os.environ.get("RED_API_KEY")
91
+ if red_api_url is self._USE_ENV:
92
+ red_api_url = os.environ.get("RED_API_URL")
93
+
94
+ if ops_api_key is self._USE_ENV:
95
+ ops_api_key = os.environ.get("OPS_API_KEY")
96
+ if ops_api_url is self._USE_ENV:
97
+ ops_api_url = os.environ.get("OPS_API_URL")
98
+
99
+ # Check for remote flacfetch client
100
+ self._remote_client = get_flacfetch_client()
101
+
102
+ # Log which mode we're using
103
+ if self._remote_client:
104
+ logger.info("AudioSearchService using REMOTE flacfetch service (torrent downloads enabled)")
105
+ else:
106
+ logger.info("AudioSearchService using LOCAL flacfetch (YouTube only in Cloud Run)")
107
+
108
+ # Delegate to shared FlacFetcher implementation (for local mode or fallback)
109
+ self._fetcher = FlacFetcher(
110
+ red_api_key=red_api_key,
111
+ red_api_url=red_api_url,
112
+ ops_api_key=ops_api_key,
113
+ ops_api_url=ops_api_url,
114
+ )
115
+
116
+ # Cache search results for API-based selection flow
117
+ # Key: index, Value: AudioSearchResult (with raw_result)
118
+ self._cached_results: List[AudioSearchResult] = []
119
+
120
+ # Cache for remote search results (search_id -> results)
121
+ self._remote_search_id: Optional[str] = None
122
+
123
+ def search(self, artist: str, title: str) -> List[AudioSearchResult]:
124
+ """
125
+ Search for audio matching the given artist and title.
126
+
127
+ Results are cached internally for later download via download().
128
+
129
+ If a remote flacfetch service is configured (FLACFETCH_API_URL), uses that
130
+ for better torrent download support. Otherwise falls back to local flacfetch.
131
+
132
+ Args:
133
+ artist: The artist name to search for
134
+ title: The track title to search for
135
+
136
+ Returns:
137
+ List of AudioSearchResult objects
138
+
139
+ Raises:
140
+ NoResultsError: If no results are found
141
+ AudioSearchError: For other errors (e.g., flacfetch not installed)
142
+ """
143
+ try:
144
+ # Try remote flacfetch service first if configured
145
+ if self._remote_client:
146
+ return self._search_remote(artist, title)
147
+
148
+ # Fallback to local flacfetch
149
+ results = self._fetcher.search(artist, title)
150
+
151
+ # Cache results for later download
152
+ self._cached_results = results
153
+ self._remote_search_id = None
154
+
155
+ logger.info(f"Found {len(results)} results for: {artist} - {title}")
156
+ return results
157
+
158
+ except (NoResultsError, AudioFetcherError):
159
+ # Re-raise these as-is
160
+ raise
161
+ except Exception as e:
162
+ logger.error(f"Search failed: {e}")
163
+ raise AudioSearchError(f"Search failed: {e}") from e
164
+
165
+ def _search_remote(self, artist: str, title: str) -> List[AudioSearchResult]:
166
+ """
167
+ Search using the remote flacfetch service.
168
+
169
+ Runs async code in a sync context for compatibility with existing API.
170
+ """
171
+ try:
172
+ # Enable nested event loops (needed when called from FastAPI async context)
173
+ # Apply to the current loop if one exists, otherwise it's a no-op
174
+ try:
175
+ loop = asyncio.get_running_loop()
176
+ nest_asyncio.apply(loop)
177
+ except RuntimeError:
178
+ pass # No running loop, nest_asyncio not needed
179
+
180
+ # Run async search in sync context
181
+ loop = asyncio.new_event_loop()
182
+ asyncio.set_event_loop(loop)
183
+ try:
184
+ response = loop.run_until_complete(
185
+ self._remote_client.search(artist, title)
186
+ )
187
+ finally:
188
+ loop.close()
189
+
190
+ # Check for empty results
191
+ if not response.get("results"):
192
+ raise NoResultsError(f"No results found for: {artist} - {title}")
193
+
194
+ # Store search_id for download
195
+ self._remote_search_id = response.get("search_id")
196
+
197
+ # Convert remote results to AudioSearchResult objects
198
+ results = []
199
+ for item in response.get("results", []):
200
+ result = AudioSearchResult(
201
+ title=item.get("title", "Unknown"),
202
+ artist=item.get("artist", "Unknown"),
203
+ url=item.get("download_url", ""), # May be empty for remote
204
+ provider=item.get("provider", "Unknown"),
205
+ duration=item.get("duration_seconds"),
206
+ quality=item.get("quality", ""),
207
+ source_id=item.get("info_hash"),
208
+ index=item.get("index", 0),
209
+ seeders=item.get("seeders"),
210
+ target_file=item.get("target_file"),
211
+ # Store full remote data in raw_result for rich display
212
+ raw_result=item,
213
+ )
214
+ results.append(result)
215
+
216
+ # Cache results
217
+ self._cached_results = results
218
+
219
+ logger.info(f"Found {len(results)} results from remote flacfetch for: {artist} - {title}")
220
+ return results
221
+
222
+ except FlacfetchServiceError as e:
223
+ logger.warning(f"Remote flacfetch search failed, falling back to local: {e}")
224
+ # Fallback to local search
225
+ self._remote_search_id = None
226
+ results = self._fetcher.search(artist, title)
227
+ self._cached_results = results
228
+ return results
229
+
230
+ def select_best(self, results: List[AudioSearchResult]) -> int:
231
+ """
232
+ Select the best result from a list of search results.
233
+
234
+ Uses flacfetch's built-in quality ranking to select the best source.
235
+
236
+ Args:
237
+ results: List of search results
238
+
239
+ Returns:
240
+ Index of the best result
241
+ """
242
+ return self._fetcher.select_best(results)
243
+
244
+ def download(
245
+ self,
246
+ result_index: int,
247
+ output_dir: str,
248
+ output_filename: Optional[str] = None,
249
+ gcs_path: Optional[str] = None,
250
+ remote_search_id: Optional[str] = None,
251
+ ) -> AudioDownloadResult:
252
+ """
253
+ Download audio from a cached search result.
254
+
255
+ This method uses the cached results from the last search() call.
256
+ The API flow is:
257
+ 1. Client calls search() -> gets list of results
258
+ 2. Client picks an index
259
+ 3. Client calls download(index) -> gets downloaded file
260
+
261
+ If a remote flacfetch service is configured and the search was performed
262
+ remotely (for torrent sources), uses the remote service for download.
263
+
264
+ Args:
265
+ result_index: Index of the result to download (from search results)
266
+ output_dir: Directory to save the downloaded file
267
+ output_filename: Optional filename (without extension)
268
+ gcs_path: Optional GCS path for remote uploads (e.g., "uploads/job123/audio/")
269
+ remote_search_id: Optional search_id for remote downloads. If provided,
270
+ uses this instead of the cached _remote_search_id. This is important
271
+ for concurrent requests where the service-level cache may be stale.
272
+
273
+ Returns:
274
+ AudioDownloadResult with the downloaded file path
275
+
276
+ Raises:
277
+ DownloadError: If download fails or no cached result for index
278
+ """
279
+ # Use provided search_id or fall back to service-level cache
280
+ effective_search_id = remote_search_id or self._remote_search_id
281
+
282
+ # Check if we have local cached results
283
+ has_local_cache = result_index >= 0 and result_index < len(self._cached_results)
284
+
285
+ # If no local cache but we have remote search_id, use remote download
286
+ # This handles multi-instance deployments where the cache doesn't persist
287
+ if not has_local_cache:
288
+ if effective_search_id and self._remote_client:
289
+ logger.info(f"No local cache, using remote download with search_id={effective_search_id}, index={result_index}")
290
+ return self._download_remote(result_index, output_dir, output_filename, gcs_path, effective_search_id)
291
+ else:
292
+ # Provide clear error message based on whether cache is empty or index out of bounds
293
+ if len(self._cached_results) == 0:
294
+ raise DownloadError(
295
+ f"No cached result for index {result_index}. "
296
+ "No cached results available. Run search() first."
297
+ )
298
+ raise DownloadError(
299
+ f"No cached result for index {result_index}. "
300
+ f"Available indices: 0-{len(self._cached_results) - 1}. "
301
+ "Run search() first."
302
+ )
303
+
304
+ result = self._cached_results[result_index]
305
+
306
+ logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider}")
307
+
308
+ # Check if this was a remote search (torrent sources need remote download)
309
+ if effective_search_id and self._remote_client:
310
+ # Check if this is a torrent source that needs remote handling
311
+ is_torrent = result.provider in ["RED", "OPS"]
312
+
313
+ if is_torrent:
314
+ return self._download_remote(result_index, output_dir, output_filename, gcs_path, effective_search_id)
315
+
316
+ # Delegate to shared FlacFetcher (local download)
317
+ fetch_result = self._fetcher.download(result, output_dir, output_filename)
318
+
319
+ logger.info(f"Downloaded to: {fetch_result.filepath}")
320
+
321
+ return fetch_result
322
+
323
+ def download_by_id(
324
+ self,
325
+ source_name: str,
326
+ source_id: str,
327
+ output_dir: str,
328
+ output_filename: Optional[str] = None,
329
+ target_file: Optional[str] = None,
330
+ download_url: Optional[str] = None,
331
+ gcs_path: Optional[str] = None,
332
+ ) -> AudioDownloadResult:
333
+ """
334
+ Download audio directly by source ID (no prior search required).
335
+
336
+ This is the preferred method when you have stored the source_id from a
337
+ previous search and want to download later without re-searching. This
338
+ avoids unnecessary API calls to private trackers.
339
+
340
+ Args:
341
+ source_name: Provider name (RED, OPS, YouTube, Spotify)
342
+ source_id: Source-specific ID (torrent ID, video ID, track ID)
343
+ output_dir: Directory to save the downloaded file
344
+ output_filename: Optional filename (without extension)
345
+ target_file: For torrents, specific file to extract from the torrent
346
+ download_url: For YouTube/Spotify, direct URL (optional)
347
+ gcs_path: Optional GCS path for remote uploads
348
+
349
+ Returns:
350
+ AudioDownloadResult with the downloaded file path
351
+
352
+ Raises:
353
+ DownloadError: If download fails
354
+ """
355
+ logger.info(f"Download by ID: {source_name} ID={source_id}")
356
+
357
+ # For torrent sources, must use remote client
358
+ if source_name in ["RED", "OPS"]:
359
+ if not self._remote_client:
360
+ raise DownloadError(
361
+ f"Cannot download from {source_name} without remote flacfetch service"
362
+ )
363
+ return self._download_by_id_remote(
364
+ source_name=source_name,
365
+ source_id=source_id,
366
+ output_dir=output_dir,
367
+ output_filename=output_filename,
368
+ target_file=target_file,
369
+ download_url=download_url,
370
+ gcs_path=gcs_path,
371
+ )
372
+
373
+ # For YouTube/Spotify, can use local flacfetch via FetchManager.download_by_id
374
+ # But we currently only have remote support, so use remote if available
375
+ if self._remote_client:
376
+ return self._download_by_id_remote(
377
+ source_name=source_name,
378
+ source_id=source_id,
379
+ output_dir=output_dir,
380
+ output_filename=output_filename,
381
+ target_file=target_file,
382
+ download_url=download_url,
383
+ gcs_path=gcs_path,
384
+ )
385
+
386
+ # Local download for YouTube/Spotify (fallback)
387
+ # Use the local fetcher's download_by_id if available
388
+ raise DownloadError(
389
+ f"Local download_by_id not yet implemented for {source_name}. "
390
+ "Configure FLACFETCH_API_URL for remote downloads."
391
+ )
392
+
393
+ def _download_by_id_remote(
394
+ self,
395
+ source_name: str,
396
+ source_id: str,
397
+ output_dir: str, # Not used for remote downloads (files go to GCS or remote server)
398
+ output_filename: Optional[str] = None,
399
+ target_file: Optional[str] = None,
400
+ download_url: Optional[str] = None,
401
+ gcs_path: Optional[str] = None,
402
+ ) -> AudioDownloadResult:
403
+ """
404
+ Download by ID using the remote flacfetch service.
405
+
406
+ Note: output_dir is accepted for API compatibility but ignored for remote
407
+ downloads. Remote downloads either go to GCS (if gcs_path is set) or to
408
+ the remote server's download directory.
409
+ """
410
+ # output_dir is intentionally unused - remote downloads don't use local paths
411
+ _ = output_dir
412
+ try:
413
+ # Enable nested event loops
414
+ try:
415
+ running_loop = asyncio.get_running_loop()
416
+ nest_asyncio.apply(running_loop)
417
+ except RuntimeError:
418
+ pass
419
+
420
+ loop = asyncio.new_event_loop()
421
+ asyncio.set_event_loop(loop)
422
+ try:
423
+ # Start download
424
+ download_id = loop.run_until_complete(
425
+ self._remote_client.download_by_id(
426
+ source_name=source_name,
427
+ source_id=source_id,
428
+ output_filename=output_filename,
429
+ target_file=target_file,
430
+ download_url=download_url,
431
+ gcs_path=gcs_path,
432
+ )
433
+ )
434
+
435
+ logger.info(f"Remote download by ID started: {download_id}")
436
+
437
+ # Wait for completion
438
+ def log_progress(status):
439
+ progress = status.get("progress", 0)
440
+ speed = status.get("download_speed_kbps", 0)
441
+ logger.debug(f"Download progress: {progress:.1f}% ({speed:.1f} KB/s)")
442
+
443
+ final_status = loop.run_until_complete(
444
+ self._remote_client.wait_for_download(
445
+ download_id,
446
+ timeout=600,
447
+ progress_callback=log_progress,
448
+ )
449
+ )
450
+ finally:
451
+ loop.close()
452
+
453
+ # Determine file path
454
+ filepath = final_status.get("gcs_path") or final_status.get("output_path")
455
+
456
+ if not filepath:
457
+ raise DownloadError("Remote download completed but no file path returned")
458
+
459
+ logger.info(f"Remote download by ID complete: {filepath}")
460
+
461
+ return AudioDownloadResult(
462
+ filepath=filepath,
463
+ artist="", # Not available without search
464
+ title="", # Not available without search
465
+ provider=source_name,
466
+ quality="", # Not available without search
467
+ )
468
+
469
+ except FlacfetchServiceError as e:
470
+ raise DownloadError(f"Remote download by ID failed: {e}") from e
471
+ except Exception as e:
472
+ logger.error(f"Remote download by ID error: {e}")
473
+ raise DownloadError(f"Remote download by ID failed: {e}") from e
474
+
475
+ def _download_remote(
476
+ self,
477
+ result_index: int,
478
+ output_dir: str,
479
+ output_filename: Optional[str] = None,
480
+ gcs_path: Optional[str] = None,
481
+ search_id: Optional[str] = None,
482
+ ) -> AudioDownloadResult:
483
+ """
484
+ Download using the remote flacfetch service.
485
+
486
+ The remote service downloads via torrent and optionally uploads to GCS.
487
+
488
+ Args:
489
+ result_index: Index of the result to download
490
+ output_dir: Directory to save downloaded file
491
+ output_filename: Optional filename
492
+ gcs_path: Optional GCS path for remote uploads
493
+ search_id: Remote search ID to use (overrides self._remote_search_id)
494
+ """
495
+ effective_search_id = search_id or self._remote_search_id
496
+ if not effective_search_id:
497
+ raise DownloadError("No remote search ID - run search() first")
498
+
499
+ # Try to get local result for metadata, but it's optional
500
+ # The remote service maintains its own cache by search_id
501
+ result = None
502
+ if result_index >= 0 and result_index < len(self._cached_results):
503
+ result = self._cached_results[result_index]
504
+
505
+ try:
506
+ # Enable nested event loops (needed when called from FastAPI async context)
507
+ try:
508
+ running_loop = asyncio.get_running_loop()
509
+ nest_asyncio.apply(running_loop)
510
+ except RuntimeError:
511
+ pass # No running loop, nest_asyncio not needed
512
+
513
+ loop = asyncio.new_event_loop()
514
+ asyncio.set_event_loop(loop)
515
+ try:
516
+ # Start download
517
+ download_id = loop.run_until_complete(
518
+ self._remote_client.download(
519
+ search_id=effective_search_id,
520
+ result_index=result_index,
521
+ output_filename=output_filename,
522
+ gcs_path=gcs_path,
523
+ )
524
+ )
525
+
526
+ logger.info(f"Remote download started: {download_id}")
527
+
528
+ # Wait for completion with progress logging
529
+ def log_progress(status):
530
+ progress = status.get("progress", 0)
531
+ speed = status.get("download_speed_kbps", 0)
532
+ logger.debug(f"Download progress: {progress:.1f}% ({speed:.1f} KB/s)")
533
+
534
+ final_status = loop.run_until_complete(
535
+ self._remote_client.wait_for_download(
536
+ download_id,
537
+ timeout=600, # 10 minute timeout
538
+ progress_callback=log_progress,
539
+ )
540
+ )
541
+ finally:
542
+ loop.close()
543
+
544
+ # Determine file path
545
+ filepath = final_status.get("gcs_path") or final_status.get("output_path")
546
+
547
+ if not filepath:
548
+ raise DownloadError("Remote download completed but no file path returned")
549
+
550
+ logger.info(f"Remote download complete: {filepath}")
551
+
552
+ # Use local result metadata if available, otherwise use empty strings
553
+ # (the actual download is handled by the remote service using search_id)
554
+ return AudioDownloadResult(
555
+ filepath=filepath,
556
+ artist=result.artist if result else "",
557
+ title=result.title if result else "",
558
+ provider=result.provider if result else "remote",
559
+ quality=result.quality if result else "",
560
+ )
561
+
562
+ except FlacfetchServiceError as e:
563
+ raise DownloadError(f"Remote download failed: {e}") from e
564
+ except Exception as e:
565
+ logger.error(f"Remote download error: {e}")
566
+ raise DownloadError(f"Remote download failed: {e}") from e
567
+
568
+ def search_and_download_auto(
569
+ self,
570
+ artist: str,
571
+ title: str,
572
+ output_dir: str,
573
+ output_filename: Optional[str] = None,
574
+ gcs_path: Optional[str] = None,
575
+ ) -> AudioDownloadResult:
576
+ """
577
+ Search for audio and automatically download the best result.
578
+
579
+ This is a convenience method that combines search(), select_best(),
580
+ and download() for automated/non-interactive usage.
581
+
582
+ Args:
583
+ artist: The artist name to search for
584
+ title: The track title to search for
585
+ output_dir: Directory to save the downloaded file
586
+ output_filename: Optional filename (without extension)
587
+ gcs_path: Optional GCS path for remote uploads
588
+
589
+ Returns:
590
+ AudioDownloadResult with the downloaded file path
591
+
592
+ Raises:
593
+ NoResultsError: If no results are found
594
+ DownloadError: If download fails
595
+ """
596
+ # Search
597
+ results = self.search(artist, title)
598
+
599
+ # Select best
600
+ best_index = self.select_best(results)
601
+ best_result = results[best_index]
602
+ logger.info(
603
+ f"Auto-selected result {best_index}: "
604
+ f"{best_result.artist} - {best_result.title} ({best_result.provider})"
605
+ )
606
+
607
+ # Download
608
+ return self.download(best_index, output_dir, output_filename, gcs_path)
609
+
610
+ async def search_async(self, artist: str, title: str) -> List[AudioSearchResult]:
611
+ """
612
+ Async version of search for use in async routes.
613
+
614
+ Note: Currently wraps sync search in executor. Future optimization
615
+ could make this fully async.
616
+ """
617
+ loop = asyncio.get_event_loop()
618
+ return await loop.run_in_executor(None, self.search, artist, title)
619
+
620
+ async def download_async(
621
+ self,
622
+ result_index: int,
623
+ output_dir: str,
624
+ output_filename: Optional[str] = None,
625
+ gcs_path: Optional[str] = None,
626
+ remote_search_id: Optional[str] = None,
627
+ ) -> AudioDownloadResult:
628
+ """
629
+ Async version of download for use in async routes.
630
+
631
+ Note: Currently wraps sync download in executor. Future optimization
632
+ could make this fully async.
633
+ """
634
+ loop = asyncio.get_event_loop()
635
+ return await loop.run_in_executor(
636
+ None,
637
+ lambda: self.download(result_index, output_dir, output_filename, gcs_path, remote_search_id)
638
+ )
639
+
640
+ async def download_by_id_async(
641
+ self,
642
+ source_name: str,
643
+ source_id: str,
644
+ output_dir: str,
645
+ output_filename: Optional[str] = None,
646
+ target_file: Optional[str] = None,
647
+ download_url: Optional[str] = None,
648
+ gcs_path: Optional[str] = None,
649
+ ) -> AudioDownloadResult:
650
+ """
651
+ Async version of download_by_id for use in async routes.
652
+ """
653
+ loop = asyncio.get_event_loop()
654
+ return await loop.run_in_executor(
655
+ None,
656
+ lambda: self.download_by_id(
657
+ source_name, source_id, output_dir, output_filename,
658
+ target_file, download_url, gcs_path
659
+ )
660
+ )
661
+
662
+ def is_remote_enabled(self) -> bool:
663
+ """Check if remote flacfetch service is configured."""
664
+ return self._remote_client is not None
665
+
666
+ @property
667
+ def last_remote_search_id(self) -> Optional[str]:
668
+ """Get the search_id from the last remote search.
669
+
670
+ This should be stored in job state_data after search and passed
671
+ back to download() to ensure correct remote download handling.
672
+ """
673
+ return self._remote_search_id
674
+
675
+ async def check_remote_health(self) -> Optional[dict]:
676
+ """
677
+ Check health of remote flacfetch service.
678
+
679
+ Returns:
680
+ Health status dict if remote service is configured and healthy,
681
+ None if not configured or unhealthy.
682
+ """
683
+ if not self._remote_client:
684
+ return None
685
+
686
+ try:
687
+ return await self._remote_client.health_check()
688
+ except Exception as e:
689
+ logger.warning(f"Remote flacfetch health check failed: {e}")
690
+ return None
691
+
692
+
693
+ # Singleton instance
694
+ _audio_search_service: Optional[AudioSearchService] = None
695
+
696
+
697
+ def get_audio_search_service() -> AudioSearchService:
698
+ """Get the singleton AudioSearchService instance."""
699
+ global _audio_search_service
700
+ if _audio_search_service is None:
701
+ _audio_search_service = AudioSearchService()
702
+ return _audio_search_service