karaoke-gen 0.96.0__py3-none-any.whl → 0.101.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
"""
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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,
|
|
@@ -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))
|
backend/services/job_manager.py
CHANGED
|
@@ -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=
|
|
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,
|