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
@@ -14,7 +14,9 @@ import html
14
14
  import logging
15
15
  import os
16
16
  from abc import ABC, abstractmethod
17
+ from datetime import datetime, timedelta, timezone
17
18
  from typing import Optional, List
19
+ from zoneinfo import ZoneInfo
18
20
 
19
21
  from backend.config import get_settings
20
22
  from karaoke_gen.utils import sanitize_filename
@@ -34,8 +36,21 @@ class EmailProvider(ABC):
34
36
  html_content: str,
35
37
  text_content: Optional[str] = None,
36
38
  cc_emails: Optional[List[str]] = None,
39
+ bcc_emails: Optional[List[str]] = None,
40
+ from_email_override: Optional[str] = None,
37
41
  ) -> bool:
38
- """Send an email. Returns True if successful."""
42
+ """
43
+ Send an email. Returns True if successful.
44
+
45
+ Args:
46
+ to_email: Recipient email address
47
+ subject: Email subject
48
+ html_content: HTML content
49
+ text_content: Plain text content (optional)
50
+ cc_emails: CC recipients (optional)
51
+ bcc_emails: BCC recipients (optional)
52
+ from_email_override: Override the default sender email (optional, for multi-tenant)
53
+ """
39
54
  pass
40
55
 
41
56
 
@@ -53,11 +68,17 @@ class ConsoleEmailProvider(EmailProvider):
53
68
  html_content: str,
54
69
  text_content: Optional[str] = None,
55
70
  cc_emails: Optional[List[str]] = None,
71
+ bcc_emails: Optional[List[str]] = None,
72
+ from_email_override: Optional[str] = None,
56
73
  ) -> bool:
57
74
  logger.info("=" * 60)
75
+ if from_email_override:
76
+ logger.info(f"FROM: {from_email_override}")
58
77
  logger.info(f"EMAIL TO: {to_email}")
59
78
  if cc_emails:
60
79
  logger.info(f"CC: {', '.join(cc_emails)}")
80
+ if bcc_emails:
81
+ logger.info(f"BCC: {', '.join(bcc_emails)}")
61
82
  logger.info(f"SUBJECT: {subject}")
62
83
  logger.info("-" * 60)
63
84
  logger.info(text_content or html_content)
@@ -85,6 +106,8 @@ class PreviewEmailProvider(EmailProvider):
85
106
  html_content: str,
86
107
  text_content: Optional[str] = None,
87
108
  cc_emails: Optional[List[str]] = None,
109
+ bcc_emails: Optional[List[str]] = None,
110
+ from_email_override: Optional[str] = None,
88
111
  ) -> bool:
89
112
  self.last_to_email = to_email
90
113
  self.last_subject = subject
@@ -115,16 +138,21 @@ class SendGridEmailProvider(EmailProvider):
115
138
  html_content: str,
116
139
  text_content: Optional[str] = None,
117
140
  cc_emails: Optional[List[str]] = None,
141
+ bcc_emails: Optional[List[str]] = None,
142
+ from_email_override: Optional[str] = None,
118
143
  ) -> bool:
119
144
  try:
120
145
  # Import here to avoid requiring sendgrid in all environments
121
146
  from sendgrid import SendGridAPIClient
122
- from sendgrid.helpers.mail import Mail, Email, To, Content, Cc
147
+ from sendgrid.helpers.mail import Mail, Email, To, Content, Cc, Bcc
123
148
 
124
149
  sg = SendGridAPIClient(api_key=self.api_key)
125
150
 
