karaoke-gen 0.86.7__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) 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/style_loader.py +3 -1
  148. karaoke_gen/utils/__init__.py +163 -8
  149. karaoke_gen/video_background_processor.py +9 -4
  150. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
  151. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
  152. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  153. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  154. lyrics_transcriber/correction/corrector.py +192 -130
  155. lyrics_transcriber/correction/operations.py +24 -9
  156. lyrics_transcriber/frontend/package-lock.json +2 -2
  157. lyrics_transcriber/frontend/package.json +1 -1
  158. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  159. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  160. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  161. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  162. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  163. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  164. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  165. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  168. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  170. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  171. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  172. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  173. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  174. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  175. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  176. lyrics_transcriber/frontend/src/theme.ts +42 -15
  177. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  178. lyrics_transcriber/frontend/vite.config.js +5 -0
  179. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  180. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  181. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  182. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  183. lyrics_transcriber/output/generator.py +17 -3
  184. lyrics_transcriber/output/video.py +60 -95
  185. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  186. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  187. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  188. {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,356 @@
1
+ """Native Google Drive API service for cloud backend.
2
+
3
+ This service provides Google Drive operations using the native API,
4
+ for uploading files to public share folders. It handles:
5
+ - Folder creation and lookup
6
+ - File uploads with resumable upload support
7
+ - Uploading to organized folder structure (MP4/, MP4-720p/, CDG/)
8
+
9
+ Credentials are loaded from Google Cloud Secret Manager and can be
10
+ shared with YouTube credentials if scopes include drive.file.
11
+ """
12
+ import json
13
+ import logging
14
+ import os
15
+ from typing import Any, Dict, Optional
16
+
17
+ from backend.config import get_settings
18
+ from karaoke_gen.utils import sanitize_filename
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class GoogleDriveService:
24
+ """Google Drive operations using native API."""
25
+
26
+ # Secret Manager secret name for Google Drive credentials
27
+ # Can be same as YouTube if scopes include drive.file
28
+ GDRIVE_CREDENTIALS_SECRET = "gdrive-oauth-credentials"
29
+
30
+ def __init__(self):
31
+ self.settings = get_settings()
32
+ self._service = None
33
+ self._credentials_data: Optional[Dict[str, Any]] = None
34
+ self._loaded = False
35
+
36
+ def _load_credentials(self) -> Optional[Dict[str, Any]]:
37
+ """Load OAuth credentials from Secret Manager."""
38
+ if self._loaded:
39
+ return self._credentials_data
40
+
41
+ try:
42
+ creds_json = self.settings.get_secret(self.GDRIVE_CREDENTIALS_SECRET)
43
+
44
+ if not creds_json:
45
+ # Try falling back to YouTube credentials (may have drive scope)
46
+ logger.info(
47
+ "Google Drive credentials not found, trying YouTube credentials"
48
+ )
49
+ creds_json = self.settings.get_secret("youtube-oauth-credentials")
50
+
51
+ if not creds_json:
52
+ logger.warning("Google Drive credentials not found in Secret Manager")
53
+ self._loaded = True
54
+ return None
55
+
56
+ self._credentials_data = json.loads(creds_json)
57
+
58
+ # Validate required fields
59
+ required_fields = ["refresh_token", "client_id", "client_secret"]
60
+ missing = [f for f in required_fields if not self._credentials_data.get(f)]
61
+
62
+ if missing:
63
+ logger.error(f"Google Drive credentials missing required fields: {missing}")
64
+ self._credentials_data = None
65
+ self._loaded = True
66
+ return None
67
+
68
+ logger.info("Google Drive credentials loaded successfully from Secret Manager")
69
+ self._loaded = True
70
+ return self._credentials_data
71
+
72
+ except json.JSONDecodeError as e:
73
+ logger.error(f"Failed to parse Google Drive credentials JSON: {e}")
74
+ self._loaded = True
75
+ return None
76
+ except Exception as e:
77
+ logger.error(f"Failed to load Google Drive credentials: {e}")
78
+ self._loaded = True
79
+ return None
80
+
81
+ @property
82
+ def is_configured(self) -> bool:
83
+ """Check if Google Drive credentials are available."""
84
+ creds = self._load_credentials()
85
+ return creds is not None
86
+
87
+ @property
88
+ def service(self):
89
+ """Get or create Google Drive service."""
90
+ if self._service is None:
91
+ # Import here to avoid import errors if google packages not installed
92
+ try:
93
+ from google.oauth2.credentials import Credentials
94
+ from googleapiclient.discovery import build
95
+ except ImportError:
96
+ raise ImportError(
97
+ "google-api-python-client and google-auth packages are required. "
98
+ "Install them with: pip install google-api-python-client google-auth"
99
+ )
100
+
101
+ creds_data = self._load_credentials()
102
+ if not creds_data:
103
+ raise RuntimeError(
104
+ "Google Drive credentials not configured in Secret Manager"
105
+ )
106
+
107
+ # Create credentials object
108
+ credentials = Credentials(
109
+ token=creds_data.get("token"),
110
+ refresh_token=creds_data.get("refresh_token"),
111
+ token_uri=creds_data.get(
112
+ "token_uri", "https://oauth2.googleapis.com/token"
113
+ ),
114
+ client_id=creds_data.get("client_id"),
115
+ client_secret=creds_data.get("client_secret"),
116
+ scopes=creds_data.get(
117
+ "scopes", ["https://www.googleapis.com/auth/drive.file"]
118
+ ),
119
+ )
120
+
121
+ self._service = build("drive", "v3", credentials=credentials)
122
+ logger.info("Google Drive service initialized successfully")
123
+
124
+ return self._service
125
+
126
+ def get_or_create_folder(self, parent_id: str, folder_name: str) -> str:
127
+ """
128
+ Get existing folder or create new one, return folder ID.
129
+
130
+ Args:
131
+ parent_id: Parent folder ID
132
+ folder_name: Name of folder to find or create
133
+
134
+ Returns:
135
+ Folder ID
136
+ """
137
+ logger.info(f"Looking for folder '{folder_name}' in parent {parent_id}")
138
+
139
+ # Search for existing folder
140
+ # Escape single quotes in folder name for Google Drive API query syntax
141
+ escaped_folder_name = folder_name.replace("'", "\\'")
142
+ query = (
143
+ f"name='{escaped_folder_name}' and '{parent_id}' in parents "
144
+ f"and mimeType='application/vnd.google-apps.folder' and trashed=false"
145
+ )
146
+ results = self.service.files().list(q=query, fields="files(id, name)").execute()
147
+
148
+ if results.get("files"):
149
+ folder_id = results["files"][0]["id"]
150
+ logger.info(f"Found existing folder '{folder_name}': {folder_id}")
151
+ return folder_id
152
+
153
+ # Create folder
154
+ logger.info(f"Creating new folder '{folder_name}'")
155
+ metadata = {
156
+ "name": folder_name,
157
+ "mimeType": "application/vnd.google-apps.folder",
158
+ "parents": [parent_id],
159
+ }
160
+ folder = self.service.files().create(body=metadata, fields="id").execute()
161
+ folder_id = folder["id"]
162
+ logger.info(f"Created folder '{folder_name}': {folder_id}")
163
+ return folder_id
164
+
165
+ def upload_file(
166
+ self,
167
+ local_path: str,
168
+ parent_id: str,
169
+ filename: str,
170
+ replace_existing: bool = True,
171
+ ) -> str:
172
+ """
173
+ Upload a file to a specific Drive folder.
174
+
175
+ Args:
176
+ local_path: Local file path
177
+ parent_id: Parent folder ID in Google Drive
178
+ filename: Name for the file in Drive
179
+ replace_existing: If True, delete existing file with same name first
180
+
181
+ Returns:
182
+ File ID of uploaded file
183
+ """
184
+ from googleapiclient.http import MediaFileUpload
185
+
186
+ file_size = os.path.getsize(local_path)
187
+ logger.info(
188
+ f"Uploading {local_path} ({file_size / 1024 / 1024:.1f} MB) "
189
+ f"as '{filename}' to folder {parent_id}"
190
+ )
191
+
192
+ # Determine MIME type
193
+ ext = os.path.splitext(local_path)[1].lower()
194
+ mime_types = {
195
+ ".mp4": "video/mp4",
196
+ ".mkv": "video/x-matroska",
197
+ ".zip": "application/zip",
198
+ ".flac": "audio/flac",
199
+ ".mp3": "audio/mpeg",
200
+ ".wav": "audio/wav",
201
+ ".png": "image/png",
202
+ ".jpg": "image/jpeg",
203
+ ".jpeg": "image/jpeg",
204
+ }
205
+ mime_type = mime_types.get(ext, "application/octet-stream")
206
+
207
+ # Check for existing file with same name
208
+ if replace_existing:
209
+ # Escape single quotes in filename for Google Drive API query syntax
210
+ escaped_filename = filename.replace("'", "\\'")
211
+ query = (
212
+ f"name='{escaped_filename}' and '{parent_id}' in parents and trashed=false"
213
+ )
214
+ results = self.service.files().list(q=query, fields="files(id)").execute()
215
+ for existing_file in results.get("files", []):
216
+ logger.info(f"Deleting existing file: {existing_file['id']}")
217
+ self.service.files().delete(fileId=existing_file["id"]).execute()
218
+
219
+ # Upload file
220
+ metadata = {"name": filename, "parents": [parent_id]}
221
+ media = MediaFileUpload(local_path, mimetype=mime_type, resumable=True)
222
+
223
+ file_result = (
224
+ self.service.files().create(body=metadata, media_body=media, fields="id").execute()
225
+ )
226
+ file_id = file_result["id"]
227
+ logger.info(f"Successfully uploaded '{filename}': {file_id}")
228
+ return file_id
229
+
230
+ def upload_to_public_share(
231
+ self,
232
+ root_folder_id: str,
233
+ brand_code: str,
234
+ base_name: str,
235
+ output_files: dict,
236
+ ) -> Dict[str, str]:
237
+ """
238
+ Upload final files to public share folder structure.
239
+
240
+ Creates/uses subfolders:
241
+ - MP4/{brand_code} - {base_name}.mp4 (lossy 4k)
242
+ - MP4-720p/{brand_code} - {base_name}.mp4
243
+ - CDG/{brand_code} - {base_name}.zip
244
+
245
+ Args:
246
+ root_folder_id: Google Drive folder ID for public share root
247
+ brand_code: Brand code (e.g., "NOMAD-1163")
248
+ base_name: Base filename (e.g., "Artist - Title")
249
+ output_files: Dictionary with output file paths:
250
+ - final_karaoke_lossy_mp4: 4K lossy MP4
251
+ - final_karaoke_lossy_720p_mp4: 720p lossy MP4
252
+ - final_karaoke_cdg_zip: CDG package ZIP
253
+
254
+ Returns:
255
+ Dictionary mapping category to uploaded file ID
256
+ """
257
+ # Sanitize base_name to handle Unicode characters (curly quotes, em dashes, etc.)
258
+ # that could cause issues with Google Drive API queries and file naming
259
+ safe_base_name = sanitize_filename(base_name) if base_name else base_name
260
+ filename_base = f"{brand_code} - {safe_base_name}"
261
+ uploaded_files = {}
262
+
263
+ logger.info(
264
+ f"Uploading public share files to Google Drive folder {root_folder_id}"
265
+ )
266
+ logger.info(f"Filename base: {filename_base}")
267
+
268
+ # Upload lossy 4k to MP4/
269
+ lossy_mp4_path = output_files.get("final_karaoke_lossy_mp4")
270
+ if lossy_mp4_path and os.path.exists(lossy_mp4_path):
271
+ mp4_folder_id = self.get_or_create_folder(root_folder_id, "MP4")
272
+ file_id = self.upload_file(
273
+ lossy_mp4_path,
274
+ mp4_folder_id,
275
+ f"{filename_base}.mp4",
276
+ )
277
+ uploaded_files["mp4"] = file_id
278
+ logger.info(f"Uploaded 4K MP4 to MP4/ folder")
279
+
280
+ # Upload 720p to MP4-720p/
281
+ mp4_720p_path = output_files.get("final_karaoke_lossy_720p_mp4")
282
+ if mp4_720p_path and os.path.exists(mp4_720p_path):
283
+ mp4_720_folder_id = self.get_or_create_folder(root_folder_id, "MP4-720p")
284
+ file_id = self.upload_file(
285
+ mp4_720p_path,
286
+ mp4_720_folder_id,
287
+ f"{filename_base}.mp4",
288
+ )
289
+ uploaded_files["mp4_720p"] = file_id
290
+ logger.info(f"Uploaded 720p MP4 to MP4-720p/ folder")
291
+
292
+ # Upload CDG ZIP to CDG/
293
+ cdg_zip_path = output_files.get("final_karaoke_cdg_zip")
294
+ if cdg_zip_path and os.path.exists(cdg_zip_path):
295
+ cdg_folder_id = self.get_or_create_folder(root_folder_id, "CDG")
296
+ file_id = self.upload_file(
297
+ cdg_zip_path,
298
+ cdg_folder_id,
299
+ f"{filename_base}.zip",
300
+ )
301
+ uploaded_files["cdg"] = file_id
302
+ logger.info(f"Uploaded CDG ZIP to CDG/ folder")
303
+
304
+ logger.info(f"Public share upload complete: {len(uploaded_files)} files uploaded")
305
+ return uploaded_files
306
+
307
+ def delete_file(self, file_id: str) -> bool:
308
+ """
309
+ Delete a file from Google Drive.
310
+
311
+ Args:
312
+ file_id: Google Drive file ID to delete
313
+
314
+ Returns:
315
+ True if deleted successfully, False otherwise
316
+ """
317
+ logger.info(f"Deleting Google Drive file: {file_id}")
318
+
319
+ try:
320
+ self.service.files().delete(fileId=file_id).execute()
321
+ logger.info(f"Successfully deleted file: {file_id}")
322
+ return True
323
+ except Exception as e:
324
+ # Check if it's a 404 (already deleted)
325
+ if hasattr(e, 'resp') and e.resp.status == 404:
326
+ logger.warning(f"File not found (already deleted?): {file_id}")
327
+ return True
328
+ logger.error(f"Failed to delete Google Drive file: {e}")
329
+ return False
330
+
331
+ def delete_files(self, file_ids: list[str]) -> dict[str, bool]:
332
+ """
333
+ Delete multiple files from Google Drive.
334
+
335
+ Args:
336
+ file_ids: List of Google Drive file IDs to delete
337
+
338
+ Returns:
339
+ Dictionary mapping file_id to success status
340
+ """
341
+ results = {}
342
+ for file_id in file_ids:
343
+ results[file_id] = self.delete_file(file_id)
344
+ return results
345
+
346
+
347
+ # Singleton instance
348
+ _gdrive_service: Optional[GoogleDriveService] = None
349
+
350
+
351
+ def get_gdrive_service() -> GoogleDriveService:
352
+ """Get the singleton Google Drive service instance."""
353
+ global _gdrive_service
354
+ if _gdrive_service is None:
355
+ _gdrive_service = GoogleDriveService()
356
+ return _gdrive_service
@@ -0,0 +1,258 @@
1
+ """
2
+ Job-aware logging utilities.
3
+
4
+ This module provides a context manager and logging handler that captures
5
+ Python logging output and forwards it to Firestore for real-time streaming
6
+ to the CLI.
7
+
8
+ Usage:
9
+ with JobLogContext(job_id, worker="review") as log_context:
10
+ # Any logging within this block will be captured and sent to Firestore
11
+ logger.info("Processing...") # This will be stored and streamed to CLI
12
+ """
13
+ import logging
14
+ import threading
15
+ from typing import Optional
16
+ from contextlib import contextmanager
17
+ from datetime import datetime
18
+
19
+ from backend.services.firestore_service import FirestoreService
20
+
21
+
22
+ class JobLogHandler(logging.Handler):
23
+ """
24
+ A logging handler that forwards log records to Firestore for job tracking.
25
+
26
+ This enables real-time log streaming to the CLI during long-running operations
27
+ like lyrics correction, preview generation, etc.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ job_id: str,
33
+ worker: str = "review",
34
+ firestore: Optional[FirestoreService] = None,
35
+ min_level: int = logging.INFO,
36
+ batch_size: int = 1, # Send immediately for real-time experience
37
+ ):
38
+ """
39
+ Initialize the job log handler.
40
+
41
+ Args:
42
+ job_id: The job ID to associate logs with
43
+ worker: Worker name (e.g., "review", "add-lyrics", "preview")
44
+ firestore: FirestoreService instance (creates one if not provided)
45
+ min_level: Minimum log level to capture (default INFO)
46
+ batch_size: Number of logs to batch before sending (1 = immediate)
47
+ """
48
+ super().__init__()
49
+ self.job_id = job_id
50
+ self.worker = worker
51
+ self.firestore = firestore or FirestoreService()
52
+ self.min_level = min_level
53
+ self.batch_size = batch_size
54
+ self._log_buffer = []
55
+ self._lock = threading.Lock()
56
+
57
+ # Set the handler's level
58
+ self.setLevel(min_level)
59
+
60
+ # Create a formatter that extracts the useful parts
61
+ self.setFormatter(logging.Formatter('%(message)s'))
62
+
63
+ def emit(self, record: logging.LogRecord) -> None:
64
+ """
65
+ Process a log record and send it to Firestore.
66
+
67
+ This method is called by the logging framework for each log message.
68
+ """
69
+ try:
70
+ # Skip if below minimum level
71
+ if record.levelno < self.min_level:
72
+ return
73
+
74
+ # Format the message
75
+ message = self.format(record)
76
+
77
+ # Truncate very long messages
78
+ if len(message) > 1000:
79
+ message = message[:997] + "..."
80
+
81
+ # Create log entry
82
+ log_entry = {
83
+ 'timestamp': datetime.utcnow().isoformat() + 'Z',
84
+ 'level': record.levelname,
85
+ 'worker': self.worker,
86
+ 'message': message,
87
+ }
88
+
89
+ # Add to buffer
90
+ with self._lock:
91
+ self._log_buffer.append(log_entry)
92
+
93
+ # Flush if batch size reached
94
+ if len(self._log_buffer) >= self.batch_size:
95
+ self._flush_buffer()
96
+
97
+ except Exception:
98
+ # Never let logging errors propagate
99
+ self.handleError(record)
100
+
101
+ def _flush_buffer(self) -> None:
102
+ """Flush buffered logs to Firestore."""
103
+ if not self._log_buffer:
104
+ return
105
+
106
+ try:
107
+ for log_entry in self._log_buffer:
108
+ self.firestore.append_worker_log(self.job_id, log_entry)
109
+ self._log_buffer = []
110
+ except Exception:
111
+ # If Firestore fails, just clear buffer and continue
112
+ self._log_buffer = []
113
+
114
+ def flush(self) -> None:
115
+ """Flush any remaining buffered logs."""
116
+ with self._lock:
117
+ self._flush_buffer()
118
+
119
+ def close(self) -> None:
120
+ """Clean up the handler."""
121
+ self.flush()
122
+ super().close()
123
+
124
+
125
+ @contextmanager
126
+ def job_log_context(
127
+ job_id: str,
128
+ worker: str = "review",
129
+ logger_names: Optional[list] = None,
130
+ min_level: int = logging.INFO,
131
+ ):
132
+ """
133
+ Context manager that captures logs and forwards them to Firestore.
134
+
135
+ This allows the CLI to see real-time logs from operations like
136
+ add-lyrics, preview generation, etc.
137
+
138
+ Args:
139
+ job_id: Job ID to associate logs with
140
+ worker: Worker name for log categorization
141
+ logger_names: List of logger names to capture (None = capture common loggers)
142
+ min_level: Minimum log level to capture
143
+
144
+ Usage:
145
+ with job_log_context(job_id, worker="add-lyrics"):
146
+ # All logging in this block will be captured
147
+ correction_operations.add_lyrics_source(...)
148
+ """
149
+ # Default loggers to capture - these cover most of our processing
150
+ # Note: We intentionally DON'T include the root logger ('') to avoid
151
+ # duplicate log entries since logs propagate up the logger hierarchy.
152
+ # Only capture at the top-level package loggers.
153
+ if logger_names is None:
154
+ logger_names = [
155
+ 'backend.api.routes.review',
156
+ 'lyrics_transcriber',
157
+ 'karaoke_gen',
158
+ ]
159
+
160
+ # Create the handler
161
+ handler = JobLogHandler(
162
+ job_id=job_id,
163
+ worker=worker,
164
+ min_level=min_level,
165
+ )
166
+
167
+ # Add handler to all specified loggers
168
+ loggers = []
169
+ for name in logger_names:
170
+ log = logging.getLogger(name) if name else logging.getLogger()
171
+ log.addHandler(handler)
172
+ loggers.append(log)
173
+
174
+ try:
175
+ # Log start
176
+ handler.emit(logging.LogRecord(
177
+ name='job_log_context',
178
+ level=logging.INFO,
179
+ pathname='',
180
+ lineno=0,
181
+ msg=f"🚀 Starting {worker} operation for job {job_id}",
182
+ args=(),
183
+ exc_info=None,
184
+ ))
185
+
186
+ yield handler
187
+
188
+ finally:
189
+ # Log completion
190
+ handler.emit(logging.LogRecord(
191
+ name='job_log_context',
192
+ level=logging.INFO,
193
+ pathname='',
194
+ lineno=0,
195
+ msg=f"✅ Completed {worker} operation for job {job_id}",
196
+ args=(),
197
+ exc_info=None,
198
+ ))
199
+
200
+ # Flush remaining logs
201
+ handler.flush()
202
+
203
+ # Remove handler from all loggers
204
+ for log in loggers:
205
+ log.removeHandler(handler)
206
+
207
+ # Close handler
208
+ handler.close()
209
+
210
+
211
+ class JobLogger:
212
+ """
213
+ A convenience class for direct logging to a job's log stream.
214
+
215
+ Use this when you want to log specific messages without capturing
216
+ all logging output.
217
+ """
218
+
219
+ def __init__(self, job_id: str, worker: str = "review"):
220
+ """
221
+ Initialize job logger.
222
+
223
+ Args:
224
+ job_id: Job ID to log to
225
+ worker: Worker name for log categorization
226
+ """
227
+ self.job_id = job_id
228
+ self.worker = worker
229
+ self.firestore = FirestoreService()
230
+
231
+ def _log(self, level: str, message: str) -> None:
232
+ """Internal logging method."""
233
+ log_entry = {
234
+ 'timestamp': datetime.utcnow().isoformat() + 'Z',
235
+ 'level': level,
236
+ 'worker': self.worker,
237
+ 'message': message[:1000],
238
+ }
239
+ try:
240
+ self.firestore.append_worker_log(self.job_id, log_entry)
241
+ except Exception:
242
+ pass # Don't let logging errors propagate
243
+
244
+ def info(self, message: str) -> None:
245
+ """Log an INFO message."""
246
+ self._log('INFO', message)
247
+
248
+ def warning(self, message: str) -> None:
249
+ """Log a WARNING message."""
250
+ self._log('WARNING', message)
251
+
252
+ def error(self, message: str) -> None:
253
+ """Log an ERROR message."""
254
+ self._log('ERROR', message)
255
+
256
+ def debug(self, message: str) -> None:
257
+ """Log a DEBUG message."""
258
+ self._log('DEBUG', message)