karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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 (197) 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 +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -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 +502 -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 +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,407 @@
1
+ """
2
+ Local Preview Encoding Service.
3
+
4
+ Provides local FFmpeg-based preview video encoding functionality, extracted from
5
+ VideoGenerator for use by the GCE worker. This ensures the same encoding logic
6
+ is used across local CLI, Cloud Run, and GCE worker environments.
7
+
8
+ This service handles:
9
+ - Generating preview videos with ASS subtitle overlay
10
+ - Hardware acceleration detection (NVENC) with fallback to software encoding
11
+ - Background image/color support
12
+ - Custom font support
13
+ - Optimized settings for fast preview generation
14
+ """
15
+
16
+ import logging
17
+ import os
18
+ import subprocess
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Optional, List
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class PreviewEncodingConfig:
28
+ """Configuration for preview video encoding."""
29
+ ass_path: str # Path to ASS subtitles file
30
+ audio_path: str # Path to audio file
31
+ output_path: str # Path for output video file
32
+
33
+ # Optional background settings
34
+ background_image_path: Optional[str] = None # Path to background image
35
+ background_color: str = "black" # Fallback background color
36
+
37
+ # Optional font settings
38
+ font_path: Optional[str] = None # Path to custom font file
39
+
40
+
41
+ @dataclass
42
+ class PreviewEncodingResult:
43
+ """Result of preview encoding operation."""
44
+ success: bool
45
+ output_path: Optional[str] = None
46
+ error: Optional[str] = None
47
+
48
+
49
+ class LocalPreviewEncodingService:
50
+ """
51
+ Service for local FFmpeg-based preview video encoding.
52
+
53
+ This is the single source of truth for preview encoding logic, used by:
54
+ - Local CLI (via VideoGenerator which delegates here)
55
+ - Cloud Run (when GCE is unavailable)
56
+ - GCE worker (via installed wheel)
57
+
58
+ Supports hardware acceleration (NVENC) with automatic fallback
59
+ to software encoding (libx264) when unavailable.
60
+ """
61
+
62
+ # Preview video settings - these are the canonical values
63
+ PREVIEW_WIDTH = 480
64
+ PREVIEW_HEIGHT = 270
65
+ PREVIEW_FPS = 24
66
+ PREVIEW_AUDIO_BITRATE = "96k"
67
+
68
+ def __init__(
69
+ self,
70
+ logger: Optional[logging.Logger] = None,
71
+ ):
72
+ """
73
+ Initialize the local preview encoding service.
74
+
75
+ Args:
76
+ logger: Optional logger instance
77
+ """
78
+ self.logger = logger or logging.getLogger(__name__)
79
+
80
+ # Hardware acceleration settings (detected on first use)
81
+ self._nvenc_available: Optional[bool] = None
82
+ self._video_encoder: Optional[str] = None
83
+ self._hwaccel_flags: Optional[List[str]] = None
84
+
85
+ def _detect_nvenc_support(self) -> bool:
86
+ """
87
+ Detect if NVENC hardware encoding is available.
88
+
89
+ Returns:
90
+ True if NVENC is available, False otherwise
91
+ """
92
+ try:
93
+ self.logger.info("Detecting NVENC hardware acceleration...")
94
+
95
+ # Test h264_nvenc encoder directly
96
+ test_cmd = [
97
+ "ffmpeg", "-hide_banner", "-loglevel", "error",
98
+ "-f", "lavfi", "-i", "testsrc=duration=1:size=320x240:rate=1",
99
+ "-c:v", "h264_nvenc", "-f", "null", "-"
100
+ ]
101
+
102
+ result = subprocess.run(
103
+ test_cmd,
104
+ capture_output=True,
105
+ text=True,
106
+ timeout=30
107
+ )
108
+
109
+ if result.returncode == 0:
110
+ self.logger.info("NVENC hardware acceleration available")
111
+ return True
112
+
113
+ # Try alternative test with different source
114
+ alt_test_cmd = [
115
+ "ffmpeg", "-hide_banner", "-loglevel", "error",
116
+ "-f", "lavfi", "-i", "color=red:size=320x240:duration=0.1",
117
+ "-c:v", "h264_nvenc", "-preset", "fast", "-f", "null", "-"
118
+ ]
119
+
120
+ alt_result = subprocess.run(
121
+ alt_test_cmd,
122
+ capture_output=True,
123
+ text=True,
124
+ timeout=30
125
+ )
126
+
127
+ if alt_result.returncode == 0:
128
+ self.logger.info("NVENC hardware acceleration available")
129
+ return True
130
+
131
+ self.logger.info("NVENC not available, using software encoding")
132
+ return False
133
+
134
+ except subprocess.TimeoutExpired:
135
+ self.logger.debug("NVENC detection timed out")
136
+ return False
137
+ except Exception as e:
138
+ self.logger.debug(f"NVENC detection failed: {e}")
139
+ return False
140
+
141
+ def _configure_hardware_acceleration(self) -> None:
142
+ """Configure hardware acceleration settings based on detected capabilities."""
143
+ if self._nvenc_available is None:
144
+ self._nvenc_available = self._detect_nvenc_support()
145
+
146
+ if self._nvenc_available:
147
+ self._video_encoder = "h264_nvenc"
148
+ self._hwaccel_flags = ["-hwaccel", "cuda", "-hwaccel_output_format", "cuda"]
149
+ self.logger.info("Using NVENC hardware acceleration for preview encoding")
150
+ else:
151
+ self._video_encoder = "libx264"
152
+ self._hwaccel_flags = []
153
+ self.logger.info("Using software encoding (libx264) for preview")
154
+
155
+ @property
156
+ def nvenc_available(self) -> bool:
157
+ """Check if NVENC hardware acceleration is available."""
158
+ if self._nvenc_available is None:
159
+ self._configure_hardware_acceleration()
160
+ return self._nvenc_available
161
+
162
+ @property
163
+ def video_encoder(self) -> str:
164
+ """Get the video encoder to use."""
165
+ if self._video_encoder is None:
166
+ self._configure_hardware_acceleration()
167
+ return self._video_encoder
168
+
169
+ @property
170
+ def hwaccel_flags(self) -> List[str]:
171
+ """Get hardware acceleration flags."""
172
+ if self._hwaccel_flags is None:
173
+ self._configure_hardware_acceleration()
174
+ return self._hwaccel_flags
175
+
176
+ def _escape_ffmpeg_filter_path(self, path: str) -> str:
177
+ """
178
+ Escape a path for FFmpeg filter expressions (for subprocess without shell).
179
+
180
+ When using subprocess with a command list (no shell), FFmpeg receives the
181
+ filter string directly. FFmpeg's filter parser requires escaping:
182
+ - Backslashes: double them (\\ -> \\\\)
183
+ - Single quotes/apostrophes: escape with three backslashes (' -> \\\\')
184
+ - Spaces: escape with backslash ( -> \\ )
185
+ - Special characters: :,[];
186
+
187
+ Example: "I'm With You" becomes "I\\\\'m\\ With\\ You"
188
+ """
189
+ # First escape existing backslashes
190
+ escaped = path.replace("\\", "\\\\")
191
+ # Escape single quotes
192
+ escaped = escaped.replace("'", "\\\\\\'")
193
+ # Escape spaces
194
+ escaped = escaped.replace(" ", "\\ ")
195
+ # Escape FFmpeg filter special characters
196
+ escaped = escaped.replace(":", "\\:")
197
+ escaped = escaped.replace(",", "\\,")
198
+ escaped = escaped.replace("[", "\\[")
199
+ escaped = escaped.replace("]", "\\]")
200
+ escaped = escaped.replace(";", "\\;")
201
+ return escaped
202
+
203
+ def _build_ass_filter(self, ass_path: str, font_path: Optional[str] = None) -> str:
204
+ """
205
+ Build ASS filter with optional font directory support.
206
+
207
+ Args:
208
+ ass_path: Path to ASS subtitles file
209
+ font_path: Optional path to custom font file
210
+
211
+ Returns:
212
+ FFmpeg ASS filter string
213
+ """
214
+ escaped_ass_path = self._escape_ffmpeg_filter_path(ass_path)
215
+ ass_filter = f"ass={escaped_ass_path}"
216
+
217
+ if font_path and os.path.isfile(font_path):
218
+ font_dir = os.path.dirname(font_path)
219
+ escaped_font_dir = self._escape_ffmpeg_filter_path(font_dir)
220
+ ass_filter += f":fontsdir={escaped_font_dir}"
221
+ self.logger.debug(f"Using font directory: {font_dir}")
222
+
223
+ return ass_filter
224
+
225
+ def _build_preview_ffmpeg_command(self, config: PreviewEncodingConfig) -> List[str]:
226
+ """
227
+ Build FFmpeg command for preview video generation.
228
+
229
+ This is the canonical preview encoding command, ensuring consistency
230
+ across all environments (local CLI, Cloud Run, GCE worker).
231
+
232
+ Args:
233
+ config: Preview encoding configuration
234
+
235
+ Returns:
236
+ FFmpeg command as a list of arguments
237
+ """
238
+ width, height = self.PREVIEW_WIDTH, self.PREVIEW_HEIGHT
239
+
240
+ cmd = [
241
+ "ffmpeg",
242
+ "-hide_banner",
243
+ "-loglevel", "error",
244
+ "-r", str(self.PREVIEW_FPS),
245
+ ]
246
+
247
+ # Add hardware acceleration flags if available
248
+ cmd.extend(self.hwaccel_flags)
249
+
250
+ # Input source (background image or solid color)
251
+ if config.background_image_path and os.path.isfile(config.background_image_path):
252
+ self.logger.debug(f"Using background image: {config.background_image_path}")
253
+ cmd.extend([
254
+ "-loop", "1",
255
+ "-i", config.background_image_path,
256
+ ])
257
+ # Build video filter with scaling and ASS subtitles
258
+ video_filter = (
259
+ f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
260
+ f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,"
261
+ f"{self._build_ass_filter(config.ass_path, config.font_path)}"
262
+ )
263
+ else:
264
+ self.logger.debug(f"Using solid {config.background_color} background")
265
+ cmd.extend([
266
+ "-f", "lavfi",
267
+ "-i", f"color=c={config.background_color}:s={width}x{height}:r={self.PREVIEW_FPS}",
268
+ ])
269
+ # Just ASS subtitles, no scaling needed
270
+ video_filter = self._build_ass_filter(config.ass_path, config.font_path)
271
+
272
+ cmd.extend([
273
+ "-i", config.audio_path,
274
+ "-vf", video_filter,
275
+ "-c:a", "aac",
276
+ "-b:a", self.PREVIEW_AUDIO_BITRATE,
277
+ "-c:v", self.video_encoder,
278
+ ])
279
+
280
+ # Add encoder-specific settings optimized for speed
281
+ if self.nvenc_available:
282
+ cmd.extend([
283
+ "-preset", "p1", # Fastest NVENC preset
284
+ "-tune", "ll", # Low latency
285
+ "-rc", "cbr", # Constant bitrate for speed
286
+ "-b:v", "800k", # Lower bitrate for speed
287
+ "-profile:v", "baseline", # Most compatible profile
288
+ "-level", "3.1", # Lower level for speed
289
+ ])
290
+ self.logger.debug("Using NVENC with maximum speed settings")
291
+ else:
292
+ cmd.extend([
293
+ "-profile:v", "baseline",
294
+ "-level", "3.0",
295
+ "-preset", "superfast",
296
+ "-tune", "fastdecode",
297
+ "-b:v", "600k",
298
+ "-maxrate", "800k",
299
+ "-bufsize", "1200k",
300
+ "-crf", "28",
301
+ ])
302
+ self.logger.debug("Using software encoding with maximum speed settings")
303
+
304
+ cmd.extend([
305
+ "-pix_fmt", "yuv420p", # Required for browser compatibility
306
+ "-movflags", "+faststart+frag_keyframe+empty_moov+dash",
307
+ "-g", "48", # Keyframe every 48 frames (2 seconds at 24fps)
308
+ "-keyint_min", "48",
309
+ "-sc_threshold", "0", # Disable scene change detection for speed
310
+ "-threads", "0", # Use all available CPU threads
311
+ "-shortest",
312
+ "-y",
313
+ config.output_path,
314
+ ])
315
+
316
+ return cmd
317
+
318
+ def encode_preview(self, config: PreviewEncodingConfig) -> PreviewEncodingResult:
319
+ """
320
+ Encode a preview video.
321
+
322
+ Args:
323
+ config: Preview encoding configuration
324
+
325
+ Returns:
326
+ PreviewEncodingResult with success status and output path
327
+ """
328
+ self.logger.info(f"Encoding preview video: {config.output_path}")
329
+
330
+ # Validate input files
331
+ if not os.path.isfile(config.ass_path):
332
+ return PreviewEncodingResult(
333
+ success=False,
334
+ error=f"ASS subtitles file not found: {config.ass_path}"
335
+ )
336
+
337
+ if not os.path.isfile(config.audio_path):
338
+ return PreviewEncodingResult(
339
+ success=False,
340
+ error=f"Audio file not found: {config.audio_path}"
341
+ )
342
+
343
+ # Ensure output directory exists
344
+ output_dir = os.path.dirname(config.output_path)
345
+ if output_dir:
346
+ os.makedirs(output_dir, exist_ok=True)
347
+
348
+ try:
349
+ # Build and execute FFmpeg command
350
+ cmd = self._build_preview_ffmpeg_command(config)
351
+ self.logger.debug(f"FFmpeg command: {' '.join(cmd)}")
352
+
353
+ result = subprocess.run(
354
+ cmd,
355
+ capture_output=True,
356
+ text=True,
357
+ timeout=300 # 5 minute timeout for preview encoding
358
+ )
359
+
360
+ if result.returncode != 0:
361
+ error_msg = result.stderr[-500:] if result.stderr else "Unknown error"
362
+ self.logger.error(f"FFmpeg failed: {error_msg}")
363
+ return PreviewEncodingResult(
364
+ success=False,
365
+ error=f"FFmpeg preview encoding failed: {error_msg}"
366
+ )
367
+
368
+ self.logger.info(f"Preview encoded successfully: {config.output_path}")
369
+ return PreviewEncodingResult(
370
+ success=True,
371
+ output_path=config.output_path
372
+ )
373
+
374
+ except subprocess.TimeoutExpired:
375
+ self.logger.error("Preview encoding timed out")
376
+ return PreviewEncodingResult(
377
+ success=False,
378
+ error="Preview encoding timed out after 5 minutes"
379
+ )
380
+ except Exception as e:
381
+ self.logger.error(f"Preview encoding failed: {e}")
382
+ return PreviewEncodingResult(
383
+ success=False,
384
+ error=str(e)
385
+ )
386
+
387
+
388
+ # Singleton instance and factory function
389
+ _local_preview_encoding_service: Optional[LocalPreviewEncodingService] = None
390
+
391
+
392
+ def get_local_preview_encoding_service(**kwargs) -> LocalPreviewEncodingService:
393
+ """
394
+ Get a local preview encoding service instance.
395
+
396
+ Args:
397
+ **kwargs: Arguments passed to LocalPreviewEncodingService
398
+
399
+ Returns:
400
+ LocalPreviewEncodingService instance
401
+ """
402
+ global _local_preview_encoding_service
403
+
404
+ if _local_preview_encoding_service is None:
405
+ _local_preview_encoding_service = LocalPreviewEncodingService(**kwargs)
406
+
407
+ return _local_preview_encoding_service
@@ -0,0 +1,216 @@
1
+ """
2
+ LyricsTranscriber cache synchronization with GCS.
3
+
4
+ This service persists LyricsTranscriber's cache files to GCS so that
5
+ cloud workers (Cloud Run instances) can share cache across containers.
6
+
7
+ Cache files are stored flat in GCS under lyrics-transcriber-cache/ prefix:
8
+ - Transcription: {provider}_{audio_hash}_raw.json, {provider}_{audio_hash}_converted.json
9
+ - Lyrics: {provider}_{artist_title_hash}_raw.json, {provider}_{artist_title_hash}_converted.json
10
+
11
+ Hash computation matches LyricsTranscriber's implementation exactly:
12
+ - Audio hash: MD5 of audio file bytes
13
+ - Lyrics hash: MD5 of "{artist.lower()}_{title.lower()}"
14
+ """
15
+ import hashlib
16
+ import logging
17
+ import os
18
+ from typing import Dict, List, Optional
19
+
20
+ from backend.services.storage_service import StorageService
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # Providers that use audio file hash as cache key
26
+ TRANSCRIPTION_PROVIDERS = ["audioshake", "whisper", "localwhisper"]
27
+
28
+ # Providers that use artist+title hash as cache key
29
+ LYRICS_PROVIDERS = ["genius", "spotify", "lrclib", "musixmatch"]
30
+
31
+ # Cache file suffixes
32
+ CACHE_SUFFIXES = ["raw", "converted"]
33
+
34
+
35
+ class LyricsCacheService:
36
+ """Service to sync LyricsTranscriber cache with GCS."""
37
+
38
+ GCS_CACHE_PREFIX = "lyrics-transcriber-cache/"
39
+
40
+ def __init__(self, storage: Optional[StorageService] = None):
41
+ """Initialize the cache service.
42
+
43
+ Args:
44
+ storage: StorageService instance. If None, creates a new one.
45
+ """
46
+ self.storage = storage or StorageService()
47
+
48
+ def compute_audio_hash(self, audio_path: str) -> str:
49
+ """Compute MD5 hash of audio file bytes.
50
+
51
+ This matches LyricsTranscriber's _get_file_hash() method exactly.
52
+
53
+ Args:
54
+ audio_path: Path to the audio file.
55
+
56
+ Returns:
57
+ MD5 hex digest of the file contents.
58
+ """
59
+ md5_hash = hashlib.md5()
60
+ with open(audio_path, "rb") as f:
61
+ for chunk in iter(lambda: f.read(4096), b""):
62
+ md5_hash.update(chunk)
63
+ return md5_hash.hexdigest()
64
+
65
+ def compute_lyrics_hash(self, artist: str, title: str) -> str:
66
+ """Compute MD5 hash of artist and title.
67
+
68
+ This matches LyricsTranscriber's _get_artist_title_hash() method exactly.
69
+
70
+ Args:
71
+ artist: Artist name.
72
+ title: Track title.
73
+
74
+ Returns:
75
+ MD5 hex digest of "{artist.lower()}_{title.lower()}".
76
+ """
77
+ combined = f"{artist.lower()}_{title.lower()}"
78
+ return hashlib.md5(combined.encode()).hexdigest()
79
+
80
+ def _get_cache_filenames(
81
+ self, providers: List[str], hash_value: str
82
+ ) -> List[str]:
83
+ """Generate list of possible cache filenames for given providers and hash.
84
+
85
+ Args:
86
+ providers: List of provider names (e.g., ["audioshake", "whisper"]).
87
+ hash_value: The hash to use in filenames.
88
+
89
+ Returns:
90
+ List of filenames like ["audioshake_abc123_raw.json", ...].
91
+ """
92
+ filenames = []
93
+ for provider in providers:
94
+ for suffix in CACHE_SUFFIXES:
95
+ filenames.append(f"{provider}_{hash_value}_{suffix}.json")
96
+ return filenames
97
+
98
+ def sync_cache_from_gcs(
99
+ self,
100
+ local_cache_dir: str,
101
+ audio_hash: str,
102
+ lyrics_hash: str,
103
+ ) -> Dict[str, int]:
104
+ """Download relevant cache files from GCS to local directory.
105
+
106
+ Downloads cache files for both transcription (audio hash) and
107
+ lyrics (artist+title hash) providers.
108
+
109
+ Args:
110
+ local_cache_dir: Local directory to download cache files to.
111
+ audio_hash: MD5 hash of audio file.
112
+ lyrics_hash: MD5 hash of artist+title.
113
+
114
+ Returns:
115
+ Dict with counts: {"downloaded": N, "not_found": M, "errors": E}
116
+ """
117
+ os.makedirs(local_cache_dir, exist_ok=True)
118
+
119
+ stats = {"downloaded": 0, "not_found": 0, "errors": 0}
120
+
121
+ # Get all possible cache filenames
122
+ transcription_files = self._get_cache_filenames(
123
+ TRANSCRIPTION_PROVIDERS, audio_hash
124
+ )
125
+ lyrics_files = self._get_cache_filenames(LYRICS_PROVIDERS, lyrics_hash)
126
+ all_files = transcription_files + lyrics_files
127
+
128
+ for filename in all_files:
129
+ gcs_path = f"{self.GCS_CACHE_PREFIX}{filename}"
130
+ local_path = os.path.join(local_cache_dir, filename)
131
+
132
+ try:
133
+ if self.storage.file_exists(gcs_path):
134
+ self.storage.download_file(gcs_path, local_path)
135
+ logger.info(f"Cache hit: downloaded {filename}")
136
+ stats["downloaded"] += 1
137
+ else:
138
+ logger.debug(f"Cache miss: {filename} not in GCS")
139
+ stats["not_found"] += 1
140
+ except Exception as e:
141
+ logger.warning(f"Error downloading cache file {filename}: {e}")
142
+ stats["errors"] += 1
143
+
144
+ logger.info(
145
+ f"Cache sync from GCS complete: "
146
+ f"{stats['downloaded']} downloaded, "
147
+ f"{stats['not_found']} not found, "
148
+ f"{stats['errors']} errors"
149
+ )
150
+ return stats
151
+
152
+ def sync_cache_to_gcs(
153
+ self,
154
+ local_cache_dir: str,
155
+ audio_hash: str,
156
+ lyrics_hash: str,
157
+ ) -> Dict[str, int]:
158
+ """Upload new cache files from local directory to GCS.
159
+
160
+ Only uploads files that match expected cache patterns for the given
161
+ hashes and don't already exist in GCS.
162
+
163
+ Args:
164
+ local_cache_dir: Local directory with cache files.
165
+ audio_hash: MD5 hash of audio file.
166
+ lyrics_hash: MD5 hash of artist+title.
167
+
168
+ Returns:
169
+ Dict with counts: {"uploaded": N, "skipped": M, "errors": E}
170
+ """
171
+ stats = {"uploaded": 0, "skipped": 0, "errors": 0}
172
+
173
+ if not os.path.exists(local_cache_dir):
174
+ logger.warning(f"Local cache dir does not exist: {local_cache_dir}")
175
+ return stats
176
+
177
+ # Get all possible cache filenames we're interested in
178
+ transcription_files = self._get_cache_filenames(
179
+ TRANSCRIPTION_PROVIDERS, audio_hash
180
+ )
181
+ lyrics_files = self._get_cache_filenames(LYRICS_PROVIDERS, lyrics_hash)
182
+ expected_files = set(transcription_files + lyrics_files)
183
+
184
+ # Check each file in local cache dir
185
+ for filename in os.listdir(local_cache_dir):
186
+ # Only process files we expect (matching our hash patterns)
187
+ if filename not in expected_files:
188
+ continue
189
+
190
+ local_path = os.path.join(local_cache_dir, filename)
191
+ if not os.path.isfile(local_path):
192
+ continue
193
+
194
+ gcs_path = f"{self.GCS_CACHE_PREFIX}{filename}"
195
+
196
+ try:
197
+ # Skip if already exists in GCS (same hash = same content)
198
+ if self.storage.file_exists(gcs_path):
199
+ logger.debug(f"Cache file already in GCS: {filename}")
200
+ stats["skipped"] += 1
201
+ continue
202
+
203
+ self.storage.upload_file(local_path, gcs_path)
204
+ logger.info(f"Uploaded cache file: {filename}")
205
+ stats["uploaded"] += 1
206
+ except Exception as e:
207
+ logger.warning(f"Error uploading cache file {filename}: {e}")
208
+ stats["errors"] += 1
209
+
210
+ logger.info(
211
+ f"Cache sync to GCS complete: "
212
+ f"{stats['uploaded']} uploaded, "
213
+ f"{stats['skipped']} skipped (already exist), "
214
+ f"{stats['errors']} errors"
215
+ )
216
+ return stats