karaoke-gen 0.96.0__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 (58) hide show
  1. backend/api/routes/admin.py +696 -92
  2. backend/api/routes/audio_search.py +29 -8
  3. backend/api/routes/file_upload.py +99 -22
  4. backend/api/routes/health.py +65 -0
  5. backend/api/routes/internal.py +6 -0
  6. backend/api/routes/jobs.py +28 -1
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +472 -51
  10. backend/main.py +31 -2
  11. backend/middleware/__init__.py +7 -1
  12. backend/middleware/tenant.py +192 -0
  13. backend/models/job.py +19 -3
  14. backend/models/tenant.py +208 -0
  15. backend/models/user.py +18 -0
  16. backend/services/email_service.py +253 -6
  17. backend/services/encoding_service.py +128 -31
  18. backend/services/firestore_service.py +6 -0
  19. backend/services/job_manager.py +44 -2
  20. backend/services/langfuse_preloader.py +98 -0
  21. backend/services/nltk_preloader.py +122 -0
  22. backend/services/spacy_preloader.py +65 -0
  23. backend/services/stripe_service.py +133 -11
  24. backend/services/tenant_service.py +285 -0
  25. backend/services/user_service.py +85 -7
  26. backend/tests/emulator/conftest.py +22 -1
  27. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  28. backend/tests/test_admin_job_files.py +337 -0
  29. backend/tests/test_admin_job_reset.py +384 -0
  30. backend/tests/test_admin_job_update.py +326 -0
  31. backend/tests/test_email_service.py +233 -0
  32. backend/tests/test_impersonation.py +223 -0
  33. backend/tests/test_job_creation_regression.py +4 -0
  34. backend/tests/test_job_manager.py +171 -9
  35. backend/tests/test_jobs_api.py +11 -1
  36. backend/tests/test_made_for_you.py +2086 -0
  37. backend/tests/test_models.py +139 -0
  38. backend/tests/test_spacy_preloader.py +119 -0
  39. backend/tests/test_tenant_api.py +350 -0
  40. backend/tests/test_tenant_middleware.py +345 -0
  41. backend/tests/test_tenant_models.py +406 -0
  42. backend/tests/test_tenant_service.py +418 -0
  43. backend/utils/test_data.py +27 -0
  44. backend/workers/screens_worker.py +16 -6
  45. backend/workers/video_worker.py +8 -3
  46. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  47. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
  48. lyrics_transcriber/correction/agentic/agent.py +17 -6
  49. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
  50. lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
  51. lyrics_transcriber/correction/anchor_sequence.py +151 -37
  52. lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
  53. lyrics_transcriber/correction/phrase_analyzer.py +18 -0
  54. lyrics_transcriber/frontend/src/api.ts +13 -5
  55. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  56. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  57. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  58. {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
@@ -33,10 +33,12 @@ from backend.services.audio_search_service import (
33
33
  NoResultsError,
34
34
  DownloadError,
35
35
  )
36
+ from backend.services.theme_service import get_theme_service
36
37
  from backend.config import get_settings
37
38
  from backend.version import VERSION
38
39
  from backend.api.dependencies import require_auth
39
40
  from backend.services.auth_service import UserType, AuthResult
41
+ from backend.middleware.tenant import get_tenant_config_from_request
40
42
  from pathlib import Path
41
43
 
42
44
  logger = logging.getLogger(__name__)
@@ -478,17 +480,25 @@ async def search_audio(
478
480
  ):
479
481
  """
480
482
  Search for audio by artist and title, creating a new job.
481
-
483
+
482
484
  This endpoint:
483
485
  1. Creates a job in PENDING state
484
486
  2. Searches for audio using flacfetch
485
487
  3. Either returns search results for user selection, or
486
488
  4. If auto_download=True, automatically selects best and starts processing
487
-
489
+
488
490
  Use cases:
489
491
  - Interactive mode (default): Returns results, user calls /select endpoint
490
492
  - Auto mode (auto_download=True): Automatically selects and downloads best
491
493
  """
494
+ # Check tenant feature flag
495
+ tenant_config = get_tenant_config_from_request(request)
496
+ if tenant_config and not tenant_config.features.audio_search:
497
+ raise HTTPException(
498
+ status_code=403,
499
+ detail="Audio search is not available for this portal"
500
+ )
501
+
492
502
  try:
493
503
  # Apply default distribution settings
494
504
  settings = get_settings()
@@ -534,9 +544,18 @@ async def search_audio(
534
544
  # Extract request metadata
535
545
  request_metadata = extract_request_metadata(request, created_from="audio_search")
536
546
 
547
+ # Apply default theme if none specified
548
+ # This ensures all karaoke videos use the Nomad theme by default
549
+ effective_theme_id = body.theme_id
550
+ if effective_theme_id is None:
551
+ theme_service = get_theme_service()
552
+ effective_theme_id = theme_service.get_default_theme_id()
553
+ if effective_theme_id:
554
+ logger.info(f"Applying default theme: {effective_theme_id}")
555
+
537
556
  # Resolve CDG/TXT defaults based on theme
538
557
  resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
539
- body.theme_id, body.enable_cdg, body.enable_txt
558
+ effective_theme_id, body.enable_cdg, body.enable_txt
540
559
  )
541
560
 
542
561
  # Use authenticated user's email
@@ -552,7 +571,7 @@ async def search_audio(
552
571
  job_create = JobCreate(
553
572
  artist=effective_display_artist, # Display value for title screens, filenames
554
573
  title=effective_display_title, # Display value for title screens, filenames
555
- theme_id=body.theme_id,
574
+ theme_id=effective_theme_id,
556
575
  color_overrides=body.color_overrides or {},
557
576
  enable_cdg=resolved_cdg,
558
577
  enable_txt=resolved_txt,
@@ -575,6 +594,8 @@ async def search_audio(
575
594
  auto_download=body.auto_download,
576
595
  request_metadata=request_metadata,
577
596
  non_interactive=body.non_interactive,
597
+ # Tenant scoping
598
+ tenant_id=tenant_config.id if tenant_config else None,
578
599
  )
579
600
  job = job_manager.create_job(job_create)
580
601
  job_id = job.job_id
@@ -591,11 +612,11 @@ async def search_audio(
591
612
  # If theme is set and no custom style files are being uploaded, prepare theme style now
592
613
  # This copies the theme's style_params.json to the job folder so LyricsTranscriber
593
614
  # can access the style configuration for preview videos
594
- if body.theme_id and not body.style_files:
615
+ if effective_theme_id and not body.style_files:
595
616
  from backend.api.routes.file_upload import _prepare_theme_for_job
596
617
  try:
597
618
  style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
598
- job_id, body.theme_id, body.color_overrides
619
+ job_id, effective_theme_id, body.color_overrides
599
620
  )
600
621
  theme_update = {
601
622
  'style_params_gcs_path': style_params_path,
@@ -604,9 +625,9 @@ async def search_audio(
604
625
  if youtube_desc and not effective_youtube_description:
605
626
  theme_update['youtube_description_template'] = youtube_desc
606
627
  job_manager.update_job(job_id, theme_update)
607
- logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
628
+ logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
608
629
  except Exception as e:
609
- logger.warning(f"Failed to prepare theme '{body.theme_id}' for job {job_id}: {e}")
630
+ logger.warning(f"Failed to prepare theme '{effective_theme_id}' for job {job_id}: {e}")
610
631
  # Continue without theme - job can still be processed with defaults
611
632
 
612
633
  # Handle style file uploads if provided
@@ -34,6 +34,7 @@ from backend.version import VERSION
34
34
  from backend.services.metrics import metrics
35
35
  from backend.api.dependencies import require_auth
36
36
  from backend.services.auth_service import UserType, AuthResult
37
+ from backend.middleware.tenant import get_tenant_config_from_request
37
38
 
38
39
  logger = logging.getLogger(__name__)
39
40
  router = APIRouter(tags=["jobs"])
@@ -446,6 +447,14 @@ async def upload_and_create_job(
446
447
  The style_params JSON can reference the uploaded images/fonts by their original
447
448
  filenames, and the backend will update the paths to GCS locations.
448
449
  """
450
+ # Check tenant feature flag
451
+ tenant_config = get_tenant_config_from_request(request)
452
+ if tenant_config and not tenant_config.features.file_upload:
453
+ raise HTTPException(
454
+ status_code=403,
455
+ detail="File upload is not available for this portal"
456
+ )
457
+
449
458
  try:
450
459
  # Validate main audio file type
451
460
  file_ext = Path(file.filename).suffix.lower()
@@ -562,9 +571,18 @@ async def upload_and_create_job(
562
571
  detail=f"Invalid color_overrides JSON: {e}"
563
572
  )
564
573
 
574
+ # Apply default theme if none specified
575
+ # This ensures all karaoke videos use the Nomad theme by default
576
+ effective_theme_id = theme_id
577
+ if effective_theme_id is None:
578
+ theme_service = get_theme_service()
579
+ effective_theme_id = theme_service.get_default_theme_id()
580
+ if effective_theme_id:
581
+ logger.info(f"Applying default theme: {effective_theme_id}")
582
+
565
583
  # Resolve CDG/TXT defaults based on theme
566
584
  resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
567
- theme_id, enable_cdg, enable_txt
585
+ effective_theme_id, enable_cdg, enable_txt
568
586
  )
569
587
 
570
588
  # Check if any custom style files are being uploaded (overrides theme)
@@ -596,7 +614,7 @@ async def upload_and_create_job(
596
614
  artist=artist,
597
615
  title=title,
598
616
  filename=file.filename,
599
- theme_id=theme_id,
617
+ theme_id=effective_theme_id,
600
618
  color_overrides=parsed_color_overrides,
601
619
  enable_cdg=resolved_cdg,
602
620
  enable_txt=resolved_txt,
@@ -623,10 +641,12 @@ async def upload_and_create_job(
623
641
  request_metadata=request_metadata,
624
642
  # Non-interactive mode
625
643
  non_interactive=non_interactive,
644
+ # Tenant scoping
645
+ tenant_id=tenant_config.id if tenant_config else None,
626
646
  )
627
647
  job = job_manager.create_job(job_create)
628
648
  job_id = job.job_id
629
-
649
+
630
650
  # Record job creation metric
631
651
  metrics.record_job_created(job_id, source="upload")
632
652
 
@@ -638,16 +658,16 @@ async def upload_and_create_job(
638
658
  theme_style_params_path = None
639
659
  theme_style_assets = {}
640
660
  theme_youtube_desc = None
641
- if theme_id and not has_custom_style_files:
661
+ if effective_theme_id and not has_custom_style_files:
642
662
  try:
643
663
  theme_style_params_path, theme_style_assets, theme_youtube_desc = _prepare_theme_for_job(
644
- job_id, theme_id, parsed_color_overrides or None
664
+ job_id, effective_theme_id, parsed_color_overrides or None
645
665
  )
646
- logger.info(f"Applied theme '{theme_id}' to job {job_id}")
666
+ logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
647
667
  except HTTPException:
648
668
  raise # Re-raise validation errors (e.g., theme not found)
649
669
  except Exception as e:
650
- logger.warning(f"Failed to prepare theme '{theme_id}' for job {job_id}: {e}")
670
+ logger.warning(f"Failed to prepare theme '{effective_theme_id}' for job {job_id}: {e}")
651
671
  # Continue without theme - job can still be processed with defaults
652
672
 
653
673
  # Upload main audio file to GCS
@@ -1020,6 +1040,14 @@ async def create_job_with_upload_urls(
1020
1040
  - Works with any HTTP client (no HTTP/2 required)
1021
1041
  - Resumable uploads possible with GCS
1022
1042
  """
1043
+ # Check tenant feature flag
1044
+ tenant_config = get_tenant_config_from_request(request)
1045
+ if tenant_config and not tenant_config.features.file_upload:
1046
+ raise HTTPException(
1047
+ status_code=403,
1048
+ detail="File upload is not available for this portal"
1049
+ )
1050
+
1023
1051
  try:
1024
1052
  # Validate files list
1025
1053
  if not body.files:
@@ -1097,9 +1125,18 @@ async def create_job_with_upload_urls(
1097
1125
  # Get original audio filename
1098
1126
  audio_file = audio_files[0]
1099
1127
 
1128
+ # Apply default theme if none specified
1129
+ # This ensures all karaoke videos use the Nomad theme by default
1130
+ effective_theme_id = body.theme_id
1131
+ if effective_theme_id is None:
1132
+ theme_service = get_theme_service()
1133
+ effective_theme_id = theme_service.get_default_theme_id()
1134
+ if effective_theme_id:
1135
+ logger.info(f"Applying default theme: {effective_theme_id}")
1136
+
1100
1137
  # Resolve CDG/TXT defaults based on theme
1101
1138
  resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
1102
- body.theme_id, body.enable_cdg, body.enable_txt
1139
+ effective_theme_id, body.enable_cdg, body.enable_txt
1103
1140
  )
1104
1141
 
1105
1142
  # Check if style_params is being uploaded (overrides theme)
@@ -1113,7 +1150,7 @@ async def create_job_with_upload_urls(
1113
1150
  artist=body.artist,
1114
1151
  title=body.title,
1115
1152
  filename=audio_file.filename,
1116
- theme_id=body.theme_id,
1153
+ theme_id=effective_theme_id,
1117
1154
  color_overrides=body.color_overrides or {},
1118
1155
  enable_cdg=resolved_cdg,
1119
1156
  enable_txt=resolved_txt,
@@ -1135,6 +1172,8 @@ async def create_job_with_upload_urls(
1135
1172
  other_stems_models=body.other_stems_models,
1136
1173
  request_metadata=request_metadata,
1137
1174
  non_interactive=body.non_interactive,
1175
+ # Tenant scoping
1176
+ tenant_id=tenant_config.id if tenant_config else None,
1138
1177
  )
1139
1178
  job = job_manager.create_job(job_create)
1140
1179
  job_id = job.job_id
@@ -1145,9 +1184,9 @@ async def create_job_with_upload_urls(
1145
1184
  logger.info(f"Created job {job_id} for {body.artist} - {body.title} (signed URL upload flow)")
1146
1185
 
1147
1186
  # If theme is set and no style_params uploaded, prepare theme style now
1148
- if body.theme_id and not has_style_params_upload:
1187
+ if effective_theme_id and not has_style_params_upload:
1149
1188
  style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(
1150
- job_id, body.theme_id, body.color_overrides
1189
+ job_id, effective_theme_id, body.color_overrides
1151
1190
  )
1152
1191
  # Update job with theme style data
1153
1192
  update_data = {
@@ -1157,7 +1196,7 @@ async def create_job_with_upload_urls(
1157
1196
  if youtube_desc and not body.youtube_description:
1158
1197
  update_data['youtube_description_template'] = youtube_desc
1159
1198
  job_manager.update_job(job_id, update_data)
1160
- logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
1199
+ logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
1161
1200
 
1162
1201
  # Generate signed upload URLs for each file
1163
1202
  upload_urls = []
@@ -1463,6 +1502,14 @@ async def create_job_from_url(
1463
1502
  Note: YouTube rate limiting may cause occasional download failures.
1464
1503
  The backend will retry automatically.
1465
1504
  """
1505
+ # Check tenant feature flag
1506
+ tenant_config = get_tenant_config_from_request(request)
1507
+ if tenant_config and not tenant_config.features.youtube_url:
1508
+ raise HTTPException(
1509
+ status_code=403,
1510
+ detail="URL-based job creation is not available for this portal"
1511
+ )
1512
+
1466
1513
  try:
1467
1514
  # Validate URL
1468
1515
  if not _validate_url(body.url):
@@ -1521,9 +1568,18 @@ async def create_job_from_url(
1521
1568
  artist = body.artist
1522
1569
  title = body.title
1523
1570
 
1571
+ # Apply default theme if none specified
1572
+ # This ensures all karaoke videos use the Nomad theme by default
1573
+ effective_theme_id = body.theme_id
1574
+ if effective_theme_id is None:
1575
+ theme_service = get_theme_service()
1576
+ effective_theme_id = theme_service.get_default_theme_id()
1577
+ if effective_theme_id:
1578
+ logger.info(f"Applying default theme: {effective_theme_id}")
1579
+
1524
1580
  # Resolve CDG/TXT defaults based on theme
1525
1581
  resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
1526
- body.theme_id, body.enable_cdg, body.enable_txt
1582
+ effective_theme_id, body.enable_cdg, body.enable_txt
1527
1583
  )
1528
1584
 
1529
1585
  # Prefer authenticated user's email over request body
@@ -1535,7 +1591,7 @@ async def create_job_from_url(
1535
1591
  artist=artist,
1536
1592
  title=title,
1537
1593
  filename=None, # No file uploaded
1538
- theme_id=body.theme_id,
1594
+ theme_id=effective_theme_id,
1539
1595
  color_overrides=body.color_overrides or {},
1540
1596
  enable_cdg=resolved_cdg,
1541
1597
  enable_txt=resolved_txt,
@@ -1556,6 +1612,8 @@ async def create_job_from_url(
1556
1612
  other_stems_models=body.other_stems_models,
1557
1613
  request_metadata=request_metadata,
1558
1614
  non_interactive=body.non_interactive,
1615
+ # Tenant scoping
1616
+ tenant_id=tenant_config.id if tenant_config else None,
1559
1617
  )
1560
1618
  job = job_manager.create_job(job_create)
1561
1619
  job_id = job.job_id
@@ -1564,9 +1622,9 @@ async def create_job_from_url(
1564
1622
  metrics.record_job_created(job_id, source="url")
1565
1623
 
1566
1624
  # If theme is set, prepare theme style now
1567
- if body.theme_id:
1625
+ if effective_theme_id:
1568
1626
  style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(
1569
- job_id, body.theme_id, body.color_overrides
1627
+ job_id, effective_theme_id, body.color_overrides
1570
1628
  )
1571
1629
  # Update job with theme style data
1572
1630
  update_data = {
@@ -1576,7 +1634,7 @@ async def create_job_from_url(
1576
1634
  if youtube_desc and not body.youtube_description:
1577
1635
  update_data['youtube_description_template'] = youtube_desc
1578
1636
  job_manager.update_job(job_id, update_data)
1579
- logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
1637
+ logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
1580
1638
 
1581
1639
  logger.info(f"Created URL-based job {job_id} for URL: {body.url}")
1582
1640
  if artist:
@@ -1676,6 +1734,14 @@ async def create_finalise_only_job(
1676
1734
 
1677
1735
  The endpoint returns signed URLs for uploading all the prep files.
1678
1736
  """
1737
+ # Check tenant feature flag - finalise-only requires file upload capability
1738
+ tenant_config = get_tenant_config_from_request(request)
1739
+ if tenant_config and not tenant_config.features.file_upload:
1740
+ raise HTTPException(
1741
+ status_code=403,
1742
+ detail="File upload is not available for this portal"
1743
+ )
1744
+
1679
1745
  try:
1680
1746
  # Validate files list
1681
1747
  if not body.files:
@@ -1768,9 +1834,18 @@ async def create_finalise_only_job(
1768
1834
  # Extract request metadata
1769
1835
  request_metadata = extract_request_metadata(request, created_from="finalise_only_upload")
1770
1836
 
1837
+ # Apply default theme if none specified
1838
+ # This ensures all karaoke videos use the Nomad theme by default
1839
+ effective_theme_id = body.theme_id
1840
+ if effective_theme_id is None:
1841
+ theme_service = get_theme_service()
1842
+ effective_theme_id = theme_service.get_default_theme_id()
1843
+ if effective_theme_id:
1844
+ logger.info(f"Applying default theme: {effective_theme_id}")
1845
+
1771
1846
  # Resolve CDG/TXT defaults based on theme
1772
1847
  resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
1773
- body.theme_id, body.enable_cdg, body.enable_txt
1848
+ effective_theme_id, body.enable_cdg, body.enable_txt
1774
1849
  )
1775
1850
 
1776
1851
  # Check if style_params is being uploaded (overrides theme)
@@ -1784,7 +1859,7 @@ async def create_finalise_only_job(
1784
1859
  artist=body.artist,
1785
1860
  title=body.title,
1786
1861
  filename="finalise_only", # No single audio file - using prep outputs
1787
- theme_id=body.theme_id,
1862
+ theme_id=effective_theme_id,
1788
1863
  color_overrides=body.color_overrides or {},
1789
1864
  enable_cdg=resolved_cdg,
1790
1865
  enable_txt=resolved_txt,
@@ -1798,6 +1873,8 @@ async def create_finalise_only_job(
1798
1873
  finalise_only=True,
1799
1874
  keep_brand_code=body.keep_brand_code,
1800
1875
  request_metadata=request_metadata,
1876
+ # Tenant scoping
1877
+ tenant_id=tenant_config.id if tenant_config else None,
1801
1878
  )
1802
1879
  job = job_manager.create_job(job_create)
1803
1880
  job_id = job.job_id
@@ -1808,9 +1885,9 @@ async def create_finalise_only_job(
1808
1885
  logger.info(f"Created finalise-only job {job_id} for {body.artist} - {body.title}")
1809
1886
 
1810
1887
  # If theme is set and no style_params uploaded, prepare theme style now
1811
- if body.theme_id and not has_style_params_upload:
1888
+ if effective_theme_id and not has_style_params_upload:
1812
1889
  style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(
1813
- job_id, body.theme_id, body.color_overrides
1890
+ job_id, effective_theme_id, body.color_overrides
1814
1891
  )
1815
1892
  # Update job with theme style data
1816
1893
  update_data = {
@@ -1820,7 +1897,7 @@ async def create_finalise_only_job(
1820
1897
  if youtube_desc and not body.youtube_description:
1821
1898
  update_data['youtube_description_template'] = youtube_desc
1822
1899
  job_manager.update_job(job_id, update_data)
1823
- logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
1900
+ logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
1824
1901
 
1825
1902
  # Generate signed upload URLs for each file
1826
1903
  upload_urls = []
@@ -13,6 +13,9 @@ from backend.services.flacfetch_client import get_flacfetch_client
13
13
  from backend.services.email_service import get_email_service
14
14
  from backend.services.stripe_service import get_stripe_service
15
15
  from backend.services.encoding_service import get_encoding_service
16
+ from backend.services.spacy_preloader import get_preloaded_model, is_model_preloaded
17
+ from backend.services.nltk_preloader import get_preloaded_cmudict, is_cmudict_preloaded
18
+ from backend.services.langfuse_preloader import get_preloaded_langfuse_handler, is_langfuse_preloaded, is_langfuse_configured
16
19
 
17
20
  router = APIRouter()
18
21
  logger = logging.getLogger(__name__)
@@ -334,6 +337,68 @@ async def detailed_health_check() -> Dict[str, Any]:
334
337
  }
335
338
 
336
339
 
340
+ @router.get("/health/preload-status")
341
+ async def preload_status() -> Dict[str, Any]:
342
+ """
343
+ Check status of preloaded resources for performance optimization.
344
+
345
+ Use this endpoint to verify that NLTK, SpaCy, and Langfuse resources
346
+ were successfully preloaded at container startup. If any show as
347
+ not preloaded, check Cloud Run startup logs for errors.
348
+
349
+ Expected state after successful deployment:
350
+ - spacy.preloaded: true
351
+ - nltk.preloaded: true
352
+ - langfuse.preloaded: true (if configured) or configured: false
353
+ """
354
+ # SpaCy status
355
+ spacy_model = get_preloaded_model("en_core_web_sm")
356
+ spacy_status = {
357
+ "preloaded": is_model_preloaded("en_core_web_sm"),
358
+ "model": "en_core_web_sm",
359
+ }
360
+ if spacy_model:
361
+ spacy_status["vocab_size"] = len(spacy_model.vocab)
362
+
363
+ # NLTK status
364
+ cmudict = get_preloaded_cmudict()
365
+ nltk_status = {
366
+ "preloaded": is_cmudict_preloaded(),
367
+ "resource": "cmudict",
368
+ }
369
+ if cmudict:
370
+ nltk_status["entries"] = len(cmudict)
371
+
372
+ # Langfuse status
373
+ langfuse_handler = get_preloaded_langfuse_handler()
374
+ langfuse_status = {
375
+ "configured": is_langfuse_configured(),
376
+ "preloaded": is_langfuse_preloaded(),
377
+ }
378
+ if langfuse_handler:
379
+ langfuse_status["handler_type"] = type(langfuse_handler).__name__
380
+
381
+ # Overall status
382
+ all_preloaded = (
383
+ spacy_status["preloaded"]
384
+ and nltk_status["preloaded"]
385
+ and (langfuse_status["preloaded"] or not langfuse_status["configured"])
386
+ )
387
+
388
+ return {
389
+ "status": "ok" if all_preloaded else "degraded",
390
+ "message": "All resources preloaded" if all_preloaded else "Some resources not preloaded - check startup logs",
391
+ "spacy": spacy_status,
392
+ "nltk": nltk_status,
393
+ "langfuse": langfuse_status,
394
+ "performance_impact": {
395
+ "spacy_preload": "Saves ~60s on first lyrics correction",
396
+ "nltk_preload": "Saves ~100-150s on SyllablesMatchHandler init",
397
+ "langfuse_preload": "Saves ~200s on AgenticCorrector init",
398
+ }
399
+ }
400
+
401
+
337
402
  @router.get("/readiness")
338
403
  async def readiness_check() -> Dict[str, str]:
339
404
  """Readiness check endpoint for Cloud Run."""
@@ -371,6 +371,12 @@ async def check_idle_reminder(
371
371
  add_span_event("already_sent")
372
372
  return {"status": "already_sent", "job_id": job_id, "message": "Reminder already sent"}
373
373
 
374
+ # Skip reminders for made-for-you jobs (admin handles these directly, no intermediate customer emails)
375
+ if getattr(job, 'made_for_you', False):
376
+ logger.info(f"[job:{job_id}] Made-for-you job, skipping customer reminder (admin handles)")
377
+ add_span_event("made_for_you_skip")
378
+ return {"status": "skipped", "job_id": job_id, "message": "Made-for-you job - admin handles directly"}
379
+
374
380
  # Check if user has an email
375
381
  if not job.user_email:
376
382
  logger.warning(f"[job:{job_id}] No user email, cannot send reminder")
@@ -11,7 +11,7 @@ import asyncio
11
11
  import logging
12
12
  import httpx
13
13
  from typing import List, Optional, Dict, Any, Tuple
14
- from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends
14
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
15
15
 
16
16
  from backend.models.job import Job, JobCreate, JobResponse, JobStatus
17
17
  from backend.models.requests import (
@@ -25,10 +25,13 @@ from backend.models.requests import (
25
25
  from backend.services.job_manager import JobManager
26
26
  from backend.services.worker_service import get_worker_service
27
27
  from backend.services.storage_service import StorageService
28
+ from backend.services.theme_service import get_theme_service
28
29
  from backend.config import get_settings
29
30
  from backend.api.dependencies import require_admin, require_auth, require_instrumental_auth
30
31
  from backend.services.auth_service import UserType, AuthResult
31
32
  from backend.services.metrics import metrics
33
+ from backend.middleware.tenant import get_tenant_from_request
34
+ from backend.utils.test_data import is_test_email
32
35
 
33
36
 
34
37
  logger = logging.getLogger(__name__)
@@ -92,11 +95,22 @@ async def create_job(
92
95
  settings = get_settings()
93
96
  effective_enable_youtube_upload = request.enable_youtube_upload if request.enable_youtube_upload is not None else settings.default_enable_youtube_upload
94
97
 
98
+ # Apply default theme - all jobs require a theme
99
+ theme_service = get_theme_service()
100
+ effective_theme_id = theme_service.get_default_theme_id()
101
+ if not effective_theme_id:
102
+ raise HTTPException(
103
+ status_code=422,
104
+ detail="No default theme configured. Please contact support or specify a theme_id."
105
+ )
106
+ logger.info(f"Applying default theme: {effective_theme_id}")
107
+
95
108
  # Create job with all preferences
96
109
  job_create = JobCreate(
97
110
  url=str(request.url),
98
111
  artist=request.artist,
99
112
  title=request.title,
113
+ theme_id=effective_theme_id, # Required - all jobs must have a theme
100
114
  enable_cdg=request.enable_cdg,
101
115
  enable_txt=request.enable_txt,
102
116
  enable_youtube_upload=effective_enable_youtube_upload,
@@ -177,11 +191,13 @@ def _check_job_ownership(job: Job, auth_result: AuthResult) -> bool:
177
191
 
178
192
  @router.get("", response_model=List[Job])
179
193
  async def list_jobs(
194
+ request: Request,
180
195
  status: Optional[JobStatus] = None,
181
196
  environment: Optional[str] = None,
182
197
  client_id: Optional[str] = None,
183
198
  created_after: Optional[str] = None,
184
199
  created_before: Optional[str] = None,
200
+ exclude_test: bool = True,
185
201
  limit: int = 100,
186
202
  auth_result: AuthResult = Depends(require_auth)
187
203
  ) -> List[Job]:
@@ -189,6 +205,7 @@ async def list_jobs(
189
205
  List jobs with optional filters.
190
206
 
191
207
  Regular users only see their own jobs. Admins see all jobs.
208
+ Users on tenant portals only see jobs from their tenant.
192
209
 
193
210
  Args:
194
211
  status: Filter by job status (pending, complete, failed, etc.)
@@ -196,6 +213,7 @@ async def list_jobs(
196
213
  client_id: Filter by request_metadata.client_id (customer identifier)
197
214
  created_after: Filter jobs created after this ISO datetime (e.g., 2024-01-01T00:00:00Z)
198
215
  created_before: Filter jobs created before this ISO datetime
216
+ exclude_test: If True (default), exclude jobs from test users (admin only)
199
217
  limit: Maximum number of jobs to return (default 100)
200
218
 
201
219
  Returns:
@@ -232,6 +250,10 @@ async def list_jobs(
232
250
  logger.warning("Non-admin auth without user_email, returning empty job list")
233
251
  return []
234
252
 
253
+ # Get tenant_id from request for portal scoping
254
+ # Tenant users only see jobs from their tenant
255
+ tenant_id = get_tenant_from_request(request)
256
+
235
257
  jobs = job_manager.list_jobs(
236
258
  status=status,
237
259
  environment=environment,
@@ -239,9 +261,14 @@ async def list_jobs(
239
261
  created_after=created_after_dt,
240
262
  created_before=created_before_dt,
241
263
  user_email=user_email_filter,
264
+ tenant_id=tenant_id,
242
265
  limit=limit
243
266
  )
244
267
 
268
+ # Filter out test user jobs if exclude_test is True (admin only)
269
+ if exclude_test and auth_result.is_admin:
270
+ jobs = [j for j in jobs if not is_test_email(j.user_email or "")]
271
+
245
272
  logger.debug(f"Listed {len(jobs)} jobs for user={auth_result.user_email}, admin={auth_result.is_admin}")
246
273
  return jobs
247
274
  except HTTPException:
@@ -412,7 +412,10 @@ async def generate_preview_video(
412
412
 
413
413
  # Check if GCE preview encoding is enabled
414
414
  use_gce_preview = encoding_service.is_preview_enabled
415
- logger.info(f"Job {job_id}: Generating preview video (GCE preview: {use_gce_preview})")
415
+
416
+ # Check if user wants theme background image (slower) or black background (faster, default)
417
+ use_background_image = updated_data.get("use_background_image", False)
418
+ logger.info(f"Job {job_id}: Generating preview video (GCE: {use_gce_preview}, background image: {use_background_image})")
416
419
 
417
420
  # Use tracing and job_log_context for full observability
418
421
  with create_span("generate-preview-video", {"job_id": job_id, "use_gce": use_gce_preview}) as span:
@@ -498,12 +501,16 @@ async def generate_preview_video(
498
501
  # Get background image and font from style assets if available
499
502
  style_assets = job.style_assets or {}
500
503
 
504
+ # Only use background image if user explicitly requested it
505
+ # Default is black background for faster preview generation (~10s vs ~30-60s)
501
506
  background_image_gcs_path = None
502
- for key in ["karaoke_background", "style_karaoke_background"]:
503
- if key in style_assets:
504
- background_image_gcs_path = f"gs://{bucket_name}/{style_assets[key]}"
505
- gce_span.set_attribute("background_image", background_image_gcs_path)
506
- break
507
+ if use_background_image:
508
+ for key in ["karaoke_background", "style_karaoke_background"]:
509
+ if key in style_assets:
510
+ background_image_gcs_path = f"gs://{bucket_name}/{style_assets[key]}"
511
+ gce_span.set_attribute("background_image", background_image_gcs_path)
512
+ break
513
+ gce_span.set_attribute("use_background_image", use_background_image)
507
514
 
508
515
  font_gcs_path = None
509
516
  for key in ["font", "style_font"]: