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.
- 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 +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -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 +502 -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 +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -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/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -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 +109 -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 +356 -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 +283 -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_spacy_preloader.py +119 -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/utils/test_data.py +27 -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 +535 -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/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- 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.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {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
|
+
|