karaoke-gen 0.99.3__py3-none-any.whl → 0.101.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +13 -2
  3. backend/api/routes/file_upload.py +42 -1
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +9 -1
  6. backend/api/routes/review.py +13 -6
  7. backend/api/routes/tenant.py +120 -0
  8. backend/api/routes/users.py +167 -245
  9. backend/main.py +6 -1
  10. backend/middleware/__init__.py +7 -1
  11. backend/middleware/tenant.py +192 -0
  12. backend/models/job.py +19 -3
  13. backend/models/tenant.py +208 -0
  14. backend/models/user.py +18 -0
  15. backend/services/email_service.py +253 -6
  16. backend/services/firestore_service.py +6 -0
  17. backend/services/job_manager.py +32 -1
  18. backend/services/stripe_service.py +61 -35
  19. backend/services/tenant_service.py +285 -0
  20. backend/services/user_service.py +85 -7
  21. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  22. backend/tests/test_admin_job_files.py +337 -0
  23. backend/tests/test_admin_job_reset.py +384 -0
  24. backend/tests/test_admin_job_update.py +326 -0
  25. backend/tests/test_email_service.py +233 -0
  26. backend/tests/test_impersonation.py +223 -0
  27. backend/tests/test_job_creation_regression.py +4 -0
  28. backend/tests/test_job_manager.py +146 -1
  29. backend/tests/test_made_for_you.py +2086 -0
  30. backend/tests/test_models.py +139 -0
  31. backend/tests/test_tenant_api.py +350 -0
  32. backend/tests/test_tenant_middleware.py +345 -0
  33. backend/tests/test_tenant_models.py +406 -0
  34. backend/tests/test_tenant_service.py +418 -0
  35. backend/workers/video_worker.py +8 -3
  36. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  37. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
  38. lyrics_transcriber/frontend/src/api.ts +13 -5
  39. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  40. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  41. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  42. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.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
 
@@ -60,8 +80,8 @@ class CreateCheckoutResponse(BaseModel):
60
80
  message: str
61
81
 
62
82
 
63
- class DoneForYouCheckoutRequest(BaseModel):
64
- """Request to create a done-for-you karaoke video order."""
83
+ class MadeForYouCheckoutRequest(BaseModel):
84
+ """Request to create a made-for-you karaoke video order."""
65
85
  email: EmailStr
66
86
  artist: str
67
87
  title: str
