karaoke-gen 0.96.0__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 (30) hide show
  1. backend/api/routes/admin.py +184 -91
  2. backend/api/routes/audio_search.py +16 -6
  3. backend/api/routes/file_upload.py +57 -21
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/jobs.py +19 -0
  6. backend/api/routes/users.py +543 -44
  7. backend/main.py +25 -1
  8. backend/services/encoding_service.py +128 -31
  9. backend/services/job_manager.py +12 -1
  10. backend/services/langfuse_preloader.py +98 -0
  11. backend/services/nltk_preloader.py +122 -0
  12. backend/services/spacy_preloader.py +65 -0
  13. backend/services/stripe_service.py +96 -0
  14. backend/tests/emulator/conftest.py +22 -1
  15. backend/tests/test_job_manager.py +25 -8
  16. backend/tests/test_jobs_api.py +11 -1
  17. backend/tests/test_spacy_preloader.py +119 -0
  18. backend/utils/test_data.py +27 -0
  19. backend/workers/screens_worker.py +16 -6
  20. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  21. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +30 -25
  22. lyrics_transcriber/correction/agentic/agent.py +17 -6
  23. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  24. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  25. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  26. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  27. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  28. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  29. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  30. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
backend/main.py CHANGED
@@ -10,6 +10,9 @@ from backend.config import settings
10
10
  from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin
11
11
  from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
12
12
  from backend.services.structured_logging import setup_structured_logging
13
+ from backend.services.spacy_preloader import preload_spacy_model
14
+ from backend.services.nltk_preloader import preload_all_nltk_resources
15
+ from backend.services.langfuse_preloader import preload_langfuse_handler
13
16
  from backend.middleware.audit_logging import AuditLoggingMiddleware
14
17
 
15
18
 
@@ -67,7 +70,28 @@ async def lifespan(app: FastAPI):
67
70
  logger.info(f"Environment: {settings.environment}")
68
71
  logger.info(f"GCS Bucket: {settings.gcs_bucket_name}")
69
72
  logger.info(f"Tracing enabled: {tracing_enabled}")
70
-
73
+
74
+ # Preload NLP models and resources to avoid cold start delays
75
+ # See docs/archive/2026-01-08-performance-investigation.md for background
76
+
77
+ # 1. SpaCy model (60+ second delay without preload)
78
+ try:
79
+ preload_spacy_model("en_core_web_sm")
80
+ except Exception as e:
81
+ logger.warning(f"SpaCy preload failed (will load lazily): {e}")
82
+
83
+ # 2. NLTK cmudict (50-100+ second delay without preload)
84
+ try:
85
+ preload_all_nltk_resources()
86
+ except Exception as e:
87
+ logger.warning(f"NLTK preload failed (will load lazily): {e}")
88
+
89
+ # 3. Langfuse callback handler (200+ second delay without preload)
90
+ try:
91
+ preload_langfuse_handler()
92
+ except Exception as e:
93
+ logger.warning(f"Langfuse preload failed (will initialize lazily): {e}")
94
+
71
95
  # Validate OAuth credentials (non-blocking)
72
96
  try:
73
97
  await validate_credentials_on_startup()
@@ -25,6 +25,11 @@ from backend.config import get_settings
25
25
 
26
26
  logger = logging.getLogger(__name__)
27
27
 
28
+ # Retry configuration for handling transient failures (e.g., worker restarts)
29
+ MAX_RETRIES = 3
30
+ INITIAL_BACKOFF_SECONDS = 2.0
31
+ MAX_BACKOFF_SECONDS = 10.0
32
+
28
33
 
29
34
  class EncodingService:
30
35
  """Service for dispatching encoding jobs to GCE worker."""
@@ -68,6 +73,81 @@ class EncodingService:
68
73
  """Check if GCE preview encoding is enabled and configured."""
69
74
  return self.settings.use_gce_preview_encoding and self.is_configured
70
75
 
