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.
- backend/api/routes/admin.py +184 -91
- backend/api/routes/audio_search.py +16 -6
- backend/api/routes/file_upload.py +57 -21
- backend/api/routes/health.py +65 -0
- backend/api/routes/jobs.py +19 -0
- backend/api/routes/users.py +543 -44
- backend/main.py +25 -1
- backend/services/encoding_service.py +128 -31
- backend/services/job_manager.py +12 -1
- 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 +96 -0
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/test_job_manager.py +25 -8
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_spacy_preloader.py +119 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +30 -25
- 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
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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,
|
backend/services/job_manager.py
CHANGED
|
@@ -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.
|