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,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"]