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
@@ -0,0 +1,113 @@
1
+ """
2
+ Centralized job defaults service.
3
+
4
+ This module provides consistent handling of job creation defaults across all
5
+ endpoints (file_upload, audio_search, made-for-you webhook, etc.).
6
+
7
+ Centralizing these defaults prevents divergence between code paths and ensures
8
+ all jobs receive the same default configuration.
9
+ """
10
+ from dataclasses import dataclass
11
+ from typing import Optional, Tuple
12
+
13
+ from backend.config import get_settings
14
+
15
+
16
+ @dataclass
17
+ class EffectiveDistributionSettings:
18
+ """Distribution settings with defaults applied from environment variables."""
19
+ dropbox_path: Optional[str]
20
+ gdrive_folder_id: Optional[str]
21
+ discord_webhook_url: Optional[str]
22
+ brand_prefix: Optional[str]
23
+ enable_youtube_upload: bool
24
+ youtube_description: Optional[str]
25
+
26
+
27
+ def get_effective_distribution_settings(
28
+ dropbox_path: Optional[str] = None,
29
+ gdrive_folder_id: Optional[str] = None,
30
+ discord_webhook_url: Optional[str] = None,
31
+ brand_prefix: Optional[str] = None,
32
+ enable_youtube_upload: Optional[bool] = None,
33
+ youtube_description: Optional[str] = None,
34
+ ) -> EffectiveDistributionSettings:
35
+ """
36
+ Get distribution settings with defaults applied from environment variables.
37
+
38
+ This ensures consistent handling of defaults across all job creation endpoints.
39
+ Each parameter, if not provided (None), falls back to the corresponding
40
+ environment variable configured in settings.
41
+
42
+ Args:
43
+ dropbox_path: Explicit Dropbox path or None for default
44
+ gdrive_folder_id: Explicit Google Drive folder ID or None for default
45
+ discord_webhook_url: Explicit Discord webhook URL or None for default
46
+ brand_prefix: Explicit brand prefix or None for default
47
+ enable_youtube_upload: Explicit flag or None for default
48
+ youtube_description: Explicit description or None for default
49
+
50
+ Returns:
51
+ EffectiveDistributionSettings with defaults applied
52
+ """
53
+ settings = get_settings()
54
+ return EffectiveDistributionSettings(
55
+ dropbox_path=dropbox_path or settings.default_dropbox_path,
56
+ gdrive_folder_id=gdrive_folder_id or settings.default_gdrive_folder_id,
57
+ discord_webhook_url=discord_webhook_url or settings.default_discord_webhook_url,
58
+ brand_prefix=brand_prefix or settings.default_brand_prefix,
59
+ enable_youtube_upload=enable_youtube_upload if enable_youtube_upload is not None else settings.default_enable_youtube_upload,
60
+ youtube_description=youtube_description or settings.default_youtube_description,
61
+ )
62
+
63
+
64
+ def resolve_cdg_txt_defaults(
65
+ theme_id: Optional[str],
66
+ enable_cdg: Optional[bool] = None,
67
+ enable_txt: Optional[bool] = None,
68
+ ) -> Tuple[bool, bool]:
69
+ """
70
+ Resolve CDG/TXT settings based on theme and explicit settings.
71
+
72
+ The resolution logic is:
73
+ 1. If explicit True/False is provided, use that value
74
+ 2. Otherwise, if a theme is set, use the server defaults (settings.default_enable_cdg/txt)
75
+ 3. If no theme is set, default to False (CDG/TXT require style configuration)
76
+
77
+ This ensures CDG/TXT are only enabled when:
78
+ - A theme is configured (provides necessary style params), AND
79
+ - The server defaults allow it (DEFAULT_ENABLE_CDG=true by default)
80
+
81
+ Args:
82
+ theme_id: Theme identifier (if any)
83
+ enable_cdg: Explicit CDG setting (None means use default)
84
+ enable_txt: Explicit TXT setting (None means use default)
85
+
86
+ Returns:
87
+ Tuple of (resolved_enable_cdg, resolved_enable_txt)
88
+ """
89
+ settings = get_settings()
90
+
91
+ # Default based on whether theme is set AND server defaults
92
+ # Theme is required because CDG/TXT need style configuration
93
+ theme_is_set = theme_id is not None
94
+ default_cdg = theme_is_set and settings.default_enable_cdg
95
+ default_txt = theme_is_set and settings.default_enable_txt
96
+
97
+ # Explicit values override defaults, None uses computed default
98
+ resolved_cdg = enable_cdg if enable_cdg is not None else default_cdg
99
+ resolved_txt = enable_txt if enable_txt is not None else default_txt
100
+
101
+ return resolved_cdg, resolved_txt
102
+
103
+
104
+ # Singleton instance (optional, for convenience)
105
+ _service_instance = None
106
+
107
+
108
+ def get_job_defaults_service():
109
+ """Get the job defaults service (module-level functions work fine, this is for consistency)."""
110
+ return {
111
+ 'get_effective_distribution_settings': get_effective_distribution_settings,
112
+ 'resolve_cdg_txt_defaults': resolve_cdg_txt_defaults,
113
+ }
@@ -14,6 +14,7 @@ from datetime import datetime
14
14
  from typing import Optional, Dict, Any, List