151
+ # Use override sender if provided (for multi-tenant)
152
+ sender_email = from_email_override or self.from_email
153
+
126
154
  message = Mail(
127
- from_email=Email(self.from_email, self.from_name),
155
+ from_email=Email(sender_email, self.from_name),
128
156
  to_emails=To(to_email),
129
157
  subject=subject,
130
158
  html_content=Content("text/html", html_content)
@@ -144,6 +172,19 @@ class SendGridEmailProvider(EmailProvider):
144
172
  for cc_email in unique_cc_emails:
145
173
  message.add_cc(Cc(cc_email))
146
174
 
175
+ # Add BCC recipients if provided (deduplicated and normalized)
176
+ if bcc_emails:
177
+ seen = set()
178
+ unique_bcc_emails = []
179
+ for bcc_email in bcc_emails:
180
+ normalized = bcc_email.strip().lower()
181
+ if normalized and normalized not in seen:
182
+ seen.add(normalized)
183
+ unique_bcc_emails.append(bcc_email.strip())
184
+
185
+ for bcc_email in unique_bcc_emails:
186
+ message.add_bcc(Bcc(bcc_email))
187
+
147
188
  if text_content:
148
189
  message.add_content(Content("text/plain", text_content))
149
190
 
@@ -151,7 +192,8 @@ class SendGridEmailProvider(EmailProvider):
151
192
 
152
193
  if response.status_code >= 200 and response.status_code < 300:
153
194
  cc_info = f" (CC: {', '.join(cc_emails)})" if cc_emails else ""
154
- logger.info(f"Email sent to {to_email}{cc_info} via SendGrid")
195
+ bcc_info = f" (BCC: {', '.join(bcc_emails)})" if bcc_emails else ""
196
+ logger.info(f"Email sent to {to_email}{cc_info}{bcc_info} via SendGrid")
155
197
  return True
156
198
  else:
157
199
  logger.error(f"SendGrid returned status {response.status_code}")
@@ -200,13 +242,19 @@ class EmailService:
200
242
  """Check if a real email provider is configured (not just console logging)."""
201
243
  return isinstance(self.provider, SendGridEmailProvider)
202
244
 
203
- def send_magic_link(self, email: str, token: str) -> bool:
245
+ def send_magic_link(
246
+ self,
247
+ email: str,
248
+ token: str,
249
+ sender_email: Optional[str] = None,
250
+ ) -> bool:
204
251
  """
205
252
  Send a magic link email for authentication.
206
253
 
207
254
  Args:
208
255
  email: User's email address
209
256
  token: Magic link token
257
+ sender_email: Override sender email address (for multi-tenant)
210
258
 
211
259
  Returns:
212
260
  True if email was sent successfully
@@ -264,7 +312,9 @@ If you didn't request this email, you can safely ignore it.
264
312
  © {self._get_year()} Nomad Karaoke
265
313
  """
266
314
 
267
- return self.provider.send_email(email, subject, html_content, text_content)
315
+ return self.provider.send_email(
316
+ email, subject, html_content, text_content, from_email_override=sender_email
317
+ )
268
318
 
269
319
  def send_credits_added(self, email: str, credits: int, total_credits: int) -> bool:
270
320
  """
@@ -1017,6 +1067,7 @@ Thanks for being part of making Nomad Karaoke better!
1017
1067
  html_content=html_content,
1018
1068
  text_content=message_content,
1019
1069
  cc_emails=cc_emails,
1070
+ bcc_emails=["done@nomadkaraoke.com"],
1020
1071
  )
1021
1072
 
1022
1073
  def send_action_reminder(
@@ -1080,6 +1131,202 @@ Thanks for being part of making Nomad Karaoke better!
1080
1131
  text_content=message_content,
1081
1132
  )
1082
1133
 
1134
+ def send_made_for_you_order_confirmation(
1135
+ self,
1136
+ to_email: str,
1137
+ artist: str,
1138
+ title: str,
1139
+ job_id: str,
1140
+ notes: Optional[str] = None,
1141
+ ) -> bool:
1142
+ """
1143
+ Send order confirmation email to customer for made-for-you orders.
1144
+
1145
+ Args:
1146
+ to_email: Customer's email address
1147
+ artist: Artist name
1148
+ title: Song title
1149
+ job_id: Order/job ID for reference
1150
+ notes: Optional customer notes
1151
+
1152
+ Returns:
1153
+ True if email was sent successfully
1154
+ """
1155
+ subject = f"Order Confirmed: {artist} - {title} | Nomad Karaoke"
1156
+
1157
+ extra_styles = """
1158
+ .order-details {
1159
+ background-color: #f8fafc;
1160
+ border-radius: 8px;
1161
+ padding: 20px;
1162
+ margin: 20px 0;
1163
+ }
1164
+ .order-details h3 {
1165
+ margin-top: 0;
1166
+ color: #1e293b;
1167
+ }
1168
+ .order-details p {
1169
+ margin: 8px 0;
1170
+ color: #475569;
1171
+ }
1172
+ .highlight {
1173
+ color: #059669;
1174
+ font-weight: bold;
1175
+ }
1176
+ """
1177
+
1178
+ notes_html = ""
1179
+ notes_text = ""
1180
+ if notes:
1181
+ notes_html = f"<p><strong>Your Notes:</strong> {html.escape(notes)}</p>"
1182
+ notes_text = f"\nYour Notes: {notes}"
1183
+
1184
+ content = f"""
1185
+ <h2>Thank You for Your Order!</h2>
1186
+
1187
+ <div class="order-details">
1188
+ <h3>Order Details</h3>
1189
+ <p><strong>Order ID:</strong> {html.escape(job_id)}</p>
1190
+ <p><strong>Artist:</strong> {html.escape(artist)}</p>
1191
+ <p><strong>Title:</strong> {html.escape(title)}</p>
1192
+ {notes_html}
1193
+ </div>
1194
+
1195
+ <p>Our team will create your custom karaoke video within <span class="highlight">24 hours</span>.</p>
1196
+
1197
+ <p>You'll receive an email with download links as soon as your video is ready.</p>
1198
+
1199
+ <p><strong>No action needed</strong> - sit back and we'll take care of everything!</p>
1200
+ """
1201
+
1202
+ text_content = f"""Thank You for Your Order!
1203
+
1204
+ Order Details:
1205
+ Order ID: {job_id}
1206
+ Artist: {artist}
1207
+ Title: {title}{notes_text}
1208
+
1209
+ Our team will create your custom karaoke video within 24 hours.
1210
+
1211
+ You'll receive an email with download links as soon as your video is ready.
1212
+
1213
+ No action needed - sit back and we'll take care of everything!
1214
+ """
1215
+
1216
+ html_content = self._build_email_html(content, extra_styles)
1217
+
1218
+ return self.provider.send_email(
1219
+ to_email=to_email,
1220
+ subject=subject,
1221
+ html_content=html_content,
1222
+ text_content=text_content,
1223
+ bcc_emails=["done@nomadkaraoke.com"],
1224
+ )
1225
+
1226
+ def send_made_for_you_admin_notification(
1227
+ self,
1228
+ to_email: str,
1229
+ customer_email: str,
1230
+ artist: str,
1231
+ title: str,
1232
+ job_id: str,
1233
+ admin_login_token: str,
1234
+ notes: Optional[str] = None,
1235
+ audio_source_count: int = 0,
1236
+ ) -> bool:
1237
+ """
1238
+ Send notification email to admin for new made-for-you orders.
1239
+
1240
+ Args:
1241
+ to_email: Admin email address
1242
+ customer_email: Customer's email address
1243
+ artist: Artist name
1244
+ title: Song title
1245
+ job_id: Job ID for reference
1246
+ admin_login_token: Token for one-click admin login
1247
+ notes: Optional customer notes
1248
+ audio_source_count: Number of audio sources found
1249
+
1250
+ Returns:
1251
+ True if email was sent successfully
1252
+ """
1253
+ subject = f"Karaoke Order: {artist} - {title} [ID: {job_id}]"
1254
+
1255
+ extra_styles = """
1256
+ .order-info {
1257
+ background-color: #f8fafc;
1258
+ border-radius: 8px;
1259
+ padding: 20px;
1260
+ margin: 20px 0;
1261
+ }
1262
+ .order-info p {
1263
+ margin: 8px 0;
1264
+ }
1265
+ .action-button {
1266
+ display: inline-block;
1267
+ background-color: #2563eb;
1268
+ color: white !important;
1269
+ padding: 12px 24px;
1270
+ border-radius: 6px;
1271
+ text-decoration: none;
1272
+ font-weight: bold;
1273
+ margin-top: 16px;
1274
+ }
1275
+ """
1276
+
1277
+ notes_html = ""
1278
+ notes_text = ""
1279
+ if notes:
1280
+ notes_html = f"<p><strong>Customer Notes:</strong> {html.escape(notes)}</p>"
1281
+ notes_text = f"\nCustomer Notes: {notes}"
1282
+
1283
+ # Calculate deadline (24 hours from now, converted to Eastern Time)
1284
+ deadline_utc = datetime.now(timezone.utc) + timedelta(hours=24)
1285
+ eastern_tz = ZoneInfo("America/New_York")
1286
+ deadline_eastern = deadline_utc.astimezone(eastern_tz)
1287
+ # Use "ET" (Eastern Time) to be correct for both EST and EDT
1288
+ deadline_str = deadline_eastern.strftime("%B %d, %Y at %I:%M %p") + " ET"
1289
+
1290
+ # Link to /app/ with admin login token for one-click access
1291
+ app_url = f"{self.frontend_url.rstrip('/')}/app/?admin_token={admin_login_token}"
1292
+
1293
+ content = f"""
1294
+ <div class="order-info">
1295
+ <p><strong>Order / Job ID:</strong> {html.escape(job_id)}</p>
1296
+ <p><strong>Customer:</strong> {html.escape(customer_email)}</p>
1297
+ <p><strong>Artist:</strong> {html.escape(artist)}</p>
1298
+ <p><strong>Title:</strong> {html.escape(title)}</p>
1299
+ <p><strong>Audio Sources Found:</strong> {audio_source_count}</p>
1300
+ {notes_html}
1301
+ <p><strong>Deliver By:</strong> {deadline_str}</p>
1302
+ <p style="margin-top: 16px;"><strong>Action Required:</strong> Select an audio source to start processing.</p>
1303
+ <a href="{app_url}" class="action-button">Open Job</a>
1304
+ </div>
1305
+ """
1306
+
1307
+ text_content = f"""New Karaoke Order
1308
+
1309
+ Order / Job ID: {job_id}
1310
+ Customer: {customer_email}
1311
+ Artist: {artist}
1312
+ Title: {title}
1313
+ Audio Sources Found: {audio_source_count}{notes_text}
1314
+ Deliver By: {deadline_str}
1315
+
1316
+ Action Required: Select an audio source to start processing.
1317
+
1318
+ Open Job: {app_url}
1319
+ """
1320
+
1321
+ html_content = self._build_email_html(content, extra_styles)
1322
+
1323
+ return self.provider.send_email(
1324
+ to_email=to_email,
1325
+ subject=subject,
1326
+ html_content=html_content,
1327
+ text_content=text_content,
1328
+ )
1329
+
1083
1330
 
1084
1331
  # Global instance
1085
1332
  _email_service = None
@@ -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,
@@ -116,6 +116,7 @@ class FirestoreService:
116
116
  created_after: Optional[datetime] = None,
117
117
  created_before: Optional[datetime] = None,
118
118
  user_email: Optional[str] = None,
119
+ tenant_id: Optional[str] = None,
119
120
  limit: int = 100
120
121
  ) -> List[Job]:
121
122
  """
@@ -128,6 +129,7 @@ class FirestoreService:
128
129
  created_after: Filter jobs created after this datetime
129
130
  created_before: Filter jobs created before this datetime
130
131
  user_email: Filter by user_email (owner of the job)
132
+ tenant_id: Filter by tenant_id (white-label portal scoping)
131
133
  limit: Maximum number of jobs to return
132
134
 
133
135
  Returns:
@@ -150,6 +152,10 @@ class FirestoreService:
150
152
  if user_email:
151
153
  query = query.where(filter=FieldFilter('user_email', '==', user_email.lower()))
152
154
 
155
+ # Filter by tenant_id (white-label portal scoping)
156
+ if tenant_id:
157
+ query = query.where(filter=FieldFilter('tenant_id', '==', tenant_id))
158
+
153
159
  # Date range filters
154
160
  if created_after:
155
161
  query = query.where(filter=FieldFilter('created_at', '>=', created_after))
@@ -23,6 +23,16 @@ from backend.services.storage_service import StorageService
23
23
  logger = logging.getLogger(__name__)
24
24
 
25
25
 
26
+ def _mask_email(email: str) -> str:
27
+ """Mask email for logging to protect PII. Shows first char + domain."""
28
+ if not email or "@" not in email:
29
+ return "***"
30
+ local, domain = email.split("@", 1)
31
+ if len(local) <= 1:
32
+ return f"*@{domain}"
33
+ return f"{local[0]}***@{domain}"
34
+
35
+
26
36
  class JobManager:
27
37
  """Manager for job lifecycle and state."""
28
38
 
@@ -34,10 +44,21 @@ class JobManager:
34
44
  def create_job(self, job_create: JobCreate) -> Job:
35
45
  """
36
46
  Create a new job with initial state PENDING.
37
-
47
+
38
48
  Jobs start in PENDING state and transition to DOWNLOADING
39
49
  when a worker picks them up.
50
+
51
+ Raises:
52
+ ValueError: If theme_id is not provided (all jobs require a theme)
40
53
  """
54
+ # Enforce theme requirement - all jobs must have a theme
55
+ # This prevents unstyled videos from ever being generated
56
+ if not job_create.theme_id:
57
+ raise ValueError(
58
+ "theme_id is required for all jobs. "
59
+ "Use get_theme_service().get_default_theme_id() to get the default theme."
60
+ )
61
+
41
62
  job_id = str(uuid.uuid4())[:8]
42
63
 
43
64
  now = datetime.utcnow()
@@ -74,6 +95,12 @@ class JobManager:
74
95
  keep_brand_code=job_create.keep_brand_code,
75
96
  # Request metadata (for tracking and filtering)
76
97
  request_metadata=job_create.request_metadata,
98
+ # Tenant scoping
99
+ tenant_id=job_create.tenant_id,
100
+ # Made-for-you order fields
101
+ made_for_you=job_create.made_for_you,
102
+ customer_email=job_create.customer_email,
103
+ customer_notes=job_create.customer_notes,
77
104
  )
