karaoke-gen 0.96.0__py3-none-any.whl → 0.99.3__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 (30) hide show
  1. backend/api/routes/admin.py +184 -91
  2. backend/api/routes/audio_search.py +16 -6
  3. backend/api/routes/file_upload.py +57 -21
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/jobs.py +19 -0
  6. backend/api/routes/users.py +543 -44
  7. backend/main.py +25 -1
  8. backend/services/encoding_service.py +128 -31
  9. backend/services/job_manager.py +12 -1
  10. backend/services/langfuse_preloader.py +98 -0
  11. backend/services/nltk_preloader.py +122 -0
  12. backend/services/spacy_preloader.py +65 -0
  13. backend/services/stripe_service.py +96 -0
  14. backend/tests/emulator/conftest.py +22 -1
  15. backend/tests/test_job_manager.py +25 -8
  16. backend/tests/test_jobs_api.py +11 -1
  17. backend/tests/test_spacy_preloader.py +119 -0
  18. backend/utils/test_data.py +27 -0
  19. backend/workers/screens_worker.py +16 -6
  20. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
  21. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +30 -25
  22. lyrics_transcriber/correction/agentic/agent.py +17 -6
  23. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  24. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  25. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  26. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  27. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  28. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
  29. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
  30. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
@@ -32,8 +32,11 @@ from backend.models.user import (
32
32
  from backend.services.user_service import get_user_service, UserService, USERS_COLLECTION
33
33
  from backend.services.email_service import get_email_service, EmailService
34
34
  from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
35
+ from backend.services.theme_service import get_theme_service
35
36
  from backend.api.dependencies import require_admin
37
+ from backend.api.routes.file_upload import _prepare_theme_for_job
36
38
  from backend.services.auth_service import UserType
39
+ from backend.utils.test_data import is_test_email
37
40
 
38
41
 
39
42
  logger = logging.getLogger(__name__)
@@ -57,6 +60,16 @@ class CreateCheckoutResponse(BaseModel):
57
60
  message: str
58
61
 
59
62
 
63
+ class DoneForYouCheckoutRequest(BaseModel):
64
+ """Request to create a done-for-you karaoke video order."""
65
+ email: EmailStr
66
+ artist: str
67
+ title: str
68
+ source_type: str = "search" # search, youtube, or upload
69
+ youtube_url: Optional[str] = None
70
+ notes: Optional[str] = None
71
+
72
+
60
73
  class CreditPackage(BaseModel):
61
74
  """Credit package information."""
62
75
  id: str
@@ -327,10 +340,459 @@ async def create_checkout(
327
340
  )
328
341
 
329
342
 
343
+ @router.post("/done-for-you/checkout", response_model=CreateCheckoutResponse)
344
+ async def create_done_for_you_checkout(
345
+ request: DoneForYouCheckoutRequest,
346
+ stripe_service: StripeService = Depends(get_stripe_service),
347
+ ):
348
+ """
349
+ Create a Stripe checkout session for a done-for-you karaoke video order.
350
+
351
+ This is the full-service option where Nomad Karaoke handles everything:
352
+ - Finding or processing the audio
353
+ - Reviewing and correcting lyrics
354
+ - Selecting the best instrumental
355
+ - Generating the final video
356
+
357
+ $15 with 24-hour delivery guarantee.
358
+ No authentication required - customer email is provided in the request.
359
+ """
360
+ if not stripe_service.is_configured():
361
+ raise HTTPException(status_code=503, detail="Payment processing is not available")
362
+
363
+ success, checkout_url, message = stripe_service.create_done_for_you_checkout_session(
364
+ customer_email=request.email,
365
+ artist=request.artist,
366
+ title=request.title,
367
+ source_type=request.source_type,
368
+ youtube_url=request.youtube_url,
369
+ notes=request.notes,
370
+ )
371
+
372
+ if not success or not checkout_url:
373
+ raise HTTPException(status_code=400, detail=message)
374
+
375
+ return CreateCheckoutResponse(
376
+ status="success",
377
+ checkout_url=checkout_url,
378
+ message=message,
379
+ )
380
+
381
+
330
382
  # =============================================================================
331
383
  # Stripe Webhooks
332
384
  # =============================================================================
333
385
 
386
+ # Admin email for done-for-you order notifications
387
+ ADMIN_EMAIL = "andrew@nomadkaraoke.com"
388
+
389
+
390
+ async def _handle_done_for_you_order(
391
+ session_id: str,
392
+ metadata: dict,
393
+ user_service: UserService,
394
+ email_service: EmailService,
395
+ ) -> None:
396
+ """
397
+ Handle a completed done-for-you order by creating a job and notifying Andrew.
398
+
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.
402
+
403
+ Args:
404
+ session_id: Stripe checkout session ID
405
+ metadata: Order metadata from Stripe session
406
+ user_service: User service for marking session processed
407
+ email_service: Email service for notifications
408
+ """
409
+ from backend.models.job import JobCreate, JobStatus
410
+ from backend.services.job_manager import JobManager
411
+ from backend.services.worker_service import get_worker_service
412
+ from backend.services.audio_search_service import (
413
+ get_audio_search_service,
414
+ NoResultsError,
415
+ AudioSearchError,
416
+ )
417
+ from backend.services.storage_service import StorageService
418
+ import asyncio
419
+ import tempfile
420
+ import os
421
+
422
+ customer_email = metadata.get("customer_email", "")
423
+ artist = metadata.get("artist", "Unknown Artist")
424
+ title = metadata.get("title", "Unknown Title")
425
+ source_type = metadata.get("source_type", "search")
426
+ youtube_url = metadata.get("youtube_url")
427
+ notes = metadata.get("notes", "")
428
+
429
+ logger.info(
430
+ f"Processing done-for-you order: {artist} - {title} for {customer_email} "
431
+ f"(session: {session_id}, source_type: {source_type})"
432
+ )
433
+
434
+ try:
435
+ job_manager = JobManager()
436
+ worker_service = get_worker_service()
437
+ storage_service = StorageService()
438
+
439
+ # Apply default theme (Nomad) - same as audio_search endpoint
440
+ theme_service = get_theme_service()
441
+ effective_theme_id = theme_service.get_default_theme_id()
442
+ 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
447
+ job_create = JobCreate(
448
+ url=youtube_url if youtube_url else None,
449
+ artist=artist,
450
+ title=title,
451
+ user_email=customer_email, # Customer owns the job
452
+ 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
455
+ audio_search_artist=artist if not youtube_url else None,
456
+ audio_search_title=title if not youtube_url else None,
457
+ auto_download=True, # Auto-select best audio source
458
+ )
459
+ job = job_manager.create_job(job_create)
460
+ job_id = job.job_id
461
+
462
+ logger.info(f"Created done-for-you job {job_id} for {customer_email}")
463
+
464
+ # Prepare theme style assets for the job (same as audio_search endpoint)
465
+ if effective_theme_id:
466
+ try:
467
+ 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
469
+ )
470
+ theme_update = {
471
+ 'style_params_gcs_path': style_params_path,
472
+ 'style_assets': theme_style_assets,
473
+ }
474
+ if youtube_desc:
475
+ theme_update['youtube_description_template'] = youtube_desc
476
+ job_manager.update_job(job_id, theme_update)
477
+ logger.info(f"Applied theme '{effective_theme_id}' to done-for-you job {job_id}")
478
+ except Exception as e:
479
+ logger.warning(f"Failed to prepare theme for done-for-you job {job_id}: {e}")
480
+
481
+ # Mark session as processed for idempotency
482
+ # Note: Using internal method since this isn't a credit transaction
483
+ user_service._mark_stripe_session_processed(
484
+ stripe_session_id=session_id,
485
+ email=customer_email,
486
+ amount=0 # No credits, just tracking the session
487
+ )
488
+
489
+ # Handle based on whether we have a YouTube URL or need to search
490
+ if youtube_url:
491
+ # URL provided - trigger workers directly
492
+ logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
493
+ await asyncio.gather(
494
+ worker_service.trigger_audio_worker(job_id),
495
+ worker_service.trigger_lyrics_worker(job_id)
496
+ )
497
+ else:
498
+ # No URL - use audio search flow with auto_download
499
+ logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
500
+
501
+ # Update job with audio search fields
502
+ job_manager.update_job(job_id, {
503
+ 'audio_search_artist': artist,
504
+ 'audio_search_title': title,
505
+ 'auto_download': True,
506
+ })
507
+
508
+ # Transition to searching state
509
+ job_manager.transition_to_state(
510
+ job_id=job_id,
511
+ new_status=JobStatus.SEARCHING_AUDIO,
512
+ progress=5,
513
+ message=f"Searching for audio: {artist} - {title}"
514
+ )
515
+
516
+ # Perform audio search
517
+ audio_search_service = get_audio_search_service()
518
+
519
+ try:
520
+ search_results = audio_search_service.search(artist, title)
521
+ except NoResultsError as e:
522
+ # No results found - transition to AWAITING_AUDIO_SELECTION so Andrew can handle manually
523
+ logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
524
+ job_manager.transition_to_state(
525
+ job_id=job_id,
526
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
527
+ progress=10,
528
+ message=f"No automatic audio sources found. Manual intervention required."
529
+ )
530
+ # Don't fail the job - Andrew can manually provide audio
531
+ search_results = None
532
+ except AudioSearchError as e:
533
+ logger.error(f"Job {job_id}: Audio search failed: {e}")
534
+ job_manager.transition_to_state(
535
+ job_id=job_id,
536
+ new_status=JobStatus.AWAITING_AUDIO_SELECTION,
537
+ progress=10,
538
+ message=f"Audio search error. Manual intervention required."
539
+ )
540
+ search_results = None
541
+
542
+ if search_results:
543
+ # Store search results in state_data
544
+ results_dicts = [r.to_dict() for r in search_results]
545
+ state_data_update = {
546
+ 'audio_search_results': results_dicts,
547
+ 'audio_search_count': len(results_dicts),
548
+ }
549
+ if audio_search_service.last_remote_search_id:
550
+ state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
551
+ job_manager.update_job(job_id, {'state_data': state_data_update})
552
+
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')}")
558
+
559
+ # Transition to downloading state
560
+ job_manager.transition_to_state(
561
+ job_id=job_id,
562
+ new_status=JobStatus.DOWNLOADING_AUDIO,
563
+ 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
+ }
569
+ )
570
+
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(
691
+ 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(),
721
+ )
722
+
723
+ # Send notification email to Andrew
724
+ email_service.send_email(
725
+ 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(),
759
+ )
760
+
761
+ logger.info(f"Sent done-for-you order notifications for job {job_id}")
762
+
763
+ except Exception as e:
764
+ logger.error(f"Error processing done-for-you order: {e}", exc_info=True)
765
+ # Still try to notify Andrew of the failure
766
+ try:
767
+ email_service.send_email(
768
+ to_email=ADMIN_EMAIL,
769
+ subject=f"[FAILED] Done For You Order: {artist} - {title}",
770
+ html_content=f"""
771
+ <h2>Done-For-You Order Failed</h2>
772
+ <p>An error occurred processing this order:</p>
773
+ <ul>
774
+ <li><strong>Customer:</strong> {customer_email}</li>
775
+ <li><strong>Artist:</strong> {artist}</li>
776
+ <li><strong>Title:</strong> {title}</li>
777
+ <li><strong>Error:</strong> {str(e)}</li>
778
+ </ul>
779
+ <p>Please manually create this job and notify the customer.</p>
780
+ """,
781
+ text_content=f"""
782
+ Done-For-You Order Failed
783
+
784
+ Customer: {customer_email}
785
+ Artist: {artist}
786
+ Title: {title}
787
+ Error: {str(e)}
788
+
789
+ Please manually create this job and notify the customer.
790
+ """.strip(),
791
+ )
792
+ except Exception as email_error:
793
+ logger.error(f"Failed to send error notification: {email_error}")
794
+
795
+
334
796
  @router.post("/webhooks/stripe")
