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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
@@ -15,7 +15,6 @@ import json
15
15
  import logging
16
16
  import tempfile
17
17
  import os
18
- from dataclasses import dataclass
19
18
  from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks, Request, Body, Depends
20
19
  from pathlib import Path
21
20
  from typing import Optional, List, Dict, Any, Tuple
@@ -29,11 +28,17 @@ from backend.services.storage_service import StorageService
29
28
  from backend.services.worker_service import get_worker_service
30
29
  from backend.services.credential_manager import get_credential_manager, CredentialStatus
31
30
  from backend.services.theme_service import get_theme_service
31
+ from backend.services.job_defaults_service import (
32
+ get_effective_distribution_settings,
33
+ resolve_cdg_txt_defaults,
34
+ EffectiveDistributionSettings,
35
+ )
32
36
  from backend.config import get_settings
33
37
  from backend.version import VERSION
34
38
  from backend.services.metrics import metrics
35
39
  from backend.api.dependencies import require_auth
36
40
  from backend.services.auth_service import UserType, AuthResult
41
+ from backend.middleware.tenant import get_tenant_config_from_request
37
42
 
38
43
  logger = logging.getLogger(__name__)
39
44
  router = APIRouter(tags=["jobs"])
@@ -263,75 +268,6 @@ async def _trigger_audio_worker_only(job_id: str) -> None:
263
268
  await worker_service.trigger_audio_worker(job_id)
264
269
 
265
270
 
