karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +835 -0
  11. backend/api/routes/audio_search.py +913 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2112 -0
  14. backend/api/routes/health.py +409 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1629 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1513 -0
  20. backend/config.py +172 -0
  21. backend/main.py +157 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +502 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +853 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/langfuse_preloader.py +98 -0
  56. backend/services/local_encoding_service.py +590 -0
  57. backend/services/local_preview_encoding_service.py +407 -0
  58. backend/services/lyrics_cache_service.py +216 -0
  59. backend/services/metrics.py +413 -0
  60. backend/services/nltk_preloader.py +122 -0
  61. backend/services/packaging_service.py +287 -0
  62. backend/services/rclone_service.py +106 -0
  63. backend/services/spacy_preloader.py +65 -0
  64. backend/services/storage_service.py +209 -0
  65. backend/services/stripe_service.py +371 -0
  66. backend/services/structured_logging.py +254 -0
  67. backend/services/template_service.py +330 -0
  68. backend/services/theme_service.py +469 -0
  69. backend/services/tracing.py +543 -0
  70. backend/services/user_service.py +721 -0
  71. backend/services/worker_service.py +558 -0
  72. backend/services/youtube_service.py +112 -0
  73. backend/services/youtube_upload_service.py +445 -0
  74. backend/tests/__init__.py +4 -0
  75. backend/tests/conftest.py +224 -0
  76. backend/tests/emulator/__init__.py +7 -0
  77. backend/tests/emulator/conftest.py +109 -0
  78. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  79. backend/tests/emulator/test_emulator_integration.py +356 -0
  80. backend/tests/emulator/test_style_loading_direct.py +436 -0
  81. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  82. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  83. backend/tests/requirements-test.txt +10 -0
  84. backend/tests/requirements.txt +6 -0
  85. backend/tests/test_admin_email_endpoints.py +411 -0
  86. backend/tests/test_api_integration.py +460 -0
  87. backend/tests/test_api_routes.py +93 -0
  88. backend/tests/test_audio_analysis_service.py +294 -0
  89. backend/tests/test_audio_editing_service.py +386 -0
  90. backend/tests/test_audio_search.py +1398 -0
  91. backend/tests/test_audio_services.py +378 -0
  92. backend/tests/test_auth_firestore.py +231 -0
  93. backend/tests/test_config_extended.py +68 -0
  94. backend/tests/test_credential_manager.py +377 -0
  95. backend/tests/test_dependencies.py +54 -0
  96. backend/tests/test_discord_service.py +244 -0
  97. backend/tests/test_distribution_services.py +820 -0
  98. backend/tests/test_dropbox_service.py +472 -0
  99. backend/tests/test_email_service.py +492 -0
  100. backend/tests/test_emulator_integration.py +322 -0
  101. backend/tests/test_encoding_interface.py +412 -0
  102. backend/tests/test_file_upload.py +1739 -0
  103. backend/tests/test_flacfetch_client.py +632 -0
  104. backend/tests/test_gdrive_service.py +524 -0
  105. backend/tests/test_instrumental_api.py +431 -0
  106. backend/tests/test_internal_api.py +343 -0
  107. backend/tests/test_job_creation_regression.py +583 -0
  108. backend/tests/test_job_manager.py +356 -0
  109. backend/tests/test_job_manager_notifications.py +329 -0
  110. backend/tests/test_job_notification_service.py +443 -0
  111. backend/tests/test_jobs_api.py +283 -0
  112. backend/tests/test_local_encoding_service.py +423 -0
  113. backend/tests/test_local_preview_encoding_service.py +567 -0
  114. backend/tests/test_main.py +87 -0
  115. backend/tests/test_models.py +918 -0
  116. backend/tests/test_packaging_service.py +382 -0
  117. backend/tests/test_requests.py +201 -0
  118. backend/tests/test_routes_jobs.py +282 -0
  119. backend/tests/test_routes_review.py +337 -0
  120. backend/tests/test_services.py +556 -0
  121. backend/tests/test_services_extended.py +112 -0
  122. backend/tests/test_spacy_preloader.py +119 -0
  123. backend/tests/test_storage_service.py +448 -0
  124. backend/tests/test_style_upload.py +261 -0
  125. backend/tests/test_template_service.py +295 -0
  126. backend/tests/test_theme_service.py +516 -0
  127. backend/tests/test_unicode_sanitization.py +522 -0
  128. backend/tests/test_upload_api.py +256 -0
  129. backend/tests/test_validate.py +156 -0
  130. backend/tests/test_video_worker_orchestrator.py +847 -0
  131. backend/tests/test_worker_log_subcollection.py +509 -0
  132. backend/tests/test_worker_logging.py +365 -0
  133. backend/tests/test_workers.py +1116 -0
  134. backend/tests/test_workers_extended.py +178 -0
  135. backend/tests/test_youtube_service.py +247 -0
  136. backend/tests/test_youtube_upload_service.py +568 -0
  137. backend/utils/test_data.py +27 -0
  138. backend/validate.py +173 -0
  139. backend/version.py +27 -0
  140. backend/workers/README.md +597 -0
  141. backend/workers/__init__.py +11 -0
  142. backend/workers/audio_worker.py +618 -0
  143. backend/workers/lyrics_worker.py +683 -0
  144. backend/workers/render_video_worker.py +483 -0
  145. backend/workers/screens_worker.py +535 -0
  146. backend/workers/style_helper.py +198 -0
  147. backend/workers/video_worker.py +1277 -0
  148. backend/workers/video_worker_orchestrator.py +701 -0
  149. backend/workers/worker_logging.py +278 -0
  150. karaoke_gen/instrumental_review/static/index.html +7 -4
  151. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  152. karaoke_gen/utils/__init__.py +163 -8
  153. karaoke_gen/video_background_processor.py +9 -4
  154. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  155. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
  156. lyrics_transcriber/correction/agentic/agent.py +17 -6
  157. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  158. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
  159. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  160. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  161. lyrics_transcriber/correction/corrector.py +192 -130
  162. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  163. lyrics_transcriber/correction/operations.py +24 -9
  164. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  165. lyrics_transcriber/frontend/package-lock.json +2 -2
  166. lyrics_transcriber/frontend/package.json +1 -1
  167. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  168. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  169. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  170. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  171. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  172. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  173. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  174. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  175. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  176. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  177. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  178. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  179. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  180. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  181. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  182. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  183. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  184. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  185. lyrics_transcriber/frontend/src/theme.ts +42 -15
  186. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  187. lyrics_transcriber/frontend/vite.config.js +5 -0
  188. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  189. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  191. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  192. lyrics_transcriber/output/generator.py +17 -3
  193. lyrics_transcriber/output/video.py +60 -95
  194. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  195. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  196. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  197. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,371 @@
1
+ """
2
+ Stripe service for payment processing.
3
+
4
+ Handles:
5
+ - Creating checkout sessions for credit purchases
6
+ - Processing webhook events
7
+ - Managing customer records
8
+ """
9
+ import logging
10
+ import os
11
+ from typing import Optional, Dict, Any, Tuple
12
+
13
+ import stripe
14
+
15
+ from backend.config import get_settings
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ # Credit packages available for purchase
22
+ CREDIT_PACKAGES = {
23
+ "1_credit": {
24
+ "credits": 1,
25
+ "price_cents": 500, # $5.00
26
+ "name": "1 Karaoke Credit",
27
+ "description": "Create 1 professional karaoke video",
28
+ },
29
+ "3_credits": {
30
+ "credits": 3,
31
+ "price_cents": 1200, # $12.00 (20% discount)
32
+ "name": "3 Karaoke Credits",
33
+ "description": "Create 3 professional karaoke videos (Save 20%)",
34
+ },
35
+ "5_credits": {
36
+ "credits": 5,
37
+ "price_cents": 1750, # $17.50 (30% discount)
38
+ "name": "5 Karaoke Credits",
39
+ "description": "Create 5 professional karaoke videos (Save 30%)",
40
+ },
41
+ "10_credits": {
42
+ "credits": 10,
43
+ "price_cents": 3000, # $30.00 (40% discount)
44
+ "name": "10 Karaoke Credits",
45
+ "description": "Create 10 professional karaoke videos (Save 40%)",
46
+ },
47
+ }
48
+
49
+ # Done-for-you service package (not a credit package - creates a job directly)
50
+ DONE_FOR_YOU_PACKAGE = {
51
+ "price_cents": 1500, # $15.00
52
+ "name": "Done For You Karaoke Video",
53
+ "description": "Full-service karaoke video creation with 24-hour delivery",
54
+ }
55
+
56
+
57
+ class StripeService:
58
+ """Service for Stripe payment processing."""
59
+
60
+ def __init__(self):
61
+ """Initialize Stripe with API key."""
62
+ self.settings = get_settings()
63
+ self.secret_key = os.getenv("STRIPE_SECRET_KEY")
64
+ self.webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
65
+ self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
66
+ # After consolidation, buy URL is the same as frontend URL
67
+ self.buy_url = os.getenv("BUY_URL", self.frontend_url)
68
+
69
+ if self.secret_key:
70
+ stripe.api_key = self.secret_key
71
+ logger.info("Stripe initialized with API key")
72
+ else:
73
+ logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
74
+
75
+ def is_configured(self) -> bool:
76
+ """Check if Stripe is properly configured."""
77
+ return bool(self.secret_key)
78
+
79
+ def get_credit_packages(self) -> Dict[str, Dict[str, Any]]:
80
+ """Get available credit packages."""
81
+ return CREDIT_PACKAGES
82
+
83
+ def create_checkout_session(
84
+ self,
85
+ package_id: str,
86
+ user_email: str,
87
+ success_url: Optional[str] = None,
88
+ cancel_url: Optional[str] = None,
89
+ ) -> Tuple[bool, Optional[str], str]:
90
+ """
91
+ Create a Stripe Checkout session for purchasing credits.
92
+
93
+ Args:
94
+ package_id: ID of the credit package to purchase
95
+ user_email: Email of the purchasing user
96
+ success_url: URL to redirect to on success (optional)
97
+ cancel_url: URL to redirect to on cancel (optional)
98
+
99
+ Returns:
100
+ (success, checkout_url, message)
101
+ """
102
+ if not self.is_configured():
103
+ return False, None, "Payment processing is not configured"
104
+
105
+ package = CREDIT_PACKAGES.get(package_id)
106
+ if not package:
107
+ return False, None, f"Invalid package: {package_id}"
108
+
109
+ try:
110
+ # Default URLs
111
+ if not success_url:
112
+ success_url = f"{self.frontend_url}/payment/success?session_id={{CHECKOUT_SESSION_ID}}"
113
+ if not cancel_url:
114
+ cancel_url = f"{self.buy_url}?cancelled=true"
115
+
116
+ # Create checkout session
117
+ session = stripe.checkout.Session.create(
118
+ payment_method_types=['card'],
119
+ line_items=[{
120
+ 'price_data': {
121
+ 'currency': 'usd',
122
+ 'product_data': {
123
+ 'name': package['name'],
124
+ 'description': package['description'],
125
+ },
126
+ 'unit_amount': package['price_cents'],
127
+ },
128
+ 'quantity': 1,
129
+ }],
130
+ mode='payment',
131
+ success_url=success_url,
132
+ cancel_url=cancel_url,
133
+ customer_email=user_email,
134
+ metadata={
135
+ 'package_id': package_id,
136
+ 'credits': str(package['credits']),
137
+ 'user_email': user_email,
138
+ },
139
+ # Allow promotion codes
140
+ allow_promotion_codes=True,
141
+ )
142
+
143
+ logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
144
+ return True, session.url, "Checkout session created"
145
+
146
+ except stripe.error.StripeError as e:
147
+ logger.error(f"Stripe error creating checkout session: {e}")
148
+ return False, None, f"Payment error: {str(e)}"
149
+ except Exception as e:
150
+ logger.error(f"Error creating checkout session: {e}")
151
+ return False, None, "Failed to create checkout session"
152
+
153
+ def create_done_for_you_checkout_session(
154
+ self,
155
+ customer_email: str,
156
+ artist: str,
157
+ title: str,
158
+ source_type: str = "search",
159
+ youtube_url: Optional[str] = None,
160
+ notes: Optional[str] = None,
161
+ success_url: Optional[str] = None,
162
+ cancel_url: Optional[str] = None,
163
+ ) -> Tuple[bool, Optional[str], str]:
164
+ """
165
+ Create a Stripe Checkout session for a done-for-you order.
166
+
167
+ This is for the full-service karaoke video creation where Nomad Karaoke
168
+ handles all the work (lyrics review, instrumental selection, etc.).
169
+
170
+ Args:
171
+ customer_email: Customer's email for delivery
172
+ artist: Song artist
173
+ title: Song title
174
+ source_type: Audio source type (search, youtube, upload)
175
+ youtube_url: YouTube URL if source_type is youtube
176
+ notes: Any special requests from customer
177
+ success_url: URL to redirect to on success (optional)
178
+ cancel_url: URL to redirect to on cancel (optional)
179
+
180
+ Returns:
181
+ (success, checkout_url, message)
182
+ """
183
+ if not self.is_configured():
184
+ return False, None, "Payment processing is not configured"
185
+
186
+ try:
187
+ # Default URLs - redirect to homepage success page
188
+ if not success_url:
189
+ success_url = "https://nomadkaraoke.com/order/success/?session_id={CHECKOUT_SESSION_ID}"
190
+ if not cancel_url:
191
+ cancel_url = "https://nomadkaraoke.com/#do-it-for-me"
192
+
193
+ # Build metadata for job creation after payment
194
+ metadata = {
195
+ 'order_type': 'done_for_you',
196
+ 'customer_email': customer_email,
197
+ 'artist': artist,
198
+ 'title': title,
199
+ 'source_type': source_type,
200
+ }
201
+ if youtube_url:
202
+ metadata['youtube_url'] = youtube_url
203
+ if notes:
204
+ # Truncate notes to fit Stripe's 500 char limit per metadata value
205
+ metadata['notes'] = notes[:500] if len(notes) > 500 else notes
206
+
207
+ # Create checkout session
208
+ session = stripe.checkout.Session.create(
209
+ payment_method_types=['card'],
210
+ line_items=[{
211
+ 'price_data': {
212
+ 'currency': 'usd',
213
+ 'product_data': {
214
+ 'name': DONE_FOR_YOU_PACKAGE['name'],
215
+ 'description': f"{artist} - {title}",
216
+ },
217
+ 'unit_amount': DONE_FOR_YOU_PACKAGE['price_cents'],
218
+ },
219
+ 'quantity': 1,
220
+ }],
221
+ mode='payment',
222
+ success_url=success_url,
223
+ cancel_url=cancel_url,
224
+ customer_email=customer_email,
225
+ metadata=metadata,
226
+ allow_promotion_codes=True,
227
+ )
228
+
229
+ logger.info(
230
+ f"Created done-for-you checkout session {session.id} for {customer_email}, "
231
+ f"song: {artist} - {title}"
232
+ )
233
+ return True, session.url, "Checkout session created"
234
+
235
+ except stripe.error.StripeError as e:
236
+ logger.error(f"Stripe error creating done-for-you checkout session: {e}")
237
+ return False, None, f"Payment error: {str(e)}"
238
+ except Exception as e:
239
+ logger.error(f"Error creating done-for-you checkout session: {e}")
240
+ return False, None, "Failed to create checkout session"
241
+
242
+ def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
243
+ """
244
+ Verify a Stripe webhook signature.
245
+
246
+ Args:
247
+ payload: Raw request body
248
+ signature: Stripe-Signature header value
249
+
250
+ Returns:
251
+ (valid, event_data, message)
252
+ """
253
+ if not self.webhook_secret:
254
+ logger.error("STRIPE_WEBHOOK_SECRET not configured")
255
+ return False, None, "Webhook secret not configured"
256
+
257
+ try:
258
+ event = stripe.Webhook.construct_event(
259
+ payload, signature, self.webhook_secret
260
+ )
261
+ return True, event, "Webhook verified"
262
+ except stripe.error.SignatureVerificationError as e:
263
+ logger.error(f"Invalid webhook signature: {e}")
264
+ return False, None, "Invalid signature"
265
+ except Exception as e:
266
+ logger.error(f"Error verifying webhook: {e}")
267
+ return False, None, str(e)
268
+
269
+ def handle_checkout_completed(self, session: Dict) -> Tuple[bool, Optional[str], int, str]:
270
+ """
271
+ Handle a completed checkout session.
272
+
273
+ Args:
274
+ session: Stripe checkout session object
275
+
276
+ Returns:
277
+ (success, user_email, credits_to_add, message)
278
+ """
279
+ try:
280
+ metadata = session.get('metadata', {})
281
+ user_email = metadata.get('user_email') or session.get('customer_email')
282
+ try:
283
+ credits = int(metadata.get('credits', 0))
284
+ except (ValueError, TypeError):
285
+ logger.error(f"Invalid credits metadata in session {session.get('id')}: {metadata.get('credits')}")
286
+ return False, user_email, 0, "Invalid credit amount in session metadata"
287
+ package_id = metadata.get('package_id')
288
+
289
+ if not user_email:
290
+ logger.error(f"No user email in checkout session {session.get('id')}")
291
+ return False, None, 0, "No user email found"
292
+
293
+ if credits <= 0:
294
+ logger.error(f"Invalid credits in checkout session {session.get('id')}")
295
+ return False, user_email, 0, "Invalid credit amount"
296
+
297
+ logger.info(
298
+ f"Checkout completed: {user_email} purchased {credits} credits "
299
+ f"(package: {package_id}, session: {session.get('id')})"
300
+ )
301
+
302
+ return True, user_email, credits, f"Successfully purchased {credits} credits"
303
+
304
+ except Exception as e:
305
+ logger.error(f"Error handling checkout completed: {e}")
306
+ return False, None, 0, str(e)
307
+
308
+ def get_session(self, session_id: str) -> Optional[Dict]:
309
+ """
310
+ Get a checkout session by ID.
311
+
312
+ Args:
313
+ session_id: Stripe checkout session ID
314
+
315
+ Returns:
316
+ Session data or None
317
+ """
318
+ if not self.is_configured():
319
+ return None
320
+
321
+ try:
322
+ session = stripe.checkout.Session.retrieve(session_id)
323
+ return dict(session)
324
+ except Exception as e:
325
+ logger.error(f"Error retrieving session {session_id}: {e}")
326
+ return None
327
+
328
+ def create_customer(self, email: str, name: Optional[str] = None) -> Optional[str]:
329
+ """
330
+ Create or get a Stripe customer for a user.
331
+
332
+ Args:
333
+ email: User's email
334
+ name: User's display name (optional)
335
+
336
+ Returns:
337
+ Stripe customer ID or None
338
+ """
339
+ if not self.is_configured():
340
+ return None
341
+
342
+ try:
343
+ # Check if customer already exists
344
+ customers = stripe.Customer.list(email=email, limit=1)
345
+ if customers.data:
346
+ return customers.data[0].id
347
+
348
+ # Create new customer
349
+ customer = stripe.Customer.create(
350
+ email=email,
351
+ name=name,
352
+ metadata={'source': 'nomad_karaoke'},
353
+ )
354
+ logger.info(f"Created Stripe customer {customer.id} for {email}")
355
+ return customer.id
356
+
357
+ except Exception as e:
358
+ logger.error(f"Error creating Stripe customer: {e}")
359
+ return None
360
+
361
+
362
+ # Global instance
363
+ _stripe_service = None
364
+
365
+
366
+ def get_stripe_service() -> StripeService:
367
+ """Get the global Stripe service instance."""
368
+ global _stripe_service
369
+ if _stripe_service is None:
370
+ _stripe_service = StripeService()
371
+ return _stripe_service
@@ -0,0 +1,254 @@
1
+ """
2
+ Structured logging with trace correlation for Cloud Logging.
3
+
4
+ This module provides JSON-formatted logging that integrates with Google Cloud Logging
5
+ and correlates logs with OpenTelemetry traces in Cloud Trace.
6
+
7
+ When running in Cloud Run:
8
+ - Logs are output as JSON for Cloud Logging to parse
9
+ - Each log entry includes trace ID and span ID for correlation
10
+ - Custom fields (job_id, worker) are preserved in the log structure
11
+
12
+ Usage:
13
+ # In main.py, before any logging:
14
+ from backend.services.structured_logging import setup_structured_logging
15
+ setup_structured_logging()
16
+
17
+ # Then use standard logging:
18
+ logger = logging.getLogger(__name__)
19
+ logger.info("Processing started", extra={"job_id": "abc123"})
20
+ """
21
+ import json
22
+ import logging
23
+ import os
24
+ import sys
25
+ from datetime import datetime
26
+ from typing import Any, Dict, Optional
27
+
28
+
29
+ # Cloud Logging severity mapping (compatible with GCP)
30
+ # https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity
31
+ SEVERITY_MAP = {
32
+ "DEBUG": "DEBUG",
33
+ "INFO": "INFO",
34
+ "WARNING": "WARNING",
35
+ "ERROR": "ERROR",
36
+ "CRITICAL": "CRITICAL",
37
+ }
38
+
39
+
40
+ class StructuredFormatter(logging.Formatter):
41
+ """
42
+ JSON formatter with trace correlation for Google Cloud Logging.
43
+
44
+ Produces log entries in the format expected by Cloud Logging, including:
45
+ - logging.googleapis.com/trace: Links to Cloud Trace
46
+ - logging.googleapis.com/spanId: Current span ID
47
+ - severity: Cloud Logging severity level
48
+ - Custom fields passed via 'extra' dict
49
+ """
50
+
51
+ def __init__(self, project_id: Optional[str] = None):
52
+ """
53
+ Initialize the structured formatter.
54
+
55
+ Args:
56
+ project_id: GCP project ID for trace URLs (auto-detected if not provided)
57
+ """
58
+ super().__init__()
59
+ self.project_id = project_id or os.environ.get("GOOGLE_CLOUD_PROJECT") or os.environ.get("GCP_PROJECT")
60
+
61
+ def format(self, record: logging.LogRecord) -> str:
62
+ """
63
+ Format a log record as JSON.
64
+
65
+ Args:
66
+ record: The log record to format
67
+
68
+ Returns:
69
+ JSON string for Cloud Logging
70
+ """
71
+ from backend.services.tracing import get_current_trace_id, get_current_span_id
72
+
73
+ # Build base log entry
74
+ log_entry: Dict[str, Any] = {
75
+ "timestamp": datetime.utcfromtimestamp(record.created).isoformat() + "Z",
76
+ "severity": SEVERITY_MAP.get(record.levelname, record.levelname),
77
+ "message": record.getMessage(),
78
+ "logger": record.name,
79
+ }
80
+
81
+ # Add trace correlation if available
82
+ trace_id = get_current_trace_id()
83
+ span_id = get_current_span_id()
84
+
85
+ if trace_id and self.project_id:
86
+ # Format for Cloud Logging trace correlation
87
+ log_entry["logging.googleapis.com/trace"] = f"projects/{self.project_id}/traces/{trace_id}"
88
+
89
+ if span_id:
90
+ log_entry["logging.googleapis.com/spanId"] = span_id
91
+
92
+ # Add custom fields from 'extra' dict
93
+ # Common fields we want to extract from log records
94
+ custom_fields = [
95
+ # Job-related fields
96
+ "job_id", "worker", "operation", "duration", "status", "error",
97
+ # Audit logging fields (from middleware and auth)
98
+ "request_id", "user_email", "client_ip", "latency_ms",
99
+ "audit_type", "method", "path", "status_code", "query_string",
100
+ "user_agent", "user_type", "is_admin", "remaining_uses",
101
+ "auth_message", "token_provided", "token_length", "auth_header_present",
102
+ ]
103
+ for field in custom_fields:
104
+ value = getattr(record, field, None)
105
+ if value is not None:
106
+ log_entry[field] = value
107
+
108
+ # Add source location for debugging
109
+ if record.pathname and record.lineno:
110
+ log_entry["logging.googleapis.com/sourceLocation"] = {
111
+ "file": record.pathname,
112
+ "line": record.lineno,
113
+ "function": record.funcName,
114
+ }
115
+
116
+ # Add exception info if present
117
+ if record.exc_info:
118
+ log_entry["exception"] = self.formatException(record.exc_info)
119
+
120
+ # Remove None values for cleaner output
121
+ return json.dumps({k: v for k, v in log_entry.items() if v is not None})
122
+
123
+
124
+ class HumanReadableFormatter(logging.Formatter):
125
+ """
126
+ Human-readable formatter for local development.
127
+
128
+ Includes trace context when available but outputs in traditional format.
129
+ """
130
+
131
+ def format(self, record: logging.LogRecord) -> str:
132
+ """Format a log record for human readability."""
133
+ from backend.services.tracing import get_current_trace_id
134
+
135
+ # Build base message
136
+ timestamp = datetime.utcfromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
137
+
138
+ # Add job_id prefix if available
139
+ job_id = getattr(record, "job_id", None)
140
+ job_prefix = f"[job:{job_id[:8]}] " if job_id else ""
141
+
142
+ # Add trace ID suffix if available
143
+ trace_id = get_current_trace_id()
144
+ trace_suffix = f" [trace:{trace_id[:8]}]" if trace_id else ""
145
+
146
+ message = f"{timestamp} - {record.name} - {record.levelname} - {job_prefix}{record.getMessage()}{trace_suffix}"
147
+
148
+ # Add exception info if present
149
+ if record.exc_info:
150
+ message += "\n" + self.formatException(record.exc_info)
151
+
152
+ return message
153
+
154
+
155
+ def is_cloud_run() -> bool:
156
+ """Check if we're running in Cloud Run."""
157
+ return os.environ.get("K_SERVICE") is not None
158
+
159
+
160
+ def setup_structured_logging(force_json: bool = False, log_level: Optional[str] = None) -> None:
161
+ """
162
+ Configure structured logging for the application.
163
+
164
+ In Cloud Run: Uses JSON format with trace correlation for Cloud Logging
165
+ In development: Uses human-readable format with optional trace context
166
+
167
+ Args:
168
+ force_json: Force JSON output even in development
169
+ log_level: Override log level (default from settings or INFO)
170
+ """
171
+ from backend.config import settings
172
+
173
+ # Determine log level
174
+ level = log_level or getattr(settings, "log_level", "INFO")
175
+ level = getattr(logging, level.upper(), logging.INFO)
176
+
177
+ # Choose formatter based on environment
178
+ if is_cloud_run() or force_json:
179
+ formatter = StructuredFormatter()
180
+ else:
181
+ formatter = HumanReadableFormatter()
182
+
183
+ # Create handler
184
+ handler = logging.StreamHandler(sys.stdout)
185
+ handler.setFormatter(formatter)
186
+ handler.setLevel(level)
187
+
188
+ # Configure root logger
189
+ root_logger = logging.getLogger()
190
+ root_logger.setLevel(level)
191
+
192
+ # Remove existing handlers and add our handler
193
+ root_logger.handlers = [handler]
194
+
195
+ # Also configure uvicorn loggers to use our format
196
+ for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error"]:
197
+ logger = logging.getLogger(logger_name)
198
+ logger.handlers = [handler]
199
+ logger.propagate = False
200
+
201
+
202
+ class JobLogAdapter(logging.LoggerAdapter):
203
+ """
204
+ Logger adapter that automatically adds job context to log records.
205
+
206
+ Usage:
207
+ logger = logging.getLogger(__name__)
208
+ job_logger = JobLogAdapter(logger, job_id="abc123", worker="audio")
209
+ job_logger.info("Processing started") # Automatically includes job_id and worker
210
+ """
211
+
212
+ def __init__(self, logger: logging.Logger, job_id: str, worker: Optional[str] = None, **extra):
213
+ """
214
+ Initialize the adapter.
215
+
216
+ Args:
217
+ logger: Base logger instance
218
+ job_id: Job ID to include in all log records
219
+ worker: Optional worker name
220
+ **extra: Additional fields to include in all log records
221
+ """
222
+ super().__init__(logger, {"job_id": job_id, "worker": worker, **extra})
223
+
224
+ def process(self, msg: str, kwargs: Dict[str, Any]) -> tuple:
225
+ """Process a logging call to add extra context."""
226
+ # Merge extra dict from adapter with any extra passed to the log call
227
+ extra = kwargs.get("extra", {})
228
+ extra.update(self.extra)
229
+ kwargs["extra"] = extra
230
+ return msg, kwargs
231
+
232
+
233
+ def get_job_logger(job_id: str, worker: Optional[str] = None, name: Optional[str] = None) -> JobLogAdapter:
234
+ """
235
+ Get a logger adapter configured for a specific job.
236
+
237
+ This is a convenience function for creating job-specific loggers.
238
+
239
+ Args:
240
+ job_id: Job ID to include in all log records
241
+ worker: Optional worker name
242
+ name: Logger name (defaults to "job")
243
+
244
+ Returns:
245
+ JobLogAdapter configured for the job
246
+
247
+ Usage:
248
+ logger = get_job_logger("abc123", "audio")
249
+ logger.info("Starting audio separation")
250
+ logger.error("Failed to process", extra={"error": str(e)})
251
+ """
252
+ base_logger = logging.getLogger(name or "job")
253
+ return JobLogAdapter(base_logger, job_id=job_id, worker=worker)
254
+