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,903 @@
1
+ """
2
+ Audio search API routes for artist+title search mode.
3
+
4
+ This module provides endpoints for:
5
+ 1. Creating a job with audio search (artist+title without file)
6
+ 2. Getting search results for a job
7
+ 3. Selecting an audio source to download
8
+
9
+ The flow is:
10
+ 1. POST /api/audio-search/search - Create job and search for audio
11
+ 2. GET /api/audio-search/{job_id}/results - Get search results
12
+ 3. POST /api/audio-search/{job_id}/select - Select audio source
13
+ """
14
+ import asyncio
15
+ import logging
16
+ import os
17
+ import tempfile
18
+ from typing import Optional, List, Dict, Any, Tuple
19
+
20
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Request, Depends
21
+ from pydantic import BaseModel, Field, validator
22
+
23
+ from backend.models.job import JobCreate, JobStatus
24
+ from karaoke_gen.utils import normalize_text
25
+ from backend.services.job_manager import JobManager
26
+ from backend.services.storage_service import StorageService
27
+ from backend.services.worker_service import get_worker_service
28
+ from backend.services.credential_manager import get_credential_manager, CredentialStatus
29
+ from backend.services.audio_search_service import (
30
+ get_audio_search_service,
31
+ AudioSearchResult,
32
+ AudioSearchError,
33
+ NoResultsError,
34
+ DownloadError,
35
+ )
36
+ from backend.config import get_settings
37
+ from backend.version import VERSION
38
+ from backend.api.dependencies import require_auth
39
+ from backend.services.auth_service import UserType, AuthResult
40
+ from pathlib import Path
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ # Valid style file types (from file_upload.py)
45
+ STYLE_FILE_TYPES = {
46
+ 'style_params': {'.json'},
47
+ 'style_intro_background': {'.jpg', '.jpeg', '.png'},
48
+ 'style_karaoke_background': {'.jpg', '.jpeg', '.png'},
49
+ 'style_end_background': {'.jpg', '.jpeg', '.png'},
50
+ 'style_font': {'.ttf', '.otf'},
51
+ 'style_cdg_instrumental_background': {'.jpg', '.jpeg', '.png'},
52
+ 'style_cdg_title_background': {'.jpg', '.jpeg', '.png'},
53
+ 'style_cdg_outro_background': {'.jpg', '.jpeg', '.png'},
54
+ }
55
+ router = APIRouter(tags=["audio-search"])
56
+
57
+ # Initialize services
58
+ job_manager = JobManager()
59
+ storage_service = StorageService()
60
+ worker_service = get_worker_service()
61
+
62
+
63
+ # ============================================================================
64
+ # Pydantic models
65
+ # ============================================================================
66
+
67
+ class StyleFileRequest(BaseModel):
68
+ """Information about a style file to be uploaded."""
69
+ filename: str = Field(..., description="Original filename with extension")
70
+ content_type: str = Field(..., description="MIME type of the file")
71
+ file_type: str = Field(..., description="Type of file: 'style_params', 'style_intro_background', 'style_karaoke_background', 'style_end_background', 'style_font', 'style_cdg_instrumental_background', 'style_cdg_title_background', 'style_cdg_outro_background'")
72
+
73
+
74
+ class StyleUploadUrl(BaseModel):
75
+ """Signed URL for uploading a style file."""
76
+ file_type: str = Field(..., description="Type of file being uploaded")
77
+ gcs_path: str = Field(..., description="Destination path in GCS")
78
+ upload_url: str = Field(..., description="Signed URL to PUT the file to")
79
+
80
+
81
+ class AudioSearchRequest(BaseModel):
82
+ """Request to search for audio by artist and title."""
83
+ artist: str = Field(..., description="Artist name to search for")
84
+ title: str = Field(..., description="Song title to search for")
85
+
86
+ # Auto-download mode
87
+ auto_download: bool = Field(False, description="Automatically select best result and download")
88
+
89
+ # Theme configuration
90
+ theme_id: Optional[str] = Field(None, description="Theme ID to use (e.g., 'nomad', 'default'). If set, CDG/TXT are enabled by default.")
91
+ color_overrides: Optional[Dict[str, str]] = Field(None, description="Color overrides: artist_color, title_color, sung_lyrics_color, unsung_lyrics_color (hex #RRGGBB)")
92
+
93
+ # Processing options (CDG/TXT require style config or theme, disabled by default unless theme is set)
94
+ enable_cdg: Optional[bool] = Field(None, description="Generate CDG+MP3 package. Default: True if theme_id set, False otherwise")
95
+ enable_txt: Optional[bool] = Field(None, description="Generate TXT+MP3 package. Default: True if theme_id set, False otherwise")
96
+
97
+ # Finalisation options
98
+ brand_prefix: Optional[str] = Field(None, description="Brand code prefix (e.g., NOMAD)")
99
+ enable_youtube_upload: Optional[bool] = Field(None, description="Upload to YouTube. None = use server default")
100
+ youtube_description: Optional[str] = Field(None, description="YouTube video description text")
101
+ discord_webhook_url: Optional[str] = Field(None, description="Discord webhook URL for notifications")
102
+
103
+ # Distribution options
104
+ dropbox_path: Optional[str] = Field(None, description="Dropbox folder path for organized output")
105
+ gdrive_folder_id: Optional[str] = Field(None, description="Google Drive folder ID for public share uploads")
106
+
107
+ # Lyrics configuration
108
+ lyrics_artist: Optional[str] = Field(None, description="Override artist name for lyrics search")
109
+ lyrics_title: Optional[str] = Field(None, description="Override title for lyrics search")
110
+ subtitle_offset_ms: int = Field(0, description="Subtitle timing offset in milliseconds")
111
+
112
+ # Display overrides (optional - if empty, search values are used for display)
113
+ display_artist: Optional[str] = Field(None, description="Artist name for title screens/filenames. If empty, uses search artist.")
114
+ display_title: Optional[str] = Field(None, description="Title for title screens/filenames. If empty, uses search title.")
115
+
116
+ # Audio separation model configuration
117
+ clean_instrumental_model: Optional[str] = Field(None, description="Model for clean instrumental separation")
118
+ backing_vocals_models: Optional[List[str]] = Field(None, description="Models for backing vocals separation")
119
+ other_stems_models: Optional[List[str]] = Field(None, description="Models for other stems")
120
+
121
+ # Style file uploads (optional)
122
+ # Style params JSON and related assets (background images, fonts)
123
+ style_files: Optional[List[StyleFileRequest]] = Field(None, description="Style files to upload (style_params JSON, backgrounds, fonts)")
124
+
125
+ # Non-interactive mode
126
+ non_interactive: bool = Field(False, description="Skip interactive steps (lyrics review, instrumental selection)")
127
+
128
+ @validator('artist', 'title', 'lyrics_artist', 'lyrics_title', 'display_artist', 'display_title')
129
+ def normalize_text_fields(cls, v):
130
+ """Normalize text fields to standardize Unicode characters."""
131
+ if v is not None and isinstance(v, str):
132
+ return normalize_text(v)
133
+ return v
134
+
135
+
136
+ class AudioSearchResultResponse(BaseModel):
137
+ """A single audio search result.
138
+
139
+ Contains all fields needed for rich display using flacfetch's
140
+ shared formatting functions.
141
+ """
142
+ index: int
143
+ title: str
144
+ artist: str
145
+ provider: str # Maps to source_name in Release
146
+ url: Optional[str] = None # Maps to download_url in Release (may be None for remote)
147
+ duration: Optional[int] = None # Maps to duration_seconds in Release
148
+ quality: Optional[str] = None # Stringified quality
149
+ source_id: Optional[str] = None # Maps to info_hash in Release
150
+ seeders: Optional[int] = None
151
+ target_file: Optional[str] = None
152
+ # Additional fields for rich display (from Release.to_dict())
153
+ year: Optional[int] = None
154
+ label: Optional[str] = None
155
+ edition_info: Optional[str] = None
156
+ release_type: Optional[str] = None
157
+ channel: Optional[str] = None # For YouTube
158
+ view_count: Optional[int] = None # For YouTube
159
+ size_bytes: Optional[int] = None
160
+ target_file_size: Optional[int] = None
161
+ track_pattern: Optional[str] = None
162
+ match_score: Optional[float] = None
163
+ # Pre-computed display fields
164
+ formatted_size: Optional[str] = None
165
+ formatted_duration: Optional[str] = None
166
+ formatted_views: Optional[str] = None
167
+ is_lossless: Optional[bool] = None
168
+ quality_str: Optional[str] = None
169
+ # Full quality object for Release.from_dict()
170
+ quality_data: Optional[Dict[str, Any]] = None
171
+
172
+
173
+ class AudioSearchResponse(BaseModel):
174
+ """Response from audio search."""
175
+ status: str
176
+ job_id: str
177
+ message: str
178
+ results: Optional[List[AudioSearchResultResponse]] = None
179
+ results_count: int = 0
180
+ auto_download: bool = False
181
+ server_version: str
182
+ # Style file upload URLs (when style_files are specified in request)
183
+ style_upload_urls: Optional[List[StyleUploadUrl]] = None
184
+
185
+
186
+ class AudioSelectRequest(BaseModel):
187
+ """Request to select an audio source."""
188
+ selection_index: int = Field(..., description="Index of the selected audio source from search results")
189
+
190
+
191
+ class AudioSelectResponse(BaseModel):
192
+ """Response from audio source selection."""
193
+ status: str
194
+ job_id: str
195
+ message: str
196
+ selected_index: int
197
+ selected_title: str
198
+ selected_artist: str
199
+ selected_provider: str
200
+
201
+
202
+ def _resolve_cdg_txt_defaults(
203
+ theme_id: Optional[str],
204
+ enable_cdg: Optional[bool],
205
+ enable_txt: Optional[bool]
206
+ ) -> Tuple[bool, bool]:
207
+ """
208
+ Resolve CDG/TXT settings based on theme and explicit settings.
209
+
210
+ When a theme is selected, CDG and TXT are enabled by default.
211
+ Explicit True/False values always override the default.
212
+
213
+ Args:
214
+ theme_id: Theme identifier (if any)
215
+ enable_cdg: Explicit CDG setting (None means use default)
216
+ enable_txt: Explicit TXT setting (None means use default)
217
+
218
+ Returns:
219
+ Tuple of (resolved_enable_cdg, resolved_enable_txt)
220
+ """
221
+ # Default based on whether theme is set
222
+ default_enabled = theme_id is not None
223
+
224
+ # Explicit values override defaults, None uses default
225
+ resolved_cdg = enable_cdg if enable_cdg is not None else default_enabled
226
+ resolved_txt = enable_txt if enable_txt is not None else default_enabled
227
+
228
+ return resolved_cdg, resolved_txt
229
+
230
+
231
+ def extract_request_metadata(request: Request, created_from: str = "audio_search") -> Dict[str, Any]:
232
+ """Extract metadata from request for job tracking."""
233
+ headers = dict(request.headers)
234
+
235
+ client_ip = headers.get('x-forwarded-for', '').split(',')[0].strip()
236
+ if not client_ip and request.client:
237
+ client_ip = request.client.host
238
+
239
+ user_agent = headers.get('user-agent', '')
240
+ environment = headers.get('x-environment', '')
241
+ client_id = headers.get('x-client-id', '')
242
+
243
+ custom_headers = {}
244
+ for key, value in headers.items():
245
+ if key.lower().startswith('x-') and key.lower() not in ('x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host'):
246
+ custom_headers[key] = value
247
+
248
+ return {
249
+ 'client_ip': client_ip,
250
+ 'user_agent': user_agent,
251
+ 'environment': environment,
252
+ 'client_id': client_id,
253
+ 'server_version': VERSION,
254
+ 'created_from': created_from,
255
+ 'custom_headers': custom_headers,
256
+ }
257
+
258
+
259
+ async def _trigger_workers_parallel(job_id: str) -> None:
260
+ """Trigger both audio and lyrics workers in parallel."""
261
+ await asyncio.gather(
262
+ worker_service.trigger_audio_worker(job_id),
263
+ worker_service.trigger_lyrics_worker(job_id)
264
+ )
265
+
266
+
267
+ async def _download_and_start_processing(
268
+ job_id: str,
269
+ selection_index: int,
270
+ audio_search_service,
271
+ background_tasks: BackgroundTasks,
272
+ ) -> Dict[str, Any]:
273
+ """
274
+ Download selected audio and start job processing.
275
+
276
+ This is called either:
277
+ - Immediately after search if auto_download=True
278
+ - When user calls the select endpoint
279
+
280
+ For remote flacfetch downloads (torrent sources), the file is downloaded
281
+ on the flacfetch VM and uploaded directly to GCS. For local downloads
282
+ (YouTube), the file is downloaded locally and then uploaded to GCS.
283
+ """
284
+ job = job_manager.get_job(job_id)
285
+ if not job:
286
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
287
+
288
+ # Get search results from state_data
289
+ search_results = job.state_data.get('audio_search_results', [])
290
+ if not search_results:
291
+ raise HTTPException(status_code=400, detail="No search results available")
292
+
293
+ if selection_index < 0 or selection_index >= len(search_results):
294
+ raise HTTPException(
295
+ status_code=400,
296
+ detail=f"Invalid selection index {selection_index}. Valid range: 0-{len(search_results)-1}"
297
+ )
298
+
299
+ selected = search_results[selection_index]
300
+
301
+ # Get remote_search_id from state_data (stored during initial search)
302
+ remote_search_id = job.state_data.get('remote_search_id')
303
+
304
+ # Transition to downloading state
305
+ job_manager.transition_to_state(
306
+ job_id=job_id,
307
+ new_status=JobStatus.DOWNLOADING_AUDIO,
308
+ progress=10,
309
+ message=f"Downloading from {selected['provider']}: {selected['artist']} - {selected['title']}",
310
+ state_data_updates={
311
+ 'selected_audio_index': selection_index,
312
+ 'selected_audio_provider': selected['provider'],
313
+ }
314
+ )
315
+
316
+ try:
317
+ # Determine if this is a remote torrent download
318
+ is_torrent_source = selected.get('provider') in ['RED', 'OPS']
319
+ is_remote_enabled = audio_search_service.is_remote_enabled()
320
+
321
+ # Check if we can use download_by_id (preferred - avoids re-searching)
322
+ source_id = selected.get('source_id')
323
+ source_name = selected.get('provider')
324
+ target_file = selected.get('target_file')
325
+ download_url = selected.get('url')
326
+
327
+ # For remote torrent downloads, have flacfetch VM upload directly to GCS
328
+ if is_torrent_source and is_remote_enabled:
329
+ # Generate GCS path for remote upload
330
+ gcs_destination = f"uploads/{job_id}/audio/"
331
+
332
+ # Use download_by_id if we have source_id (preferred - no re-search needed)
333
+ if source_id and source_name:
334
+ logger.info(f"Using download_by_id for {source_name} ID={source_id} with GCS upload to: {gcs_destination}")
335
+
336
+ result = audio_search_service.download_by_id(
337
+ source_name=source_name,
338
+ source_id=source_id,
339
+ output_dir="", # Not used for remote
340
+ target_file=target_file,
341
+ download_url=download_url,
342
+ gcs_path=gcs_destination,
343
+ )
344
+ else:
345
+ # Fallback to search-based download (requires re-search)
346
+ logger.info(f"No source_id available, falling back to search-based download to: {gcs_destination}")
347
+
348
+ result = audio_search_service.download(
349
+ result_index=selection_index,
350
+ output_dir="", # Not used for remote
351
+ gcs_path=gcs_destination,
352
+ remote_search_id=remote_search_id,
353
+ )
354
+
355
+ # For remote downloads, filepath is already the GCS path
356
+ if result.filepath.startswith("gs://"):
357
+ # Extract the path portion after the bucket name
358
+ # Format: gs://bucket/uploads/job_id/audio/filename.flac
359
+ parts = result.filepath.replace("gs://", "").split("/", 1)
360
+ if len(parts) == 2:
361
+ audio_gcs_path = parts[1]
362
+ else:
363
+ audio_gcs_path = result.filepath
364
+ filename = os.path.basename(result.filepath)
365
+ else:
366
+ # Fallback: treat as local path (shouldn't happen for remote)
367
+ filename = os.path.basename(result.filepath)
368
+ audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
369
+
370
+ logger.warning(f"Remote download returned local path: {result.filepath}, uploading manually")
371
+ with open(result.filepath, 'rb') as f:
372
+ storage_service.upload_fileobj(f, audio_gcs_path, content_type='audio/flac')
373
+
374
+ logger.info(f"Remote download complete, GCS path: {audio_gcs_path}")
375
+ else:
376
+ # Local download (YouTube or fallback)
377
+ temp_dir = tempfile.mkdtemp(prefix=f"audio_download_{job_id}_")
378
+
379
+ # Use download_by_id if we have source_id and remote is enabled
380
+ if source_id and source_name and is_remote_enabled:
381
+ logger.info(f"Using download_by_id for local download: {source_name} ID={source_id}")
382
+
383
+ result = audio_search_service.download_by_id(
384
+ source_name=source_name,
385
+ source_id=source_id,
386
+ output_dir=temp_dir,
387
+ target_file=target_file,
388
+ download_url=download_url,
389
+ )
390
+ elif source_name == 'YouTube' and download_url:
391
+ # For YouTube without remote, download directly using URL from state_data
392
+ # This avoids relying on in-memory cache which doesn't persist across instances
393
+ logger.info(f"Downloading YouTube audio directly from URL: {download_url}")
394
+ from backend.workers.audio_worker import download_from_url
395
+
396
+ local_path = await download_from_url(
397
+ download_url,
398
+ temp_dir,
399
+ selected.get('artist'),
400
+ selected.get('title')
401
+ )
402
+
403
+ if not local_path or not os.path.exists(local_path):
404
+ raise DownloadError(f"Failed to download from YouTube: {download_url}")
405
+
406
+ # Create a result-like object
407
+ class DownloadResult:
408
+ def __init__(self, filepath):
409
+ self.filepath = filepath
410
+
411
+ result = DownloadResult(local_path)
412
+ else:
413
+ # Fallback to search-based download (may fail if cache is empty)
414
+ logger.warning(f"Falling back to cache-based download for {source_name} - this may fail on multi-instance deployments")
415
+ result = audio_search_service.download(
416
+ result_index=selection_index,
417
+ output_dir=temp_dir,
418
+ remote_search_id=remote_search_id, # Pass for potential fallback scenarios
419
+ )
420
+
421
+ # Upload to GCS
422
+ filename = os.path.basename(result.filepath)
423
+ audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
424
+
425
+ with open(result.filepath, 'rb') as f:
426
+ storage_service.upload_fileobj(
427
+ f,
428
+ audio_gcs_path,
429
+ content_type='audio/flac' # flacfetch typically returns FLAC
430
+ )
431
+
432
+ logger.info(f"Uploaded audio to GCS: {audio_gcs_path}")
433
+
434
+ # Clean up temp file
435
+ try:
436
+ os.remove(result.filepath)
437
+ os.rmdir(temp_dir)
438
+ except Exception as e:
439
+ logger.warning(f"Failed to clean up temp files: {e}")
440
+
441
+ # Update job with GCS path and transition to DOWNLOADING
442
+ job_manager.update_job(job_id, {
443
+ 'input_media_gcs_path': audio_gcs_path,
444
+ 'filename': filename,
445
+ })
446
+
447
+ job_manager.transition_to_state(
448
+ job_id=job_id,
449
+ new_status=JobStatus.DOWNLOADING,
450
+ progress=15,
451
+ message="Audio downloaded, starting processing"
452
+ )
453
+
454
+ # Trigger workers
455
+ background_tasks.add_task(_trigger_workers_parallel, job_id)
456
+
457
+ return {
458
+ 'selected_index': selection_index,
459
+ 'selected_title': selected['title'],
460
+ 'selected_artist': selected['artist'],
461
+ 'selected_provider': selected['provider'],
462
+ }
463
+
464
+ except DownloadError as e:
465
+ job_manager.fail_job(job_id, f"Audio download failed: {e}")
466
+ raise HTTPException(status_code=500, detail=f"Download failed: {e}")
467
+ except Exception as e:
468
+ job_manager.fail_job(job_id, f"Audio download failed: {e}")
469
+ raise HTTPException(status_code=500, detail=f"Download failed: {e}")
470
+
471
+
472
+ @router.post("/audio-search/search", response_model=AudioSearchResponse)
473
+ async def search_audio(
474
+ request: Request,
475
+ background_tasks: BackgroundTasks,
476
+ body: AudioSearchRequest,
477
+ auth_result: AuthResult = Depends(require_auth)
478
+ ):
479
+ """
480
+ Search for audio by artist and title, creating a new job.
481
+
482
+ This endpoint:
483
+ 1. Creates a job in PENDING state
484
+ 2. Searches for audio using flacfetch
485
+ 3. Either returns search results for user selection, or
486
+ 4. If auto_download=True, automatically selects best and starts processing
487
+
488
+ Use cases:
489
+ - Interactive mode (default): Returns results, user calls /select endpoint
490
+ - Auto mode (auto_download=True): Automatically selects and downloads best
491
+ """
492
+ try:
493
+ # Apply default distribution settings
494
+ settings = get_settings()
495
+ effective_dropbox_path = body.dropbox_path or settings.default_dropbox_path
496
+ effective_gdrive_folder_id = body.gdrive_folder_id or settings.default_gdrive_folder_id
497
+ effective_discord_webhook_url = body.discord_webhook_url or settings.default_discord_webhook_url
498
+
499
+ # Apply defaults for YouTube/Dropbox distribution (for web service)
500
+ # Use explicit value if provided, otherwise fall back to server default
501
+ effective_enable_youtube_upload = body.enable_youtube_upload if body.enable_youtube_upload is not None else settings.default_enable_youtube_upload
502
+ effective_brand_prefix = body.brand_prefix or settings.default_brand_prefix
503
+ effective_youtube_description = body.youtube_description or settings.default_youtube_description
504
+
505
+ # Validate credentials if distribution services are requested
506
+ invalid_services = []
507
+ credential_manager = get_credential_manager()
508
+
509
+ if effective_enable_youtube_upload:
510
+ result = credential_manager.check_youtube_credentials()
511
+ if result.status != CredentialStatus.VALID:
512
+ invalid_services.append(f"youtube ({result.message})")
513
+
514
+ if effective_dropbox_path:
515
+ result = credential_manager.check_dropbox_credentials()
516
+ if result.status != CredentialStatus.VALID:
517
+ invalid_services.append(f"dropbox ({result.message})")
518
+
519
+ if effective_gdrive_folder_id:
520
+ result = credential_manager.check_gdrive_credentials()
521
+ if result.status != CredentialStatus.VALID:
522
+ invalid_services.append(f"gdrive ({result.message})")
523
+
524
+ if invalid_services:
525
+ raise HTTPException(
526
+ status_code=400,
527
+ detail={
528
+ "error": "credentials_invalid",
529
+ "message": f"Distribution services need re-authorization: {', '.join(invalid_services)}",
530
+ "invalid_services": invalid_services,
531
+ }
532
+ )
533
+
534
+ # Extract request metadata
535
+ request_metadata = extract_request_metadata(request, created_from="audio_search")
536
+
537
+ # Resolve CDG/TXT defaults based on theme
538
+ resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
539
+ body.theme_id, body.enable_cdg, body.enable_txt
540
+ )
541
+
542
+ # Use authenticated user's email
543
+ effective_user_email = auth_result.user_email
544
+
545
+ # Determine display values - use display_* if provided, otherwise fall back to search values
546
+ # Display values are used for title screens, filenames, YouTube, etc.
547
+ # Search values (body.artist, body.title) are used for audio search
548
+ effective_display_artist = body.display_artist.strip() if body.display_artist else body.artist
549
+ effective_display_title = body.display_title.strip() if body.display_title else body.title
550
+
551
+ # Create job
552
+ job_create = JobCreate(
553
+ artist=effective_display_artist, # Display value for title screens, filenames
554
+ title=effective_display_title, # Display value for title screens, filenames
555
+ theme_id=body.theme_id,
556
+ color_overrides=body.color_overrides or {},
557
+ enable_cdg=resolved_cdg,
558
+ enable_txt=resolved_txt,
559
+ brand_prefix=effective_brand_prefix,
560
+ enable_youtube_upload=effective_enable_youtube_upload,
561
+ youtube_description=effective_youtube_description,
562
+ youtube_description_template=effective_youtube_description, # video_worker reads this field
563
+ discord_webhook_url=effective_discord_webhook_url,
564
+ dropbox_path=effective_dropbox_path,
565
+ gdrive_folder_id=effective_gdrive_folder_id,
566
+ user_email=effective_user_email,
567
+ lyrics_artist=body.lyrics_artist,
568
+ lyrics_title=body.lyrics_title,
569
+ subtitle_offset_ms=body.subtitle_offset_ms,
570
+ clean_instrumental_model=body.clean_instrumental_model,
571
+ backing_vocals_models=body.backing_vocals_models,
572
+ other_stems_models=body.other_stems_models,
573
+ audio_search_artist=body.artist,
574
+ audio_search_title=body.title,
575
+ auto_download=body.auto_download,
576
+ request_metadata=request_metadata,
577
+ non_interactive=body.non_interactive,
578
+ )
579
+ job = job_manager.create_job(job_create)
580
+ job_id = job.job_id
581
+
582
+ logger.info(f"Created job {job_id} for audio search: {body.artist} - {body.title}")
583
+
584
+ # Update job with audio search fields
585
+ job_manager.update_job(job_id, {
586
+ 'audio_search_artist': body.artist,
587
+ 'audio_search_title': body.title,
588
+ 'auto_download': body.auto_download,
589
+ })
590
+
591
+ # If theme is set and no custom style files are being uploaded, prepare theme style now
592
+ # This copies the theme's style_params.json to the job folder so LyricsTranscriber
593
+ # can access the style configuration for preview videos
594
+ if body.theme_id and not body.style_files:
595
+ from backend.api.routes.file_upload import _prepare_theme_for_job
596
+ try:
597
+ style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
598
+ job_id, body.theme_id, body.color_overrides
599
+ )
600
+ theme_update = {
601
+ 'style_params_gcs_path': style_params_path,
602
+ 'style_assets': theme_style_assets,
603
+ }
604
+ if youtube_desc and not effective_youtube_description:
605
+ theme_update['youtube_description_template'] = youtube_desc
606
+ job_manager.update_job(job_id, theme_update)
607
+ logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
608
+ except Exception as e:
609
+ logger.warning(f"Failed to prepare theme '{body.theme_id}' for job {job_id}: {e}")
610
+ # Continue without theme - job can still be processed with defaults
611
+
612
+ # Handle style file uploads if provided
613
+ style_upload_urls: List[StyleUploadUrl] = []
614
+ style_assets = {}
615
+
616
+ if body.style_files:
617
+ logger.info(f"Processing {len(body.style_files)} style file uploads for job {job_id}")
618
+
619
+ for file_info in body.style_files:
620
+ # Validate file type
621
+ if file_info.file_type not in STYLE_FILE_TYPES:
622
+ raise HTTPException(
623
+ status_code=400,
624
+ detail=f"Invalid file type '{file_info.file_type}'. Must be one of: {', '.join(STYLE_FILE_TYPES.keys())}"
625
+ )
626
+
627
+ # Validate extension
628
+ ext = Path(file_info.filename).suffix.lower()
629
+ allowed_exts = STYLE_FILE_TYPES[file_info.file_type]
630
+ if ext not in allowed_exts:
631
+ raise HTTPException(
632
+ status_code=400,
633
+ detail=f"Invalid extension '{ext}' for {file_info.file_type}. Allowed: {', '.join(allowed_exts)}"
634
+ )
635
+
636
+ # Generate GCS path
637
+ if file_info.file_type == 'style_params':
638
+ gcs_path = f"uploads/{job_id}/style/style_params.json"
639
+ else:
640
+ # style_intro_background -> intro_background, etc.
641
+ asset_key = file_info.file_type.replace('style_', '')
642
+ gcs_path = f"uploads/{job_id}/style/{asset_key}{ext}"
643
+
644
+ # Generate signed upload URL
645
+ signed_url = storage_service.generate_signed_upload_url(
646
+ gcs_path,
647
+ content_type=file_info.content_type,
648
+ expiration_minutes=60
649
+ )
650
+
651
+ style_upload_urls.append(StyleUploadUrl(
652
+ file_type=file_info.file_type,
653
+ gcs_path=gcs_path,
654
+ upload_url=signed_url
655
+ ))
656
+
657
+ # Track the expected asset paths
658
+ if file_info.file_type == 'style_params':
659
+ style_assets['style_params'] = gcs_path
660
+ else:
661
+ asset_key = file_info.file_type.replace('style_', '')
662
+ style_assets[asset_key] = gcs_path
663
+
664
+ # Update job with style asset expectations
665
+ style_update = {'style_assets': style_assets}
666
+ if 'style_params' in style_assets:
667
+ style_update['style_params_gcs_path'] = style_assets['style_params']
668
+ job_manager.update_job(job_id, style_update)
669
+
670
+ logger.info(f"Generated {len(style_upload_urls)} style upload URLs for job {job_id}")
671
+
672
+ # Transition to searching state
673
+ job_manager.transition_to_state(
674
+ job_id=job_id,
675
+ new_status=JobStatus.SEARCHING_AUDIO,
676
+ progress=5,
677
+ message=f"Searching for: {body.artist} - {body.title}"
678
+ )
679
+
680
+ # Perform search
681
+ audio_search_service = get_audio_search_service()
682
+
683
+ try:
684
+ search_results = audio_search_service.search(body.artist, body.title)
685
+ except NoResultsError as e:
686
+ job_manager.fail_job(job_id, f"No audio sources found for: {body.artist} - {body.title}")
687
+ raise HTTPException(
688
+ status_code=404,
689
+ detail={
690
+ "error": "no_results",
691
+ "message": str(e),
692
+ "job_id": job_id,
693
+ }
694
+ )
695
+ except AudioSearchError as e:
696
+ job_manager.fail_job(job_id, f"Audio search failed: {e}")
697
+ raise HTTPException(status_code=500, detail=f"Search failed: {e}")
698
+
699
+ # Store results in job state_data, including remote_search_id if available
700
+ results_dicts = [r.to_dict() for r in search_results]
701
+ state_data_update = {
702
+ 'audio_search_results': results_dicts,
703
+ 'audio_search_count': len(results_dicts),
704
+ }
705
+ # Store remote_search_id for use during download (important for concurrent requests)
706
+ if audio_search_service.last_remote_search_id:
707
+ state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
708
+ job_manager.update_job(job_id, {
709
+ 'state_data': state_data_update
710
+ })
711
+
712
+ # If auto_download, select best and start processing
713
+ if body.auto_download:
714
+ best_index = audio_search_service.select_best(search_results)
715
+
716
+ logger.info(f"Auto-download enabled, selecting result {best_index}")
717
+
718
+ selection_info = await _download_and_start_processing(
719
+ job_id=job_id,
720
+ selection_index=best_index,
721
+ audio_search_service=audio_search_service,
722
+ background_tasks=background_tasks,
723
+ )
724
+
725
+ return AudioSearchResponse(
726
+ status="success",
727
+ job_id=job_id,
728
+ message=f"Audio found and download started: {selection_info['selected_artist']} - {selection_info['selected_title']} ({selection_info['selected_provider']})",
729
+ results=None, # Don't return results in auto mode
730
+ results_count=len(search_results),
731
+ auto_download=True,
732
+ server_version=VERSION,
733
+ style_upload_urls=style_upload_urls if style_upload_urls else None,
734
+ )
735
+
736
+ # Interactive mode: return results for user selection
737
+ job_manager.transition_to_state(
738
+ job_id=job_id,
739
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
740
+ progress=10,
741
+ message=f"Found {len(search_results)} audio sources. Waiting for selection."
742
+ )
743
+
744
+ # Convert to response format with full Release data for rich display
745
+ result_responses = []
746
+ for r in search_results:
747
+ # Get full serialized data from raw_result if available
748
+ # raw_result can be either:
749
+ # - A dict (from remote flacfetch API)
750
+ # - A Release object (from local flacfetch)
751
+ raw_dict = {}
752
+ if r.raw_result:
753
+ if isinstance(r.raw_result, dict):
754
+ # Remote flacfetch API returns dicts directly
755
+ raw_dict = r.raw_result
756
+ else:
757
+ # Local flacfetch returns Release objects
758
+ try:
759
+ raw_dict = r.raw_result.to_dict()
760
+ except AttributeError:
761
+ pass # Not a Release object
762
+
763
+ result_responses.append(
764
+ AudioSearchResultResponse(
765
+ index=r.index,
766
+ title=r.title,
767
+ artist=r.artist,
768
+ provider=r.provider,
769
+ url=r.url,
770
+ duration=r.duration,
771
+ quality=r.quality,
772
+ source_id=r.source_id,
773
+ seeders=raw_dict.get('seeders') or r.seeders,
774
+ target_file=raw_dict.get('target_file') or r.target_file,
775
+ # Additional fields for rich display
776
+ year=raw_dict.get('year'),
777
+ label=raw_dict.get('label'),
778
+ edition_info=raw_dict.get('edition_info'),
779
+ release_type=raw_dict.get('release_type'),
780
+ channel=raw_dict.get('channel'),
781
+ view_count=raw_dict.get('view_count'),
782
+ size_bytes=raw_dict.get('size_bytes'),
783
+ target_file_size=raw_dict.get('target_file_size'),
784
+ track_pattern=raw_dict.get('track_pattern'),
785
+ match_score=raw_dict.get('match_score'),
786
+ # Pre-computed display fields
787
+ formatted_size=raw_dict.get('formatted_size'),
788
+ formatted_duration=raw_dict.get('formatted_duration'),
789
+ formatted_views=raw_dict.get('formatted_views'),
790
+ is_lossless=raw_dict.get('is_lossless'),
791
+ quality_str=raw_dict.get('quality_str'),
792
+ # Full quality object for Release.from_dict()
793
+ # Remote API uses 'quality_data', local uses 'quality' for the dict
794
+ quality_data=raw_dict.get('quality_data') or raw_dict.get('quality'),
795
+ )
796
+ )
797
+
798
+ return AudioSearchResponse(
799
+ status="awaiting_selection",
800
+ job_id=job_id,
801
+ message=f"Found {len(search_results)} audio sources. Call /api/audio-search/{job_id}/select to choose one.",
802
+ results=result_responses,
803
+ results_count=len(search_results),
804
+ auto_download=False,
805
+ server_version=VERSION,
806
+ style_upload_urls=style_upload_urls if style_upload_urls else None,
807
+ )
808
+
809
+ except HTTPException:
810
+ raise
811
+ except Exception as e:
812
+ logger.error(f"Error in audio search: {e}", exc_info=True)
813
+ raise HTTPException(status_code=500, detail=str(e))
814
+
815
+
816
+ @router.get("/audio-search/{job_id}/results")
817
+ async def get_audio_search_results(
818
+ job_id: str,
819
+ auth_result: AuthResult = Depends(require_auth)
820
+ ):
821
+ """
822
+ Get audio search results for a job.
823
+
824
+ Returns the cached search results so user can select one.
825
+ """
826
+ job = job_manager.get_job(job_id)
827
+ if not job:
828
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
829
+
830
+ search_results = job.state_data.get('audio_search_results', [])
831
+
832
+ if not search_results:
833
+ raise HTTPException(
834
+ status_code=400,
835
+ detail="No search results available for this job"
836
+ )
837
+
838
+ return {
839
+ "status": "success",
840
+ "job_id": job_id,
841
+ "job_status": job.status,
842
+ "artist": job.audio_search_artist or job.artist,
843
+ "title": job.audio_search_title or job.title,
844
+ "results": search_results,
845
+ "results_count": len(search_results),
846
+ }
847
+
848
+
849
+ @router.post("/audio-search/{job_id}/select", response_model=AudioSelectResponse)
850
+ async def select_audio_source(
851
+ job_id: str,
852
+ background_tasks: BackgroundTasks,
853
+ body: AudioSelectRequest,
854
+ auth_result: AuthResult = Depends(require_auth)
855
+ ):
856
+ """
857
+ Select an audio source and start job processing.
858
+
859
+ This endpoint:
860
+ 1. Validates the job is awaiting selection
861
+ 2. Downloads the selected audio
862
+ 3. Uploads to GCS
863
+ 4. Triggers audio and lyrics workers
864
+ """
865
+ job = job_manager.get_job(job_id)
866
+ if not job:
867
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
868
+
869
+ # Verify job is awaiting selection
870
+ if job.status != JobStatus.AWAITING_AUDIO_SELECTION:
871
+ raise HTTPException(
872
+ status_code=400,
873
+ detail=f"Job is not awaiting audio selection (status: {job.status})"
874
+ )
875
+
876
+ # Get search service instance
877
+ # Note: With download_by_id, we no longer need to re-search to populate the cache.
878
+ # The source_id stored in job.state_data['audio_search_results'] is sufficient.
879
+ audio_search_service = get_audio_search_service()
880
+
881
+ # Validate search results exist in job state_data
882
+ search_results = job.state_data.get('audio_search_results', [])
883
+ if not search_results:
884
+ raise HTTPException(status_code=400, detail="No search results cached for this job")
885
+
886
+ selection_info = await _download_and_start_processing(
887
+ job_id=job_id,
888
+ selection_index=body.selection_index,
889
+ audio_search_service=audio_search_service,
890
+ background_tasks=background_tasks,
891
+ )
892
+
893
+ return AudioSelectResponse(
894
+ status="success",
895
+ job_id=job_id,
896
+ message="Audio selected and download started",
897
+ selected_index=selection_info['selected_index'],
898
+ selected_title=selection_info['selected_title'],
899
+ selected_artist=selection_info['selected_artist'],
900
+ selected_provider=selection_info['selected_provider'],
901
+ )
902
+
903
+