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.
- backend/api/routes/admin.py +184 -91
- backend/api/routes/audio_search.py +16 -6
- backend/api/routes/file_upload.py +57 -21
- backend/api/routes/health.py +65 -0
- backend/api/routes/jobs.py +19 -0
- backend/api/routes/users.py +543 -44
- backend/main.py +25 -1
- backend/services/encoding_service.py +128 -31
- backend/services/job_manager.py +12 -1
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +96 -0
- backend/tests/emulator/conftest.py +22 -1
- backend/tests/test_job_manager.py +25 -8
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_spacy_preloader.py +119 -0
- backend/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +30 -25
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
backend/api/routes/users.py
CHANGED
|
@@ -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
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
973
|
-
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
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
|
|
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)
|