@@ -112,7 +132,11 @@ async def send_magic_link(
112
132
 
113
133
  The user will receive an email with a link that logs them in.
114
134
  Links expire after 15 minutes and can only be used once.
135
+
136
+ For white-label tenants, validates email domain against tenant config.
115
137
  """
138
+ from backend.middleware.tenant import get_tenant_from_request, get_tenant_config_from_request
139
+
116
140
  # Check if email service is configured
117
141
  if not email_service.is_configured():
118
142
  logger.error("Email service not configured - cannot send magic links")
@@ -123,19 +147,39 @@ async def send_magic_link(
123
147
 
124
148
  email = request.email.lower()
125
149
 
150
+ # Get tenant context from middleware
151
+ tenant_id = get_tenant_from_request(http_request)
152
+ tenant_config = get_tenant_config_from_request(http_request)
153
+
154
+ # Validate email domain for tenant if configured
155
+ if tenant_config and tenant_config.auth.allowed_email_domains:
156
+ if not tenant_config.is_email_allowed(email):
157
+ logger.warning(f"Email domain not allowed for tenant {tenant_id}: {_mask_email(email)}")
158
+ # Return success anyway to prevent email enumeration
159
+ return SendMagicLinkResponse(
160
+ status="success",
161
+ message="If this email is registered, you will receive a sign-in link shortly."
162
+ )
163
+
126
164
  # Get client info for security logging
127
165
  ip_address = http_request.client.host if http_request.client else None
128
166
  user_agent = http_request.headers.get("user-agent")
129
167
 
130
- # Create magic link token
168
+ # Create magic link token with tenant context
131
169
  magic_link = user_service.create_magic_link(
132
170
  email,
133
171
  ip_address=ip_address,
134
- user_agent=user_agent
172
+ user_agent=user_agent,
173
+ tenant_id=tenant_id
135
174
  )
136
175
 
137
- # Send email
138
- sent = email_service.send_magic_link(email, magic_link.token)
176
+ # Get tenant-specific sender email
177
+ sender_email = None
178
+ if tenant_config:
179
+ sender_email = tenant_config.get_sender_email()
180
+
181
+ # Send email (with tenant-specific sender if configured)
182
+ sent = email_service.send_magic_link(email, magic_link.token, sender_email=sender_email)
139
183
 
140
184
  if not sent:
141
185
  logger.error(f"Failed to send magic link email to {email}")
@@ -159,18 +203,26 @@ async def verify_magic_link(
159
203
  Verify a magic link token and create a session.
160
204
 
161
205
  Returns a session token that should be stored and used for subsequent requests.
206
+ The session will be associated with the tenant from the magic link.
162
207
  """
208
+ # Reject empty tokens early to avoid invalid Firestore document paths
209
+ if not token or not token.strip():
210
+ raise HTTPException(status_code=401, detail="Invalid token")
211
+
163
212
  # Get client info
164
213
  ip_address = http_request.client.host if http_request.client else None
165
214
  user_agent = http_request.headers.get("user-agent")
166
215
 
167
216
  # Check if this is a first login BEFORE verification (which sets last_login_at)
168
217
  # We need to get the user's state before verify_magic_link updates it
218
+ # Also extract tenant_id from the magic link for session creation
169
219
  from backend.services.user_service import MAGIC_LINKS_COLLECTION
170
220
  magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
171
221
  is_first_login = False
222
+ magic_link_tenant_id = None
172
223
  if magic_link_doc.exists:
173
224
  magic_link_data = magic_link_doc.to_dict()
225
+ magic_link_tenant_id = magic_link_data.get('tenant_id')
174
226
  pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
175
227
  if pre_verify_user:
176
228
  is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
@@ -181,18 +233,19 @@ async def verify_magic_link(
181
233
  if not success or not user:
182
234
  raise HTTPException(status_code=401, detail=message)
183
235
 
184
- # Create session
236
+ # Create session with tenant context from the magic link
185
237
  session = user_service.create_session(
186
238
  user.email,
187
239
  ip_address=ip_address,
188
- user_agent=user_agent
240
+ user_agent=user_agent,
241
+ tenant_id=magic_link_tenant_id
189
242
  )
190
243
 
191
244
  # Send welcome email to first-time users
192
245
  if is_first_login:
193
246
  email_service.send_welcome_email(user.email, user.credits)
194
247
 
195
- # Return user info
248
+ # Return user info with tenant_id
196
249
  user_public = UserPublic(
197
250
  email=user.email,
198
251
  role=user.role,
@@ -200,6 +253,7 @@ async def verify_magic_link(
200
253
  display_name=user.display_name,
201
254
  total_jobs_created=user.total_jobs_created,
202
255
  total_jobs_completed=user.total_jobs_completed,
256
+ tenant_id=user.tenant_id,
203
257
  )
204
258
 
205
259
  return VerifyMagicLinkResponse(
@@ -340,13 +394,13 @@ async def create_checkout(
340
394
  )
341
395
 
342
396
 
343
- @router.post("/done-for-you/checkout", response_model=CreateCheckoutResponse)
344
- async def create_done_for_you_checkout(
345
- request: DoneForYouCheckoutRequest,
397
+ @router.post("/made-for-you/checkout", response_model=CreateCheckoutResponse)
398
+ async def create_made_for_you_checkout(
399
+ request: MadeForYouCheckoutRequest,
346
400
  stripe_service: StripeService = Depends(get_stripe_service),
347
401
  ):
348
402
  """
349
- Create a Stripe checkout session for a done-for-you karaoke video order.
403
+ Create a Stripe checkout session for a made-for-you karaoke video order.
350
404
 
351
405
  This is the full-service option where Nomad Karaoke handles everything:
352
406
  - Finding or processing the audio
@@ -360,7 +414,7 @@ async def create_done_for_you_checkout(
360
414
  if not stripe_service.is_configured():
361
415
  raise HTTPException(status_code=503, detail="Payment processing is not available")
362
416
 
363
- success, checkout_url, message = stripe_service.create_done_for_you_checkout_session(
417
+ success, checkout_url, message = stripe_service.create_made_for_you_checkout_session(
364
418
  customer_email=request.email,
365
419
  artist=request.artist,
366
420
  title=request.title,
@@ -383,22 +437,28 @@ async def create_done_for_you_checkout(
383
437
  # Stripe Webhooks
384
438
  # =============================================================================
385
439
 
386
- # Admin email for done-for-you order notifications
387
- ADMIN_EMAIL = "andrew@nomadkaraoke.com"
440
+ # Admin email for made-for-you order notifications
441
+ ADMIN_EMAIL = "madeforyou@nomadkaraoke.com"
388
442
 
389
443
 
390
- async def _handle_done_for_you_order(
444
+ async def _handle_made_for_you_order(
391
445
  session_id: str,
392
446
  metadata: dict,
393
447
  user_service: UserService,
394
448
  email_service: EmailService,
395
449
  ) -> None:
396
450
  """
397
- Handle a completed done-for-you order by creating a job and notifying Andrew.
451
+ Handle a completed made-for-you order by creating a job and notifying Andrew.
452
+
453
+ The made-for-you flow:
454
+ 1. Job is created with made_for_you=True, owned by admin during processing
455
+ 2. For search orders: audio search runs, results stored, job pauses at AWAITING_AUDIO_SELECTION
456
+ 3. Admin receives notification email with link to select audio source
457
+ 4. Customer receives order confirmation email
458
+ 5. Admin selects audio in UI, job proceeds through normal pipeline
459
+ 6. On completion, ownership transfers to customer
398
460
 
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.
461
+ For orders with a YouTube URL, workers are triggered immediately (no search needed).
402
462
 
403
463
  Args:
404
464
  session_id: Stripe checkout session ID
@@ -415,6 +475,7 @@ async def _handle_done_for_you_order(
415
475
  AudioSearchError,
416
476
  )
417
477
  from backend.services.storage_service import StorageService
478
+ from backend.config import get_settings
418
479
  import asyncio
419
480
  import tempfile
420
481
  import os
@@ -427,7 +488,7 @@ async def _handle_done_for_you_order(
427
488
  notes = metadata.get("notes", "")
428
489
 
429
490
  logger.info(
430
- f"Processing done-for-you order: {artist} - {title} for {customer_email} "
491
+ f"Processing made-for-you order: {artist} - {title} for {customer_email} "
431
492
  f"(session: {session_id}, source_type: {source_type})"
432
493
  )
433
494
 
@@ -435,37 +496,58 @@ async def _handle_done_for_you_order(
435
496
  job_manager = JobManager()
436
497
  worker_service = get_worker_service()
437
498
  storage_service = StorageService()
499
+ settings = get_settings()
438
500
 
439
501
  # Apply default theme (Nomad) - same as audio_search endpoint
440
502
  theme_service = get_theme_service()
441
503
  effective_theme_id = theme_service.get_default_theme_id()
442
504
  if effective_theme_id:
443
- logger.info(f"Applying default theme '{effective_theme_id}' for done-for-you order")
444
-
445
- # Create job for the customer
446
- # Note: done-for-you jobs should NOT be non_interactive - Andrew needs to review
505
+ logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
506
+
507
+ # Get distribution defaults from settings (same as audio_search endpoint)
508
+ effective_dropbox_path = settings.default_dropbox_path
509
+ effective_gdrive_folder_id = settings.default_gdrive_folder_id
510
+ effective_enable_youtube_upload = settings.default_enable_youtube_upload
511
+ effective_brand_prefix = settings.default_brand_prefix
512
+ effective_discord_webhook_url = settings.default_discord_webhook_url
513
+ effective_youtube_description = settings.default_youtube_description
514
+
515
+ # Create job with admin ownership during processing
516
+ # CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
517
+ # auto_download=False to pause at audio selection for admin to choose
447
518
  job_create = JobCreate(
448
519
  url=youtube_url if youtube_url else None,
449
520
  artist=artist,
450
521
  title=title,
451
- user_email=customer_email, # Customer owns the job
522
+ user_email=ADMIN_EMAIL, # Admin owns during processing
452
523
  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
524
+ non_interactive=False, # Admin will review lyrics/instrumental
525
+ # Made-for-you specific fields
526
+ made_for_you=True, # Flag for ownership transfer on completion
527
+ customer_email=customer_email, # Customer email for final delivery
528
+ customer_notes=notes if notes else None, # Customer's special requests
529
+ # Audio search fields for search-based orders
455
530
  audio_search_artist=artist if not youtube_url else None,
456
531
  audio_search_title=title if not youtube_url else None,
457
- auto_download=True, # Auto-select best audio source
532
+ auto_download=False, # Pause at audio selection for admin to choose
533
+ # Distribution settings from server defaults
534
+ enable_youtube_upload=effective_enable_youtube_upload,
535
+ dropbox_path=effective_dropbox_path,
536
+ gdrive_folder_id=effective_gdrive_folder_id,
537
+ brand_prefix=effective_brand_prefix,
538
+ discord_webhook_url=effective_discord_webhook_url,
539
+ youtube_description=effective_youtube_description,
458
540
  )
459
541
  job = job_manager.create_job(job_create)
460
542
  job_id = job.job_id
461
543
 
462
- logger.info(f"Created done-for-you job {job_id} for {customer_email}")
544
+ logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
463
545
 
464
546
  # Prepare theme style assets for the job (same as audio_search endpoint)
465
547
  if effective_theme_id:
466
548
  try:
467
549
  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
550
+ job_id, effective_theme_id, None # No color overrides for made-for-you
469
551
  )
470
552
  theme_update = {
471
553
  'style_params_gcs_path': style_params_path,
@@ -474,9 +556,9 @@ async def _handle_done_for_you_order(
474
556
  if youtube_desc:
475
557
  theme_update['youtube_description_template'] = youtube_desc
476
558
  job_manager.update_job(job_id, theme_update)
477
- logger.info(f"Applied theme '{effective_theme_id}' to done-for-you job {job_id}")
559
+ logger.info(f"Applied theme '{effective_theme_id}' to made-for-you job {job_id}")
478
560
  except Exception as e:
479
- logger.warning(f"Failed to prepare theme for done-for-you job {job_id}: {e}")
561
+ logger.warning(f"Failed to prepare theme for made-for-you job {job_id}: {e}")
480
562
 
481
563
  # Mark session as processed for idempotency
482
564
  # Note: Using internal method since this isn't a credit transaction
@@ -486,23 +568,27 @@ async def _handle_done_for_you_order(
486
568
  amount=0 # No credits, just tracking the session
487
569
  )
488
570
 
571
+ # Initialize search_results for later use in email notification
572
+ search_results = None
573
+
489
574
  # Handle based on whether we have a YouTube URL or need to search
490
575
  if youtube_url:
491
- # URL provided - trigger workers directly
576
+ # URL provided - trigger workers directly (no audio selection needed)
492
577
  logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
493
578
  await asyncio.gather(
494
579
  worker_service.trigger_audio_worker(job_id),
495
580
  worker_service.trigger_lyrics_worker(job_id)
496
581
  )
497
582
  else:
498
- # No URL - use audio search flow with auto_download
583
+ # No URL - use audio search flow, pause for admin selection
584
+ # Made-for-you jobs require admin to select audio source
499
585
  logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
500
586
 
501
587
  # Update job with audio search fields
502
588
  job_manager.update_job(job_id, {
503
589
  'audio_search_artist': artist,
504
590
  'audio_search_title': title,
505
- 'auto_download': True,
591
+ 'auto_download': False, # Admin must select
506
592
  })
507
593
 
508
594
  # Transition to searching state
@@ -519,7 +605,8 @@ async def _handle_done_for_you_order(
519
605
  try:
520
606
  search_results = audio_search_service.search(artist, title)
521
607
  except NoResultsError as e:
522
- # No results found - transition to AWAITING_AUDIO_SELECTION so Andrew can handle manually
608
+ # No results found - still transition to AWAITING_AUDIO_SELECTION
609
+ # Admin can manually provide audio
523
610
  logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
524
611
  job_manager.transition_to_state(
525
612
  job_id=job_id,
@@ -527,7 +614,6 @@ async def _handle_done_for_you_order(
527
614
  progress=10,
528
615
  message=f"No automatic audio sources found. Manual intervention required."
529
616
  )
530
- # Don't fail the job - Andrew can manually provide audio
531
617
  search_results = None
532
618
  except AudioSearchError as e:
533
619
  logger.error(f"Job {job_id}: Audio search failed: {e}")
@@ -540,7 +626,7 @@ async def _handle_done_for_you_order(
540
626
  search_results = None
541
627
 
542
628
  if search_results:
543
- # Store search results in state_data
629
+ # Store search results in state_data for admin to review
544
630
  results_dicts = [r.to_dict() for r in search_results]
545
631
  state_data_update = {
546
632
  'audio_search_results': results_dicts,
@@ -550,225 +636,61 @@ async def _handle_done_for_you_order(
550
636
  state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
551
637
  job_manager.update_job(job_id, {'state_data': state_data_update})
552
638
 
553
- # Auto-select best result and download
554
- best_index = audio_search_service.select_best(search_results)
555
- selected = results_dicts[best_index]
639
+ # Transition to AWAITING_AUDIO_SELECTION for admin to choose
640
+ # Do NOT auto-select or download - admin must review and select
641
+ logger.info(f"Job {job_id}: Found {len(results_dicts)} audio sources, awaiting admin selection")
556
642
 
557
- logger.info(f"Job {job_id}: Auto-selected result {best_index}: {selected.get('provider')} - {selected.get('title')}")
558
-
559
- # Transition to downloading state
643
+ # Transition to AWAITING_AUDIO_SELECTION for admin to choose
644
+ # Admin will select from results in the UI, then job proceeds
560
645
  job_manager.transition_to_state(
561
646
  job_id=job_id,
562
- new_status=JobStatus.DOWNLOADING_AUDIO,
647
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
563
648
  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
- }
649
+ message=f"Found {len(results_dicts)} audio sources. Awaiting admin selection."
569
650
  )
570
651
 
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(
652
+ # Get audio source count for admin notification
653
+ # (search_results may be set from the search flow above, or None for YouTube URL orders)
654
+ audio_source_count = len(search_results) if search_results else 0
655
+
656
+ # Generate admin login token for one-click email access (24hr expiry)
657
+ admin_login = user_service.create_admin_login_token(
658
+ email=ADMIN_EMAIL,
659
+ expiry_hours=24,
660
+ )
661
+
662
+ # Send confirmation email to customer using professional template
663
+ email_service.send_made_for_you_order_confirmation(
691
664
  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(),
665
+ artist=artist,
666
+ title=title,
667
+ job_id=job_id,
668
+ notes=notes,
721
669
  )
722
670
 
723
- # Send notification email to Andrew
724
- email_service.send_email(
671
+ # Send notification email to admin using professional template
672
+ email_service.send_made_for_you_admin_notification(
725
673
  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(),
674
+ customer_email=customer_email,
675
+ artist=artist,
676
+ title=title,
677
+ job_id=job_id,
678
+ admin_login_token=admin_login.token,
679
+ notes=notes,
680
+ audio_source_count=audio_source_count,
759
681
  )
760
682
 
761
- logger.info(f"Sent done-for-you order notifications for job {job_id}")
683
+ logger.info(f"Sent made-for-you order notifications for job {job_id}")
762
684
 
763
685
  except Exception as e:
764
- logger.error(f"Error processing done-for-you order: {e}", exc_info=True)
686
+ logger.error(f"Error processing made-for-you order: {e}", exc_info=True)
765
687
  # Still try to notify Andrew of the failure
766
688
  try:
767
689
  email_service.send_email(
768
690
  to_email=ADMIN_EMAIL,
769
- subject=f"[FAILED] Done For You Order: {artist} - {title}",
691
+ subject=f"[FAILED] Made For You Order: {artist} - {title}",
770
692
  html_content=f"""
771
- <h2>Done-For-You Order Failed</h2>
693
+ <h2>Made-For-You Order Failed</h2>
772
694
  <p>An error occurred processing this order:</p>
773
695
  <ul>
774
696
  <li><strong>Customer:</strong> {customer_email}</li>
@@ -779,7 +701,7 @@ Deadline: 24 hours from now
779
701
  <p>Please manually create this job and notify the customer.</p>
780
702
  """,
781
703
  text_content=f"""
782
- Done-For-You Order Failed
704
+ Made-For-You Order Failed
783
705
 
784
706
  Customer: {customer_email}
785
707
  Artist: {artist}
@@ -834,10 +756,10 @@ async def stripe_webhook(
834
756
  logger.info(f"Skipping already processed session: {session_id}")
835
757
  return {"status": "received", "type": event_type, "note": "already_processed"}
836
758
 
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(
759
+ # Check if this is a made-for-you order
760
+ if metadata.get("order_type") == "made_for_you":
761
+ # Handle made-for-you order - create a job
762
+ await _handle_made_for_you_order(
841
763
  session_id=session_id,
842
764
  metadata=metadata,
843
765
  user_service=user_service,