karaoke-gen 0.101.0__py3-none-any.whl → 0.105.4__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 (41) hide show
  1. backend/Dockerfile.base +1 -0
  2. backend/api/routes/admin.py +226 -3
  3. backend/api/routes/audio_search.py +4 -32
  4. backend/api/routes/file_upload.py +18 -83
  5. backend/api/routes/jobs.py +2 -2
  6. backend/api/routes/push.py +238 -0
  7. backend/api/routes/rate_limits.py +428 -0
  8. backend/api/routes/users.py +79 -19
  9. backend/config.py +25 -1
  10. backend/exceptions.py +66 -0
  11. backend/main.py +26 -1
  12. backend/models/job.py +4 -0
  13. backend/models/user.py +20 -2
  14. backend/services/email_validation_service.py +646 -0
  15. backend/services/firestore_service.py +21 -0
  16. backend/services/gce_encoding/main.py +22 -8
  17. backend/services/job_defaults_service.py +113 -0
  18. backend/services/job_manager.py +109 -13
  19. backend/services/push_notification_service.py +409 -0
  20. backend/services/rate_limit_service.py +641 -0
  21. backend/services/stripe_service.py +2 -2
  22. backend/tests/conftest.py +8 -1
  23. backend/tests/test_admin_delete_outputs.py +352 -0
  24. backend/tests/test_audio_search.py +12 -8
  25. backend/tests/test_email_validation_service.py +298 -0
  26. backend/tests/test_file_upload.py +8 -6
  27. backend/tests/test_gce_encoding_worker.py +229 -0
  28. backend/tests/test_impersonation.py +18 -3
  29. backend/tests/test_made_for_you.py +6 -4
  30. backend/tests/test_push_notification_service.py +460 -0
  31. backend/tests/test_push_routes.py +357 -0
  32. backend/tests/test_rate_limit_service.py +396 -0
  33. backend/tests/test_rate_limits_api.py +392 -0
  34. backend/tests/test_stripe_service.py +205 -0
  35. backend/workers/video_worker_orchestrator.py +42 -0
  36. karaoke_gen/instrumental_review/static/index.html +35 -9
  37. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
  38. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
  39. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
  40. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
  41. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
@@ -53,6 +53,10 @@ from backend.services.user_service import get_user_service, UserService, USERS_C
53
53
  from backend.services.email_service import get_email_service, EmailService
54
54
  from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
55
55
  from backend.services.theme_service import get_theme_service
56
+ from backend.services.job_defaults_service import (
57
+ get_effective_distribution_settings,
58
+ resolve_cdg_txt_defaults,
59
+ )
56
60
  from backend.api.dependencies import require_admin
57
61
  from backend.api.routes.file_upload import _prepare_theme_for_job
58
62
  from backend.services.auth_service import UserType
@@ -475,7 +479,6 @@ async def _handle_made_for_you_order(
475
479
  AudioSearchError,
476
480
  )
477
481
  from backend.services.storage_service import StorageService
478
- from backend.config import get_settings
479
482
  import asyncio
480
483
  import tempfile
481
484
  import os
