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,792 @@
1
+ """
2
+ OAuth Credential Manager for validating and refreshing credentials.
3
+
4
+ This service provides:
5
+ 1. Credential validation for YouTube, Dropbox, and Google Drive
6
+ 2. Device Authorization Flow for re-authentication
7
+ 3. Proactive monitoring with alerts when credentials expire
8
+ """
9
+ import json
10
+ import logging
11
+ import time
12
+ from typing import Optional, Dict, Any, Tuple
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from datetime import datetime
16
+
17
+ from google.oauth2.credentials import Credentials as GoogleCredentials
18
+ from google.auth.transport.requests import Request as GoogleAuthRequest
19
+
20
+ from backend.config import get_settings
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class CredentialStatus(str, Enum):
26
+ """Status of OAuth credentials."""
27
+ VALID = "valid"
28
+ EXPIRED = "expired" # Token expired but refresh may work
29
+ INVALID = "invalid" # Refresh failed, re-auth needed
30
+ NOT_CONFIGURED = "not_configured" # No credentials stored
31
+ ERROR = "error" # Unknown error during validation
32
+
33
+
34
+ @dataclass
35
+ class CredentialCheckResult:
36
+ """Result of a credential validation check."""
37
+ service: str
38
+ status: CredentialStatus
39
+ message: str
40
+ last_checked: datetime
41
+ expires_at: Optional[datetime] = None
42
+
43
+
44
+ @dataclass
45
+ class DeviceAuthInfo:
46
+ """Information for device authorization flow."""
47
+ device_code: str
48
+ user_code: str
49
+ verification_url: str
50
+ expires_in: int
51
+ interval: int
52
+ started_at: datetime
53
+
54
+
55
+ class CredentialManager:
56
+ """
57
+ Manages OAuth credentials for all external services.
58
+
59
+ Provides validation, refresh, and device authorization flow for:
60
+ - YouTube (Google OAuth)
61
+ - Google Drive (Google OAuth)
62
+ - Dropbox
63
+ """
64
+
65
+ # Secret names in Secret Manager
66
+ YOUTUBE_SECRET = "youtube-oauth-credentials"
67
+ GDRIVE_SECRET = "gdrive-oauth-credentials"
68
+ DROPBOX_SECRET = "dropbox-oauth-credentials"
69
+
70
+ # OAuth client credentials (for device auth flow)
71
+ YOUTUBE_CLIENT_SECRET = "youtube-client-credentials"
72
+ GDRIVE_CLIENT_SECRET = "gdrive-client-credentials"
73
+
74
+ # Google OAuth endpoints
75
+ GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
76
+ GOOGLE_DEVICE_AUTH_URI = "https://oauth2.googleapis.com/device/code"
77
+
78
+ # Dropbox OAuth endpoints
79
+ DROPBOX_TOKEN_URI = "https://api.dropboxapi.com/oauth2/token"
80
+
81
+ # Scopes for each service
82
+ YOUTUBE_SCOPES = ["https://www.googleapis.com/auth/youtube"]
83
+ GDRIVE_SCOPES = ["https://www.googleapis.com/auth/drive.file"]
84
+
85
+ def __init__(self):
86
+ self.settings = get_settings()
87
+ self._pending_device_auths: Dict[str, DeviceAuthInfo] = {}
88
+
89
+ def _get_client_credentials(self, secret_name: str) -> Optional[Dict[str, str]]:
90
+ """
91
+ Load OAuth client credentials from Secret Manager.
92
+
93
+ The secret should contain:
94
+ {
95
+ "client_id": "...",
96
+ "client_secret": "..."
97
+ }
98
+ """
99
+ try:
100
+ creds_json = self.settings.get_secret(secret_name)
101
+ if not creds_json:
102
+ return None
103
+
104
+ creds = json.loads(creds_json)
105
+ if creds.get("client_id") and creds.get("client_secret"):
106
+ return creds
107
+
108
+ logger.warning(f"{secret_name} missing client_id or client_secret")
109
+ return None
110
+
111
+ except Exception as e:
112
+ logger.error(f"Failed to load {secret_name}: {e}")
113
+ return None
114
+
115
+ def get_youtube_client_credentials(self) -> Optional[Dict[str, str]]:
116
+ """Get YouTube OAuth client credentials from Secret Manager."""
117
+ return self._get_client_credentials(self.YOUTUBE_CLIENT_SECRET)
118
+
119
+ def get_gdrive_client_credentials(self) -> Optional[Dict[str, str]]:
120
+ """Get Google Drive OAuth client credentials from Secret Manager."""
121
+ return self._get_client_credentials(self.GDRIVE_CLIENT_SECRET)
122
+
123
+ # =========================================================================
124
+ # Credential Validation
125
+ # =========================================================================
126
+
127
+ def check_all_credentials(self) -> Dict[str, CredentialCheckResult]:
128
+ """
129
+ Check validity of all OAuth credentials.
130
+
131
+ Returns:
132
+ Dictionary mapping service name to check result
133
+ """
134
+ results = {}
135
+
136
+ results["youtube"] = self.check_youtube_credentials()
137
+ results["gdrive"] = self.check_gdrive_credentials()
138
+ results["dropbox"] = self.check_dropbox_credentials()
139
+
140
+ return results
141
+
142
+ def check_youtube_credentials(self) -> CredentialCheckResult:
143
+ """Check if YouTube credentials are valid and can be refreshed."""
144
+ return self._check_google_credentials(
145
+ secret_name=self.YOUTUBE_SECRET,
146
+ service_name="youtube",
147
+ scopes=self.YOUTUBE_SCOPES,
148
+ test_api_call=self._test_youtube_api
149
+ )
150
+
151
+ def check_gdrive_credentials(self) -> CredentialCheckResult:
152
+ """Check if Google Drive credentials are valid and can be refreshed."""
153
+ return self._check_google_credentials(
154
+ secret_name=self.GDRIVE_SECRET,
155
+ service_name="gdrive",
156
+ scopes=self.GDRIVE_SCOPES,
157
+ test_api_call=self._test_gdrive_api
158
+ )
159
+
160
+ def check_dropbox_credentials(self) -> CredentialCheckResult:
161
+ """Check if Dropbox credentials are valid."""
162
+ try:
163
+ creds_json = self.settings.get_secret(self.DROPBOX_SECRET)
164
+
165
+ if not creds_json:
166
+ return CredentialCheckResult(
167
+ service="dropbox",
168
+ status=CredentialStatus.NOT_CONFIGURED,
169
+ message="Dropbox credentials not configured",
170
+ last_checked=datetime.utcnow()
171
+ )
172
+
173
+ creds = json.loads(creds_json)
174
+
175
+ if not creds.get("access_token"):
176
+ return CredentialCheckResult(
177
+ service="dropbox",
178
+ status=CredentialStatus.INVALID,
179
+ message="Dropbox credentials missing access_token",
180
+ last_checked=datetime.utcnow()
181
+ )
182
+
183
+ # Test the credentials with a simple API call
184
+ if self._test_dropbox_api(creds):
185
+ return CredentialCheckResult(
186
+ service="dropbox",
187
+ status=CredentialStatus.VALID,
188
+ message="Dropbox credentials are valid",
189
+ last_checked=datetime.utcnow()
190
+ )
191
+ else:
192
+ # Try to refresh if we have refresh token
193
+ if creds.get("refresh_token") and creds.get("app_key") and creds.get("app_secret"):
194
+ if self._refresh_dropbox_token(creds):
195
+ return CredentialCheckResult(
196
+ service="dropbox",
197
+ status=CredentialStatus.VALID,
198
+ message="Dropbox credentials refreshed successfully",
199
+ last_checked=datetime.utcnow()
200
+ )
201
+
202
+ return CredentialCheckResult(
203
+ service="dropbox",
204
+ status=CredentialStatus.INVALID,
205
+ message="Dropbox credentials invalid and refresh failed",
206
+ last_checked=datetime.utcnow()
207
+ )
208
+
209
+ except Exception as e:
210
+ logger.error(f"Error checking Dropbox credentials: {e}")
211
+ return CredentialCheckResult(
212
+ service="dropbox",
213
+ status=CredentialStatus.ERROR,
214
+ message=f"Error checking credentials: {str(e)}",
215
+ last_checked=datetime.utcnow()
216
+ )
217
+
218
+ def _check_google_credentials(
219
+ self,
220
+ secret_name: str,
221
+ service_name: str,
222
+ scopes: list,
223
+ test_api_call: callable
224
+ ) -> CredentialCheckResult:
225
+ """Generic Google OAuth credential check."""
226
+ try:
227
+ creds_json = self.settings.get_secret(secret_name)
228
+
229
+ if not creds_json:
230
+ return CredentialCheckResult(
231
+ service=service_name,
232
+ status=CredentialStatus.NOT_CONFIGURED,
233
+ message=f"{service_name} credentials not configured",
234
+ last_checked=datetime.utcnow()
235
+ )
236
+
237
+ creds_data = json.loads(creds_json)
238
+
239
+ # Check for required fields
240
+ required = ["refresh_token", "client_id", "client_secret"]
241
+ missing = [f for f in required if not creds_data.get(f)]
242
+ if missing:
243
+ return CredentialCheckResult(
244
+ service=service_name,
245
+ status=CredentialStatus.INVALID,
246
+ message=f"Missing required fields: {missing}",
247
+ last_checked=datetime.utcnow()
248
+ )
249
+
250
+ # Create credentials object
251
+ credentials = GoogleCredentials(
252
+ token=creds_data.get("token"),
253
+ refresh_token=creds_data.get("refresh_token"),
254
+ token_uri=creds_data.get("token_uri", self.GOOGLE_TOKEN_URI),
255
+ client_id=creds_data.get("client_id"),
256
+ client_secret=creds_data.get("client_secret"),
257
+ scopes=creds_data.get("scopes", scopes)
258
+ )
259
+
260
+ # Try to refresh if expired
261
+ if credentials.expired or not credentials.token:
262
+ try:
263
+ credentials.refresh(GoogleAuthRequest())
264
+ # Update stored credentials with new token
265
+ self._update_google_credentials(secret_name, creds_data, credentials)
266
+ except Exception as e:
267
+ logger.error(f"Failed to refresh {service_name} credentials: {e}")
268
+ return CredentialCheckResult(
269
+ service=service_name,
270
+ status=CredentialStatus.INVALID,
271
+ message=f"Token refresh failed: {str(e)}",
272
+ last_checked=datetime.utcnow()
273
+ )
274
+
275
+ # Test with API call
276
+ if test_api_call(credentials):
277
+ return CredentialCheckResult(
278
+ service=service_name,
279
+ status=CredentialStatus.VALID,
280
+ message=f"{service_name} credentials are valid",
281
+ last_checked=datetime.utcnow(),
282
+ expires_at=credentials.expiry
283
+ )
284
+ else:
285
+ return CredentialCheckResult(
286
+ service=service_name,
287
+ status=CredentialStatus.INVALID,
288
+ message=f"{service_name} API test failed",
289
+ last_checked=datetime.utcnow()
290
+ )
291
+
292
+ except json.JSONDecodeError as e:
293
+ return CredentialCheckResult(
294
+ service=service_name,
295
+ status=CredentialStatus.INVALID,
296
+ message=f"Invalid JSON in credentials: {str(e)}",
297
+ last_checked=datetime.utcnow()
298
+ )
299
+ except Exception as e:
300
+ logger.error(f"Error checking {service_name} credentials: {e}")
301
+ return CredentialCheckResult(
302
+ service=service_name,
303
+ status=CredentialStatus.ERROR,
304
+ message=f"Error: {str(e)}",
305
+ last_checked=datetime.utcnow()
306
+ )
307
+
308
+ def _update_google_credentials(
309
+ self,
310
+ secret_name: str,
311
+ original_data: dict,
312
+ credentials: GoogleCredentials
313
+ ) -> bool:
314
+ """Update stored Google credentials with refreshed token."""
315
+ try:
316
+ from google.cloud import secretmanager
317
+
318
+ updated_data = original_data.copy()
319
+ updated_data["token"] = credentials.token
320
+ if credentials.expiry:
321
+ updated_data["expiry"] = credentials.expiry.isoformat()
322
+
323
+ # Add new secret version
324
+ client = secretmanager.SecretManagerServiceClient()
325
+ project_id = self.settings.google_cloud_project
326
+ secret_path = f"projects/{project_id}/secrets/{secret_name}"
327
+
328
+ client.add_secret_version(
329
+ parent=secret_path,
330
+ payload={"data": json.dumps(updated_data).encode("utf-8")}
331
+ )
332
+
333
+ logger.info(f"Updated {secret_name} with refreshed token")
334
+ return True
335
+
336
+ except Exception as e:
337
+ logger.error(f"Failed to update {secret_name}: {e}")
338
+ return False
339
+
340
+ # =========================================================================
341
+ # API Test Methods
342
+ # =========================================================================
343
+
344
+ def _test_youtube_api(self, credentials: GoogleCredentials) -> bool:
345
+ """Test YouTube credentials with a simple API call."""
346
+ try:
347
+ from googleapiclient.discovery import build
348
+
349
+ youtube = build("youtube", "v3", credentials=credentials)
350
+ # Just get channel info - minimal API call
351
+ request = youtube.channels().list(part="id", mine=True)
352
+ response = request.execute()
353
+
354
+ return "items" in response
355
+
356
+ except Exception as e:
357
+ logger.error(f"YouTube API test failed: {e}")
358
+ return False
359
+
360
+ def _test_gdrive_api(self, credentials: GoogleCredentials) -> bool:
361
+ """Test Google Drive credentials with a simple API call."""
362
+ try:
363
+ from googleapiclient.discovery import build
364
+
365
+ drive = build("drive", "v3", credentials=credentials)
366
+ # Just get about info - minimal API call
367
+ request = drive.about().get(fields="user")
368
+ response = request.execute()
369
+
370
+ return "user" in response
371
+
372
+ except Exception as e:
373
+ logger.error(f"Google Drive API test failed: {e}")
374
+ return False
375
+
376
+ def _test_dropbox_api(self, creds: dict) -> bool:
377
+ """Test Dropbox credentials with a simple API call."""
378
+ try:
379
+ import dropbox
380
+
381
+ dbx = dropbox.Dropbox(creds["access_token"])
382
+ # Just get account info - minimal API call
383
+ dbx.users_get_current_account()
384
+ return True
385
+
386
+ except Exception as e:
387
+ logger.error(f"Dropbox API test failed: {e}")
388
+ return False
389
+
390
+ def _refresh_dropbox_token(self, creds: dict) -> bool:
391
+ """Try to refresh Dropbox token."""
392
+ try:
393
+ import requests
394
+
395
+ response = requests.post(
396
+ self.DROPBOX_TOKEN_URI,
397
+ data={
398
+ "grant_type": "refresh_token",
399
+ "refresh_token": creds["refresh_token"],
400
+ "client_id": creds["app_key"],
401
+ "client_secret": creds["app_secret"],
402
+ },
403
+ timeout=30
404
+ )
405
+
406
+ if response.status_code == 200:
407
+ token_data = response.json()
408
+ creds["access_token"] = token_data["access_token"]
409
+
410
+ # Update in Secret Manager
411
+ self._update_dropbox_credentials(creds)
412
+ return True
413
+
414
+ return False
415
+
416
+ except Exception as e:
417
+ logger.error(f"Dropbox token refresh failed: {e}")
418
+ return False
419
+
420
+ def _update_dropbox_credentials(self, creds: dict) -> bool:
421
+ """Update stored Dropbox credentials."""
422
+ try:
423
+ from google.cloud import secretmanager
424
+
425
+ client = secretmanager.SecretManagerServiceClient()
426
+ project_id = self.settings.google_cloud_project
427
+ secret_path = f"projects/{project_id}/secrets/{self.DROPBOX_SECRET}"
428
+
429
+ client.add_secret_version(
430
+ parent=secret_path,
431
+ payload={"data": json.dumps(creds).encode("utf-8")}
432
+ )
433
+
434
+ logger.info("Updated Dropbox credentials with refreshed token")
435
+ return True
436
+
437
+ except Exception as e:
438
+ logger.error(f"Failed to update Dropbox credentials: {e}")
439
+ return False
440
+
441
+ # =========================================================================
442
+ # Device Authorization Flow
443
+ # =========================================================================
444
+
445
+ def start_youtube_device_auth(
446
+ self,
447
+ client_id: Optional[str] = None,
448
+ client_secret: Optional[str] = None
449
+ ) -> DeviceAuthInfo:
450
+ """
451
+ Start YouTube device authorization flow.
452
+
453
+ Args:
454
+ client_id: Google OAuth client ID (optional, reads from Secret Manager if not provided)
455
+ client_secret: Google OAuth client secret (optional, reads from Secret Manager if not provided)
456
+
457
+ Returns:
458
+ DeviceAuthInfo with user code and verification URL
459
+ """
460
+ # Load from Secret Manager if not provided
461
+ if not client_id or not client_secret:
462
+ stored_creds = self.get_youtube_client_credentials()
463
+ if not stored_creds:
464
+ raise Exception(
465
+ "YouTube client credentials not found. Either pass client_id/client_secret "
466
+ "or create the 'youtube-client-credentials' secret in Secret Manager."
467
+ )
468
+ client_id = stored_creds["client_id"]
469
+ client_secret = stored_creds["client_secret"]
470
+
471
+ return self._start_google_device_auth(
472
+ client_id=client_id,
473
+ client_secret=client_secret,
474
+ scopes=self.YOUTUBE_SCOPES,
475
+ service_name="youtube"
476
+ )
477
+
478
+ def start_gdrive_device_auth(
479
+ self,
480
+ client_id: Optional[str] = None,
481
+ client_secret: Optional[str] = None
482
+ ) -> DeviceAuthInfo:
483
+ """
484
+ Start Google Drive device authorization flow.
485
+
486
+ Args:
487
+ client_id: Google OAuth client ID (optional, reads from Secret Manager if not provided)
488
+ client_secret: Google OAuth client secret (optional, reads from Secret Manager if not provided)
489
+
490
+ Returns:
491
+ DeviceAuthInfo with user code and verification URL
492
+ """
493
+ # Load from Secret Manager if not provided
494
+ if not client_id or not client_secret:
495
+ stored_creds = self.get_gdrive_client_credentials()
496
+ if not stored_creds:
497
+ raise Exception(
498
+ "Google Drive client credentials not found. Either pass client_id/client_secret "
499
+ "or create the 'gdrive-client-credentials' secret in Secret Manager."
500
+ )
501
+ client_id = stored_creds["client_id"]
502
+ client_secret = stored_creds["client_secret"]
503
+
504
+ return self._start_google_device_auth(
505
+ client_id=client_id,
506
+ client_secret=client_secret,
507
+ scopes=self.GDRIVE_SCOPES,
508
+ service_name="gdrive"
509
+ )
510
+
511
+ def _start_google_device_auth(
512
+ self,
513
+ client_id: str,
514
+ client_secret: str,
515
+ scopes: list,
516
+ service_name: str
517
+ ) -> DeviceAuthInfo:
518
+ """Start Google device authorization flow."""
519
+ import requests
520
+
521
+ logger.info(f"[{service_name}] Starting device auth flow with scopes: {scopes}")
522
+
523
+ response = requests.post(
524
+ self.GOOGLE_DEVICE_AUTH_URI,
525
+ data={
526
+ "client_id": client_id,
527
+ "scope": " ".join(scopes),
528
+ },
529
+ timeout=30
530
+ )
531
+
532
+ if response.status_code != 200:
533
+ logger.error(f"[{service_name}] Device auth request failed: {response.status_code} - {response.text}")
534
+ raise Exception(f"Device auth request failed: {response.text}")
535
+
536
+ logger.info(f"[{service_name}] Device auth initiated successfully")
537
+
538
+ data = response.json()
539
+
540
+ # Google uses 'verification_uri' but some docs show 'verification_url'
541
+ verification_url = data.get("verification_uri") or data.get("verification_url")
542
+
543
+ device_info = DeviceAuthInfo(
544
+ device_code=data["device_code"],
545
+ user_code=data["user_code"],
546
+ verification_url=verification_url,
547
+ expires_in=data["expires_in"],
548
+ interval=data.get("interval", 5),
549
+ started_at=datetime.utcnow()
550
+ )
551
+
552
+ # Store for polling, include client secret for token exchange
553
+ self._pending_device_auths[f"{service_name}:{data['device_code']}"] = {
554
+ "info": device_info,
555
+ "client_id": client_id,
556
+ "client_secret": client_secret,
557
+ "scopes": scopes,
558
+ "service_name": service_name
559
+ }
560
+
561
+ return device_info
562
+
563
+ def poll_device_auth(self, service_name: str, device_code: str) -> Tuple[str, Optional[dict]]:
564
+ """
565
+ Poll for device authorization completion.
566
+
567
+ This method is STATELESS - it fetches client credentials from Secret Manager
568
+ and polls Google directly. This works correctly in serverless environments
569
+ like Cloud Run where in-memory state is not preserved between requests.
570
+
571
+ Args:
572
+ service_name: "youtube" or "gdrive"
573
+ device_code: The device code from start_*_device_auth
574
+
575
+ Returns:
576
+ Tuple of (status, token_data)
577
+ status: "pending", "complete", "expired", "error"
578
+ token_data: Token data if complete, None otherwise
579
+ """
580
+ import requests
581
+
582
+ logger.info(f"[{service_name}] Polling device auth for code: {device_code[:20]}...")
583
+
584
+ # Get client credentials from Secret Manager (stateless approach)
585
+ if service_name == "youtube":
586
+ client_creds = self.get_youtube_client_credentials()
587
+ scopes = self.YOUTUBE_SCOPES
588
+ secret_name = self.YOUTUBE_SECRET
589
+ elif service_name == "gdrive":
590
+ client_creds = self.get_gdrive_client_credentials()
591
+ scopes = self.GDRIVE_SCOPES
592
+ secret_name = self.GDRIVE_SECRET
593
+ else:
594
+ logger.error(f"[{service_name}] Unknown service")
595
+ return ("error", {"message": f"Unknown service: {service_name}"})
596
+
597
+ if not client_creds:
598
+ logger.error(f"[{service_name}] Client credentials not found in Secret Manager")
599
+ return ("error", {"message": f"Client credentials not found in Secret Manager for {service_name}"})
600
+
601
+ client_id = client_creds.get("client_id")
602
+ client_secret = client_creds.get("client_secret")
603
+
604
+ if not client_id or not client_secret:
605
+ logger.error(f"[{service_name}] Invalid client credentials (missing client_id or client_secret)")
606
+ return ("error", {"message": f"Invalid client credentials for {service_name}"})
607
+
608
+ logger.info(f"[{service_name}] Got client credentials, polling Google token endpoint...")
609
+
610
+ # Poll Google token endpoint
611
+ response = requests.post(
612
+ self.GOOGLE_TOKEN_URI,
613
+ data={
614
+ "client_id": client_id,
615
+ "client_secret": client_secret,
616
+ "device_code": device_code,
617
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
618
+ },
619
+ timeout=30
620
+ )
621
+
622
+ data = response.json()
623
+ logger.info(f"[{service_name}] Token endpoint response: status={response.status_code}")
624
+
625
+ if response.status_code == 200:
626
+ logger.info(f"[{service_name}] Token exchange successful! Got access token and refresh_token={bool(data.get('refresh_token'))}")
627
+
628
+ # Success! Store the credentials
629
+ token_data = {
630
+ "token": data["access_token"],
631
+ "refresh_token": data.get("refresh_token"),
632
+ "token_uri": self.GOOGLE_TOKEN_URI,
633
+ "client_id": client_id,
634
+ "client_secret": client_secret,
635
+ "scopes": scopes,
636
+ }
637
+
638
+ # Save to Secret Manager
639
+ logger.info(f"[{service_name}] Saving credentials to secret: {secret_name}")
640
+ saved = self._save_credentials_to_secret(secret_name, token_data)
641
+ if not saved:
642
+ logger.error(f"[{service_name}] Failed to save credentials to Secret Manager!")
643
+ return ("error", {"message": "Token exchange succeeded but failed to save credentials to Secret Manager"})
644
+
645
+ logger.info(f"[{service_name}] Device auth flow COMPLETE - credentials saved successfully")
646
+ return ("complete", token_data)
647
+
648
+ elif "error" in data:
649
+ error = data["error"]
650
+ logger.info(f"[{service_name}] Token endpoint returned error: {error}")
651
+
652
+ if error == "authorization_pending":
653
+ return ("pending", {"message": "Waiting for user to authorize. Please visit the verification URL and enter the code."})
654
+ elif error == "slow_down":
655
+ return ("pending", {"message": "Please wait a few more seconds before polling again."})
656
+ elif error == "expired_token":
657
+ logger.warning(f"[{service_name}] Device code expired")
658
+ return ("expired", {"message": "Device code expired. Please start a new device auth flow."})
659
+ elif error == "access_denied":
660
+ logger.warning(f"[{service_name}] User denied access")
661
+ return ("error", {"message": "User denied access"})
662
+ else:
663
+ logger.error(f"[{service_name}] Unexpected error: {data}")
664
+ return ("error", {"message": data.get("error_description", error)})
665
+
666
+ logger.error(f"[{service_name}] Unknown response from token endpoint: {data}")
667
+ return ("error", {"message": "Unknown response from token endpoint"})
668
+
669
+ def _save_credentials_to_secret(self, secret_name: str, token_data: dict) -> bool:
670
+ """Save credentials to Secret Manager, creating the secret if needed."""
671
+ logger.info(f"[SecretManager] Attempting to save credentials to: {secret_name}")
672
+
673
+ try:
674
+ from google.cloud import secretmanager
675
+ from google.api_core import exceptions as gcp_exceptions
676
+
677
+ client = secretmanager.SecretManagerServiceClient()
678
+ project_id = self.settings.google_cloud_project
679
+
680
+ if not project_id:
681
+ logger.error(f"[SecretManager] No GCP project configured!")
682
+ return False
683
+
684
+ parent = f"projects/{project_id}"
685
+ secret_path = f"{parent}/secrets/{secret_name}"
686
+
687
+ logger.info(f"[SecretManager] Using project: {project_id}, secret path: {secret_path}")
688
+
689
+ # Try to add a version to existing secret
690
+ try:
691
+ client.add_secret_version(
692
+ parent=secret_path,
693
+ payload={"data": json.dumps(token_data).encode("utf-8")}
694
+ )
695
+ logger.info(f"[SecretManager] SUCCESS - Added new version to existing secret: {secret_name}")
696
+ return True
697
+
698
+ except gcp_exceptions.NotFound:
699
+ # Secret doesn't exist, create it first
700
+ logger.info(f"[SecretManager] Secret {secret_name} not found, creating new secret...")
701
+ client.create_secret(
702
+ parent=parent,
703
+ secret_id=secret_name,
704
+ secret={"replication": {"automatic": {}}}
705
+ )
706
+ logger.info(f"[SecretManager] Created secret: {secret_name}")
707
+
708
+ # Now add the version
709
+ client.add_secret_version(
710
+ parent=secret_path,
711
+ payload={"data": json.dumps(token_data).encode("utf-8")}
712
+ )
713
+ logger.info(f"[SecretManager] SUCCESS - Created secret and saved credentials to: {secret_name}")
714
+ return True
715
+
716
+ except Exception as e:
717
+ logger.error(f"[SecretManager] FAILED to save credentials to {secret_name}: {type(e).__name__}: {e}")
718
+ import traceback
719
+ logger.error(f"[SecretManager] Traceback: {traceback.format_exc()}")
720
+ return False
721
+
722
+ # =========================================================================
723
+ # Alerts
724
+ # =========================================================================
725
+
726
+ def send_credential_alert(
727
+ self,
728
+ invalid_services: list[CredentialCheckResult],
729
+ discord_webhook_url: Optional[str] = None
730
+ ) -> bool:
731
+ """
732
+ Send alert about invalid credentials.
733
+
734
+ Args:
735
+ invalid_services: List of services with invalid credentials
736
+ discord_webhook_url: Discord webhook URL for notifications
737
+
738
+ Returns:
739
+ True if alert was sent successfully
740
+ """
741
+ if not discord_webhook_url:
742
+ logger.warning("No Discord webhook URL configured for alerts")
743
+ return False
744
+
745
+ try:
746
+ import requests
747
+
748
+ services_list = "\n".join([
749
+ f"• **{r.service}**: {r.message}"
750
+ for r in invalid_services
751
+ ])
752
+
753
+ # Get the API base URL for re-auth links
754
+ api_url = self.settings.api_base_url if hasattr(self.settings, 'api_base_url') else "https://your-api-url"
755
+
756
+ message = {
757
+ "embeds": [{
758
+ "title": "⚠️ OAuth Credentials Need Attention",
759
+ "description": f"The following service credentials need re-authorization:\n\n{services_list}",
760
+ "color": 16744256, # Orange
761
+ "fields": [
762
+ {
763
+ "name": "Re-authorize",
764
+ "value": f"Visit `{api_url}/api/auth/status` to start re-authorization flow",
765
+ "inline": False
766
+ }
767
+ ],
768
+ "footer": {
769
+ "text": "karaoke-gen backend"
770
+ },
771
+ "timestamp": datetime.utcnow().isoformat()
772
+ }]
773
+ }
774
+
775
+ response = requests.post(discord_webhook_url, json=message, timeout=30)
776
+ return response.status_code in (200, 204)
777
+
778
+ except Exception as e:
779
+ logger.error(f"Failed to send credential alert: {e}")
780
+ return False
781
+
782
+
783
+ # Singleton instance
784
+ _credential_manager: Optional[CredentialManager] = None
785
+
786
+
787
+ def get_credential_manager() -> CredentialManager:
788
+ """Get the singleton credential manager instance."""
789
+ global _credential_manager
790
+ if _credential_manager is None:
791
+ _credential_manager = CredentialManager()
792
+ return _credential_manager