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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/style_loader.py +3 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +2 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +187 -42
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.86.7.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {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)
|