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,413 @@
1
+ """
2
+ Custom metrics for Cloud Monitoring.
3
+
4
+ This module provides application-level metrics for tracking job processing,
5
+ worker performance, and external API usage. Metrics can be viewed in:
6
+ 1. Cloud Logging (via structured log entries)
7
+ 2. Cloud Monitoring (via log-based metrics or OpenTelemetry)
8
+
9
+ The metrics service uses a pragmatic approach:
10
+ - Always emits metrics as structured log entries (works immediately)
11
+ - Uses the same JSON format as Cloud Logging
12
+ - Can be enhanced with OpenTelemetry metrics exporters when available
13
+
14
+ Usage:
15
+ from backend.services.metrics import metrics
16
+
17
+ # Record a job completion
18
+ metrics.record_job_completed("abc123", source="upload")
19
+
20
+ # Record worker duration
21
+ metrics.record_worker_duration("audio", 45.2, success=True)
22
+
23
+ # Record external API call
24
+ with metrics.time_external_api("modal"):
25
+ response = await modal_client.separate_audio(...)
26
+ """
27
+ import logging
28
+ import time
29
+ from contextlib import contextmanager
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Dict, Optional
32
+
33
+ from backend.services.tracing import get_current_trace_id, get_current_span_id
34
+
35
+
36
+ logger = logging.getLogger("metrics")
37
+
38
+
39
+ @dataclass
40
+ class MetricLabels:
41
+ """Common metric labels."""
42
+ job_id: Optional[str] = None
43
+ worker: Optional[str] = None
44
+ status: Optional[str] = None
45
+ source: Optional[str] = None
46
+ api: Optional[str] = None
47
+ operation: Optional[str] = None
48
+ bucket: Optional[str] = None
49
+
50
+ def to_dict(self) -> Dict[str, Any]:
51
+ """Convert to dict, excluding None values."""
52
+ return {k: v for k, v in self.__dict__.items() if v is not None}
53
+
54
+
55
+ class MetricsService:
56
+ """
57
+ Application metrics service.
58
+
59
+ Emits metrics as structured log entries that can be:
60
+ 1. Queried directly in Cloud Logging
61
+ 2. Converted to Cloud Monitoring metrics via log-based metrics
62
+ 3. Exported via OpenTelemetry (when configured)
63
+ """
64
+
65
+ def __init__(self):
66
+ """Initialize the metrics service."""
67
+ self._logger = logging.getLogger("metrics")
68
+ # Ensure metrics logger outputs at INFO level
69
+ self._logger.setLevel(logging.INFO)
70
+
71
+ def _emit_metric(self, metric_name: str, metric_type: str, value: float, labels: Dict[str, Any]) -> None:
72
+ """
73
+ Emit a metric as a structured log entry.
74
+
75
+ The log format is designed to be easily parsed by Cloud Logging
76
+ and converted to log-based metrics.
77
+
78
+ Args:
79
+ metric_name: Name of the metric (e.g., "jobs_total")
80
+ metric_type: Type of metric (counter, histogram, gauge)
81
+ value: Metric value
82
+ labels: Metric labels/dimensions
83
+ """
84
+ # Add trace context if available
85
+ trace_id = get_current_trace_id()
86
+ span_id = get_current_span_id()
87
+
88
+ # Build metric entry
89
+ metric_entry = {
90
+ "metric_name": metric_name,
91
+ "metric_type": metric_type,
92
+ "metric_value": value,
93
+ **labels,
94
+ }
95
+
96
+ if trace_id:
97
+ metric_entry["trace_id"] = trace_id
98
+ if span_id:
99
+ metric_entry["span_id"] = span_id
100
+
101
+ # Emit as structured log entry
102
+ # Use INFO level so metrics always show up
103
+ self._logger.info(
104
+ f"METRIC {metric_name}={value}",
105
+ extra=metric_entry
106
+ )
107
+
108
+ # =========================================
109
+ # Job Metrics
110
+ # =========================================
111
+
112
+ def record_job_created(self, job_id: str, source: str = "unknown") -> None:
113
+ """
114
+ Record a new job creation.
115
+
116
+ Args:
117
+ job_id: Job ID
118
+ source: Job source (upload, url, search)
119
+ """
120
+ self._emit_metric(
121
+ metric_name="jobs_total",
122
+ metric_type="counter",
123
+ value=1,
124
+ labels={"job_id": job_id, "status": "created", "source": source}
125
+ )
126
+
127
+ def record_job_completed(self, job_id: str, source: str = "unknown") -> None:
128
+ """
129
+ Record a job completion.
130
+
131
+ Args:
132
+ job_id: Job ID
133
+ source: Job source (upload, url, search)
134
+ """
135
+ self._emit_metric(
136
+ metric_name="jobs_total",
137
+ metric_type="counter",
138
+ value=1,
139
+ labels={"job_id": job_id, "status": "completed", "source": source}
140
+ )
141
+
142
+ def record_job_failed(self, job_id: str, source: str = "unknown", error: Optional[str] = None) -> None:
143
+ """
144
+ Record a job failure.
145
+
146
+ Args:
147
+ job_id: Job ID
148
+ source: Job source (upload, url, search)
149
+ error: Optional error message
150
+ """
151
+ labels = {"job_id": job_id, "status": "failed", "source": source}
152
+ if error:
153
+ labels["error"] = error[:200] # Truncate long errors
154
+ self._emit_metric(
155
+ metric_name="jobs_total",
156
+ metric_type="counter",
157
+ value=1,
158
+ labels=labels
159
+ )
160
+
161
+ def record_job_duration(self, job_id: str, duration_seconds: float, source: str = "unknown") -> None:
162
+ """
163
+ Record total job processing duration.
164
+
165
+ Args:
166
+ job_id: Job ID
167
+ duration_seconds: Total processing time in seconds
168
+ source: Job source (upload, url, search)
169
+ """
170
+ self._emit_metric(
171
+ metric_name="job_duration_seconds",
172
+ metric_type="histogram",
173
+ value=duration_seconds,
174
+ labels={"job_id": job_id, "source": source}
175
+ )
176
+
177
+ # =========================================
178
+ # Worker Metrics
179
+ # =========================================
180
+
181
+ def record_worker_started(self, worker: str, job_id: str) -> None:
182
+ """
183
+ Record a worker invocation start.
184
+
185
+ Args:
186
+ worker: Worker name (audio, lyrics, screens, video, render_video)
187
+ job_id: Job ID
188
+ """
189
+ self._emit_metric(
190
+ metric_name="worker_invocations_total",
191
+ metric_type="counter",
192
+ value=1,
193
+ labels={"worker": worker, "job_id": job_id, "status": "started"}
194
+ )
195
+
196
+ def record_worker_duration(self, worker: str, duration_seconds: float, success: bool, job_id: Optional[str] = None) -> None:
197
+ """
198
+ Record worker processing duration.
199
+
200
+ Args:
201
+ worker: Worker name
202
+ duration_seconds: Processing time in seconds
203
+ success: Whether worker completed successfully
204
+ job_id: Optional job ID
205
+ """
206
+ labels = {
207
+ "worker": worker,
208
+ "success": str(success).lower(),
209
+ }
210
+ if job_id:
211
+ labels["job_id"] = job_id
212
+
213
+ self._emit_metric(
214
+ metric_name="job_stage_duration_seconds",
215
+ metric_type="histogram",
216
+ value=duration_seconds,
217
+ labels=labels
218
+ )
219
+
220
+ # Also emit a counter for success/failure tracking
221
+ self._emit_metric(
222
+ metric_name="worker_invocations_total",
223
+ metric_type="counter",
224
+ value=1,
225
+ labels={"worker": worker, "success": str(success).lower(), "job_id": job_id or "unknown"}
226
+ )
227
+
228
+ # =========================================
229
+ # GCS Metrics
230
+ # =========================================
231
+
232
+ def record_gcs_operation(
233
+ self,
234
+ operation: str,
235
+ bucket: str,
236
+ success: bool,
237
+ size_bytes: Optional[int] = None,
238
+ duration_seconds: Optional[float] = None,
239
+ job_id: Optional[str] = None,
240
+ ) -> None:
241
+ """
242
+ Record a GCS operation.
243
+
244
+ Args:
245
+ operation: Operation type (upload, download, delete)
246
+ bucket: GCS bucket name
247
+ success: Whether operation succeeded
248
+ size_bytes: Optional file size in bytes
249
+ duration_seconds: Optional operation duration
250
+ job_id: Optional job ID
251
+ """
252
+ labels = {
253
+ "operation": operation,
254
+ "bucket": bucket,
255
+ "success": str(success).lower(),
256
+ }
257
+ if job_id:
258
+ labels["job_id"] = job_id
259
+
260
+ self._emit_metric(
261
+ metric_name="gcs_operations_total",
262
+ metric_type="counter",
263
+ value=1,
264
+ labels=labels
265
+ )
266
+
267
+ if size_bytes is not None:
268
+ self._emit_metric(
269
+ metric_name="gcs_operation_bytes",
270
+ metric_type="histogram",
271
+ value=size_bytes,
272
+ labels={**labels, "operation": operation}
273
+ )
274
+
275
+ if duration_seconds is not None:
276
+ self._emit_metric(
277
+ metric_name="gcs_operation_duration_seconds",
278
+ metric_type="histogram",
279
+ value=duration_seconds,
280
+ labels={**labels, "operation": operation}
281
+ )
282
+
283
+ # =========================================
284
+ # External API Metrics
285
+ # =========================================
286
+
287
+ def record_external_api_call(
288
+ self,
289
+ api: str,
290
+ success: bool,
291
+ duration_seconds: float,
292
+ job_id: Optional[str] = None,
293
+ error: Optional[str] = None,
294
+ ) -> None:
295
+ """
296
+ Record an external API call.
297
+
298
+ Args:
299
+ api: API name (modal, audioshake, genius, spotify)
300
+ success: Whether call succeeded
301
+ duration_seconds: API call duration
302
+ job_id: Optional job ID
303
+ error: Optional error message
304
+ """
305
+ labels = {
306
+ "api": api,
307
+ "success": str(success).lower(),
308
+ }
309
+ if job_id:
310
+ labels["job_id"] = job_id
311
+ if error:
312
+ labels["error"] = error[:100] # Truncate
313
+
314
+ self._emit_metric(
315
+ metric_name="external_api_calls_total",
316
+ metric_type="counter",
317
+ value=1,
318
+ labels=labels
319
+ )
320
+
321
+ self._emit_metric(
322
+ metric_name="external_api_duration_seconds",
323
+ metric_type="histogram",
324
+ value=duration_seconds,
325
+ labels={"api": api, "success": str(success).lower()}
326
+ )
327
+
328
+ @contextmanager
329
+ def time_external_api(self, api: str, job_id: Optional[str] = None):
330
+ """
331
+ Context manager to time an external API call.
332
+
333
+ Usage:
334
+ with metrics.time_external_api("modal", job_id) as timer:
335
+ response = await client.call_api()
336
+ timer.set_success(True)
337
+
338
+ Args:
339
+ api: API name
340
+ job_id: Optional job ID
341
+
342
+ Yields:
343
+ Timer object with set_success() method
344
+ """
345
+ timer = _ApiTimer()
346
+ start_time = time.time()
347
+
348
+ try:
349
+ yield timer
350
+ except Exception as e:
351
+ timer.set_success(False)
352
+ timer.error = str(e)
353
+ raise
354
+ finally:
355
+ duration = time.time() - start_time
356
+ self.record_external_api_call(
357
+ api=api,
358
+ success=timer.success,
359
+ duration_seconds=duration,
360
+ job_id=job_id,
361
+ error=timer.error,
362
+ )
363
+
364
+ @contextmanager
365
+ def time_worker(self, worker: str, job_id: str):
366
+ """
367
+ Context manager to time a worker execution.
368
+
369
+ Usage:
370
+ with metrics.time_worker("audio", job_id) as timer:
371
+ await process_audio()
372
+ timer.set_success(True)
373
+
374
+ Args:
375
+ worker: Worker name
376
+ job_id: Job ID
377
+
378
+ Yields:
379
+ Timer object with set_success() method
380
+ """
381
+ timer = _ApiTimer()
382
+ self.record_worker_started(worker, job_id)
383
+ start_time = time.time()
384
+
385
+ try:
386
+ yield timer
387
+ except Exception as e:
388
+ timer.set_success(False)
389
+ raise
390
+ finally:
391
+ duration = time.time() - start_time
392
+ self.record_worker_duration(
393
+ worker=worker,
394
+ duration_seconds=duration,
395
+ success=timer.success,
396
+ job_id=job_id,
397
+ )
398
+
399
+
400
+ class _ApiTimer:
401
+ """Helper class for tracking API call success state."""
402
+
403
+ def __init__(self):
404
+ self.success = True # Assume success unless set otherwise
405
+ self.error: Optional[str] = None
406
+
407
+ def set_success(self, success: bool) -> None:
408
+ self.success = success
409
+
410
+
411
+ # Global metrics instance
412
+ metrics = MetricsService()
413
+
@@ -0,0 +1,287 @@
1
+ """
2
+ Packaging Service.
3
+
4
+ Provides CDG and TXT package generation functionality, extracted from KaraokeFinalise
5
+ for use by both the cloud backend (video_worker) and local CLI.
6
+
7
+ This service handles:
8
+ - CDG (CD+G) file generation from LRC files
9
+ - TXT lyric file generation from LRC files
10
+ - ZIP packaging of CDG/MP3 and TXT/MP3 pairs
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import zipfile
16
+ from typing import Optional, Dict, Any, Tuple
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class PackagingService:
22
+ """
23
+ Service for creating CDG and TXT karaoke packages.
24
+
25
+ CDG (CD+Graphics) is a format used by karaoke machines.
26
+ TXT packages are used by software karaoke players.
27
+ Both formats are packaged as ZIP files with an MP3 audio track.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ cdg_styles: Optional[Dict[str, Any]] = None,
33
+ dry_run: bool = False,
34
+ non_interactive: bool = False,
35
+ logger: Optional[logging.Logger] = None,
36
+ ):
37
+ """
38
+ Initialize the packaging service.
39
+
40
+ Args:
41
+ cdg_styles: CDG style configuration for CDG generation
42
+ dry_run: If True, log actions without performing them
43
+ non_interactive: If True, skip interactive prompts
44
+ logger: Optional logger instance
45
+ """
46
+ self.cdg_styles = cdg_styles
47
+ self.dry_run = dry_run
48
+ self.non_interactive = non_interactive
49
+ self.logger = logger or logging.getLogger(__name__)
50
+
51
+ def create_cdg_package(
52
+ self,
53
+ lrc_file: str,
54
+ audio_file: str,
55
+ output_zip_path: str,
56
+ artist: str,
57
+ title: str,
58
+ output_mp3_path: Optional[str] = None,
59
+ output_cdg_path: Optional[str] = None,
60
+ ) -> Tuple[str, Optional[str], Optional[str]]:
61
+ """
62
+ Create a CDG package (ZIP containing CDG and MP3 files).
63
+
64
+ Args:
65
+ lrc_file: Path to the LRC lyrics file
66
+ audio_file: Path to the instrumental audio file
67
+ output_zip_path: Path for the output ZIP file
68
+ artist: Artist name for metadata
69
+ title: Song title for metadata
70
+ output_mp3_path: Optional path for the extracted MP3 file
71
+ output_cdg_path: Optional path for the extracted CDG file
72
+
73
+ Returns:
74
+ Tuple of (zip_path, mp3_path, cdg_path)
75
+
76
+ Raises:
77
+ ValueError: If CDG styles are not configured
78
+ FileNotFoundError: If input files are missing
79
+ Exception: If CDG generation fails
80
+ """
81
+ self.logger.info(f"Creating CDG package for {artist} - {title}")
82
+
83
+ # Validate inputs
84
+ if not os.path.isfile(lrc_file):
85
+ raise FileNotFoundError(f"LRC file not found: {lrc_file}")
86
+ if not os.path.isfile(audio_file):
87
+ raise FileNotFoundError(f"Audio file not found: {audio_file}")
88
+
89
+ # Check if ZIP already exists
90
+ if os.path.isfile(output_zip_path):
91
+ if self.non_interactive:
92
+ self.logger.info(
93
+ f"CDG ZIP exists, will be overwritten: {output_zip_path}"
94
+ )
95
+ else:
96
+ self.logger.info(f"CDG ZIP already exists: {output_zip_path}")
97
+
98
+ # Check if individual files exist (allows skipping generation)
99
+ if output_mp3_path and output_cdg_path:
100
+ if os.path.isfile(output_mp3_path) and os.path.isfile(output_cdg_path):
101
+ self.logger.info("Found existing MP3 and CDG files, creating ZIP directly")
102
+ if not self.dry_run:
103
+ self._create_zip_from_files(
104
+ output_zip_path,
105
+ [(output_mp3_path, os.path.basename(output_mp3_path)),
106
+ (output_cdg_path, os.path.basename(output_cdg_path))]
107
+ )
108
+ return output_zip_path, output_mp3_path, output_cdg_path
109
+
110
+ if self.dry_run:
111
+ self.logger.info(
112
+ f"DRY RUN: Would generate CDG package: {output_zip_path}"
113
+ )
114
+ return output_zip_path, output_mp3_path, output_cdg_path
115
+
116
+ # Generate CDG files
117
+ if self.cdg_styles is None:
118
+ raise ValueError(
119
+ "CDG styles configuration is required for CDG generation"
120
+ )
121
+
122
+ self.logger.info("Generating CDG and MP3 files")
123
+ from lyrics_transcriber.output.cdg import CDGGenerator
124
+
125
+ output_dir = os.path.dirname(output_zip_path) or os.getcwd()
126
+ generator = CDGGenerator(output_dir=output_dir, logger=self.logger)
127
+
128
+ cdg_file, mp3_file, zip_file = generator.generate_cdg_from_lrc(
129
+ lrc_file=lrc_file,
130
+ audio_file=audio_file,
131
+ title=title,
132
+ artist=artist,
133
+ cdg_styles=self.cdg_styles,
134
+ )
135
+
136
+ # Rename ZIP to expected output path if different
137
+ if os.path.isfile(zip_file) and zip_file != output_zip_path:
138
+ os.rename(zip_file, output_zip_path)
139
+ self.logger.info(f"Renamed CDG ZIP: {zip_file} -> {output_zip_path}")
140
+
141
+ if not os.path.isfile(output_zip_path):
142
+ raise Exception(f"Failed to create CDG ZIP file: {output_zip_path}")
143
+
144
+ # Extract the ZIP to get individual files if paths provided
145
+ extracted_mp3 = None
146
+ extracted_cdg = None
147
+ if output_mp3_path or output_cdg_path:
148
+ self.logger.info(f"Extracting CDG ZIP file: {output_zip_path}")
149
+ with zipfile.ZipFile(output_zip_path, "r") as zip_ref:
150
+ zip_ref.extractall(output_dir)
151
+
152
+ # Find extracted files
153
+ if output_mp3_path and os.path.isfile(output_mp3_path):
154
+ extracted_mp3 = output_mp3_path
155
+ self.logger.info(f"Extracted MP3: {extracted_mp3}")
156
+ if output_cdg_path and os.path.isfile(output_cdg_path):
157
+ extracted_cdg = output_cdg_path
158
+ self.logger.info(f"Extracted CDG: {extracted_cdg}")
159
+
160
+ self.logger.info(f"CDG package created: {output_zip_path}")
161
+ return output_zip_path, extracted_mp3, extracted_cdg
162
+
163
+ def create_txt_package(
164
+ self,
165
+ lrc_file: str,
166
+ mp3_file: str,
167
+ output_zip_path: str,
168
+ output_txt_path: Optional[str] = None,
169
+ ) -> Tuple[str, Optional[str]]:
170
+ """
171
+ Create a TXT package (ZIP containing TXT lyrics and MP3 files).
172
+
173
+ Args:
174
+ lrc_file: Path to the LRC lyrics file
175
+ mp3_file: Path to the MP3 audio file
176
+ output_zip_path: Path for the output ZIP file
177
+ output_txt_path: Optional path for the generated TXT file
178
+
179
+ Returns:
180
+ Tuple of (zip_path, txt_path)
181
+
182
+ Raises:
183
+ FileNotFoundError: If input files are missing
184
+ Exception: If TXT generation fails
185
+ """
186
+ self.logger.info(f"Creating TXT package from {lrc_file}")
187
+
188
+ # Validate inputs
189
+ if not os.path.isfile(lrc_file):
190
+ raise FileNotFoundError(f"LRC file not found: {lrc_file}")
191
+ if not os.path.isfile(mp3_file):
192
+ raise FileNotFoundError(f"MP3 file not found: {mp3_file}")
193
+
194
+ # Check if ZIP already exists
195
+ if os.path.isfile(output_zip_path):
196
+ if self.non_interactive:
197
+ self.logger.info(
198
+ f"TXT ZIP exists, will be overwritten: {output_zip_path}"
199
+ )
200
+ else:
201
+ self.logger.info(f"TXT ZIP already exists: {output_zip_path}")
202
+
203
+ if self.dry_run:
204
+ self.logger.info(
205
+ f"DRY RUN: Would create TXT package: {output_zip_path}"
206
+ )
207
+ return output_zip_path, output_txt_path
208
+
209
+ # Generate TXT from LRC
210
+ self.logger.info(f"Converting LRC to TXT format: {lrc_file}")
211
+ from lyrics_converter import LyricsConverter
212
+
213
+ txt_converter = LyricsConverter(output_format="txt", filepath=lrc_file)
214
+ converted_txt = txt_converter.convert_file()
215
+
216
+ # Write TXT file
217
+ if output_txt_path is None:
218
+ # Default to same name as ZIP but with .txt extension
219
+ output_txt_path = output_zip_path.replace(".zip", ".txt")
220
+
221
+ with open(output_txt_path, "w") as txt_file:
222
+ txt_file.write(converted_txt)
223
+ self.logger.info(f"TXT file written: {output_txt_path}")
224
+
225
+ # Create ZIP containing MP3 and TXT
226
+ self.logger.info(f"Creating TXT ZIP: {output_zip_path}")
227
+ self._create_zip_from_files(
228
+ output_zip_path,
229
+ [(mp3_file, os.path.basename(mp3_file)),
230
+ (output_txt_path, os.path.basename(output_txt_path))]
231
+ )
232
+
233
+ if not os.path.isfile(output_zip_path):
234
+ raise Exception(f"Failed to create TXT ZIP file: {output_zip_path}")
235
+
236
+ self.logger.info(f"TXT package created: {output_zip_path}")
237
+ return output_zip_path, output_txt_path
238
+
239
+ def _create_zip_from_files(
240
+ self,
241
+ zip_path: str,
242
+ files: list,
243
+ ) -> None:
244
+ """
245
+ Create a ZIP file from a list of files.
246
+
247
+ Args:
248
+ zip_path: Path for the output ZIP file
249
+ files: List of (file_path, archive_name) tuples
250
+ """
251
+ with zipfile.ZipFile(zip_path, "w") as zipf:
252
+ for file_path, archive_name in files:
253
+ if os.path.isfile(file_path):
254
+ zipf.write(file_path, archive_name)
255
+ self.logger.debug(f"Added to ZIP: {archive_name}")
256
+ else:
257
+ self.logger.warning(f"File not found for ZIP: {file_path}")
258
+
259
+
260
+ # Singleton instance and factory function (following existing service pattern)
261
+ _packaging_service: Optional[PackagingService] = None
262
+
263
+
264
+ def get_packaging_service(
265
+ cdg_styles: Optional[Dict[str, Any]] = None,
266
+ **kwargs
267
+ ) -> PackagingService:
268
+ """
269
+ Get a packaging service instance.
270
+
271
+ Args:
272
+ cdg_styles: CDG style configuration
273
+ **kwargs: Additional arguments passed to PackagingService
274
+
275
+ Returns:
276
+ PackagingService instance
277
+ """
278
+ global _packaging_service
279
+
280
+ # Create new instance if settings changed
281
+ if _packaging_service is None or cdg_styles:
282
+ _packaging_service = PackagingService(
283
+ cdg_styles=cdg_styles,
284
+ **kwargs
285
+ )
286
+
287
+ return _packaging_service