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.
- 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/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- 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.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for the remote flacfetch HTTP API service.
|
|
3
|
+
|
|
4
|
+
This client communicates with a dedicated flacfetch VM that handles:
|
|
5
|
+
- BitTorrent downloads from private trackers (RED, OPS)
|
|
6
|
+
- YouTube downloads
|
|
7
|
+
- GCS uploads of downloaded files
|
|
8
|
+
|
|
9
|
+
The flacfetch service provides:
|
|
10
|
+
- Full peer connectivity for torrents (not possible in Cloud Run)
|
|
11
|
+
- Indefinite seeding of completed torrents
|
|
12
|
+
- Automatic disk cleanup
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
client = get_flacfetch_client()
|
|
16
|
+
if client:
|
|
17
|
+
# Search for audio
|
|
18
|
+
search_result = await client.search("ABBA", "Waterloo")
|
|
19
|
+
|
|
20
|
+
# Download the best result
|
|
21
|
+
download_id = await client.download(
|
|
22
|
+
search_id=search_result["search_id"],
|
|
23
|
+
result_index=0,
|
|
24
|
+
gcs_path="uploads/job123/audio/",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Wait for completion
|
|
28
|
+
result = await client.wait_for_download(download_id)
|
|
29
|
+
print(f"Downloaded to: {result['gcs_path']}")
|
|
30
|
+
"""
|
|
31
|
+
import asyncio
|
|
32
|
+
import logging
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FlacfetchServiceError(Exception):
|
|
41
|
+
"""Error communicating with or returned from flacfetch service."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FlacfetchClient:
|
|
46
|
+
"""
|
|
47
|
+
Client for remote flacfetch HTTP API.
|
|
48
|
+
|
|
49
|
+
All endpoints require authentication via X-API-Key header.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
base_url: str,
|
|
55
|
+
api_key: str,
|
|
56
|
+
timeout: int = 60,
|
|
57
|
+
):
|
|
58
|
+
"""
|
|
59
|
+
Initialize flacfetch client.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
base_url: Base URL of flacfetch service (e.g., http://10.0.0.5:8080)
|
|
63
|
+
api_key: API key for authentication
|
|
64
|
+
timeout: Default request timeout in seconds
|
|
65
|
+
"""
|
|
66
|
+
self.base_url = base_url.rstrip('/')
|
|
67
|
+
self.api_key = api_key
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
logger.info(f"FlacfetchClient initialized with base_url={self.base_url}")
|
|
70
|
+
|
|
71
|
+
def _headers(self) -> Dict[str, str]:
|
|
72
|
+
"""Get request headers with authentication."""
|
|
73
|
+
return {
|
|
74
|
+
"X-API-Key": self.api_key,
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async def health_check(self) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Check if flacfetch service is healthy.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Health status dict with transmission, disk, and provider info
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
FlacfetchServiceError: If service is unhealthy or unreachable
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
async with httpx.AsyncClient() as client:
|
|
90
|
+
resp = await client.get(
|
|
91
|
+
f"{self.base_url}/health",
|
|
92
|
+
headers=self._headers(),
|
|
93
|
+
timeout=10,
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
data = resp.json()
|
|
97
|
+
|
|
98
|
+
if data.get("status") not in ["healthy", "degraded"]:
|
|
99
|
+
raise FlacfetchServiceError(f"Service unhealthy: {data}")
|
|
100
|
+
|
|
101
|
+
return data
|
|
102
|
+
except httpx.RequestError as e:
|
|
103
|
+
raise FlacfetchServiceError(f"Cannot reach flacfetch service: {e}")
|
|
104
|
+
except httpx.HTTPStatusError as e:
|
|
105
|
+
raise FlacfetchServiceError(f"Health check failed: {e.response.status_code}")
|
|
106
|
+
|
|
107
|
+
async def search(
|
|
108
|
+
self,
|
|
109
|
+
artist: str,
|
|
110
|
+
title: str,
|
|
111
|
+
) -> Dict[str, Any]:
|
|
112
|
+
"""
|
|
113
|
+
Search for audio matching artist and title.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
artist: Artist name
|
|
117
|
+
title: Track title
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Search response dict with search_id and results list
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
FlacfetchServiceError: On search failure
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
async with httpx.AsyncClient() as client:
|
|
127
|
+
resp = await client.post(
|
|
128
|
+
f"{self.base_url}/search",
|
|
129
|
+
headers=self._headers(),
|
|
130
|
+
json={"artist": artist, "title": title},
|
|
131
|
+
timeout=self.timeout,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if resp.status_code == 404:
|
|
135
|
+
# No results found - return empty results
|
|
136
|
+
return {
|
|
137
|
+
"search_id": None,
|
|
138
|
+
"artist": artist,
|
|
139
|
+
"title": title,
|
|
140
|
+
"results": [],
|
|
141
|
+
"results_count": 0,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
return resp.json()
|
|
146
|
+
|
|
147
|
+
except httpx.RequestError as e:
|
|
148
|
+
raise FlacfetchServiceError(f"Search request failed: {e}")
|
|
149
|
+
except httpx.HTTPStatusError as e:
|
|
150
|
+
raise FlacfetchServiceError(f"Search failed: {e.response.status_code} - {e.response.text}")
|
|
151
|
+
|
|
152
|
+
async def download(
|
|
153
|
+
self,
|
|
154
|
+
search_id: str,
|
|
155
|
+
result_index: int,
|
|
156
|
+
output_filename: Optional[str] = None,
|
|
157
|
+
gcs_path: Optional[str] = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""
|
|
160
|
+
Start downloading an audio file.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
search_id: Search ID from previous search
|
|
164
|
+
result_index: Index of result to download
|
|
165
|
+
output_filename: Optional custom filename (without extension)
|
|
166
|
+
gcs_path: GCS path for upload (e.g., "uploads/job123/audio/")
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Download ID for tracking progress
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
FlacfetchServiceError: On download start failure
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
async with httpx.AsyncClient() as client:
|
|
176
|
+
payload = {
|
|
177
|
+
"search_id": search_id,
|
|
178
|
+
"result_index": result_index,
|
|
179
|
+
}
|
|
180
|
+
if output_filename:
|
|
181
|
+
payload["output_filename"] = output_filename
|
|
182
|
+
if gcs_path:
|
|
183
|
+
payload["upload_to_gcs"] = True
|
|
184
|
+
payload["gcs_path"] = gcs_path
|
|
185
|
+
|
|
186
|
+
resp = await client.post(
|
|
187
|
+
f"{self.base_url}/download",
|
|
188
|
+
headers=self._headers(),
|
|
189
|
+
json=payload,
|
|
190
|
+
timeout=self.timeout,
|
|
191
|
+
)
|
|
192
|
+
resp.raise_for_status()
|
|
193
|
+
|
|
194
|
+
data = resp.json()
|
|
195
|
+
return data["download_id"]
|
|
196
|
+
|
|
197
|
+
except httpx.RequestError as e:
|
|
198
|
+
raise FlacfetchServiceError(f"Download request failed: {e}")
|
|
199
|
+
except httpx.HTTPStatusError as e:
|
|
200
|
+
raise FlacfetchServiceError(f"Download start failed: {e.response.status_code} - {e.response.text}")
|
|
201
|
+
|
|
202
|
+
async def download_by_id(
|
|
203
|
+
self,
|
|
204
|
+
source_name: str,
|
|
205
|
+
source_id: str,
|
|
206
|
+
output_filename: Optional[str] = None,
|
|
207
|
+
target_file: Optional[str] = None,
|
|
208
|
+
download_url: Optional[str] = None,
|
|
209
|
+
gcs_path: Optional[str] = None,
|
|
210
|
+
) -> str:
|
|
211
|
+
"""
|
|
212
|
+
Start downloading directly by source ID (no prior search required).
|
|
213
|
+
|
|
214
|
+
This is useful when you have stored the source_id from a previous search
|
|
215
|
+
and want to download later without re-searching.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
source_name: Provider name (RED, OPS, YouTube, Spotify)
|
|
219
|
+
source_id: Source-specific ID (torrent ID, video ID, track ID)
|
|
220
|
+
output_filename: Optional custom filename (without extension)
|
|
221
|
+
target_file: For torrents, specific file to extract
|
|
222
|
+
download_url: For YouTube/Spotify, direct URL (optional)
|
|
223
|
+
gcs_path: GCS path for upload (e.g., "uploads/job123/audio/")
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Download ID for tracking progress
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
FlacfetchServiceError: On download start failure
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
async with httpx.AsyncClient() as client:
|
|
233
|
+
payload = {
|
|
234
|
+
"source_name": source_name,
|
|
235
|
+
"source_id": source_id,
|
|
236
|
+
}
|
|
237
|
+
if output_filename:
|
|
238
|
+
payload["output_filename"] = output_filename
|
|
239
|
+
if target_file:
|
|
240
|
+
payload["target_file"] = target_file
|
|
241
|
+
if download_url:
|
|
242
|
+
payload["download_url"] = download_url
|
|
243
|
+
if gcs_path:
|
|
244
|
+
payload["upload_to_gcs"] = True
|
|
245
|
+
payload["gcs_path"] = gcs_path
|
|
246
|
+
|
|
247
|
+
resp = await client.post(
|
|
248
|
+
f"{self.base_url}/download-by-id",
|
|
249
|
+
headers=self._headers(),
|
|
250
|
+
json=payload,
|
|
251
|
+
timeout=self.timeout,
|
|
252
|
+
)
|
|
253
|
+
resp.raise_for_status()
|
|
254
|
+
|
|
255
|
+
data = resp.json()
|
|
256
|
+
return data["download_id"]
|
|
257
|
+
|
|
258
|
+
except httpx.RequestError as e:
|
|
259
|
+
raise FlacfetchServiceError(f"Download by ID request failed: {e}")
|
|
260
|
+
except httpx.HTTPStatusError as e:
|
|
261
|
+
raise FlacfetchServiceError(f"Download by ID failed: {e.response.status_code} - {e.response.text}")
|
|
262
|
+
|
|
263
|
+
async def get_download_status(self, download_id: str) -> Dict[str, Any]:
|
|
264
|
+
"""
|
|
265
|
+
Get the current status of a download.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
download_id: Download ID from start_download
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Status dict with progress, speed, path info
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
FlacfetchServiceError: On status check failure
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
async with httpx.AsyncClient() as client:
|
|
278
|
+
resp = await client.get(
|
|
279
|
+
f"{self.base_url}/download/{download_id}/status",
|
|
280
|
+
headers=self._headers(),
|
|
281
|
+
timeout=10,
|
|
282
|
+
)
|
|
283
|
+
resp.raise_for_status()
|
|
284
|
+
return resp.json()
|
|
285
|
+
|
|
286
|
+
except httpx.RequestError as e:
|
|
287
|
+
raise FlacfetchServiceError(f"Status request failed: {e}")
|
|
288
|
+
except httpx.HTTPStatusError as e:
|
|
289
|
+
if e.response.status_code == 404:
|
|
290
|
+
raise FlacfetchServiceError(f"Download not found: {download_id}")
|
|
291
|
+
raise FlacfetchServiceError(f"Status check failed: {e.response.status_code}")
|
|
292
|
+
|
|
293
|
+
async def wait_for_download(
|
|
294
|
+
self,
|
|
295
|
+
download_id: str,
|
|
296
|
+
timeout: int = 600,
|
|
297
|
+
poll_interval: float = 2.0,
|
|
298
|
+
progress_callback: Optional[callable] = None,
|
|
299
|
+
) -> Dict[str, Any]:
|
|
300
|
+
"""
|
|
301
|
+
Wait for a download to complete.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
download_id: Download ID to wait for
|
|
305
|
+
timeout: Maximum wait time in seconds
|
|
306
|
+
poll_interval: Time between status checks
|
|
307
|
+
progress_callback: Optional callback(status_dict) for progress updates
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Final status dict with output_path or gcs_path
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
FlacfetchServiceError: On download failure or timeout
|
|
314
|
+
"""
|
|
315
|
+
elapsed = 0.0
|
|
316
|
+
|
|
317
|
+
while elapsed < timeout:
|
|
318
|
+
status = await self.get_download_status(download_id)
|
|
319
|
+
|
|
320
|
+
if progress_callback:
|
|
321
|
+
try:
|
|
322
|
+
progress_callback(status)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
logger.warning(f"Progress callback error: {e}")
|
|
325
|
+
|
|
326
|
+
download_status = status.get("status")
|
|
327
|
+
|
|
328
|
+
if download_status in ["complete", "seeding"]:
|
|
329
|
+
logger.info(f"Download {download_id} complete: {status.get('gcs_path') or status.get('output_path')}")
|
|
330
|
+
return status
|
|
331
|
+
elif download_status == "failed":
|
|
332
|
+
error = status.get("error", "Unknown error")
|
|
333
|
+
raise FlacfetchServiceError(f"Download failed: {error}")
|
|
334
|
+
elif download_status == "cancelled":
|
|
335
|
+
raise FlacfetchServiceError("Download was cancelled")
|
|
336
|
+
|
|
337
|
+
# Log progress
|
|
338
|
+
progress = status.get("progress", 0)
|
|
339
|
+
speed = status.get("download_speed_kbps", 0)
|
|
340
|
+
peers = status.get("peers", 0)
|
|
341
|
+
logger.debug(
|
|
342
|
+
f"Download {download_id}: {progress:.1f}% "
|
|
343
|
+
f"({speed:.1f} KB/s, {peers} peers)"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
await asyncio.sleep(poll_interval)
|
|
347
|
+
elapsed += poll_interval
|
|
348
|
+
|
|
349
|
+
raise FlacfetchServiceError(f"Download timed out after {timeout}s")
|
|
350
|
+
|
|
351
|
+
async def list_torrents(self) -> Dict[str, Any]:
|
|
352
|
+
"""
|
|
353
|
+
List all torrents in Transmission.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Torrent list response with torrent info and totals
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
async with httpx.AsyncClient() as client:
|
|
360
|
+
resp = await client.get(
|
|
361
|
+
f"{self.base_url}/torrents",
|
|
362
|
+
headers=self._headers(),
|
|
363
|
+
timeout=30,
|
|
364
|
+
)
|
|
365
|
+
resp.raise_for_status()
|
|
366
|
+
return resp.json()
|
|
367
|
+
|
|
368
|
+
except httpx.RequestError as e:
|
|
369
|
+
raise FlacfetchServiceError(f"List torrents failed: {e}")
|
|
370
|
+
except httpx.HTTPStatusError as e:
|
|
371
|
+
raise FlacfetchServiceError(f"List torrents failed: {e.response.status_code}")
|
|
372
|
+
|
|
373
|
+
async def delete_torrent(
|
|
374
|
+
self,
|
|
375
|
+
torrent_id: int,
|
|
376
|
+
delete_data: bool = True,
|
|
377
|
+
) -> Dict[str, Any]:
|
|
378
|
+
"""
|
|
379
|
+
Delete a torrent from Transmission.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
torrent_id: Transmission torrent ID
|
|
383
|
+
delete_data: Also delete downloaded files
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
Delete response
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
async with httpx.AsyncClient() as client:
|
|
390
|
+
resp = await client.delete(
|
|
391
|
+
f"{self.base_url}/torrents/{torrent_id}",
|
|
392
|
+
headers=self._headers(),
|
|
393
|
+
params={"delete_data": str(delete_data).lower()},
|
|
394
|
+
timeout=30,
|
|
395
|
+
)
|
|
396
|
+
resp.raise_for_status()
|
|
397
|
+
return resp.json()
|
|
398
|
+
|
|
399
|
+
except httpx.RequestError as e:
|
|
400
|
+
raise FlacfetchServiceError(f"Delete torrent failed: {e}")
|
|
401
|
+
except httpx.HTTPStatusError as e:
|
|
402
|
+
raise FlacfetchServiceError(f"Delete torrent failed: {e.response.status_code}")
|
|
403
|
+
|
|
404
|
+
async def cleanup_torrents(
|
|
405
|
+
self,
|
|
406
|
+
strategy: str = "oldest",
|
|
407
|
+
target_free_gb: float = 10.0,
|
|
408
|
+
) -> Dict[str, Any]:
|
|
409
|
+
"""
|
|
410
|
+
Trigger disk cleanup.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
strategy: Cleanup strategy (oldest, largest, lowest_ratio)
|
|
414
|
+
target_free_gb: Target free space to achieve
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Cleanup response with removed count and freed space
|
|
418
|
+
"""
|
|
419
|
+
try:
|
|
420
|
+
async with httpx.AsyncClient() as client:
|
|
421
|
+
resp = await client.post(
|
|
422
|
+
f"{self.base_url}/torrents/cleanup",
|
|
423
|
+
headers=self._headers(),
|
|
424
|
+
json={
|
|
425
|
+
"strategy": strategy,
|
|
426
|
+
"target_free_gb": target_free_gb,
|
|
427
|
+
},
|
|
428
|
+
timeout=60,
|
|
429
|
+
)
|
|
430
|
+
resp.raise_for_status()
|
|
431
|
+
return resp.json()
|
|
432
|
+
|
|
433
|
+
except httpx.RequestError as e:
|
|
434
|
+
raise FlacfetchServiceError(f"Cleanup failed: {e}")
|
|
435
|
+
except httpx.HTTPStatusError as e:
|
|
436
|
+
raise FlacfetchServiceError(f"Cleanup failed: {e.response.status_code}")
|
|
437
|
+
|
|
438
|
+
# =========================================================================
|
|
439
|
+
# Cache Management
|
|
440
|
+
# =========================================================================
|
|
441
|
+
|
|
442
|
+
async def clear_search_cache(self, artist: str, title: str) -> bool:
|
|
443
|
+
"""
|
|
444
|
+
Clear a specific cached search result by artist and title.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
artist: Artist name
|
|
448
|
+
title: Track title
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
True if a cache entry was deleted, False if no entry existed
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
FlacfetchServiceError: On request failure
|
|
455
|
+
"""
|
|
456
|
+
try:
|
|
457
|
+
async with httpx.AsyncClient() as client:
|
|
458
|
+
resp = await client.request(
|
|
459
|
+
"DELETE",
|
|
460
|
+
f"{self.base_url}/cache/search",
|
|
461
|
+
headers=self._headers(),
|
|
462
|
+
json={"artist": artist, "title": title},
|
|
463
|
+
timeout=30,
|
|
464
|
+
)
|
|
465
|
+
resp.raise_for_status()
|
|
466
|
+
data = resp.json()
|
|
467
|
+
deleted = data.get("deleted", False)
|
|
468
|
+
logger.info(
|
|
469
|
+
f"Cleared flacfetch cache for '{artist}' - '{title}': "
|
|
470
|
+
f"{'deleted' if deleted else 'no entry found'}"
|
|
471
|
+
)
|
|
472
|
+
return deleted
|
|
473
|
+
|
|
474
|
+
except httpx.RequestError as e:
|
|
475
|
+
raise FlacfetchServiceError(f"Clear search cache request failed: {e}")
|
|
476
|
+
except httpx.HTTPStatusError as e:
|
|
477
|
+
raise FlacfetchServiceError(
|
|
478
|
+
f"Clear search cache failed: {e.response.status_code} - {e.response.text}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
async def clear_all_cache(self) -> int:
|
|
482
|
+
"""
|
|
483
|
+
Clear all cached search results.
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Number of cache entries deleted
|
|
487
|
+
|
|
488
|
+
Raises:
|
|
489
|
+
FlacfetchServiceError: On request failure
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
async with httpx.AsyncClient() as client:
|
|
493
|
+
resp = await client.delete(
|
|
494
|
+
f"{self.base_url}/cache",
|
|
495
|
+
headers=self._headers(),
|
|
496
|
+
timeout=60,
|
|
497
|
+
)
|
|
498
|
+
resp.raise_for_status()
|
|
499
|
+
data = resp.json()
|
|
500
|
+
deleted_count = data.get("deleted_count", 0)
|
|
501
|
+
logger.info(f"Cleared all flacfetch cache: {deleted_count} entries deleted")
|
|
502
|
+
return deleted_count
|
|
503
|
+
|
|
504
|
+
except httpx.RequestError as e:
|
|
505
|
+
raise FlacfetchServiceError(f"Clear all cache request failed: {e}")
|
|
506
|
+
except httpx.HTTPStatusError as e:
|
|
507
|
+
raise FlacfetchServiceError(
|
|
508
|
+
f"Clear all cache failed: {e.response.status_code} - {e.response.text}"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
async def get_cache_stats(self) -> Dict[str, Any]:
|
|
512
|
+
"""
|
|
513
|
+
Get statistics about the cache.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
Dict with count, total_size_bytes, oldest_entry, newest_entry, configured
|
|
517
|
+
|
|
518
|
+
Raises:
|
|
519
|
+
FlacfetchServiceError: On request failure
|
|
520
|
+
"""
|
|
521
|
+
try:
|
|
522
|
+
async with httpx.AsyncClient() as client:
|
|
523
|
+
resp = await client.get(
|
|
524
|
+
f"{self.base_url}/cache/stats",
|
|
525
|
+
headers=self._headers(),
|
|
526
|
+
timeout=30,
|
|
527
|
+
)
|
|
528
|
+
resp.raise_for_status()
|
|
529
|
+
return resp.json()
|
|
530
|
+
|
|
531
|
+
except httpx.RequestError as e:
|
|
532
|
+
raise FlacfetchServiceError(f"Get cache stats request failed: {e}")
|
|
533
|
+
except httpx.HTTPStatusError as e:
|
|
534
|
+
raise FlacfetchServiceError(
|
|
535
|
+
f"Get cache stats failed: {e.response.status_code} - {e.response.text}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# Singleton client instance
|
|
540
|
+
_client: Optional[FlacfetchClient] = None
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def get_flacfetch_client() -> Optional[FlacfetchClient]:
|
|
544
|
+
"""
|
|
545
|
+
Get the flacfetch client if configured.
|
|
546
|
+
|
|
547
|
+
Returns:
|
|
548
|
+
FlacfetchClient instance if FLACFETCH_API_URL and FLACFETCH_API_KEY are set,
|
|
549
|
+
None otherwise (indicating local-only mode).
|
|
550
|
+
"""
|
|
551
|
+
global _client
|
|
552
|
+
|
|
553
|
+
if _client is None:
|
|
554
|
+
from backend.config import get_settings
|
|
555
|
+
settings = get_settings()
|
|
556
|
+
|
|
557
|
+
if settings.flacfetch_api_url and settings.flacfetch_api_key:
|
|
558
|
+
_client = FlacfetchClient(
|
|
559
|
+
base_url=settings.flacfetch_api_url,
|
|
560
|
+
api_key=settings.flacfetch_api_key,
|
|
561
|
+
)
|
|
562
|
+
logger.info("FlacfetchClient initialized for remote service")
|
|
563
|
+
else:
|
|
564
|
+
logger.debug("FlacfetchClient not configured (missing URL or API key)")
|
|
565
|
+
|
|
566
|
+
return _client
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def reset_flacfetch_client():
|
|
570
|
+
"""Reset the singleton client (for testing)."""
|
|
571
|
+
global _client
|
|
572
|
+
_client = None
|
|
573
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# GCE Encoding Worker
|
|
2
|
+
|
|
3
|
+
HTTP API service that runs on the GCE encoding worker VM for video encoding jobs.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This service provides HTTP endpoints for submitting and monitoring video encoding jobs. It runs as a systemd service on the `encoding-worker` GCE VM instance.
|
|
8
|
+
|
|
9
|
+
## Endpoints
|
|
10
|
+
|
|
11
|
+
| Endpoint | Method | Description |
|
|
12
|
+
|----------|--------|-------------|
|
|
13
|
+
| `/encode` | POST | Submit a full encoding job |
|
|
14
|
+
| `/encode-preview` | POST | Submit a preview encoding job (480x270, fast) |
|
|
15
|
+
| `/status/{job_id}` | GET | Get job status and progress |
|
|
16
|
+
| `/health` | GET | Health check |
|
|
17
|
+
|
|
18
|
+
## Authentication
|
|
19
|
+
|
|
20
|
+
All endpoints (except `/health`) require an API key via the `X-API-Key` header. The key is stored in Secret Manager (`encoding-worker-api-key`).
|
|
21
|
+
|
|
22
|
+
## Encoding Process
|
|
23
|
+
|
|
24
|
+
### Full Encoding (`/encode`)
|
|
25
|
+
1. Downloads input files from GCS (title screen, karaoke video, end screen, instrumental audio)
|
|
26
|
+
2. Uses `LocalEncodingService` from the karaoke-gen wheel to produce:
|
|
27
|
+
- Lossless 4K MP4
|
|
28
|
+
- Lossy 4K MP4
|
|
29
|
+
- Lossless MKV
|
|
30
|
+
- 720p MP4
|
|
31
|
+
3. Uploads results to GCS
|
|
32
|
+
4. Reports progress via status endpoint
|
|
33
|
+
|
|
34
|
+
### Preview Encoding (`/encode-preview`)
|
|
35
|
+
1. Downloads ASS subtitle and audio files from GCS
|
|
36
|
+
2. Runs FFmpeg to create a quick 480x270 preview video
|
|
37
|
+
3. Uploads result to GCS
|
|
38
|
+
4. Used for real-time preview in the web UI
|
|
39
|
+
|
|
40
|
+
## Dependencies
|
|
41
|
+
|
|
42
|
+
- **FFmpeg**: Static build from John Van Sickle
|
|
43
|
+
- **Python 3.13**: Built from source (required for karaoke-gen)
|
|
44
|
+
- **karaoke-gen wheel**: Downloaded from GCS at job start (enables hot updates)
|
|
45
|
+
|
|
46
|
+
## Deployment
|
|
47
|
+
|
|
48
|
+
This service is deployed via the VM's startup script. The script:
|
|
49
|
+
1. Installs system dependencies (FFmpeg, fonts, Python build deps)
|
|
50
|
+
2. Builds Python 3.13 from source
|
|
51
|
+
3. Creates venv and installs dependencies
|
|
52
|
+
4. Writes `main.py` to `/opt/encoding-worker/`
|
|
53
|
+
5. Creates systemd service
|
|
54
|
+
6. Starts the service on port 8080
|
|
55
|
+
|
|
56
|
+
## Local Development
|
|
57
|
+
|
|
58
|
+
This code is extracted from the infrastructure for maintainability. To test locally:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
cd backend/services/gce_encoding
|
|
62
|
+
pip install -r requirements.txt
|
|
63
|
+
pip install ../../../ # Install karaoke-gen wheel
|
|
64
|
+
ENCODING_API_KEY=test python main.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Architecture Notes
|
|
68
|
+
|
|
69
|
+
- **Hot code updates**: The karaoke-gen wheel is re-downloaded at the start of each job, allowing code updates without VM restart
|
|
70
|
+
- **In-memory job tracking**: Job state is stored in memory. Restart clears queue.
|
|
71
|
+
- **Parallel encoding**: ThreadPoolExecutor with 4 workers
|
|
72
|
+
- **GCS integration**: Direct download/upload via google-cloud-storage client
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GCE Encoding Worker Service.
|
|
3
|
+
|
|
4
|
+
This package contains the FastAPI application that runs on the GCE encoding worker VM.
|
|
5
|
+
It provides HTTP endpoints for video encoding jobs.
|
|
6
|
+
|
|
7
|
+
The service:
|
|
8
|
+
- Downloads input files from GCS
|
|
9
|
+
- Runs FFmpeg encoding via LocalEncodingService
|
|
10
|
+
- Uploads results back to GCS
|
|
11
|
+
- Provides job status tracking
|
|
12
|
+
|
|
13
|
+
Endpoints:
|
|
14
|
+
- POST /encode - Submit a full encoding job
|
|
15
|
+
- POST /encode-preview - Submit a preview encoding job (fast, low-res)
|
|
16
|
+
- GET /status/{job_id} - Get job status
|
|
17
|
+
- GET /health - Health check
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .main import app
|
|
21
|
+
|
|
22
|
+
__all__ = ["app"]
|