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.
- backend/api/routes/admin.py +512 -1
- backend/api/routes/audio_search.py +17 -34
- backend/api/routes/file_upload.py +60 -84
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +11 -3
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +229 -247
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +30 -1
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +27 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +73 -3
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/conftest.py +7 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +146 -1
- backend/tests/test_made_for_you.py +2088 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/workers/video_worker.py +8 -3
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {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
|
+
}
|
backend/services/job_manager.py
CHANGED
|
@@ -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=
|
|
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,
|