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,454 @@
1
+ """
2
+ Encoding Interface.
3
+
4
+ Defines the abstract interface for video encoding backends, allowing
5
+ the video worker orchestrator to use either GCE or local encoding
6
+ interchangeably.
7
+
8
+ This follows the Strategy pattern - different encoding implementations
9
+ can be swapped without changing the orchestration logic.
10
+ """
11
+
12
+ import logging
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass, field
15
+ from typing import Dict, List, Optional, Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class EncodingInput:
22
+ """
23
+ Input configuration for video encoding.
24
+
25
+ Contains all the paths and metadata needed to encode a karaoke video.
26
+ """
27
+ # Required input files
28
+ title_video_path: str # Title card video (MOV)
29
+ karaoke_video_path: str # Main karaoke video with vocals (MOV/MKV)
30
+ instrumental_audio_path: str # Instrumental audio track (FLAC)
31
+
32
+ # Optional input files
33
+ end_video_path: Optional[str] = None # End credits video (MOV)
34
+
35
+ # Metadata for output naming
36
+ artist: str = ""
37
+ title: str = ""
38
+ brand_code: Optional[str] = None
39
+
40
+ # Output directory
41
+ output_dir: str = ""
42
+
43
+ # Additional options
44
+ options: Dict[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ @dataclass
48
+ class EncodingOutput:
49
+ """
50
+ Output from video encoding.
51
+
52
+ Contains paths to all generated video files and status information.
53
+ """
54
+ success: bool
55
+ error_message: Optional[str] = None
56
+
57
+ # Output file paths (relative to output_dir or absolute)
58
+ karaoke_mp4_path: Optional[str] = None # Karaoke video with instrumental audio
59
+ with_vocals_mp4_path: Optional[str] = None # With vocals MP4 version
60
+ lossless_4k_mp4_path: Optional[str] = None # Final lossless 4K MP4
61
+ lossy_4k_mp4_path: Optional[str] = None # Final lossy 4K MP4
62
+ lossless_mkv_path: Optional[str] = None # MKV with FLAC (for YouTube)
63
+ lossy_720p_mp4_path: Optional[str] = None # 720p web version
64
+
65
+ # All output files as a dict for convenience
66
+ output_files: Dict[str, str] = field(default_factory=dict)
67
+
68
+ # Encoding metadata
69
+ encoding_time_seconds: Optional[float] = None
70
+ encoding_backend: Optional[str] = None # "gce" or "local"
71
+
72
+
73
+ class EncodingBackend(ABC):
74
+ """
75
+ Abstract base class for video encoding backends.
76
+
77
+ Implementations:
78
+ - GCEEncodingBackend: Cloud-based encoding using Google Compute Engine
79
+ - LocalEncodingBackend: Local FFmpeg-based encoding
80
+ """
81
+
82
+ @property
83
+ @abstractmethod
84
+ def name(self) -> str:
85
+ """Return the name of this encoding backend."""
86
+ pass
87
+
88
+ @abstractmethod
89
+ async def encode(self, input_config: EncodingInput) -> EncodingOutput:
90
+ """
91
+ Encode video files according to the input configuration.
92
+
93
+ This method should:
94
+ 1. Create karaoke video with instrumental audio
95
+ 2. Convert with-vocals video to MP4 if needed
96
+ 3. Encode lossless 4K MP4 (concatenating title + karaoke + end)
97
+ 4. Encode lossy 4K MP4 with AAC audio
98
+ 5. Create MKV with FLAC audio for YouTube
99
+ 6. Encode 720p version for web
100
+
101
+ Args:
102
+ input_config: EncodingInput with all required paths and options
103
+
104
+ Returns:
105
+ EncodingOutput with paths to generated files and status
106
+ """
107
+ pass
108
+
109
+ @abstractmethod
110
+ async def is_available(self) -> bool:
111
+ """
112
+ Check if this encoding backend is available and configured.
113
+
114
+ Returns:
115
+ True if the backend can be used, False otherwise
116
+ """
117
+ pass
118
+
119
+ @abstractmethod
120
+ async def get_status(self) -> Dict[str, Any]:
121
+ """
122
+ Get the current status of the encoding backend.
123
+
124
+ Returns:
125
+ Dict with status information (availability, queue length, etc.)
126
+ """
127
+ pass
128
+
129
+
130
+ class LocalEncodingBackend(EncodingBackend):
131
+ """
132
+ Local FFmpeg-based encoding backend.
133
+
134
+ Wraps the LocalEncodingService to implement the EncodingBackend interface.
135
+ Uses asyncio.to_thread() to run synchronous FFmpeg operations asynchronously.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ dry_run: bool = False,
141
+ logger: Optional[logging.Logger] = None,
142
+ ):
143
+ """
144
+ Initialize the local encoding backend.
145
+
146
+ Args:
147
+ dry_run: If True, log operations without executing
148
+ logger: Optional logger instance
149
+ """
150
+ self.dry_run = dry_run
151
+ self.logger = logger or logging.getLogger(__name__)
152
+ self._service = None
153
+
154
+ @property
155
+ def name(self) -> str:
156
+ return "local"
157
+
158
+ def _get_service(self):
159
+ """Lazy-load the local encoding service."""
160
+ if self._service is None:
161
+ from backend.services.local_encoding_service import LocalEncodingService
162
+ self._service = LocalEncodingService(
163
+ dry_run=self.dry_run,
164
+ logger=self.logger
165
+ )
166
+ return self._service
167
+
168
+ async def encode(self, input_config: EncodingInput) -> EncodingOutput:
169
+ """
170
+ Encode video using local FFmpeg.
171
+
172
+ Args:
173
+ input_config: Encoding input configuration
174
+
175
+ Returns:
176
+ EncodingOutput with results
177
+ """
178
+ import asyncio
179
+ import time
180
+ from backend.services.local_encoding_service import EncodingConfig
181
+
182
+ start_time = time.time()
183
+
184
+ # Build output file paths
185
+ base_name = f"{input_config.artist} - {input_config.title}"
186
+ output_dir = input_config.output_dir or "."
187
+
188
+ import os
189
+ config = EncodingConfig(
190
+ title_video=input_config.title_video_path,
191
+ karaoke_video=input_config.karaoke_video_path,
192
+ instrumental_audio=input_config.instrumental_audio_path,
193
+ end_video=input_config.end_video_path,
194
+ output_karaoke_mp4=os.path.join(output_dir, f"{base_name} (Karaoke).mp4"),
195
+ output_with_vocals_mp4=os.path.join(output_dir, f"{base_name} (With Vocals).mp4"),
196
+ output_lossless_4k_mp4=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossless 4k).mp4"),
197
+ output_lossy_4k_mp4=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossy 4k).mp4"),
198
+ output_lossless_mkv=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossless 4k).mkv"),
199
+ output_720p_mp4=os.path.join(output_dir, f"{base_name} (Final Karaoke Lossy 720p).mp4"),
200
+ )
201
+
202
+ # Run encoding in thread pool to avoid blocking
203
+ service = self._get_service()
204
+ result = await asyncio.to_thread(service.encode_all_formats, config)
205
+
206
+ encoding_time = time.time() - start_time
207
+
208
+ if result.success:
209
+ return EncodingOutput(
210
+ success=True,
211
+ karaoke_mp4_path=config.output_karaoke_mp4,
212
+ with_vocals_mp4_path=config.output_with_vocals_mp4,
213
+ lossless_4k_mp4_path=config.output_lossless_4k_mp4,
214
+ lossy_4k_mp4_path=config.output_lossy_4k_mp4,
215
+ lossless_mkv_path=config.output_lossless_mkv,
216
+ lossy_720p_mp4_path=config.output_720p_mp4,
217
+ output_files=result.output_files,
218
+ encoding_time_seconds=encoding_time,
219
+ encoding_backend=self.name
220
+ )
221
+ else:
222
+ return EncodingOutput(
223
+ success=False,
224
+ error_message=result.error,
225
+ output_files=result.output_files,
226
+ encoding_time_seconds=encoding_time,
227
+ encoding_backend=self.name
228
+ )
229
+
230
+ async def is_available(self) -> bool:
231
+ """Check if local encoding is available (FFmpeg installed)."""
232
+ import subprocess
233
+ import asyncio
234
+
235
+ try:
236
+ result = await asyncio.to_thread(
237
+ subprocess.run,
238
+ ["ffmpeg", "-version"],
239
+ capture_output=True,
240
+ timeout=10
241
+ )
242
+ return result.returncode == 0
243
+ except (FileNotFoundError, subprocess.TimeoutExpired):
244
+ return False
245
+
246
+ async def get_status(self) -> Dict[str, Any]:
247
+ """Get local encoding status."""
248
+ available = await self.is_available()
249
+ service = self._get_service()
250
+
251
+ return {
252
+ "backend": self.name,
253
+ "available": available,
254
+ "hwaccel_available": service.hwaccel_available if available else False,
255
+ "video_encoder": service.video_encoder if available else None,
256
+ }
257
+
258
+
259
+ class GCEEncodingBackend(EncodingBackend):
260
+ """
261
+ GCE-based encoding backend.
262
+
263
+ Wraps the existing EncodingService to implement the EncodingBackend interface.
264
+ Submits jobs to a remote GCE worker for high-performance encoding.
265
+ """
266
+
267
+ def __init__(
268
+ self,
269
+ dry_run: bool = False,
270
+ logger: Optional[logging.Logger] = None,
271
+ ):
272
+ """
273
+ Initialize the GCE encoding backend.
274
+
275
+ Args:
276
+ dry_run: Ignored for GCE backend (remote service doesn't support dry run)
277
+ logger: Optional logger instance
278
+ """
279
+ self.dry_run = dry_run # Stored but not used (GCE is remote)
280
+ self.logger = logger or logging.getLogger(__name__)
281
+ self._service = None
282
+
283
+ @property
284
+ def name(self) -> str:
285
+ return "gce"
286
+
287
+ def _get_service(self):
288
+ """Lazy-load the GCE encoding service."""
289
+ if self._service is None:
290
+ from backend.services.encoding_service import get_encoding_service
291
+ self._service = get_encoding_service()
292
+ return self._service
293
+
294
+ async def encode(self, input_config: EncodingInput) -> EncodingOutput:
295
+ """
296
+ Encode video using GCE worker.
297
+
298
+ Note: GCE encoding requires files to be in GCS, so this method
299
+ expects input_config.options to contain 'input_gcs_path' and
300
+ 'output_gcs_path'.
301
+
302
+ Args:
303
+ input_config: Encoding input configuration
304
+
305
+ Returns:
306
+ EncodingOutput with results
307
+ """
308
+ import time
309
+
310
+ start_time = time.time()
311
+ service = self._get_service()
312
+
313
+ # GCE requires GCS paths
314
+ input_gcs_path = input_config.options.get("input_gcs_path")
315
+ output_gcs_path = input_config.options.get("output_gcs_path")
316
+ job_id = input_config.options.get("job_id", input_config.brand_code or "unknown")
317
+
318
+ if not input_gcs_path or not output_gcs_path:
319
+ return EncodingOutput(
320
+ success=False,
321
+ error_message="GCE encoding requires input_gcs_path and output_gcs_path in options",
322
+ encoding_backend=self.name
323
+ )
324
+
325
+ try:
326
+ # Build encoding config
327
+ encoding_config = {
328
+ "formats": ["mp4_4k_lossless", "mp4_4k_lossy", "mkv_4k", "mp4_720p"],
329
+ "artist": input_config.artist,
330
+ "title": input_config.title,
331
+ }
332
+
333
+ # Submit and wait for completion
334
+ result = await service.encode_videos(
335
+ job_id=job_id,
336
+ input_gcs_path=input_gcs_path,
337
+ output_gcs_path=output_gcs_path,
338
+ encoding_config=encoding_config,
339
+ )
340
+
341
+ encoding_time = time.time() - start_time
342
+
343
+ # Extract output file paths from result
344
+ # Handle case where GCE worker returns a list or unexpected format
345
+ if isinstance(result, list):
346
+ # If result is a list, try to find the output_files in the first dict
347
+ self.logger.warning(f"GCE returned list instead of dict: {result}")
348
+ result = result[0] if result and isinstance(result[0], dict) else {}
349
+ if not isinstance(result, dict):
350
+ self.logger.error(f"Unexpected GCE result type: {type(result)}")
351
+ result = {}
352
+ raw_output_files = result.get("output_files", {})
353
+
354
+ # Convert output_files from list of paths to dict
355
+ # GCE worker returns list like: ["path/Artist - Title (Final Karaoke Lossless 4k).mp4", ...]
356
+ # We need dict like: {"mp4_4k_lossless": "path/...", "mp4_720p": "path/..."}
357
+ if isinstance(raw_output_files, list):
358
+ output_files = {}
359
+ for path in raw_output_files:
360
+ if not isinstance(path, str):
361
+ continue
362
+ filename = path.split("/")[-1] if "/" in path else path
363
+ filename_lower = filename.lower()
364
+ # Map filename patterns to output format keys
365
+ # Files are named like "Artist - Title (Final Karaoke Lossless 4k).mp4"
366
+ if "lossless 4k" in filename_lower:
367
+ if filename.endswith(".mkv"):
368
+ output_files["mkv_4k"] = path
369
+ else:
370
+ output_files["mp4_4k_lossless"] = path
371
+ elif "lossy 4k" in filename_lower:
372
+ output_files["mp4_4k_lossy"] = path
373
+ elif "720p" in filename_lower:
374
+ output_files["mp4_720p"] = path
375
+ self.logger.info(f"Converted output_files list to dict: {output_files}")
376
+ else:
377
+ output_files = raw_output_files if isinstance(raw_output_files, dict) else {}
378
+
379
+ return EncodingOutput(
380
+ success=True,
381
+ lossless_4k_mp4_path=output_files.get("mp4_4k_lossless"),
382
+ lossy_4k_mp4_path=output_files.get("mp4_4k_lossy"),
383
+ lossless_mkv_path=output_files.get("mkv_4k"),
384
+ lossy_720p_mp4_path=output_files.get("mp4_720p"),
385
+ output_files=output_files,
386
+ encoding_time_seconds=encoding_time,
387
+ encoding_backend=self.name
388
+ )
389
+
390
+ except Exception as e:
391
+ encoding_time = time.time() - start_time
392
+ self.logger.error(f"GCE encoding failed: {e}")
393
+ return EncodingOutput(
394
+ success=False,
395
+ error_message=str(e),
396
+ encoding_time_seconds=encoding_time,
397
+ encoding_backend=self.name
398
+ )
399
+
400
+ async def is_available(self) -> bool:
401
+ """Check if GCE encoding is available and configured."""
402
+ service = self._get_service()
403
+ return service.is_enabled
404
+
405
+ async def get_status(self) -> Dict[str, Any]:
406
+ """Get GCE encoding status."""
407
+ service = self._get_service()
408
+
409
+ status = {
410
+ "backend": self.name,
411
+ "available": service.is_enabled,
412
+ "configured": service.is_configured,
413
+ }
414
+
415
+ if service.is_configured:
416
+ try:
417
+ health = await service.health_check()
418
+ status["health"] = health
419
+ except Exception as e:
420
+ status["health_error"] = str(e)
421
+
422
+ return status
423
+
424
+
425
+ # Factory function to get an encoding backend
426
+ def get_encoding_backend(
427
+ backend_type: str = "auto",
428
+ **kwargs
429
+ ) -> EncodingBackend:
430
+ """
431
+ Get an encoding backend instance.
432
+
433
+ Args:
434
+ backend_type: Type of backend - "local", "gce", or "auto"
435
+ **kwargs: Additional arguments for the backend
436
+
437
+ Returns:
438
+ EncodingBackend instance
439
+
440
+ Raises:
441
+ ValueError: If backend_type is unknown
442
+ """
443
+ if backend_type == "local":
444
+ return LocalEncodingBackend(**kwargs)
445
+ elif backend_type == "gce":
446
+ return GCEEncodingBackend(**kwargs)
447
+ elif backend_type == "auto":
448
+ # Check if GCE is available, otherwise use local
449
+ gce_backend = GCEEncodingBackend(**kwargs)
450
+ if gce_backend._get_service().is_enabled:
451
+ return gce_backend
452
+ return LocalEncodingBackend(**kwargs)
453
+ else:
454
+ raise ValueError(f"Unknown encoding backend type: {backend_type}")