76
+ async def _request_with_retry(
77
+ self,
78
+ method: str,
79
+ url: str,
80
+ headers: Dict[str, str],
81
+ json_payload: Optional[Dict[str, Any]] = None,
82
+ timeout: float = 30.0,
83
+ job_id: str = "unknown",
84
+ ) -> Dict[str, Any]:
85
+ """
86
+ Make an HTTP request with retry logic for transient failures.
87
+
88
+ This handles connection errors that occur when the GCE worker is
89
+ restarting (e.g., during deployments) by retrying with exponential backoff.
90
+
91
+ Args:
92
+ method: HTTP method (GET, POST)
93
+ url: Request URL
94
+ headers: Request headers
95
+ json_payload: JSON body for POST requests
96
+ timeout: Request timeout in seconds
97
+ job_id: Job ID for logging
98
+
99
+ Returns:
100
+ Dict with keys:
101
+ - status (int): HTTP status code
102
+ - json (Any): Parsed JSON response body (if status 200, else None)
103
+ - text (str): Raw response text (if status != 200, else None)
104
+
105
+ Raises:
106
+ aiohttp.ClientConnectorError: If all retries fail due to connection errors
107
+ aiohttp.ServerDisconnectedError: If all retries fail due to server disconnect
108
+ asyncio.TimeoutError: If all retries fail due to timeout
109
+ """
110
+ last_exception = None
111
+ backoff = INITIAL_BACKOFF_SECONDS
112
+
113
+ for attempt in range(MAX_RETRIES + 1):
114
+ try:
115
+ async with aiohttp.ClientSession() as session:
116
+ if method.upper() == "POST":
117
+ async with session.post(
118
+ url, json=json_payload, headers=headers, timeout=timeout
119
+ ) as resp:
120
+ # Return a copy of the response data since we exit the context
121
+ return {
122
+ "status": resp.status,
123
+ "json": await resp.json() if resp.status == 200 else None,
124
+ "text": await resp.text() if resp.status != 200 else None,
125
+ }
126
+ else: # GET
127
+ async with session.get(
128
+ url, headers=headers, timeout=timeout
129
+ ) as resp:
130
+ return {
131
+ "status": resp.status,
132
+ "json": await resp.json() if resp.status == 200 else None,
133
+ "text": await resp.text() if resp.status != 200 else None,
134
+ }
135
+ except (aiohttp.ClientConnectorError, aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
136
+ last_exception = e
137
+ if attempt < MAX_RETRIES:
138
+ logger.warning(
139
+ f"[job:{job_id}] GCE worker connection failed (attempt {attempt + 1}/{MAX_RETRIES + 1}): {e}. "
140
+ f"Retrying in {backoff:.1f}s..."
141
+ )
142
+ await asyncio.sleep(backoff)
143
+ backoff = min(backoff * 2, MAX_BACKOFF_SECONDS)
144
+ else:
145
+ logger.error(
146
+ f"[job:{job_id}] GCE worker connection failed after {MAX_RETRIES + 1} attempts: {e}"
147
+ )
148
+
149
+ raise last_exception
150
+
71
151
  async def submit_encoding_job(
72
152
  self,
73
153
  job_id: str,
@@ -106,17 +186,23 @@ class EncodingService:
106
186
 
107
187
  logger.info(f"[job:{job_id}] Submitting encoding job to GCE worker: {url}")
108
188
 
109
- async with aiohttp.ClientSession() as session:
110
- async with session.post(url, json=payload, headers=headers, timeout=30) as resp:
111
- if resp.status == 401:
112
- raise RuntimeError("Invalid API key for encoding worker")
113
- if resp.status == 409:
114
- raise RuntimeError(f"Encoding job {job_id} already exists")
115
- if resp.status != 200:
116
- text = await resp.text()
117
- raise RuntimeError(f"Failed to submit encoding job: {resp.status} - {text}")
189
+ resp = await self._request_with_retry(
190
+ method="POST",
191
+ url=url,
192
+ headers=headers,
193
+ json_payload=payload,
194
+ timeout=30.0,
195
+ job_id=job_id,
196
+ )
197
+
198
+ if resp["status"] == 401:
199
+ raise RuntimeError("Invalid API key for encoding worker")
200
+ if resp["status"] == 409:
201
+ raise RuntimeError(f"Encoding job {job_id} already exists")
202
+ if resp["status"] != 200:
203
+ raise RuntimeError(f"Failed to submit encoding job: {resp['status']} - {resp['text']}")
118
204
 
119
- return await resp.json()
205
+ return resp["json"]
120
206
 
121
207
  async def get_job_status(self, job_id: str) -> Dict[str, Any]:
122
208
  """
@@ -136,17 +222,22 @@ class EncodingService:
136
222
  url = f"{self._url}/status/{job_id}"
137
223
  headers = {"X-API-Key": self._api_key}
138
224
 
139
- async with aiohttp.ClientSession() as session:
140
- async with session.get(url, headers=headers, timeout=30) as resp:
141
- if resp.status == 401:
142
- raise RuntimeError("Invalid API key for encoding worker")
143
- if resp.status == 404:
144
- raise RuntimeError(f"Encoding job {job_id} not found")
145
- if resp.status != 200:
146
- text = await resp.text()
147
- raise RuntimeError(f"Failed to get job status: {resp.status} - {text}")
225
+ resp = await self._request_with_retry(
226
+ method="GET",
227
+ url=url,
228
+ headers=headers,
229
+ timeout=30.0,
230
+ job_id=job_id,
231
+ )
148
232
 
149
- return await resp.json()
233
+ if resp["status"] == 401:
234
+ raise RuntimeError("Invalid API key for encoding worker")
235
+ if resp["status"] == 404:
236
+ raise RuntimeError(f"Encoding job {job_id} not found")
237
+ if resp["status"] != 200:
238
+ raise RuntimeError(f"Failed to get job status: {resp['status']} - {resp['text']}")
239
+
240
+ return resp["json"]
150
241
 
151
242
  async def wait_for_completion(
152
243
  self,
@@ -296,17 +387,23 @@ class EncodingService:
296
387
 
297
388
  logger.info(f"[job:{job_id}] Submitting preview encoding job to GCE worker: {url}")
298
389
 
299
- async with aiohttp.ClientSession() as session:
300
- async with session.post(url, json=payload, headers=headers, timeout=30) as resp:
301
- if resp.status == 401:
302
- raise RuntimeError("Invalid API key for encoding worker")
303
- if resp.status == 409:
304
- raise RuntimeError(f"Preview encoding job {job_id} already exists")
305
- if resp.status != 200:
306
- text = await resp.text()
307
- raise RuntimeError(f"Failed to submit preview encoding job: {resp.status} - {text}")
308
-
309
- return await resp.json()
390
+ resp = await self._request_with_retry(
391
+ method="POST",
392
+ url=url,
393
+ headers=headers,
394
+ json_payload=payload,
395
+ timeout=30.0,
396
+ job_id=job_id,
397
+ )
398
+
399
+ if resp["status"] == 401:
400
+ raise RuntimeError("Invalid API key for encoding worker")
401
+ if resp["status"] == 409:
402
+ raise RuntimeError(f"Preview encoding job {job_id} already exists")
403
+ if resp["status"] != 200:
404
+ raise RuntimeError(f"Failed to submit preview encoding job: {resp['status']} - {resp['text']}")
405
+
406
+ return resp["json"]
310
407
 
311
408
  async def encode_preview_video(
312
409
  self,
@@ -34,10 +34,21 @@ class JobManager:
34
34
  def create_job(self, job_create: JobCreate) -> Job:
35
35
  """
36
36
  Create a new job with initial state PENDING.
37
-
37
+
38
38
  Jobs start in PENDING state and transition to DOWNLOADING
39
39
  when a worker picks them up.
40
+
41
+ Raises:
42
+ ValueError: If theme_id is not provided (all jobs require a theme)
40
43
  """
44
+ # Enforce theme requirement - all jobs must have a theme
45
+ # This prevents unstyled videos from ever being generated
46
+ if not job_create.theme_id:
47
+ raise ValueError(
48
+ "theme_id is required for all jobs. "
49
+ "Use get_theme_service().get_default_theme_id() to get the default theme."
50
+ )
51
+
41
52
  job_id = str(uuid.uuid4())[:8]
42
53
 
43
54
  now = datetime.utcnow()
@@ -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,13 @@ CREDIT_PACKAGES = {
46
46
  },
47
47
  }
48
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
+
49
56
 
50
57
  class StripeService:
51
58
  """Service for Stripe payment processing."""
@@ -143,6 +150,95 @@ class StripeService:
143
150
  logger.error(f"Error creating checkout session: {e}")
144
151
  return False, None, "Failed to create checkout session"
145
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
+
146
242
  def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
147
243
  """
148
244
  Verify a Stripe webhook signature.