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.
Files changed (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
@@ -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 DoneForYouCheckoutRequest(BaseModel):
64
- """Request to create a done-for-you karaoke video order."""
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
- # Send email
138
- sent = email_service.send_magic_link(email, magic_link.token)
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("/done-for-you/checkout", response_model=CreateCheckoutResponse)
344
- async def create_done_for_you_checkout(
345
- request: DoneForYouCheckoutRequest,
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 done-for-you karaoke video order.
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.create_done_for_you_checkout_session(
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 done-for-you order notifications
387
- ADMIN_EMAIL = "andrew@nomadkaraoke.com"
444
+ # Admin email for made-for-you order notifications
445
+ ADMIN_EMAIL = "madeforyou@nomadkaraoke.com"
388
446
 
389
447
 
390
- async def _handle_done_for_you_order(
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 done-for-you order by creating a job and notifying Andrew.
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, the job is created and workers are triggered immediately.
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 done-for-you order: {artist} - {title} for {customer_email} "
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 done-for-you order")
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 for the customer
446
- # Note: done-for-you jobs should NOT be non_interactive - Andrew needs to review
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=customer_email, # Customer owns the job
523
+ user_email=ADMIN_EMAIL, # Admin owns during processing
452
524
  theme_id=effective_theme_id, # Apply default theme
453
- non_interactive=False, # Andrew will review lyrics/instrumental
454
- # Set audio search fields for search-based orders
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=True, # Auto-select best audio source
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
- job = job_manager.create_job(job_create)
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 done-for-you job {job_id} for {customer_email}")
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 done-for-you
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 done-for-you job {job_id}")
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 done-for-you job {job_id}: {e}")
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 with auto_download
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': True,
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 so Andrew can handle manually
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
- # Auto-select best result and download
554
- best_index = audio_search_service.select_best(search_results)
555
- selected = results_dicts[best_index]
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 downloading state
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.DOWNLOADING_AUDIO,
652
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
563
653
  progress=10,
564
- message=f"Downloading from {selected.get('provider')}: {selected.get('artist')} - {selected.get('title')}",
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
- # Download audio
572
- try:
573
- is_torrent_source = selected.get('provider') in ['RED', 'OPS']
574
- is_remote_enabled = audio_search_service.is_remote_enabled()
575
- source_id = selected.get('source_id')
576
- source_name = selected.get('provider')
577
- target_file = selected.get('target_file')
578
- download_url = selected.get('url')
579
- remote_search_id = state_data_update.get('remote_search_id')
580
-
581
- if is_torrent_source and is_remote_enabled:
582
- # Remote torrent download - upload directly to GCS
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
- subject=f"Your Karaoke Video Order: {artist} - {title}",
693
- html_content=f"""
694
- <h2>Thank you for your order!</h2>
695
- <p>We've received your request for a karaoke video:</p>
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 Andrew
724
- email_service.send_email(
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
- subject=f"[Done For You Order] {artist} - {title}",
727
- html_content=f"""
728
- <h2>New Done-For-You Order</h2>
729
- <p>A customer has ordered a karaoke video:</p>
730
- <ul>
731
- <li><strong>Customer:</strong> {customer_email}</li>
732
- <li><strong>Artist:</strong> {artist}</li>
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 done-for-you order notifications for job {job_id}")
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 done-for-you order: {e}", exc_info=True)
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] Done For You Order: {artist} - {title}",
696
+ subject=f"[FAILED] Made For You Order: {artist} - {title}",
770
697
  html_content=f"""
771
- <h2>Done-For-You Order Failed</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
- Done-For-You Order Failed
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 done-for-you order
838
- if metadata.get("order_type") == "done_for_you":
839
- # Handle done-for-you order - create a job
840
- await _handle_done_for_you_order(
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