78
105
 
79
106
  self.firestore.create_job(job)
@@ -114,6 +141,7 @@ class JobManager:
114
141
  created_after: Optional[datetime] = None,
115
142
  created_before: Optional[datetime] = None,
116
143
  user_email: Optional[str] = None,
144
+ tenant_id: Optional[str] = None,
117
145
  limit: int = 100
118
146
  ) -> List[Job]:
119
147
  """
@@ -126,6 +154,7 @@ class JobManager:
126
154
  created_after: Filter jobs created after this datetime
127
155
  created_before: Filter jobs created before this datetime
128
156
  user_email: Filter by user_email (job owner)
157
+ tenant_id: Filter by tenant_id (white-label portal scoping)
129
158
  limit: Maximum number of jobs to return
130
159
 
131
160
  Returns:
@@ -138,6 +167,7 @@ class JobManager:
138
167
  created_after=created_after,
139
168
  created_before=created_before,
140
169
  user_email=user_email,
170
+ tenant_id=tenant_id,
141
171
  limit=limit
142
172
  )
143
173
 
@@ -402,6 +432,7 @@ class JobManager:
402
432
  """
403
433
  Schedule sending a job completion email.
404
434
 
435
+ For made-for-you orders, also transfers ownership from admin to customer.
405
436
  Uses asyncio to fire-and-forget the email sending.
406
437
  """
407
438
  import asyncio
@@ -418,11 +449,22 @@ class JobManager:
418
449
  dropbox_url = state_data.get('dropbox_link')
419
450
  brand_code = state_data.get('brand_code')
420
451
 
452
+ # For made-for-you orders, send to customer and transfer ownership
453
+ recipient_email = job.user_email
454
+ if job.made_for_you and job.customer_email:
455
+ recipient_email = job.customer_email
456
+ # Transfer ownership from admin to customer (non-blocking - email still goes out if this fails)
457
+ try:
458
+ self.update_job(job.job_id, {'user_email': job.customer_email})
459
+ logger.info(f"Transferred ownership of made-for-you job {job.job_id} to {_mask_email(job.customer_email)}")
460
+ except Exception as e:
461
+ logger.error(f"Failed to transfer ownership for job {job.job_id}: {e}")
462
+
421
463
  # Create async task (fire-and-forget)
422
464
  async def send_email():
423
465
  await notification_service.send_job_completion_email(
424
466
  job_id=job.job_id,
425
- user_email=job.user_email,
467
+ user_email=recipient_email,
426
468
  user_name=None, # Could fetch from user service if needed
427
469
  artist=job.artist,
428
470
  title=job.title,