335
797
  async def stripe_webhook(
336
798
  request: Request,
@@ -365,30 +827,41 @@ async def stripe_webhook(
365
827
  if event_type == "checkout.session.completed":
366
828
  session = event["data"]["object"]
367
829
  session_id = session.get("id")
830
+ metadata = session.get("metadata", {})
368
831
 
369
832
  # Idempotency check: Skip if this session was already processed
370
833
  if session_id and user_service.is_stripe_session_processed(session_id):
371
834
  logger.info(f"Skipping already processed session: {session_id}")
372
835
  return {"status": "received", "type": event_type, "note": "already_processed"}
373
836
 
374
- # Process the completed checkout
375
- success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
376
-
377
- if success and user_email and credits > 0:
378
- # Add credits to user account
379
- ok, new_balance, credit_msg = user_service.add_credits(
380
- email=user_email,
381
- amount=credits,
382
- reason="stripe_purchase",
383
- stripe_session_id=session_id,
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(
841
+ session_id=session_id,
842
+ metadata=metadata,
843
+ user_service=user_service,
844
+ email_service=email_service,
384
845
  )
385
-
386
- if ok:
387
- # Send confirmation email
388
- email_service.send_credits_added(user_email, credits, new_balance)
389
- logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
390
- else:
391
- logger.error(f"Failed to add credits: {credit_msg}")
846
+ else:
847
+ # Handle regular credit purchase
848
+ success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
849
+
850
+ if success and user_email and credits > 0:
851
+ # Add credits to user account
852
+ ok, new_balance, credit_msg = user_service.add_credits(
853
+ email=user_email,
854
+ amount=credits,
855
+ reason="stripe_purchase",
856
+ stripe_session_id=session_id,
857
+ )
858
+
859
+ if ok:
860
+ # Send confirmation email
861
+ email_service.send_credits_added(user_email, credits, new_balance)
862
+ logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
863
+ else:
864
+ logger.error(f"Failed to add credits: {credit_msg}")
392
865
 
393
866
  elif event_type == "checkout.session.expired":
394
867
  logger.info(f"Checkout session expired: {event['data']['object'].get('id')}")
@@ -663,6 +1136,7 @@ async def list_users(
663
1136
  sort_by: str = "created_at",
664
1137
  sort_order: str = "desc",
665
1138
  include_inactive: bool = False,
1139
+ exclude_test: bool = True,
666
1140
  auth_data: Tuple[str, UserType, int] = Depends(require_admin),
667
1141
  user_service: UserService = Depends(get_user_service),
668
1142
  ):
@@ -676,6 +1150,7 @@ async def list_users(
676
1150
  sort_by: Field to sort by (created_at, last_login_at, credits, email)
677
1151
  sort_order: Sort direction (asc, desc)
678
1152
  include_inactive: Include disabled users
1153
+ exclude_test: If True (default), exclude test users (e.g., @inbox.testmail.app)
679
1154
  """
680
1155
  from google.cloud import firestore
681
1156
  from google.cloud.firestore_v1 import FieldFilter
@@ -704,9 +1179,14 @@ async def list_users(
704
1179
  else:
705
1180
  query = query.order_by("created_at", direction=direction)
706
1181
 
707
- # Get total count (without pagination) for has_more calculation
1182
+ # Get all docs and filter in Python
708
1183
  # Note: This is expensive for large datasets, consider caching
709
1184
  all_docs = list(query.stream())
1185
+
1186
+ # Filter out test users if exclude_test is True
1187
+ if exclude_test:
1188
+ all_docs = [d for d in all_docs if not is_test_email(d.to_dict().get('email', ''))]
1189
+
710
1190
  total_count = len(all_docs)
711
1191
 
712
1192
  # Apply pagination manually (Firestore doesn't support offset well)
@@ -942,44 +1422,64 @@ async def list_beta_feedback(
942
1422
 
943
1423
  @router.get("/admin/beta/stats")
944
1424
  async def get_beta_stats(
1425
+ exclude_test: bool = True,
945
1426
  auth_data: Tuple[str, UserType, int] = Depends(require_admin),
946
1427
  user_service: UserService = Depends(get_user_service),
947
1428
  ):
948
1429
  """
949
1430
  Get beta tester program statistics (admin only).
1431
+
1432
+ Args:
1433
+ exclude_test: If True (default), exclude test users from beta stats
950
1434
  """
951
1435
  from google.cloud.firestore_v1 import FieldFilter
952
1436
  from google.cloud.firestore_v1 import aggregation
953
1437
 
954
- # Count beta testers by status using efficient aggregation queries
955
1438
  users_collection = user_service.db.collection(USERS_COLLECTION)
956
1439
 
957
- # Helper function to get count using aggregation
958
- def get_count(query) -> int:
959
- agg_query = aggregation.AggregationQuery(query)
960
- agg_query.count(alias="count")
961
- results = agg_query.get()
962
- return results[0][0].value if results else 0
963
-
964
- total_beta_testers = get_count(
965
- users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
966
- )
967
-
968
- active_testers = get_count(
969
- users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
970
- )
1440
+ if exclude_test:
1441
+ # Stream and filter in Python since Firestore doesn't support "not ends with"
1442
+ all_beta_users = []
1443
+ for doc in users_collection.where(filter=FieldFilter("is_beta_tester", "==", True)).stream():
1444
+ data = doc.to_dict()
1445
+ if not is_test_email(data.get("email", "")):
1446
+ all_beta_users.append(data)
971
1447
 
972
- pending_feedback = get_count(
973
- users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
974
- )
1448
+ total_beta_testers = len(all_beta_users)
1449
+ active_testers = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "active")
1450
+ pending_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "pending_feedback")
1451
+ completed_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "completed")
975
1452
 
976
- completed_feedback = get_count(
977
- users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
978
- )
979
-
980
- # Get average ratings from feedback
981
- feedback_docs = list(user_service.db.collection("beta_feedback").stream())
1453
+ # Filter feedback by non-test users
1454
+ all_feedback = []
1455
+ for doc in user_service.db.collection("beta_feedback").stream():
1456
+ data = doc.to_dict()
1457
+ if not is_test_email(data.get("user_email", "")):
1458
+ all_feedback.append(data)
1459
+ feedback_docs = all_feedback
1460
+ else:
1461
+ # Use efficient aggregation queries when including test data
1462
+ def get_count(query) -> int:
1463
+ agg_query = aggregation.AggregationQuery(query)
1464
+ agg_query.count(alias="count")
1465
+ results = agg_query.get()
1466
+ return results[0][0].value if results else 0
1467
+
1468
+ total_beta_testers = get_count(
1469
+ users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
1470
+ )
1471
+ active_testers = get_count(
1472
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
1473
+ )
1474
+ pending_feedback = get_count(
1475
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
1476
+ )
1477
+ completed_feedback = get_count(
1478
+ users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
1479
+ )
1480
+ feedback_docs = [doc.to_dict() for doc in user_service.db.collection("beta_feedback").stream()]
982
1481
 
1482
+ # Calculate average ratings from feedback
983
1483
  avg_overall = 0
984
1484
  avg_ease = 0
985
1485
  avg_accuracy = 0
@@ -987,8 +1487,7 @@ async def get_beta_stats(
987
1487
 
988
1488
  if feedback_docs:
989
1489
  total = len(feedback_docs)
990
- for doc in feedback_docs:
991
- data = doc.to_dict()
1490
+ for data in feedback_docs:
992
1491
  avg_overall += data.get("overall_rating", 0)
993
1492
  avg_ease += data.get("ease_of_use_rating", 0)
994
1493
  avg_accuracy += data.get("lyrics_accuracy_rating", 0)