15
15
 
16
16
  from backend.config import settings
17
+ from backend.exceptions import RateLimitExceededError
17
18
  from backend.models.job import Job, JobStatus, JobCreate, STATE_TRANSITIONS
18
19
  from backend.models.worker_log import WorkerLogEntry
19
20
  from backend.services.firestore_service import FirestoreService
@@ -23,6 +24,16 @@ from backend.services.storage_service import StorageService
23
24
  logger = logging.getLogger(__name__)
24
25
 
25
26
 
27
+ def _mask_email(email: str) -> str:
28
+ """Mask email for logging to protect PII. Shows first char + domain."""
29
+ if not email or "@" not in email:
30
+ return "***"
31
+ local, domain = email.split("@", 1)
32
+ if len(local) <= 1:
33
+ return f"*@{domain}"
34
+ return f"{local[0]}***@{domain}"
35
+
36
+
26
37
  class JobManager:
27
38
  """Manager for job lifecycle and state."""
28
39
 
@@ -31,16 +42,44 @@ class JobManager:
31
42
  self.firestore = FirestoreService()
32
43
  self.storage = StorageService()
33
44
 
34
- def create_job(self, job_create: JobCreate) -> Job:
45
+ def create_job(self, job_create: JobCreate, is_admin: bool = False) -> Job:
35
46
  """
36
47
  Create a new job with initial state PENDING.
37
48
 
38
49
  Jobs start in PENDING state and transition to DOWNLOADING
39
50
  when a worker picks them up.
40
51
 
52
+ Args:
53
+ job_create: Job creation parameters
54
+ is_admin: Whether the requesting user is an admin (bypasses rate limits)
55
+
41
56
  Raises:
42
57
  ValueError: If theme_id is not provided (all jobs require a theme)
58
+ RateLimitExceededError: If user has exceeded their daily job limit
43
59
  """
60
+ # Check rate limit FIRST (before any other validation)
61
+ # This prevents wasted work if user is rate limited
62
+ if job_create.user_email:
63
+ from backend.services.rate_limit_service import get_rate_limit_service
64
+
65
+ rate_limit_service = get_rate_limit_service()
66
+ allowed, remaining, message = rate_limit_service.check_user_job_limit(
67
+ user_email=job_create.user_email,
68
+ is_admin=is_admin
69
+ )
70
+ if not allowed:
71
+ from backend.services.rate_limit_service import _seconds_until_midnight_utc
72
+
73
+ # Get actual current count - remaining is clamped to 0 which loses info
74
+ current_count = rate_limit_service.get_user_job_count_today(job_create.user_email)
75
+ raise RateLimitExceededError(
76
+ message=message,
77
+ limit_type="jobs_per_day",
78
+ remaining_seconds=_seconds_until_midnight_utc(),
79
+ current_count=current_count,
80
+ limit_value=settings.rate_limit_jobs_per_day
81
+ )
82
+
44
83
  # Enforce theme requirement - all jobs must have a theme