@@ -496,7 +499,6 @@ async def _handle_made_for_you_order(
496
499
  job_manager = JobManager()
497
500
  worker_service = get_worker_service()
498
501
  storage_service = StorageService()
499
- settings = get_settings()
500
502
 
501
503
  # Apply default theme (Nomad) - same as audio_search endpoint
502
504
  theme_service = get_theme_service()
@@ -504,13 +506,12 @@ async def _handle_made_for_you_order(
504
506
  if effective_theme_id:
505
507
  logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
506
508
 
507
- # Get distribution defaults from settings (same as audio_search endpoint)
508
- effective_dropbox_path = settings.default_dropbox_path
509
- effective_gdrive_folder_id = settings.default_gdrive_folder_id
510
- effective_enable_youtube_upload = settings.default_enable_youtube_upload
511
- effective_brand_prefix = settings.default_brand_prefix
512
- effective_discord_webhook_url = settings.default_discord_webhook_url
513
- effective_youtube_description = settings.default_youtube_description
509
+ # Get distribution defaults using centralized service
510
+ dist = get_effective_distribution_settings()
511
+
512
+ # Resolve CDG/TXT defaults based on theme (uses centralized service)
513
+ # This ensures made-for-you jobs get the same CDG/TXT defaults as regular jobs
514
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(effective_theme_id)
514
515
 
515
516
  # Create job with admin ownership during processing
516
517
  # CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
@@ -530,15 +531,19 @@ async def _handle_made_for_you_order(
530
531
  audio_search_artist=artist if not youtube_url else None,
531
532
  audio_search_title=title if not youtube_url else None,
532
533
  auto_download=False, # Pause at audio selection for admin to choose
533
- # Distribution settings from server defaults
534
- enable_youtube_upload=effective_enable_youtube_upload,
535
- dropbox_path=effective_dropbox_path,
536
- gdrive_folder_id=effective_gdrive_folder_id,
537
- brand_prefix=effective_brand_prefix,
538
- discord_webhook_url=effective_discord_webhook_url,
539
- youtube_description=effective_youtube_description,
534
+ # CDG/TXT settings (resolved via centralized service)
535
+ enable_cdg=resolved_cdg,
536
+ enable_txt=resolved_txt,
537
+ # Distribution settings from centralized defaults
538
+ enable_youtube_upload=dist.enable_youtube_upload,
539
+ dropbox_path=dist.dropbox_path,
540
+ gdrive_folder_id=dist.gdrive_folder_id,
541
+ brand_prefix=dist.brand_prefix,
542
+ discord_webhook_url=dist.discord_webhook_url,
543
+ youtube_description=dist.youtube_description,
540
544
  )
541
- job = job_manager.create_job(job_create)
545
+ # Made-for-you jobs are created by admin (via Stripe webhook) - bypass rate limits
546
+ job = job_manager.create_job(job_create, is_admin=True)
542
547
  job_id = job.job_id
543
548
 
544
549
  logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
@@ -818,6 +823,9 @@ async def enroll_beta_tester(
818
823
 
819
824
  Returns free credits and optionally a session token for new users.
820
825
  """
826
+ from backend.services.email_validation_service import get_email_validation_service
827
+ from backend.services.rate_limit_service import get_rate_limit_service
828
+
821
829
  # Check if email service is configured
822
830
  if not email_service.is_configured():
823
831
  logger.error("Email service not configured - cannot send beta welcome emails")
@@ -827,6 +835,52 @@ async def enroll_beta_tester(
827
835
  )
828
836
 
829
837
  email = request.email.lower()
838
+ email_validation = get_email_validation_service()
839
+ rate_limit_service = get_rate_limit_service()
840
+
841
+ # ----- ABUSE PREVENTION CHECKS -----
842
+
843
+ # 1. Validate email (disposable domain, blocked email checks)
844
+ is_valid, error_message = email_validation.validate_email_for_beta(email)
845
+ if not is_valid:
846
+ logger.warning(f"Beta enrollment rejected - email validation failed: {email} - {error_message}")
847
+ raise HTTPException(
848
+ status_code=400,
849
+ detail=error_message
850
+ )
851
+
852
+ # 2. Check IP blocking
853
+ ip_address = http_request.client.host if http_request.client else None
854
+ if ip_address and email_validation.is_ip_blocked(ip_address):
855
+ logger.warning(f"Beta enrollment rejected - IP blocked: {ip_address}")
856
+ raise HTTPException(
857
+ status_code=403,
858
+ detail="Access denied from this location"
859
+ )
860
+
861
+ # 3. Check IP-based enrollment rate limit (1 per 24h per IP)
862
+ if ip_address:
863
+ allowed, remaining, message = rate_limit_service.check_beta_ip_limit(ip_address)
864
+ if not allowed:
865
+ logger.warning(f"Beta enrollment rejected - IP rate limit: {ip_address} - {message}")
866
+ raise HTTPException(
867
+ status_code=429,
868
+ detail="Too many beta enrollments from your location. Please try again tomorrow."
869
+ )
870
+
871
+ # 4. Check for duplicate enrollment via normalized email
872
+ normalized_email = email_validation.normalize_email(email)
873
+ if normalized_email != email:
874
+ # Check if normalized version is already enrolled
875
+ normalized_user = user_service.get_user(normalized_email)
876
+ if normalized_user and normalized_user.is_beta_tester:
877
+ logger.warning(f"Beta enrollment rejected - normalized email already enrolled: {email} -> {normalized_email}")
878
+ raise HTTPException(
879
+ status_code=400,
880
+ detail="An account with this email address is already enrolled in the beta program"
881
+ )
882
+
883
+ # ----- END ABUSE PREVENTION CHECKS -----
830
884
 
831
885
  # Validate acceptance
832
886
  if not request.accept_corrections_work:
@@ -851,8 +905,7 @@ async def enroll_beta_tester(
851
905
  detail="You are already enrolled in the beta program"
852
906
  )
853
907
 
854
- # Get client info
855
- ip_address = http_request.client.host if http_request.client else None
908
+ # Get client info (ip_address already set above in abuse prevention)
856
909
  user_agent = http_request.headers.get("user-agent")
857
910
 
858
911
  # Enroll as beta tester
@@ -872,6 +925,13 @@ async def enroll_beta_tester(
872
925
  reason="beta_tester_enrollment",
873
926
  )
874
927
 
928
+ # Record IP enrollment for rate limiting
929
+ if ip_address:
930
+ try:
931
+ rate_limit_service.record_beta_enrollment(ip_address, email)
932
+ except Exception as e:
933
+ logger.warning(f"Failed to record beta IP enrollment: {e}")
934
+
875
935
  # Create session for the user (so they can start using the service immediately)
876
936
  session = user_service.create_session(email, ip_address=ip_address, user_agent=user_agent)
877
937
 
backend/config.py CHANGED
@@ -101,13 +101,37 @@ class Settings(BaseSettings):
101
101
  # Default values for web service jobs (YouTube/Dropbox distribution)
102
102
  default_enable_youtube_upload: bool = os.getenv("DEFAULT_ENABLE_YOUTUBE_UPLOAD", "false").lower() in ("true", "1", "yes")
103
103
  default_brand_prefix: Optional[str] = os.getenv("DEFAULT_BRAND_PREFIX")
104
+
105
+ # Rate Limiting Configuration
106
+ # Enable/disable rate limiting system-wide (useful for development)
107
+ enable_rate_limiting: bool = os.getenv("ENABLE_RATE_LIMITING", "true").lower() in ("true", "1", "yes")
108
+ # Maximum jobs a user can create per day (0 = unlimited)
109
+ rate_limit_jobs_per_day: int = int(os.getenv("RATE_LIMIT_JOBS_PER_DAY", "5"))
110
+ # Maximum YouTube uploads system-wide per day (0 = unlimited)
111
+ rate_limit_youtube_uploads_per_day: int = int(os.getenv("RATE_LIMIT_YOUTUBE_UPLOADS_PER_DAY", "10"))
112
+ # Maximum beta enrollments from same IP per day (0 = unlimited)
113
+ rate_limit_beta_ip_per_day: int = int(os.getenv("RATE_LIMIT_BETA_IP_PER_DAY", "1"))
104
114
  default_youtube_description: str = os.getenv(
105
115
  "DEFAULT_YOUTUBE_DESCRIPTION",
106
116
  "Karaoke video created with Nomad Karaoke (https://nomadkaraoke.com)\n\n"
107
117
  "AI-powered vocal separation and synchronized lyrics.\n\n"
108
118
  "#karaoke #music #singing #instrumental #lyrics"
109
119
  )
110
-
120
+
121
+ # Default CDG/TXT generation settings
122
+ # When True, CDG and TXT packages are generated by default (when a theme is set)
123
+ # These can be overridden per-request via explicit enable_cdg/enable_txt parameters
124
+ default_enable_cdg: bool = os.getenv("DEFAULT_ENABLE_CDG", "true").lower() in ("true", "1", "yes")
125
+ default_enable_txt: bool = os.getenv("DEFAULT_ENABLE_TXT", "true").lower() in ("true", "1", "yes")
126
+
127
+ # Push Notifications Configuration
128
+ # When enabled, users can subscribe to push notifications for job status updates
129
+ enable_push_notifications: bool = os.getenv("ENABLE_PUSH_NOTIFICATIONS", "false").lower() in ("true", "1", "yes")
130
+ # Maximum number of push subscriptions per user (oldest removed when exceeded)
131
+ max_push_subscriptions_per_user: int = int(os.getenv("MAX_PUSH_SUBSCRIPTIONS_PER_USER", "5"))
132
+ # VAPID subject (email or URL for push service to contact)
133
+ vapid_subject: str = os.getenv("VAPID_SUBJECT", "mailto:gen@nomadkaraoke.com")
134
+
111
135
  # Secret Manager cache
112
136
  _secret_cache: Dict[str, str] = {}
113
137
 
backend/exceptions.py ADDED
@@ -0,0 +1,66 @@
1
+ """
2
+ Custom exceptions for the karaoke generation backend.
3
+
4
+ These exceptions are used for structured error handling across the application,
5
+ particularly for rate limiting and validation errors.
6
+ """
7
+
8
+
9
+ class RateLimitExceededError(Exception):
10
+ """
11
+ Raised when a rate limit is exceeded.
12
+
13
+ Includes information about when the limit will reset for client retry logic.
14
+
15
+ Attributes:
16
+ message: Human-readable error message
17
+ limit_type: Type of limit exceeded (e.g., "jobs_per_day", "youtube_uploads")
18
+ remaining_seconds: Seconds until the limit resets (for Retry-After header)
19
+ current_count: Current usage count
20
+ limit_value: The limit that was exceeded
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ message: str,
26
+ limit_type: str = "unknown",
27
+ remaining_seconds: int = 0,
28
+ current_count: int = 0,
29
+ limit_value: int = 0
30
+ ):
31
+ self.message = message
32
+ self.limit_type = limit_type
33
+ self.remaining_seconds = remaining_seconds
34
+ self.current_count = current_count
35
+ self.limit_value = limit_value
36
+ super().__init__(message)
37
+
38
+
39
+ class EmailValidationError(Exception):
40
+ """
41
+ Raised when email validation fails.
42
+
43
+ Attributes:
44
+ message: Human-readable error message
45
+ reason: Specific reason for failure (e.g., "disposable", "blocked", "invalid")
46
+ """
47
+
48
+ def __init__(self, message: str, reason: str = "invalid"):
49
+ self.message = message
50
+ self.reason = reason
51
+ super().__init__(message)
52
+
53
+
54
+ class IPBlockedError(Exception):
55
+ """
56
+ Raised when a request comes from a blocked IP address.
57
+
58
+ Attributes:
59
+ message: Human-readable error message
60
+ ip_address: The blocked IP address (may be partially masked)
61
+ """
62
+
63
+ def __init__(self, message: str, ip_address: str = ""):
64
+ self.message = message
65
+ self.ip_address = ip_address
66
+ super().__init__(message)
backend/main.py CHANGED
@@ -7,7 +7,7 @@ from fastapi import FastAPI
7
7
  from fastapi.middleware.cors import CORSMiddleware
8
8
 
9
9
  from backend.config import settings
10
- from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant
10
+ from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant, rate_limits, push
11
11
  from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
12
12
  from backend.services.structured_logging import setup_structured_logging
13
13
  from backend.services.spacy_preloader import preload_spacy_model
@@ -143,9 +143,34 @@ app.include_router(audio_search.router, prefix="/api") # Audio search (artist+t
143
143
  app.include_router(themes.router, prefix="/api") # Theme selection for styles
144
144
  app.include_router(users.router, prefix="/api") # User auth, credits, and Stripe webhooks
145
145
  app.include_router(admin.router, prefix="/api") # Admin dashboard and management
146
+ app.include_router(rate_limits.router, prefix="/api") # Rate limits admin management
147
+ app.include_router(push.router, prefix="/api") # Push notification subscription management
146
148
  app.include_router(tenant.router) # Tenant/white-label configuration (no /api prefix, router has it)
147
149
 
148
150
 
151
+ # Exception handler for rate limiting
152
+ from fastapi import Request
153
+ from fastapi.responses import JSONResponse
154
+ from backend.exceptions import RateLimitExceededError
155
+
156
+
157
+ @app.exception_handler(RateLimitExceededError)
158
+ async def rate_limit_exception_handler(request: Request, exc: RateLimitExceededError):
159
+ """Handle rate limit exceeded errors with 429 status."""
160
+ return JSONResponse(
161
+ status_code=429,
162
+ content={
163
+ "detail": exc.message,
164
+ "limit_type": exc.limit_type,
165
+ "current_count": exc.current_count,
166
+ "limit_value": exc.limit_value,
167
+ },
168
+ headers={
169
+ "Retry-After": str(exc.remaining_seconds),
170
+ } if exc.remaining_seconds > 0 else None,
171
+ )
172
+
173
+
149
174
  @app.get("/")
150
175
  async def root():
151
176
  """Root endpoint."""
backend/models/job.py CHANGED
@@ -285,6 +285,10 @@ class Job(BaseModel):
285
285
  customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
286
286
  customer_notes: Optional[str] = None # Notes provided by customer with their order
287
287
 
288
+ # Output deletion tracking (for admin cleanup without deleting job)
289
+ outputs_deleted_at: Optional[datetime] = None # Timestamp when outputs were deleted by admin
290
+ outputs_deleted_by: Optional[str] = None # Admin email who deleted outputs
291
+
288
292
  # Processing state
289
293
  track_output_dir: Optional[str] = None # Local output directory (temp)
290
294
  audio_hash: Optional[str] = None # Hash for deduplication
backend/models/user.py CHANGED
@@ -8,9 +8,9 @@ Supports:
8
8
  - Stripe integration for payments
9
9
  - Beta tester program with feedback collection
10
10
  """
11
- from datetime import datetime
11
+ from datetime import datetime, timezone
12
12
  from enum import Enum
13
- from typing import Optional, List
13
+ from typing import Optional, List, Dict
14
14
  from pydantic import BaseModel, Field
15
15
 
16
16
 
@@ -39,6 +39,20 @@ class CreditTransaction(BaseModel):
39
39
  created_by: Optional[str] = None # Admin email if granted by admin
40
40
 
41
41
 
42
+ class PushSubscription(BaseModel):
43
+ """
44
+ Web Push subscription for a user's device.
45
+
46
+ Stores the push subscription endpoint and encryption keys needed
47
+ to send push notifications to the user's browser/device.
48
+ """
49
+ endpoint: str # Push service endpoint URL
50
+ keys: Dict[str, str] # p256dh and auth keys for encryption
51
+ device_name: Optional[str] = None # e.g., "iPhone", "Chrome on Windows"
52
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
53
+ last_used_at: Optional[datetime] = None # Last time a notification was sent
54
+
55
+
42
56
  class User(BaseModel):
43
57
  """
44
58
  User model stored in Firestore.
@@ -86,6 +100,10 @@ class User(BaseModel):
86
100
  beta_feedback_due_at: Optional[datetime] = None # 24hr after job completion
87
101
  beta_feedback_email_sent: bool = False
88
102
 
103
+ # Push notification subscriptions (Web Push API)
104
+ # Users can subscribe from multiple devices/browsers
105
+ push_subscriptions: List[PushSubscription] = Field(default_factory=list)
106
+
89
107
 
90
108
  class MagicLinkToken(BaseModel):
91
109
  """