karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__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 +512 -1
- backend/api/routes/audio_search.py +17 -34
- backend/api/routes/file_upload.py +60 -84
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +11 -3
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +229 -247
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +30 -1
- 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/email_validation_service.py +646 -0
- backend/services/firestore_service.py +27 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +73 -3
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/conftest.py +7 -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_audio_search.py +12 -8
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +146 -1
- backend/tests/test_made_for_you.py +2088 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -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/workers/video_worker.py +8 -3
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
backend/api/routes/users.py
CHANGED
|
@@ -8,8 +8,28 @@ Handles:
|
|
|
8
8
|
- Stripe checkout and webhooks
|
|
9
9
|
- Admin user management
|
|
10
10
|
"""
|
|
11
|
+
import hashlib
|
|
11
12
|
import logging
|
|
12
13
|
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _mask_email(email: str) -> str:
|
|
17
|
+
"""Mask email for logging to avoid PII exposure.
|
|
18
|
+
|
|
19
|
+
Example: test@example.com -> te***@ex***.com
|
|
20
|
+
"""
|
|
21
|
+
if not email or "@" not in email:
|
|
22
|
+
return "***"
|
|
23
|
+
local, domain = email.split("@", 1)
|
|
24
|
+
masked_local = local[:2] + "***" if len(local) > 2 else "***"
|
|
25
|
+
domain_parts = domain.split(".")
|
|
26
|
+
if len(domain_parts) >= 2:
|
|
27
|
+
masked_domain = domain_parts[0][:2] + "***." + domain_parts[-1]
|
|
28
|
+
else:
|
|
29
|
+
masked_domain = "***"
|
|
30
|
+
return f"{masked_local}@{masked_domain}"
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
from fastapi import APIRouter, HTTPException, Depends, Request, Header
|
|
14
34
|
from pydantic import BaseModel, EmailStr
|
|
15
35
|
|
|
@@ -33,6 +53,10 @@ from backend.services.user_service import get_user_service, UserService, USERS_C
|
|
|
33
53
|
from backend.services.email_service import get_email_service, EmailService
|
|
34
54
|
from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
|
|
35
55
|
from backend.services.theme_service import get_theme_service
|
|
56
|
+
from backend.services.job_defaults_service import (
|
|
57
|
+
get_effective_distribution_settings,
|
|
58
|
+
resolve_cdg_txt_defaults,
|
|
59
|
+
)
|
|
36
60
|
from backend.api.dependencies import require_admin
|
|
37
61
|
from backend.api.routes.file_upload import _prepare_theme_for_job
|
|
38
62
|
from backend.services.auth_service import UserType
|
|
@@ -60,8 +84,8 @@ class CreateCheckoutResponse(BaseModel):
|
|
|
60
84
|
message: str
|
|
61
85
|
|
|
62
86
|
|
|
63
|
-
class
|
|
64
|
-
"""Request to create a
|
|
87
|
+
class MadeForYouCheckoutRequest(BaseModel):
|
|
88
|
+
"""Request to create a made-for-you karaoke video order."""
|
|
65
89
|
email: EmailStr
|
|
66
90
|
artist: str
|
|
67
91
|
title: str
|
|
@@ -112,7 +136,11 @@ async def send_magic_link(
|
|
|
112
136
|
|
|
113
137
|
The user will receive an email with a link that logs them in.
|
|
114
138
|
Links expire after 15 minutes and can only be used once.
|
|
139
|
+
|
|
140
|
+
For white-label tenants, validates email domain against tenant config.
|
|
115
141
|
"""
|
|
142
|
+
from backend.middleware.tenant import get_tenant_from_request, get_tenant_config_from_request
|
|
143
|
+
|
|
116
144
|
# Check if email service is configured
|
|
117
145
|
if not email_service.is_configured():
|
|
118
146
|
logger.error("Email service not configured - cannot send magic links")
|
|
@@ -123,19 +151,39 @@ async def send_magic_link(
|
|
|
123
151
|
|
|
124
152
|
email = request.email.lower()
|
|
125
153
|
|
|
154
|
+
# Get tenant context from middleware
|
|
155
|
+
tenant_id = get_tenant_from_request(http_request)
|
|
156
|
+
tenant_config = get_tenant_config_from_request(http_request)
|
|
157
|
+
|
|
158
|
+
# Validate email domain for tenant if configured
|
|
159
|
+
if tenant_config and tenant_config.auth.allowed_email_domains:
|
|
160
|
+
if not tenant_config.is_email_allowed(email):
|
|
161
|
+
logger.warning(f"Email domain not allowed for tenant {tenant_id}: {_mask_email(email)}")
|
|
162
|
+
# Return success anyway to prevent email enumeration
|
|
163
|
+
return SendMagicLinkResponse(
|
|
164
|
+
status="success",
|
|
165
|
+
message="If this email is registered, you will receive a sign-in link shortly."
|
|
166
|
+
)
|
|
167
|
+
|
|
126
168
|
# Get client info for security logging
|
|
127
169
|
ip_address = http_request.client.host if http_request.client else None
|
|
128
170
|
user_agent = http_request.headers.get("user-agent")
|
|
129
171
|
|
|
130
|
-
# Create magic link token
|
|
172
|
+
# Create magic link token with tenant context
|
|
131
173
|
magic_link = user_service.create_magic_link(
|
|
132
174
|
email,
|
|
133
175
|
ip_address=ip_address,
|
|
134
|
-
user_agent=user_agent
|
|
176
|
+
user_agent=user_agent,
|
|
177
|
+
tenant_id=tenant_id
|
|
135
178
|
)
|
|
136
179
|
|
|
137
|
-
#
|
|
138
|
-
|
|
180
|
+
# Get tenant-specific sender email
|
|
181
|
+
sender_email = None
|
|
182
|
+
if tenant_config:
|
|
183
|
+
sender_email = tenant_config.get_sender_email()
|
|
184
|
+
|
|
185
|
+
# Send email (with tenant-specific sender if configured)
|
|
186
|
+
sent = email_service.send_magic_link(email, magic_link.token, sender_email=sender_email)
|
|
139
187
|
|
|
140
188
|
if not sent:
|
|
141
189
|
logger.error(f"Failed to send magic link email to {email}")
|
|
@@ -159,18 +207,26 @@ async def verify_magic_link(
|
|
|
159
207
|
Verify a magic link token and create a session.
|
|
160
208
|
|
|
161
209
|
Returns a session token that should be stored and used for subsequent requests.
|
|
210
|
+
The session will be associated with the tenant from the magic link.
|
|
162
211
|
"""
|
|
212
|
+
# Reject empty tokens early to avoid invalid Firestore document paths
|
|
213
|
+
if not token or not token.strip():
|
|
214
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
215
|
+
|
|
163
216
|
# Get client info
|
|
164
217
|
ip_address = http_request.client.host if http_request.client else None
|
|
165
218
|
user_agent = http_request.headers.get("user-agent")
|
|
166
219
|
|
|
167
220
|
# Check if this is a first login BEFORE verification (which sets last_login_at)
|
|
168
221
|
# We need to get the user's state before verify_magic_link updates it
|
|
222
|
+
# Also extract tenant_id from the magic link for session creation
|
|
169
223
|
from backend.services.user_service import MAGIC_LINKS_COLLECTION
|
|
170
224
|
magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
|
|
171
225
|
is_first_login = False
|
|
226
|
+
magic_link_tenant_id = None
|
|
172
227
|
if magic_link_doc.exists:
|
|
173
228
|
magic_link_data = magic_link_doc.to_dict()
|
|
229
|
+
magic_link_tenant_id = magic_link_data.get('tenant_id')
|
|
174
230
|
pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
|
|
175
231
|
if pre_verify_user:
|
|
176
232
|
is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
|
|
@@ -181,18 +237,19 @@ async def verify_magic_link(
|
|
|
181
237
|
if not success or not user:
|
|
182
238
|
raise HTTPException(status_code=401, detail=message)
|
|
183
239
|
|
|
184
|
-
# Create session
|
|
240
|
+
# Create session with tenant context from the magic link
|
|
185
241
|
session = user_service.create_session(
|
|
186
242
|
user.email,
|
|
187
243
|
ip_address=ip_address,
|
|
188
|
-
user_agent=user_agent
|
|
244
|
+
user_agent=user_agent,
|
|
245
|
+
tenant_id=magic_link_tenant_id
|
|
189
246
|
)
|
|
190
247
|
|
|
191
248
|
# Send welcome email to first-time users
|
|
192
249
|
if is_first_login:
|
|
193
250
|
email_service.send_welcome_email(user.email, user.credits)
|
|
194
251
|
|
|
195
|
-
# Return user info
|
|
252
|
+
# Return user info with tenant_id
|
|
196
253
|
user_public = UserPublic(
|
|
197
254
|
email=user.email,
|
|
198
255
|
role=user.role,
|
|
@@ -200,6 +257,7 @@ async def verify_magic_link(
|
|
|
200
257
|
display_name=user.display_name,
|
|
201
258
|
total_jobs_created=user.total_jobs_created,
|
|
202
259
|
total_jobs_completed=user.total_jobs_completed,
|
|
260
|
+
tenant_id=user.tenant_id,
|
|
203
261
|
)
|
|
204
262
|
|
|
205
263
|
return VerifyMagicLinkResponse(
|
|
@@ -340,13 +398,13 @@ async def create_checkout(
|
|
|
340
398
|
)
|
|
341
399
|
|
|
342
400
|
|
|
343
|
-
@router.post("/
|
|
344
|
-
async def
|
|
345
|
-
request:
|
|
401
|
+
@router.post("/made-for-you/checkout", response_model=CreateCheckoutResponse)
|
|
402
|
+
async def create_made_for_you_checkout(
|
|
403
|
+
request: MadeForYouCheckoutRequest,
|
|
346
404
|
stripe_service: StripeService = Depends(get_stripe_service),
|
|
347
405
|
):
|
|
348
406
|
"""
|
|
349
|
-
Create a Stripe checkout session for a
|
|
407
|
+
Create a Stripe checkout session for a made-for-you karaoke video order.
|
|
350
408
|
|
|
351
409
|
This is the full-service option where Nomad Karaoke handles everything:
|
|
352
410
|
- Finding or processing the audio
|
|
@@ -360,7 +418,7 @@ async def create_done_for_you_checkout(
|
|
|
360
418
|
if not stripe_service.is_configured():
|
|
361
419
|
raise HTTPException(status_code=503, detail="Payment processing is not available")
|
|
362
420
|
|
|
363
|
-
success, checkout_url, message = stripe_service.
|
|
421
|
+
success, checkout_url, message = stripe_service.create_made_for_you_checkout_session(
|
|
364
422
|
customer_email=request.email,
|
|
365
423
|
artist=request.artist,
|
|
366
424
|
title=request.title,
|
|
@@ -383,22 +441,28 @@ async def create_done_for_you_checkout(
|
|
|
383
441
|
# Stripe Webhooks
|
|
384
442
|
# =============================================================================
|
|
385
443
|
|
|
386
|
-
# Admin email for
|
|
387
|
-
ADMIN_EMAIL = "
|
|
444
|
+
# Admin email for made-for-you order notifications
|
|
445
|
+
ADMIN_EMAIL = "madeforyou@nomadkaraoke.com"
|
|
388
446
|
|
|
389
447
|
|
|
390
|
-
async def
|
|
448
|
+
async def _handle_made_for_you_order(
|
|
391
449
|
session_id: str,
|
|
392
450
|
metadata: dict,
|
|
393
451
|
user_service: UserService,
|
|
394
452
|
email_service: EmailService,
|
|
395
453
|
) -> None:
|
|
396
454
|
"""
|
|
397
|
-
Handle a completed
|
|
455
|
+
Handle a completed made-for-you order by creating a job and notifying Andrew.
|
|
456
|
+
|
|
457
|
+
The made-for-you flow:
|
|
458
|
+
1. Job is created with made_for_you=True, owned by admin during processing
|
|
459
|
+
2. For search orders: audio search runs, results stored, job pauses at AWAITING_AUDIO_SELECTION
|
|
460
|
+
3. Admin receives notification email with link to select audio source
|
|
461
|
+
4. Customer receives order confirmation email
|
|
462
|
+
5. Admin selects audio in UI, job proceeds through normal pipeline
|
|
463
|
+
6. On completion, ownership transfers to customer
|
|
398
464
|
|
|
399
|
-
For orders with a YouTube URL,
|
|
400
|
-
For orders without a URL (search mode), the audio search flow is used to find and
|
|
401
|
-
download the best matching audio source automatically.
|
|
465
|
+
For orders with a YouTube URL, workers are triggered immediately (no search needed).
|
|
402
466
|
|
|
403
467
|
Args:
|
|
404
468
|
session_id: Stripe checkout session ID
|
|
@@ -427,7 +491,7 @@ async def _handle_done_for_you_order(
|
|
|
427
491
|
notes = metadata.get("notes", "")
|
|
428
492
|
|
|
429
493
|
logger.info(
|
|
430
|
-
f"Processing
|
|
494
|
+
f"Processing made-for-you order: {artist} - {title} for {customer_email} "
|
|
431
495
|
f"(session: {session_id}, source_type: {source_type})"
|
|
432
496
|
)
|
|
433
497
|
|
|
@@ -440,32 +504,55 @@ async def _handle_done_for_you_order(
|
|
|
440
504
|
theme_service = get_theme_service()
|
|
441
505
|
effective_theme_id = theme_service.get_default_theme_id()
|
|
442
506
|
if effective_theme_id:
|
|
443
|
-
logger.info(f"Applying default theme '{effective_theme_id}' for
|
|
507
|
+
logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
|
|
508
|
+
|
|
509
|
+
# Get distribution defaults using centralized service
|
|
510
|
+
dist = get_effective_distribution_settings()
|
|
511
|
+
|
|
512
|
+
# Resolve CDG/TXT defaults based on theme (uses centralized service)
|
|
513
|
+
# This ensures made-for-you jobs get the same CDG/TXT defaults as regular jobs
|
|
514
|
+
resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(effective_theme_id)
|
|
444
515
|
|
|
445
|
-
# Create job
|
|
446
|
-
#
|
|
516
|
+
# Create job with admin ownership during processing
|
|
517
|
+
# CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
|
|
518
|
+
# auto_download=False to pause at audio selection for admin to choose
|
|
447
519
|
job_create = JobCreate(
|
|
448
520
|
url=youtube_url if youtube_url else None,
|
|
449
521
|
artist=artist,
|
|
450
522
|
title=title,
|
|
451
|
-
user_email=
|
|
523
|
+
user_email=ADMIN_EMAIL, # Admin owns during processing
|
|
452
524
|
theme_id=effective_theme_id, # Apply default theme
|
|
453
|
-
non_interactive=False, #
|
|
454
|
-
#
|
|
525
|
+
non_interactive=False, # Admin will review lyrics/instrumental
|
|
526
|
+
# Made-for-you specific fields
|
|
527
|
+
made_for_you=True, # Flag for ownership transfer on completion
|
|
528
|
+
customer_email=customer_email, # Customer email for final delivery
|
|
529
|
+
customer_notes=notes if notes else None, # Customer's special requests
|
|
530
|
+
# Audio search fields for search-based orders
|
|
455
531
|
audio_search_artist=artist if not youtube_url else None,
|
|
456
532
|
audio_search_title=title if not youtube_url else None,
|
|
457
|
-
auto_download=
|
|
533
|
+
auto_download=False, # Pause at audio selection for admin to choose
|
|
534
|
+
# CDG/TXT settings (resolved via centralized service)
|
|
535
|
+
enable_cdg=resolved_cdg,
|
|
536
|
+
enable_txt=resolved_txt,
|
|
537
|
+
# Distribution settings from centralized defaults
|
|
538
|
+
enable_youtube_upload=dist.enable_youtube_upload,
|
|
539
|
+
dropbox_path=dist.dropbox_path,
|
|
540
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
541
|
+
brand_prefix=dist.brand_prefix,
|
|
542
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
543
|
+
youtube_description=dist.youtube_description,
|
|
458
544
|
)
|
|
459
|
-
|
|
545
|
+
# Made-for-you jobs are created by admin (via Stripe webhook) - bypass rate limits
|
|
546
|
+
job = job_manager.create_job(job_create, is_admin=True)
|
|
460
547
|
job_id = job.job_id
|
|
461
548
|
|
|
462
|
-
logger.info(f"Created
|
|
549
|
+
logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
|
|
463
550
|
|
|
464
551
|
# Prepare theme style assets for the job (same as audio_search endpoint)
|
|
465
552
|
if effective_theme_id:
|
|
466
553
|
try:
|
|
467
554
|
style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
|
|
468
|
-
job_id, effective_theme_id, None # No color overrides for
|
|
555
|
+
job_id, effective_theme_id, None # No color overrides for made-for-you
|
|
469
556
|
)
|
|
470
557
|
theme_update = {
|
|
471
558
|
'style_params_gcs_path': style_params_path,
|
|
@@ -474,9 +561,9 @@ async def _handle_done_for_you_order(
|
|
|
474
561
|
if youtube_desc:
|
|
475
562
|
theme_update['youtube_description_template'] = youtube_desc
|
|
476
563
|
job_manager.update_job(job_id, theme_update)
|
|
477
|
-
logger.info(f"Applied theme '{effective_theme_id}' to
|
|
564
|
+
logger.info(f"Applied theme '{effective_theme_id}' to made-for-you job {job_id}")
|
|
478
565
|
except Exception as e:
|
|
479
|
-
logger.warning(f"Failed to prepare theme for
|
|
566
|
+
logger.warning(f"Failed to prepare theme for made-for-you job {job_id}: {e}")
|
|
480
567
|
|
|
481
568
|
# Mark session as processed for idempotency
|
|
482
569
|
# Note: Using internal method since this isn't a credit transaction
|
|
@@ -486,23 +573,27 @@ async def _handle_done_for_you_order(
|
|
|
486
573
|
amount=0 # No credits, just tracking the session
|
|
487
574
|
)
|
|
488
575
|
|
|
576
|
+
# Initialize search_results for later use in email notification
|
|
577
|
+
search_results = None
|
|
578
|
+
|
|
489
579
|
# Handle based on whether we have a YouTube URL or need to search
|
|
490
580
|
if youtube_url:
|
|
491
|
-
# URL provided - trigger workers directly
|
|
581
|
+
# URL provided - trigger workers directly (no audio selection needed)
|
|
492
582
|
logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
|
|
493
583
|
await asyncio.gather(
|
|
494
584
|
worker_service.trigger_audio_worker(job_id),
|
|
495
585
|
worker_service.trigger_lyrics_worker(job_id)
|
|
496
586
|
)
|
|
497
587
|
else:
|
|
498
|
-
# No URL - use audio search flow
|
|
588
|
+
# No URL - use audio search flow, pause for admin selection
|
|
589
|
+
# Made-for-you jobs require admin to select audio source
|
|
499
590
|
logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
|
|
500
591
|
|
|
501
592
|
# Update job with audio search fields
|
|
502
593
|
job_manager.update_job(job_id, {
|
|
503
594
|
'audio_search_artist': artist,
|
|
504
595
|
'audio_search_title': title,
|
|
505
|
-
'auto_download':
|
|
596
|
+
'auto_download': False, # Admin must select
|
|
506
597
|
})
|
|
507
598
|
|
|
508
599
|
# Transition to searching state
|
|
@@ -519,7 +610,8 @@ async def _handle_done_for_you_order(
|
|
|
519
610
|
try:
|
|
520
611
|
search_results = audio_search_service.search(artist, title)
|
|
521
612
|
except NoResultsError as e:
|
|
522
|
-
# No results found - transition to AWAITING_AUDIO_SELECTION
|
|
613
|
+
# No results found - still transition to AWAITING_AUDIO_SELECTION
|
|
614
|
+
# Admin can manually provide audio
|
|
523
615
|
logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
|
|
524
616
|
job_manager.transition_to_state(
|
|
525
617
|
job_id=job_id,
|
|
@@ -527,7 +619,6 @@ async def _handle_done_for_you_order(
|
|
|
527
619
|
progress=10,
|
|
528
620
|
message=f"No automatic audio sources found. Manual intervention required."
|
|
529
621
|
)
|
|
530
|
-
# Don't fail the job - Andrew can manually provide audio
|
|
531
622
|
search_results = None
|
|
532
623
|
except AudioSearchError as e:
|
|
533
624
|
logger.error(f"Job {job_id}: Audio search failed: {e}")
|
|
@@ -540,7 +631,7 @@ async def _handle_done_for_you_order(
|
|
|
540
631
|
search_results = None
|
|
541
632
|
|
|
542
633
|
if search_results:
|
|
543
|
-
# Store search results in state_data
|
|
634
|
+
# Store search results in state_data for admin to review
|
|
544
635
|
results_dicts = [r.to_dict() for r in search_results]
|
|
545
636
|
state_data_update = {
|
|
546
637
|
'audio_search_results': results_dicts,
|
|
@@ -550,225 +641,61 @@ async def _handle_done_for_you_order(
|
|
|
550
641
|
state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
|
|
551
642
|
job_manager.update_job(job_id, {'state_data': state_data_update})
|
|
552
643
|
|
|
553
|
-
#
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
logger.info(f"Job {job_id}: Auto-selected result {best_index}: {selected.get('provider')} - {selected.get('title')}")
|
|
644
|
+
# Transition to AWAITING_AUDIO_SELECTION for admin to choose
|
|
645
|
+
# Do NOT auto-select or download - admin must review and select
|
|
646
|
+
logger.info(f"Job {job_id}: Found {len(results_dicts)} audio sources, awaiting admin selection")
|
|
558
647
|
|
|
559
|
-
# Transition to
|
|
648
|
+
# Transition to AWAITING_AUDIO_SELECTION for admin to choose
|
|
649
|
+
# Admin will select from results in the UI, then job proceeds
|
|
560
650
|
job_manager.transition_to_state(
|
|
561
651
|
job_id=job_id,
|
|
562
|
-
new_status=JobStatus.
|
|
652
|
+
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
563
653
|
progress=10,
|
|
564
|
-
message=f"
|
|
565
|
-
state_data_updates={
|
|
566
|
-
'selected_audio_index': best_index,
|
|
567
|
-
'selected_audio_provider': selected.get('provider'),
|
|
568
|
-
}
|
|
654
|
+
message=f"Found {len(results_dicts)} audio sources. Awaiting admin selection."
|
|
569
655
|
)
|
|
570
656
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
gcs_destination = f"uploads/{job_id}/audio/"
|
|
584
|
-
|
|
585
|
-
if source_id and source_name:
|
|
586
|
-
result = audio_search_service.download_by_id(
|
|
587
|
-
source_name=source_name,
|
|
588
|
-
source_id=source_id,
|
|
589
|
-
output_dir="",
|
|
590
|
-
target_file=target_file,
|
|
591
|
-
download_url=download_url,
|
|
592
|
-
gcs_path=gcs_destination,
|
|
593
|
-
)
|
|
594
|
-
else:
|
|
595
|
-
result = audio_search_service.download(
|
|
596
|
-
result_index=best_index,
|
|
597
|
-
output_dir="",
|
|
598
|
-
gcs_path=gcs_destination,
|
|
599
|
-
remote_search_id=remote_search_id,
|
|
600
|
-
)
|
|
601
|
-
|
|
602
|
-
# Extract GCS path
|
|
603
|
-
if result.filepath.startswith("gs://"):
|
|
604
|
-
parts = result.filepath.replace("gs://", "").split("/", 1)
|
|
605
|
-
audio_gcs_path = parts[1] if len(parts) == 2 else result.filepath
|
|
606
|
-
filename = os.path.basename(result.filepath)
|
|
607
|
-
else:
|
|
608
|
-
filename = os.path.basename(result.filepath)
|
|
609
|
-
audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
|
|
610
|
-
else:
|
|
611
|
-
# Local download (YouTube or local torrent)
|
|
612
|
-
temp_dir = tempfile.mkdtemp(prefix=f"audio_download_{job_id}_")
|
|
613
|
-
import shutil
|
|
614
|
-
|
|
615
|
-
try:
|
|
616
|
-
if source_id and source_name and is_remote_enabled:
|
|
617
|
-
result = audio_search_service.download_by_id(
|
|
618
|
-
source_name=source_name,
|
|
619
|
-
source_id=source_id,
|
|
620
|
-
output_dir=temp_dir,
|
|
621
|
-
target_file=target_file,
|
|
622
|
-
download_url=download_url,
|
|
623
|
-
)
|
|
624
|
-
elif source_name == 'YouTube' and download_url:
|
|
625
|
-
# YouTube download
|
|
626
|
-
from backend.workers.audio_worker import download_from_url
|
|
627
|
-
local_path = await download_from_url(
|
|
628
|
-
download_url,
|
|
629
|
-
temp_dir,
|
|
630
|
-
selected.get('artist'),
|
|
631
|
-
selected.get('title')
|
|
632
|
-
)
|
|
633
|
-
if not local_path or not os.path.exists(local_path):
|
|
634
|
-
raise Exception(f"Failed to download from YouTube: {download_url}")
|
|
635
|
-
|
|
636
|
-
class DownloadResult:
|
|
637
|
-
def __init__(self, filepath):
|
|
638
|
-
self.filepath = filepath
|
|
639
|
-
result = DownloadResult(local_path)
|
|
640
|
-
else:
|
|
641
|
-
result = audio_search_service.download(
|
|
642
|
-
result_index=best_index,
|
|
643
|
-
output_dir=temp_dir,
|
|
644
|
-
remote_search_id=remote_search_id,
|
|
645
|
-
)
|
|
646
|
-
|
|
647
|
-
# Upload to GCS
|
|
648
|
-
filename = os.path.basename(result.filepath)
|
|
649
|
-
audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
|
|
650
|
-
|
|
651
|
-
with open(result.filepath, 'rb') as f:
|
|
652
|
-
storage_service.upload_fileobj(f, audio_gcs_path, content_type='audio/flac')
|
|
653
|
-
finally:
|
|
654
|
-
# Always cleanup temp directory
|
|
655
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
656
|
-
|
|
657
|
-
# Update job with GCS path
|
|
658
|
-
job_manager.update_job(job_id, {
|
|
659
|
-
'input_media_gcs_path': audio_gcs_path,
|
|
660
|
-
'filename': filename,
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
# Transition to DOWNLOADING and trigger workers
|
|
664
|
-
job_manager.transition_to_state(
|
|
665
|
-
job_id=job_id,
|
|
666
|
-
new_status=JobStatus.DOWNLOADING,
|
|
667
|
-
progress=15,
|
|
668
|
-
message="Audio downloaded, starting processing"
|
|
669
|
-
)
|
|
670
|
-
|
|
671
|
-
# Trigger workers
|
|
672
|
-
await asyncio.gather(
|
|
673
|
-
worker_service.trigger_audio_worker(job_id),
|
|
674
|
-
worker_service.trigger_lyrics_worker(job_id)
|
|
675
|
-
)
|
|
676
|
-
|
|
677
|
-
logger.info(f"Job {job_id}: Audio downloaded and workers triggered")
|
|
678
|
-
|
|
679
|
-
except Exception as download_error:
|
|
680
|
-
logger.error(f"Job {job_id}: Audio download failed: {download_error}")
|
|
681
|
-
# Don't fail job - transition to awaiting selection so Andrew can handle
|
|
682
|
-
job_manager.transition_to_state(
|
|
683
|
-
job_id=job_id,
|
|
684
|
-
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
685
|
-
progress=10,
|
|
686
|
-
message=f"Auto-download failed: {download_error}. Manual intervention required."
|
|
687
|
-
)
|
|
688
|
-
|
|
689
|
-
# Send confirmation email to customer
|
|
690
|
-
email_service.send_email(
|
|
657
|
+
# Get audio source count for admin notification
|
|
658
|
+
# (search_results may be set from the search flow above, or None for YouTube URL orders)
|
|
659
|
+
audio_source_count = len(search_results) if search_results else 0
|
|
660
|
+
|
|
661
|
+
# Generate admin login token for one-click email access (24hr expiry)
|
|
662
|
+
admin_login = user_service.create_admin_login_token(
|
|
663
|
+
email=ADMIN_EMAIL,
|
|
664
|
+
expiry_hours=24,
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# Send confirmation email to customer using professional template
|
|
668
|
+
email_service.send_made_for_you_order_confirmation(
|
|
691
669
|
to_email=customer_email,
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
<ul>
|
|
697
|
-
<li><strong>Artist:</strong> {artist}</li>
|
|
698
|
-
<li><strong>Title:</strong> {title}</li>
|
|
699
|
-
{f'<li><strong>Notes:</strong> {notes}</li>' if notes else ''}
|
|
700
|
-
</ul>
|
|
701
|
-
<p>Our team will review and create your video within <strong>24 hours</strong>.</p>
|
|
702
|
-
<p>You'll receive another email with download links when your video is ready.</p>
|
|
703
|
-
<p>If you have any questions, reply to this email or contact us at support@nomadkaraoke.com</p>
|
|
704
|
-
<p>Thanks for using Nomad Karaoke!</p>
|
|
705
|
-
""",
|
|
706
|
-
text_content=f"""
|
|
707
|
-
Thank you for your order!
|
|
708
|
-
|
|
709
|
-
We've received your request for a karaoke video:
|
|
710
|
-
- Artist: {artist}
|
|
711
|
-
- Title: {title}
|
|
712
|
-
{f'- Notes: {notes}' if notes else ''}
|
|
713
|
-
|
|
714
|
-
Our team will review and create your video within 24 hours.
|
|
715
|
-
You'll receive another email with download links when your video is ready.
|
|
716
|
-
|
|
717
|
-
If you have any questions, reply to this email or contact us at support@nomadkaraoke.com
|
|
718
|
-
|
|
719
|
-
Thanks for using Nomad Karaoke!
|
|
720
|
-
""".strip(),
|
|
670
|
+
artist=artist,
|
|
671
|
+
title=title,
|
|
672
|
+
job_id=job_id,
|
|
673
|
+
notes=notes,
|
|
721
674
|
)
|
|
722
675
|
|
|
723
|
-
# Send notification email to
|
|
724
|
-
email_service.
|
|
676
|
+
# Send notification email to admin using professional template
|
|
677
|
+
email_service.send_made_for_you_admin_notification(
|
|
725
678
|
to_email=ADMIN_EMAIL,
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
<li><strong>Title:</strong> {title}</li>
|
|
734
|
-
<li><strong>Source:</strong> {source_type}</li>
|
|
735
|
-
{f'<li><strong>YouTube URL:</strong> <a href="{youtube_url}">{youtube_url}</a></li>' if youtube_url else ''}
|
|
736
|
-
{f'<li><strong>Notes:</strong> {notes}</li>' if notes else ''}
|
|
737
|
-
</ul>
|
|
738
|
-
<p><strong>Job ID:</strong> {job_id}</p>
|
|
739
|
-
<p>View job in admin: <a href="https://gen.nomadkaraoke.com/admin/jobs/{job_id}">Admin Link</a></p>
|
|
740
|
-
<p>View job as customer: <a href="https://gen.nomadkaraoke.com/jobs/{job_id}">Customer Link</a></p>
|
|
741
|
-
<p><strong>Deadline:</strong> 24 hours from now</p>
|
|
742
|
-
""",
|
|
743
|
-
text_content=f"""
|
|
744
|
-
New Done-For-You Order
|
|
745
|
-
|
|
746
|
-
Customer: {customer_email}
|
|
747
|
-
Artist: {artist}
|
|
748
|
-
Title: {title}
|
|
749
|
-
Source: {source_type}
|
|
750
|
-
{f'YouTube URL: {youtube_url}' if youtube_url else ''}
|
|
751
|
-
{f'Notes: {notes}' if notes else ''}
|
|
752
|
-
|
|
753
|
-
Job ID: {job_id}
|
|
754
|
-
Admin: https://gen.nomadkaraoke.com/admin/jobs/{job_id}
|
|
755
|
-
Customer: https://gen.nomadkaraoke.com/jobs/{job_id}
|
|
756
|
-
|
|
757
|
-
Deadline: 24 hours from now
|
|
758
|
-
""".strip(),
|
|
679
|
+
customer_email=customer_email,
|
|
680
|
+
artist=artist,
|
|
681
|
+
title=title,
|
|
682
|
+
job_id=job_id,
|
|
683
|
+
admin_login_token=admin_login.token,
|
|
684
|
+
notes=notes,
|
|
685
|
+
audio_source_count=audio_source_count,
|
|
759
686
|
)
|
|
760
687
|
|
|
761
|
-
logger.info(f"Sent
|
|
688
|
+
logger.info(f"Sent made-for-you order notifications for job {job_id}")
|
|
762
689
|
|
|
763
690
|
except Exception as e:
|
|
764
|
-
logger.error(f"Error processing
|
|
691
|
+
logger.error(f"Error processing made-for-you order: {e}", exc_info=True)
|
|
765
692
|
# Still try to notify Andrew of the failure
|
|
766
693
|
try:
|
|
767
694
|
email_service.send_email(
|
|
768
695
|
to_email=ADMIN_EMAIL,
|
|
769
|
-
subject=f"[FAILED]
|
|
696
|
+
subject=f"[FAILED] Made For You Order: {artist} - {title}",
|
|
770
697
|
html_content=f"""
|
|
771
|
-
<h2>
|
|
698
|
+
<h2>Made-For-You Order Failed</h2>
|
|
772
699
|
<p>An error occurred processing this order:</p>
|
|
773
700
|
<ul>
|
|
774
701
|
<li><strong>Customer:</strong> {customer_email}</li>
|
|
@@ -779,7 +706,7 @@ Deadline: 24 hours from now
|
|
|
779
706
|
<p>Please manually create this job and notify the customer.</p>
|
|
780
707
|
""",
|
|
781
708
|
text_content=f"""
|
|
782
|
-
|
|
709
|
+
Made-For-You Order Failed
|
|
783
710
|
|
|
784
711
|
Customer: {customer_email}
|
|
785
712
|
Artist: {artist}
|
|
@@ -834,10 +761,10 @@ async def stripe_webhook(
|
|
|
834
761
|
logger.info(f"Skipping already processed session: {session_id}")
|
|
835
762
|
return {"status": "received", "type": event_type, "note": "already_processed"}
|
|
836
763
|
|
|
837
|
-
# Check if this is a
|
|
838
|
-
if metadata.get("order_type") == "
|
|
839
|
-
# Handle
|
|
840
|
-
await
|
|
764
|
+
# Check if this is a made-for-you order
|
|
765
|
+
if metadata.get("order_type") == "made_for_you":
|
|
766
|
+
# Handle made-for-you order - create a job
|
|
767
|
+
await _handle_made_for_you_order(
|
|
841
768
|
session_id=session_id,
|
|
842
769
|
metadata=metadata,
|
|
843
770
|
user_service=user_service,
|
|
@@ -896,6 +823,9 @@ async def enroll_beta_tester(
|
|
|
896
823
|
|
|
897
824
|
Returns free credits and optionally a session token for new users.
|
|
898
825
|
"""
|
|
826
|
+
from backend.services.email_validation_service import get_email_validation_service
|
|
827
|
+
from backend.services.rate_limit_service import get_rate_limit_service
|
|
828
|
+
|
|
899
829
|
# Check if email service is configured
|
|
900
830
|
if not email_service.is_configured():
|
|
901
831
|
logger.error("Email service not configured - cannot send beta welcome emails")
|
|
@@ -905,6 +835,52 @@ async def enroll_beta_tester(
|
|
|
905
835
|
)
|
|
906
836
|
|
|
907
837
|
email = request.email.lower()
|
|
838
|
+
email_validation = get_email_validation_service()
|
|
839
|
+
rate_limit_service = get_rate_limit_service()
|
|
840
|
+
|
|
841
|
+
# ----- ABUSE PREVENTION CHECKS -----
|
|
842
|
+
|
|
843
|
+
# 1. Validate email (disposable domain, blocked email checks)
|
|
844
|
+
is_valid, error_message = email_validation.validate_email_for_beta(email)
|
|
845
|
+
if not is_valid:
|
|
846
|
+
logger.warning(f"Beta enrollment rejected - email validation failed: {email} - {error_message}")
|
|
847
|
+
raise HTTPException(
|
|
848
|
+
status_code=400,
|
|
849
|
+
detail=error_message
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
# 2. Check IP blocking
|
|
853
|
+
ip_address = http_request.client.host if http_request.client else None
|
|
854
|
+
if ip_address and email_validation.is_ip_blocked(ip_address):
|
|
855
|
+
logger.warning(f"Beta enrollment rejected - IP blocked: {ip_address}")
|
|
856
|
+
raise HTTPException(
|
|
857
|
+
status_code=403,
|
|
858
|
+
detail="Access denied from this location"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
# 3. Check IP-based enrollment rate limit (1 per 24h per IP)
|
|
862
|
+
if ip_address:
|
|
863
|
+
allowed, remaining, message = rate_limit_service.check_beta_ip_limit(ip_address)
|
|
864
|
+
if not allowed:
|
|
865
|
+
logger.warning(f"Beta enrollment rejected - IP rate limit: {ip_address} - {message}")
|
|
866
|
+
raise HTTPException(
|
|
867
|
+
status_code=429,
|
|
868
|
+
detail="Too many beta enrollments from your location. Please try again tomorrow."
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# 4. Check for duplicate enrollment via normalized email
|
|
872
|
+
normalized_email = email_validation.normalize_email(email)
|
|
873
|
+
if normalized_email != email:
|
|
874
|
+
# Check if normalized version is already enrolled
|
|
875
|
+
normalized_user = user_service.get_user(normalized_email)
|
|
876
|
+
if normalized_user and normalized_user.is_beta_tester:
|
|
877
|
+
logger.warning(f"Beta enrollment rejected - normalized email already enrolled: {email} -> {normalized_email}")
|
|
878
|
+
raise HTTPException(
|
|
879
|
+
status_code=400,
|
|
880
|
+
detail="An account with this email address is already enrolled in the beta program"
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# ----- END ABUSE PREVENTION CHECKS -----
|
|
908
884
|
|
|
909
885
|
# Validate acceptance
|
|
910
886
|
if not request.accept_corrections_work:
|
|
@@ -929,8 +905,7 @@ async def enroll_beta_tester(
|
|
|
929
905
|
detail="You are already enrolled in the beta program"
|
|
930
906
|
)
|
|
931
907
|
|
|
932
|
-
# Get client info
|
|
933
|
-
ip_address = http_request.client.host if http_request.client else None
|
|
908
|
+
# Get client info (ip_address already set above in abuse prevention)
|
|
934
909
|
user_agent = http_request.headers.get("user-agent")
|
|
935
910
|
|
|
936
911
|
# Enroll as beta tester
|
|
@@ -950,6 +925,13 @@ async def enroll_beta_tester(
|
|
|
950
925
|
reason="beta_tester_enrollment",
|
|
951
926
|
)
|
|
952
927
|
|
|
928
|
+
# Record IP enrollment for rate limiting
|
|
929
|
+
if ip_address:
|
|
930
|
+
try:
|
|
931
|
+
rate_limit_service.record_beta_enrollment(ip_address, email)
|
|
932
|
+
except Exception as e:
|
|
933
|
+
logger.warning(f"Failed to record beta IP enrollment: {e}")
|
|
934
|
+
|
|
953
935
|
# Create session for the user (so they can start using the service immediately)
|
|
954
936
|
session = user_service.create_session(email, ip_address=ip_address, user_agent=user_agent)
|
|
955
937
|
|