45
84
  # This prevents unstyled videos from ever being generated
46
85
  if not job_create.theme_id:
@@ -85,11 +124,27 @@ class JobManager:
85
124
  keep_brand_code=job_create.keep_brand_code,
86
125
  # Request metadata (for tracking and filtering)
87
126
  request_metadata=job_create.request_metadata,
127
+ # Tenant scoping
128
+ tenant_id=job_create.tenant_id,
129
+ # Made-for-you order fields
130
+ made_for_you=job_create.made_for_you,
131
+ customer_email=job_create.customer_email,
132
+ customer_notes=job_create.customer_notes,
88
133
  )
89
134
 
90
135
  self.firestore.create_job(job)
91
136
  logger.info(f"Created new job {job_id} with status PENDING")
92
-
137
+
138
+ # Record job creation for rate limiting (after successful persistence)
139
+ if job_create.user_email:
140
+ try:
141
+ from backend.services.rate_limit_service import get_rate_limit_service
142
+ rate_limit_service = get_rate_limit_service()
143
+ rate_limit_service.record_job_creation(job_create.user_email, job_id)
144
+ except Exception as e:
145
+ # Don't fail job creation if rate limit recording fails
146
+ logger.warning(f"Failed to record job creation for rate limiting: {e}")
147
+
93
148
  return job
94
149
 
95
150
  def get_job(self, job_id: str) -> Optional[Job]:
@@ -125,6 +180,7 @@ class JobManager:
125
180
  created_after: Optional[datetime] = None,
126
181
  created_before: Optional[datetime] = None,
127
182
  user_email: Optional[str] = None,
183
+ tenant_id: Optional[str] = None,
128
184
  limit: int = 100
129
185
  ) -> List[Job]:
130
186
  """
@@ -137,6 +193,7 @@ class JobManager:
137
193
  created_after: Filter jobs created after this datetime
138
194
  created_before: Filter jobs created before this datetime
139
195
  user_email: Filter by user_email (job owner)
196
+ tenant_id: Filter by tenant_id (white-label portal scoping)
140
197
  limit: Maximum number of jobs to return
141
198
 
142
199
  Returns:
@@ -149,6 +206,7 @@ class JobManager:
149
206
  created_after=created_after,
150
207
  created_before=created_before,
151
208
  user_email=user_email,
209
+ tenant_id=tenant_id,
152
210
  limit=limit
153
211
  )
154
212
 
@@ -413,6 +471,7 @@ class JobManager:
413
471
  """
414
472
  Schedule sending a job completion email.
415
473
 
474
+ For made-for-you orders, also transfers ownership from admin to customer.
416
475
  Uses asyncio to fire-and-forget the email sending.
417
476
  """
418
477
  import asyncio
@@ -429,11 +488,22 @@ class JobManager:
429
488
  dropbox_url = state_data.get('dropbox_link')
430
489
  brand_code = state_data.get('brand_code')
431
490
 
491
+ # For made-for-you orders, send to customer and transfer ownership
492
+ recipient_email = job.user_email
493
+ if job.made_for_you and job.customer_email:
494
+ recipient_email = job.customer_email
495
+ # Transfer ownership from admin to customer (non-blocking - email still goes out if this fails)
496
+ try:
497
+ self.update_job(job.job_id, {'user_email': job.customer_email})
498
+ logger.info(f"Transferred ownership of made-for-you job {job.job_id} to {_mask_email(job.customer_email)}")
499
+ except Exception as e:
500
+ logger.error(f"Failed to transfer ownership for job {job.job_id}: {e}")
501
+
432
502
  # Create async task (fire-and-forget)
433
503
  async def send_email():
434
504
  await notification_service.send_job_completion_email(
435
505
  job_id=job.job_id,
436
- user_email=job.user_email,
506
+ user_email=recipient_email,
437
507
  user_name=None, # Could fetch from user service if needed
438
508
  artist=job.artist,
439
509
  title=job.title,