266
- def _resolve_cdg_txt_defaults(
267
- theme_id: Optional[str],
268
- enable_cdg: Optional[bool],
269
- enable_txt: Optional[bool]
270
- ) -> Tuple[bool, bool]:
271
- """
272
- Resolve CDG/TXT settings based on theme and explicit settings.
273
-
274
- When a theme is selected, CDG and TXT are enabled by default.
275
- Explicit True/False values always override the default.
276
-
277
- Args:
278
- theme_id: Theme identifier (if any)
279
- enable_cdg: Explicit CDG setting (None means use default)
280
- enable_txt: Explicit TXT setting (None means use default)
281
-
282
- Returns:
283
- Tuple of (resolved_enable_cdg, resolved_enable_txt)
284
- """
285
- # Default based on whether theme is set
286
- default_enabled = theme_id is not None
287
-
288
- # Resolve with explicit override taking precedence
289
- resolved_cdg = enable_cdg if enable_cdg is not None else default_enabled
290
- resolved_txt = enable_txt if enable_txt is not None else default_enabled
291
-
292
- return resolved_cdg, resolved_txt
293
-
294
-
295
- @dataclass
296
- class EffectiveDistributionSettings:
297
- """Settings with defaults applied from environment variables."""
298
- dropbox_path: Optional[str]
299
- gdrive_folder_id: Optional[str]
300
- discord_webhook_url: Optional[str]
301
- brand_prefix: Optional[str]
302
-
303
-
304
- def _get_effective_distribution_settings(
305
- dropbox_path: Optional[str] = None,
306
- gdrive_folder_id: Optional[str] = None,
307
- discord_webhook_url: Optional[str] = None,
308
- brand_prefix: Optional[str] = None,
309
- ) -> EffectiveDistributionSettings:
310
- """
311
- Get distribution settings with defaults applied from environment variables.
312
-
313
- This ensures consistent handling of defaults across all job creation endpoints.
314
- Each parameter, if not provided (None), falls back to the corresponding
315
- environment variable configured in settings.
316
-
317
- Args:
318
- dropbox_path: Explicit Dropbox path or None for default
319
- gdrive_folder_id: Explicit Google Drive folder ID or None for default
320
- discord_webhook_url: Explicit Discord webhook URL or None for default
321
- brand_prefix: Explicit brand prefix or None for default
322
-
323
- Returns:
324
- EffectiveDistributionSettings with defaults applied
325
- """
326
- settings = get_settings()
327
- return EffectiveDistributionSettings(
328
- dropbox_path=dropbox_path or settings.default_dropbox_path,
329
- gdrive_folder_id=gdrive_folder_id or settings.default_gdrive_folder_id,
330
- discord_webhook_url=discord_webhook_url or settings.default_discord_webhook_url,
331
- brand_prefix=brand_prefix or settings.default_brand_prefix,
332
- )
333
-
334
-
335
271
  def _prepare_theme_for_job(
336
272
  job_id: str,
337
273
  theme_id: str,
@@ -446,6 +382,14 @@ async def upload_and_create_job(
446
382
  The style_params JSON can reference the uploaded images/fonts by their original
447
383
  filenames, and the backend will update the paths to GCS locations.
448
384
  """
385
+ # Check tenant feature flag
386
+ tenant_config = get_tenant_config_from_request(request)
387
+ if tenant_config and not tenant_config.features.file_upload:
388
+ raise HTTPException(
389
+ status_code=403,
390
+ detail="File upload is not available for this portal"
391
+ )
392
+
449
393
  try:
450
394
  # Validate main audio file type
451
395
  file_ext = Path(file.filename).suffix.lower()
@@ -494,7 +438,7 @@ async def upload_and_create_job(
494
438
  )
495
439
 
496
440
  # Apply default distribution settings from environment if not provided
497
- dist = _get_effective_distribution_settings(
441
+ dist = get_effective_distribution_settings(
498
442
  dropbox_path=dropbox_path,
499
443
  gdrive_folder_id=gdrive_folder_id,
500
444
  discord_webhook_url=discord_webhook_url,
@@ -572,7 +516,7 @@ async def upload_and_create_job(
572
516
  logger.info(f"Applying default theme: {effective_theme_id}")
573
517
 
574
518
  # Resolve CDG/TXT defaults based on theme
575
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
519
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
576
520
  effective_theme_id, enable_cdg, enable_txt
577
521
  )
578
522
 
@@ -632,10 +576,12 @@ async def upload_and_create_job(
632
576
  request_metadata=request_metadata,
633
577
  # Non-interactive mode
634
578
  non_interactive=non_interactive,
579
+ # Tenant scoping
580
+ tenant_id=tenant_config.id if tenant_config else None,
635
581
  )
636
- job = job_manager.create_job(job_create)
582
+ job = job_manager.create_job(job_create, is_admin=auth_result.is_admin)
637
583
  job_id = job.job_id
638
-
584
+
639
585
  # Record job creation metric
640
586
  metrics.record_job_created(job_id, source="upload")
641
587
 
@@ -1029,6 +975,14 @@ async def create_job_with_upload_urls(
1029
975
  - Works with any HTTP client (no HTTP/2 required)
1030
976
  - Resumable uploads possible with GCS
1031
977
  """
978
+ # Check tenant feature flag
979
+ tenant_config = get_tenant_config_from_request(request)
980
+ if tenant_config and not tenant_config.features.file_upload:
981
+ raise HTTPException(
982
+ status_code=403,
983
+ detail="File upload is not available for this portal"
984
+ )
985
+
1032
986
  try:
1033
987
  # Validate files list
1034
988
  if not body.files:
@@ -1058,7 +1012,7 @@ async def create_job_with_upload_urls(
1058
1012
  )
1059
1013
 
1060
1014
  # Apply default distribution settings from environment if not provided
1061
- dist = _get_effective_distribution_settings(
1015
+ dist = get_effective_distribution_settings(
1062
1016
  dropbox_path=body.dropbox_path,
1063
1017
  gdrive_folder_id=body.gdrive_folder_id,
1064
1018
  discord_webhook_url=body.discord_webhook_url,
@@ -1116,7 +1070,7 @@ async def create_job_with_upload_urls(
1116
1070
  logger.info(f"Applying default theme: {effective_theme_id}")
1117
1071
 
1118
1072
  # Resolve CDG/TXT defaults based on theme
1119
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
1073
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
1120
1074
  effective_theme_id, body.enable_cdg, body.enable_txt
1121
1075
  )
1122
1076
 
@@ -1153,8 +1107,10 @@ async def create_job_with_upload_urls(
1153
1107
  other_stems_models=body.other_stems_models,
1154
1108
  request_metadata=request_metadata,
1155
1109
  non_interactive=body.non_interactive,
1110
+ # Tenant scoping
1111
+ tenant_id=tenant_config.id if tenant_config else None,
1156
1112
  )
1157
- job = job_manager.create_job(job_create)
1113
+ job = job_manager.create_job(job_create, is_admin=auth_result.is_admin)
1158
1114
  job_id = job.job_id
1159
1115
 
1160
1116
  # Record job creation metric
@@ -1481,6 +1437,14 @@ async def create_job_from_url(
1481
1437
  Note: YouTube rate limiting may cause occasional download failures.
1482
1438
  The backend will retry automatically.
1483
1439
  """
1440
+ # Check tenant feature flag
1441
+ tenant_config = get_tenant_config_from_request(request)
1442
+ if tenant_config and not tenant_config.features.youtube_url:
1443
+ raise HTTPException(
1444
+ status_code=403,
1445
+ detail="URL-based job creation is not available for this portal"
1446
+ )
1447
+
1484
1448
  try:
1485
1449
  # Validate URL
1486
1450
  if not _validate_url(body.url):
@@ -1490,7 +1454,7 @@ async def create_job_from_url(
1490
1454
  )
1491
1455
 
1492
1456
  # Apply default distribution settings from environment if not provided
1493
- dist = _get_effective_distribution_settings(
1457
+ dist = get_effective_distribution_settings(
1494
1458
  dropbox_path=body.dropbox_path,
1495
1459
  gdrive_folder_id=body.gdrive_folder_id,
1496
1460
  discord_webhook_url=body.discord_webhook_url,
@@ -1549,7 +1513,7 @@ async def create_job_from_url(
1549
1513
  logger.info(f"Applying default theme: {effective_theme_id}")
1550
1514
 
1551
1515
  # Resolve CDG/TXT defaults based on theme
1552
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
1516
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
1553
1517
  effective_theme_id, body.enable_cdg, body.enable_txt
1554
1518
  )
1555
1519
 
@@ -1583,8 +1547,10 @@ async def create_job_from_url(
1583
1547
  other_stems_models=body.other_stems_models,
1584
1548
  request_metadata=request_metadata,
1585
1549
  non_interactive=body.non_interactive,
1550
+ # Tenant scoping
1551
+ tenant_id=tenant_config.id if tenant_config else None,
1586
1552
  )
1587
- job = job_manager.create_job(job_create)
1553
+ job = job_manager.create_job(job_create, is_admin=auth_result.is_admin)
1588
1554
  job_id = job.job_id
1589
1555
 
1590
1556
  # Record job creation metric
@@ -1604,7 +1570,7 @@ async def create_job_from_url(
1604
1570
  update_data['youtube_description_template'] = youtube_desc
1605
1571
  job_manager.update_job(job_id, update_data)
1606
1572
  logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
1607
-
1573
+
1608
1574
  logger.info(f"Created URL-based job {job_id} for URL: {body.url}")
1609
1575
  if artist:
1610
1576
  logger.info(f" Artist: {artist}")
@@ -1703,6 +1669,14 @@ async def create_finalise_only_job(
1703
1669
 
1704
1670
  The endpoint returns signed URLs for uploading all the prep files.
1705
1671
  """
1672
+ # Check tenant feature flag - finalise-only requires file upload capability
1673
+ tenant_config = get_tenant_config_from_request(request)
1674
+ if tenant_config and not tenant_config.features.file_upload:
1675
+ raise HTTPException(
1676
+ status_code=403,
1677
+ detail="File upload is not available for this portal"
1678
+ )
1679
+
1706
1680
  try:
1707
1681
  # Validate files list
1708
1682
  if not body.files:
@@ -1750,7 +1724,7 @@ async def create_finalise_only_job(
1750
1724
  )
1751
1725
 
1752
1726
  # Apply default distribution settings
1753
- dist = _get_effective_distribution_settings(
1727
+ dist = get_effective_distribution_settings(
1754
1728
  dropbox_path=body.dropbox_path,
1755
1729
  gdrive_folder_id=body.gdrive_folder_id,
1756
1730
  discord_webhook_url=body.discord_webhook_url,
@@ -1805,7 +1779,7 @@ async def create_finalise_only_job(
1805
1779
  logger.info(f"Applying default theme: {effective_theme_id}")
1806
1780
 
1807
1781
  # Resolve CDG/TXT defaults based on theme
1808
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
1782
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
1809
1783
  effective_theme_id, body.enable_cdg, body.enable_txt
1810
1784
  )
1811
1785
 
@@ -1834,8 +1808,10 @@ async def create_finalise_only_job(
1834
1808
  finalise_only=True,
1835
1809
  keep_brand_code=body.keep_brand_code,
1836
1810
  request_metadata=request_metadata,
1811
+ # Tenant scoping
1812
+ tenant_id=tenant_config.id if tenant_config else None,
1837
1813
  )
1838
- job = job_manager.create_job(job_create)
1814
+ job = job_manager.create_job(job_create, is_admin=auth_result.is_admin)
1839
1815
  job_id = job.job_id
1840
1816
 
1841
1817
  # Record job creation metric
@@ -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 (
@@ -30,6 +30,7 @@ from backend.config import get_settings
30
30
  from backend.api.dependencies import require_admin, require_auth, require_instrumental_auth
31
31
  from backend.services.auth_service import UserType, AuthResult
32
32
  from backend.services.metrics import metrics
33
+ from backend.middleware.tenant import get_tenant_from_request
33
34
  from backend.utils.test_data import is_test_email
34
35
 
35
36
 
@@ -117,8 +118,8 @@ async def create_job(
117
118
  webhook_url=request.webhook_url,
118
119
  user_email=user_email
119
120
  )
120
- job = job_manager.create_job(job_create)
121
-
121
+ job = job_manager.create_job(job_create, is_admin=auth_result.is_admin)
122
+
122
123
  # Record job creation metric
123
124
  metrics.record_job_created(job.job_id, source="url")
124
125
 
@@ -190,6 +191,7 @@ def _check_job_ownership(job: Job, auth_result: AuthResult) -> bool:
190
191
 
191
192
  @router.get("", response_model=List[Job])
192
193
  async def list_jobs(
194
+ request: Request,
193
195
  status: Optional[JobStatus] = None,
194
196
  environment: Optional[str] = None,
195
197
  client_id: Optional[str] = None,
@@ -203,6 +205,7 @@ async def list_jobs(
203
205
  List jobs with optional filters.
204
206
 
205
207
  Regular users only see their own jobs. Admins see all jobs.
208
+ Users on tenant portals only see jobs from their tenant.
206
209
 
207
210
  Args:
208
211
  status: Filter by job status (pending, complete, failed, etc.)
@@ -247,6 +250,10 @@ async def list_jobs(
247
250
  logger.warning("Non-admin auth without user_email, returning empty job list")
248
251
  return []
249
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
+
250
257
  jobs = job_manager.list_jobs(
251
258
  status=status,
252
259
  environment=environment,
@@ -254,6 +261,7 @@ async def list_jobs(
254
261
  created_after=created_after_dt,
255
262
  created_before=created_before_dt,
256
263
  user_email=user_email_filter,
264
+ tenant_id=tenant_id,
257
265
  limit=limit
258
266
  )
259
267