karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Langfuse callback handler preloader for container startup.
|
|
2
|
+
|
|
3
|
+
Initializes the Langfuse callback handler at container startup to avoid
|
|
4
|
+
slow initialization during request processing. The CallbackHandler()
|
|
5
|
+
constructor makes blocking network calls to the Langfuse API, which can
|
|
6
|
+
take 3+ minutes on Cloud Run cold starts.
|
|
7
|
+
|
|
8
|
+
See docs/archive/2026-01-08-performance-investigation.md for background.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from typing import Optional, Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Singleton storage for preloaded handler
|
|
19
|
+
_preloaded_handler: Optional[Any] = None
|
|
20
|
+
_initialization_attempted: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def preload_langfuse_handler() -> Optional[Any]:
|
|
24
|
+
"""Preload Langfuse callback handler at startup.
|
|
25
|
+
|
|
26
|
+
Only initializes if LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY
|
|
27
|
+
environment variables are set.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The preloaded CallbackHandler, or None if not configured
|
|
31
|
+
"""
|
|
32
|
+
global _preloaded_handler, _initialization_attempted
|
|
33
|
+
|
|
34
|
+
if _initialization_attempted:
|
|
35
|
+
logger.debug("Langfuse initialization already attempted")
|
|
36
|
+
return _preloaded_handler
|
|
37
|
+
|
|
38
|
+
_initialization_attempted = True
|
|
39
|
+
|
|
40
|
+
# Check if Langfuse is configured
|
|
41
|
+
public_key = os.getenv("LANGFUSE_PUBLIC_KEY")
|
|
42
|
+
secret_key = os.getenv("LANGFUSE_SECRET_KEY")
|
|
43
|
+
|
|
44
|
+
if not (public_key and secret_key):
|
|
45
|
+
logger.info("Langfuse not configured (missing keys), skipping preload")
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
logger.info("Preloading Langfuse callback handler...")
|
|
49
|
+
start_time = time.time()
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
from langfuse.langchain import CallbackHandler
|
|
53
|
+
|
|
54
|
+
# Initialize the handler - this is the slow part that makes network calls
|
|
55
|
+
_preloaded_handler = CallbackHandler()
|
|
56
|
+
|
|
57
|
+
elapsed = time.time() - start_time
|
|
58
|
+
host = os.getenv("LANGFUSE_HOST", "cloud.langfuse.com")
|
|
59
|
+
logger.info(f"Langfuse handler preloaded in {elapsed:.2f}s (host: {host})")
|
|
60
|
+
|
|
61
|
+
return _preloaded_handler
|
|
62
|
+
|
|
63
|
+
except ImportError:
|
|
64
|
+
logger.warning("langfuse package not installed, skipping preload")
|
|
65
|
+
return None
|
|
66
|
+
except Exception as e:
|
|
67
|
+
elapsed = time.time() - start_time
|
|
68
|
+
logger.error(f"Failed to preload Langfuse handler after {elapsed:.2f}s: {e}")
|
|
69
|
+
# Don't raise - Langfuse is optional
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_preloaded_langfuse_handler() -> Optional[Any]:
|
|
74
|
+
"""Get the preloaded Langfuse callback handler if available.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The preloaded CallbackHandler, or None if not preloaded/configured
|
|
78
|
+
"""
|
|
79
|
+
return _preloaded_handler
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_langfuse_preloaded() -> bool:
|
|
83
|
+
"""Check if Langfuse handler has been preloaded."""
|
|
84
|
+
return _preloaded_handler is not None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_langfuse_configured() -> bool:
|
|
88
|
+
"""Check if Langfuse environment variables are configured."""
|
|
89
|
+
return bool(
|
|
90
|
+
os.getenv("LANGFUSE_PUBLIC_KEY") and os.getenv("LANGFUSE_SECRET_KEY")
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def clear_preloaded_handler() -> None:
|
|
95
|
+
"""Clear preloaded handler. Useful for testing."""
|
|
96
|
+
global _preloaded_handler, _initialization_attempted
|
|
97
|
+
_preloaded_handler = None
|
|
98
|
+
_initialization_attempted = False
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""NLTK resource preloader for container startup.
|
|
2
|
+
|
|
3
|
+
Loads NLTK data at container startup to avoid slow downloads during request processing.
|
|
4
|
+
Cloud Run's ephemeral filesystem means NLTK data must be re-downloaded on each cold start,
|
|
5
|
+
which can take 30-100+ seconds for cmudict.
|
|
6
|
+
|
|
7
|
+
See docs/archive/2026-01-08-performance-investigation.md for background.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional, Dict, Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Singleton storage for preloaded resources
|
|
17
|
+
_preloaded_resources: Dict[str, Any] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def preload_nltk_cmudict() -> None:
|
|
21
|
+
"""Preload NLTK's CMU Pronouncing Dictionary at startup.
|
|
22
|
+
|
|
23
|
+
The cmudict is used by SyllablesMatchHandler for syllable counting.
|
|
24
|
+
Without preloading, each SyllablesMatchHandler init downloads ~30MB,
|
|
25
|
+
which took 50-100+ seconds in Cloud Run.
|
|
26
|
+
"""
|
|
27
|
+
global _preloaded_resources
|
|
28
|
+
|
|
29
|
+
if "cmudict" in _preloaded_resources:
|
|
30
|
+
logger.info("NLTK cmudict already preloaded")
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
logger.info("Preloading NLTK cmudict...")
|
|
34
|
+
start_time = time.time()
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import nltk
|
|
38
|
+
|
|
39
|
+
# Ensure the data is downloaded
|
|
40
|
+
try:
|
|
41
|
+
from nltk.corpus import cmudict
|
|
42
|
+
|
|
43
|
+
# Try to access it - will raise LookupError if not downloaded
|
|
44
|
+
_ = cmudict.dict()
|
|
45
|
+
except LookupError:
|
|
46
|
+
logger.info("Downloading NLTK cmudict data...")
|
|
47
|
+
nltk.download("cmudict", quiet=True)
|
|
48
|
+
from nltk.corpus import cmudict
|
|
49
|
+
|
|
50
|
+
# Load into memory
|
|
51
|
+
cmu_dict = cmudict.dict()
|
|
52
|
+
_preloaded_resources["cmudict"] = cmu_dict
|
|
53
|
+
|
|
54
|
+
elapsed = time.time() - start_time
|
|
55
|
+
logger.info(f"NLTK cmudict preloaded in {elapsed:.2f}s ({len(cmu_dict)} entries)")
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(f"Failed to preload NLTK cmudict: {e}")
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_preloaded_cmudict() -> Optional[Dict]:
|
|
63
|
+
"""Get the preloaded CMU dictionary if available.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
The preloaded cmudict dictionary, or None if not preloaded
|
|
67
|
+
"""
|
|
68
|
+
return _preloaded_resources.get("cmudict")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_cmudict_preloaded() -> bool:
|
|
72
|
+
"""Check if cmudict has been preloaded."""
|
|
73
|
+
return "cmudict" in _preloaded_resources
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def preload_nltk_punkt() -> None:
|
|
77
|
+
"""Preload NLTK's punkt tokenizer (optional, used for sentence tokenization)."""
|
|
78
|
+
global _preloaded_resources
|
|
79
|
+
|
|
80
|
+
if "punkt" in _preloaded_resources:
|
|
81
|
+
logger.info("NLTK punkt already preloaded")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
logger.info("Preloading NLTK punkt tokenizer...")
|
|
85
|
+
start_time = time.time()
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
import nltk
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
from nltk.tokenize import word_tokenize
|
|
92
|
+
|
|
93
|
+
# Test it works
|
|
94
|
+
_ = word_tokenize("test")
|
|
95
|
+
except LookupError:
|
|
96
|
+
logger.info("Downloading NLTK punkt data...")
|
|
97
|
+
nltk.download("punkt", quiet=True)
|
|
98
|
+
nltk.download("punkt_tab", quiet=True)
|
|
99
|
+
|
|
100
|
+
_preloaded_resources["punkt"] = True
|
|
101
|
+
|
|
102
|
+
elapsed = time.time() - start_time
|
|
103
|
+
logger.info(f"NLTK punkt preloaded in {elapsed:.2f}s")
|
|
104
|
+
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.warning(f"Failed to preload NLTK punkt (non-critical): {e}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def preload_all_nltk_resources() -> None:
|
|
110
|
+
"""Preload all NLTK resources used by the application."""
|
|
111
|
+
preload_nltk_cmudict()
|
|
112
|
+
# punkt is optional and less critical
|
|
113
|
+
try:
|
|
114
|
+
preload_nltk_punkt()
|
|
115
|
+
except Exception:
|
|
116
|
+
pass # Non-critical
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def clear_preloaded_resources() -> None:
|
|
120
|
+
"""Clear all preloaded resources. Useful for testing."""
|
|
121
|
+
global _preloaded_resources
|
|
122
|
+
_preloaded_resources.clear()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SpaCy model preloader for container startup.
|
|
2
|
+
|
|
3
|
+
Loads SpaCy models at container startup to avoid slow loading during request processing.
|
|
4
|
+
Cloud Run filesystem I/O can cause 60+ second delays when loading SpaCy models lazily.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Singleton storage for preloaded models
|
|
14
|
+
_preloaded_models: dict = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def preload_spacy_model(model_name: str = "en_core_web_sm") -> None:
|
|
18
|
+
"""Preload a SpaCy model at startup.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
model_name: The SpaCy model to load (default: en_core_web_sm)
|
|
22
|
+
"""
|
|
23
|
+
global _preloaded_models
|
|
24
|
+
|
|
25
|
+
if model_name in _preloaded_models:
|
|
26
|
+
logger.info(f"SpaCy model '{model_name}' already preloaded")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
logger.info(f"Preloading SpaCy model '{model_name}'...")
|
|
30
|
+
start_time = time.time()
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import spacy
|
|
34
|
+
|
|
35
|
+
nlp = spacy.load(model_name)
|
|
36
|
+
_preloaded_models[model_name] = nlp
|
|
37
|
+
|
|
38
|
+
elapsed = time.time() - start_time
|
|
39
|
+
logger.info(f"SpaCy model '{model_name}' preloaded in {elapsed:.2f}s")
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.error(f"Failed to preload SpaCy model '{model_name}': {e}")
|
|
42
|
+
raise
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_preloaded_model(model_name: str = "en_core_web_sm") -> Optional[object]:
|
|
46
|
+
"""Get a preloaded SpaCy model if available.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
model_name: The SpaCy model name
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The preloaded SpaCy Language object, or None if not preloaded
|
|
53
|
+
"""
|
|
54
|
+
return _preloaded_models.get(model_name)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_model_preloaded(model_name: str = "en_core_web_sm") -> bool:
|
|
58
|
+
"""Check if a SpaCy model has been preloaded."""
|
|
59
|
+
return model_name in _preloaded_models
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def clear_preloaded_models() -> None:
|
|
63
|
+
"""Clear all preloaded models. Useful for testing."""
|
|
64
|
+
global _preloaded_models
|
|
65
|
+
_preloaded_models.clear()
|
|
@@ -46,6 +46,14 @@ CREDIT_PACKAGES = {
|
|
|
46
46
|
},
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
# Made-for-you service package (not a credit package - creates a job directly)
|
|
50
|
+
MADE_FOR_YOU_PACKAGE = {
|
|
51
|
+
"price_cents": 1500, # $15.00
|
|
52
|
+
"name": "Made For You Karaoke Video",
|
|
53
|
+
"description": "Professional 4K karaoke video with perfectly synced lyrics, delivered to your email within 24 hours",
|
|
54
|
+
"images": ["https://gen.nomadkaraoke.com/nomad-logo.png"],
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
|
|
50
58
|
class StripeService:
|
|
51
59
|
"""Service for Stripe payment processing."""
|
|
@@ -58,6 +66,10 @@ class StripeService:
|
|
|
58
66
|
self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
|
|
59
67
|
# After consolidation, buy URL is the same as frontend URL
|
|
60
68
|
self.buy_url = os.getenv("BUY_URL", self.frontend_url)
|
|
69
|
+
# Payment method configuration ID from Stripe Dashboard
|
|
70
|
+
# Enables Google Pay, Apple Pay, Link, and other wallet methods
|
|
71
|
+
# Get this from: Dashboard > Settings > Payment methods > [Your Config] > Copy ID
|
|
72
|
+
self.payment_method_config = os.getenv("STRIPE_PAYMENT_METHOD_CONFIG")
|
|
61
73
|
|
|
62
74
|
if self.secret_key:
|
|
63
75
|
stripe.api_key = self.secret_key
|
|
@@ -65,6 +77,11 @@ class StripeService:
|
|
|
65
77
|
else:
|
|
66
78
|
logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
|
|
67
79
|
|
|
80
|
+
if self.payment_method_config:
|
|
81
|
+
logger.info(f"Using payment method config: {self.payment_method_config}")
|
|
82
|
+
else:
|
|
83
|
+
logger.info("No STRIPE_PAYMENT_METHOD_CONFIG set - using Stripe defaults")
|
|
84
|
+
|
|
68
85
|
def is_configured(self) -> bool:
|
|
69
86
|
"""Check if Stripe is properly configured."""
|
|
70
87
|
return bool(self.secret_key)
|
|
@@ -106,32 +123,40 @@ class StripeService:
|
|
|
106
123
|
if not cancel_url:
|
|
107
124
|
cancel_url = f"{self.buy_url}?cancelled=true"
|
|
108
125
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
payment_method_types
|
|
112
|
-
line_items
|
|
126
|
+
# Build checkout session params
|
|
127
|
+
session_params = {
|
|
128
|
+
# Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
|
|
129
|
+
'line_items': [{
|
|
113
130
|
'price_data': {
|
|
114
131
|
'currency': 'usd',
|
|
115
132
|
'product_data': {
|
|
116
133
|
'name': package['name'],
|
|
117
134
|
'description': package['description'],
|
|
135
|
+
'images': ['https://gen.nomadkaraoke.com/nomad-logo.png'],
|
|
118
136
|
},
|
|
119
137
|
'unit_amount': package['price_cents'],
|
|
120
138
|
},
|
|
121
139
|
'quantity': 1,
|
|
122
140
|
}],
|
|
123
|
-
mode
|
|
124
|
-
success_url
|
|
125
|
-
cancel_url
|
|
126
|
-
customer_email
|
|
127
|
-
metadata
|
|
141
|
+
'mode': 'payment',
|
|
142
|
+
'success_url': success_url,
|
|
143
|
+
'cancel_url': cancel_url,
|
|
144
|
+
'customer_email': user_email,
|
|
145
|
+
'metadata': {
|
|
128
146
|
'package_id': package_id,
|
|
129
147
|
'credits': str(package['credits']),
|
|
130
148
|
'user_email': user_email,
|
|
131
149
|
},
|
|
132
150
|
# Allow promotion codes
|
|
133
|
-
allow_promotion_codes
|
|
134
|
-
|
|
151
|
+
'allow_promotion_codes': True,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Add payment method configuration if set (enables Google Pay, Link, etc.)
|
|
155
|
+
if self.payment_method_config:
|
|
156
|
+
session_params['payment_method_configuration'] = self.payment_method_config
|
|
157
|
+
|
|
158
|
+
# Create checkout session
|
|
159
|
+
session = stripe.checkout.Session.create(**session_params)
|
|
135
160
|
|
|
136
161
|
logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
|
|
137
162
|
return True, session.url, "Checkout session created"
|
|
@@ -143,6 +168,103 @@ class StripeService:
|
|
|
143
168
|
logger.error(f"Error creating checkout session: {e}")
|
|
144
169
|
return False, None, "Failed to create checkout session"
|
|
145
170
|
|
|
171
|
+
def create_made_for_you_checkout_session(
|
|
172
|
+
self,
|
|
173
|
+
customer_email: str,
|
|
174
|
+
artist: str,
|
|
175
|
+
title: str,
|
|
176
|
+
source_type: str = "search",
|
|
177
|
+
youtube_url: Optional[str] = None,
|
|
178
|
+
notes: Optional[str] = None,
|
|
179
|
+
success_url: Optional[str] = None,
|
|
180
|
+
cancel_url: Optional[str] = None,
|
|
181
|
+
) -> Tuple[bool, Optional[str], str]:
|
|
182
|
+
"""
|
|
183
|
+
Create a Stripe Checkout session for a made-for-you order.
|
|
184
|
+
|
|
185
|
+
This is for the full-service karaoke video creation where Nomad Karaoke
|
|
186
|
+
handles all the work (lyrics review, instrumental selection, etc.).
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
customer_email: Customer's email for delivery
|
|
190
|
+
artist: Song artist
|
|
191
|
+
title: Song title
|
|
192
|
+
source_type: Audio source type (search, youtube, upload)
|
|
193
|
+
youtube_url: YouTube URL if source_type is youtube
|
|
194
|
+
notes: Any special requests from customer
|
|
195
|
+
success_url: URL to redirect to on success (optional)
|
|
196
|
+
cancel_url: URL to redirect to on cancel (optional)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
(success, checkout_url, message)
|
|
200
|
+
"""
|
|
201
|
+
if not self.is_configured():
|
|
202
|
+
return False, None, "Payment processing is not configured"
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
# Default URLs - redirect to homepage success page
|
|
206
|
+
if not success_url:
|
|
207
|
+
success_url = "https://nomadkaraoke.com/order/success/?session_id={CHECKOUT_SESSION_ID}"
|
|
208
|
+
if not cancel_url:
|
|
209
|
+
cancel_url = "https://nomadkaraoke.com/#do-it-for-me"
|
|
210
|
+
|
|
211
|
+
# Build metadata for job creation after payment
|
|
212
|
+
metadata = {
|
|
213
|
+
'order_type': 'made_for_you',
|
|
214
|
+
'customer_email': customer_email,
|
|
215
|
+
'artist': artist,
|
|
216
|
+
'title': title,
|
|
217
|
+
'source_type': source_type,
|
|
218
|
+
}
|
|
219
|
+
if youtube_url:
|
|
220
|
+
metadata['youtube_url'] = youtube_url
|
|
221
|
+
if notes:
|
|
222
|
+
# Truncate notes to fit Stripe's 500 char limit per metadata value
|
|
223
|
+
metadata['notes'] = notes[:500] if len(notes) > 500 else notes
|
|
224
|
+
|
|
225
|
+
# Build checkout session params
|
|
226
|
+
session_params = {
|
|
227
|
+
# Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
|
|
228
|
+
'line_items': [{
|
|
229
|
+
'price_data': {
|
|
230
|
+
'currency': 'usd',
|
|
231
|
+
'product_data': {
|
|
232
|
+
'name': f"Karaoke Video: {artist} - {title}",
|
|
233
|
+
'description': MADE_FOR_YOU_PACKAGE['description'],
|
|
234
|
+
'images': MADE_FOR_YOU_PACKAGE['images'],
|
|
235
|
+
},
|
|
236
|
+
'unit_amount': MADE_FOR_YOU_PACKAGE['price_cents'],
|
|
237
|
+
},
|
|
238
|
+
'quantity': 1,
|
|
239
|
+
}],
|
|
240
|
+
'mode': 'payment',
|
|
241
|
+
'success_url': success_url,
|
|
242
|
+
'cancel_url': cancel_url,
|
|
243
|
+
'customer_email': customer_email,
|
|
244
|
+
'metadata': metadata,
|
|
245
|
+
'allow_promotion_codes': True,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Add payment method configuration if set (enables Google Pay, Link, etc.)
|
|
249
|
+
if self.payment_method_config:
|
|
250
|
+
session_params['payment_method_configuration'] = self.payment_method_config
|
|
251
|
+
|
|
252
|
+
# Create checkout session
|
|
253
|
+
session = stripe.checkout.Session.create(**session_params)
|
|
254
|
+
|
|
255
|
+
logger.info(
|
|
256
|
+
f"Created made-for-you checkout session {session.id} for {customer_email}, "
|
|
257
|
+
f"song: {artist} - {title}"
|
|
258
|
+
)
|
|
259
|
+
return True, session.url, "Checkout session created"
|
|
260
|
+
|
|
261
|
+
except stripe.error.StripeError as e:
|
|
262
|
+
logger.error(f"Stripe error creating made-for-you checkout session: {e}")
|
|
263
|
+
return False, None, f"Payment error: {str(e)}"
|
|
264
|
+
except Exception as e:
|
|
265
|
+
logger.error(f"Error creating made-for-you checkout session: {e}")
|
|
266
|
+
return False, None, "Failed to create checkout session"
|
|
267
|
+
|
|
146
268
|
def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
|
|
147
269
|
"""
|
|
148
270
|
Verify a Stripe webhook signature.
|