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.
Files changed (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {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
- # Create checkout session
110
- session = stripe.checkout.Session.create(
111
- payment_method_types=['card'],
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='payment',
124
- success_url=success_url,
125
- cancel_url=cancel_url,
126
- customer_email=user_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=True,
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.