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