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.
- backend/api/routes/admin.py +696 -92
- backend/api/routes/audio_search.py +29 -8
- backend/api/routes/file_upload.py +99 -22
- backend/api/routes/health.py +65 -0
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +28 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +472 -51
- backend/main.py +31 -2
- 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/encoding_service.py +128 -31
- backend/services/firestore_service.py +6 -0
- backend/services/job_manager.py +44 -2
- backend/services/langfuse_preloader.py +98 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/stripe_service.py +133 -11
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/emulator/conftest.py +22 -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_email_service.py +233 -0
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +171 -9
- backend/tests/test_jobs_api.py +11 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_spacy_preloader.py +119 -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/utils/test_data.py +27 -0
- backend/workers/screens_worker.py +16 -6
- backend/workers/video_worker.py +8 -3
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +58 -39
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -43
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.96.0.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
backend/api/routes/users.py
CHANGED
|
@@ -8,8 +8,28 @@ Handles:
|
|
|
8
8
|
- Stripe checkout and webhooks
|
|
9
9
|
- Admin user management
|
|
10
10
|
"""
|
|
11
|
+
import hashlib
|
|
11
12
|
import logging
|
|
12
13
|
from typing import Optional, Tuple
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _mask_email(email: str) -> str:
|
|
17
|
+
"""Mask email for logging to avoid PII exposure.
|
|
18
|
+
|
|
19
|
+
Example: test@example.com -> te***@ex***.com
|
|
20
|
+
"""
|
|
21
|
+
if not email or "@" not in email:
|
|
22
|
+
return "***"
|
|
23
|
+
local, domain = email.split("@", 1)
|
|
24
|
+
masked_local = local[:2] + "***" if len(local) > 2 else "***"
|
|
25
|
+
domain_parts = domain.split(".")
|
|
26
|
+
if len(domain_parts) >= 2:
|
|
27
|
+
masked_domain = domain_parts[0][:2] + "***." + domain_parts[-1]
|
|
28
|
+
else:
|
|
29
|
+
masked_domain = "***"
|
|
30
|
+
return f"{masked_local}@{masked_domain}"
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
from fastapi import APIRouter, HTTPException, Depends, Request, Header
|
|
14
34
|
from pydantic import BaseModel, EmailStr
|
|
15
35
|
|
|
@@ -32,8 +52,11 @@ from backend.models.user import (
|
|
|
32
52
|
from backend.services.user_service import get_user_service, UserService, USERS_COLLECTION
|
|
33
53
|
from backend.services.email_service import get_email_service, EmailService
|
|
34
54
|
from backend.services.stripe_service import get_stripe_service, StripeService, CREDIT_PACKAGES
|
|
55
|
+
from backend.services.theme_service import get_theme_service
|
|
35
56
|
from backend.api.dependencies import require_admin
|
|
57
|
+
from backend.api.routes.file_upload import _prepare_theme_for_job
|
|
36
58
|
from backend.services.auth_service import UserType
|
|
59
|
+
from backend.utils.test_data import is_test_email
|
|
37
60
|
|
|
38
61
|
|
|
39
62
|
logger = logging.getLogger(__name__)
|
|
@@ -57,6 +80,16 @@ class CreateCheckoutResponse(BaseModel):
|
|
|
57
80
|
message: str
|
|
58
81
|
|
|
59
82
|
|
|
83
|
+
class MadeForYouCheckoutRequest(BaseModel):
|
|
84
|
+
"""Request to create a made-for-you karaoke video order."""
|
|
85
|
+
email: EmailStr
|
|
86
|
+
artist: str
|
|
87
|
+
title: str
|
|
88
|
+
source_type: str = "search" # search, youtube, or upload
|
|
89
|
+
youtube_url: Optional[str] = None
|
|
90
|
+
notes: Optional[str] = None
|
|
91
|
+
|
|
92
|
+
|
|
60
93
|
class CreditPackage(BaseModel):
|
|
61
94
|
"""Credit package information."""
|
|
62
95
|
id: str
|
|
@@ -99,7 +132,11 @@ async def send_magic_link(
|
|
|
99
132
|
|
|
100
133
|
The user will receive an email with a link that logs them in.
|
|
101
134
|
Links expire after 15 minutes and can only be used once.
|
|
135
|
+
|
|
136
|
+
For white-label tenants, validates email domain against tenant config.
|
|
102
137
|
"""
|
|
138
|
+
from backend.middleware.tenant import get_tenant_from_request, get_tenant_config_from_request
|
|
139
|
+
|
|
103
140
|
# Check if email service is configured
|
|
104
141
|
if not email_service.is_configured():
|
|
105
142
|
logger.error("Email service not configured - cannot send magic links")
|
|
@@ -110,19 +147,39 @@ async def send_magic_link(
|
|
|
110
147
|
|
|
111
148
|
email = request.email.lower()
|
|
112
149
|
|
|
150
|
+
# Get tenant context from middleware
|
|
151
|
+
tenant_id = get_tenant_from_request(http_request)
|
|
152
|
+
tenant_config = get_tenant_config_from_request(http_request)
|
|
153
|
+
|
|
154
|
+
# Validate email domain for tenant if configured
|
|
155
|
+
if tenant_config and tenant_config.auth.allowed_email_domains:
|
|
156
|
+
if not tenant_config.is_email_allowed(email):
|
|
157
|
+
logger.warning(f"Email domain not allowed for tenant {tenant_id}: {_mask_email(email)}")
|
|
158
|
+
# Return success anyway to prevent email enumeration
|
|
159
|
+
return SendMagicLinkResponse(
|
|
160
|
+
status="success",
|
|
161
|
+
message="If this email is registered, you will receive a sign-in link shortly."
|
|
162
|
+
)
|
|
163
|
+
|
|
113
164
|
# Get client info for security logging
|
|
114
165
|
ip_address = http_request.client.host if http_request.client else None
|
|
115
166
|
user_agent = http_request.headers.get("user-agent")
|
|
116
167
|
|
|
117
|
-
# Create magic link token
|
|
168
|
+
# Create magic link token with tenant context
|
|
118
169
|
magic_link = user_service.create_magic_link(
|
|
119
170
|
email,
|
|
120
171
|
ip_address=ip_address,
|
|
121
|
-
user_agent=user_agent
|
|
172
|
+
user_agent=user_agent,
|
|
173
|
+
tenant_id=tenant_id
|
|
122
174
|
)
|
|
123
175
|
|
|
124
|
-
#
|
|
125
|
-
|
|
176
|
+
# Get tenant-specific sender email
|
|
177
|
+
sender_email = None
|
|
178
|
+
if tenant_config:
|
|
179
|
+
sender_email = tenant_config.get_sender_email()
|
|
180
|
+
|
|
181
|
+
# Send email (with tenant-specific sender if configured)
|
|
182
|
+
sent = email_service.send_magic_link(email, magic_link.token, sender_email=sender_email)
|
|
126
183
|
|
|
127
184
|
if not sent:
|
|
128
185
|
logger.error(f"Failed to send magic link email to {email}")
|
|
@@ -146,18 +203,26 @@ async def verify_magic_link(
|
|
|
146
203
|
Verify a magic link token and create a session.
|
|
147
204
|
|
|
148
205
|
Returns a session token that should be stored and used for subsequent requests.
|
|
206
|
+
The session will be associated with the tenant from the magic link.
|
|
149
207
|
"""
|
|
208
|
+
# Reject empty tokens early to avoid invalid Firestore document paths
|
|
209
|
+
if not token or not token.strip():
|
|
210
|
+
raise HTTPException(status_code=401, detail="Invalid token")
|
|
211
|
+
|
|
150
212
|
# Get client info
|
|
151
213
|
ip_address = http_request.client.host if http_request.client else None
|
|
152
214
|
user_agent = http_request.headers.get("user-agent")
|
|
153
215
|
|
|
154
216
|
# Check if this is a first login BEFORE verification (which sets last_login_at)
|
|
155
217
|
# We need to get the user's state before verify_magic_link updates it
|
|
218
|
+
# Also extract tenant_id from the magic link for session creation
|
|
156
219
|
from backend.services.user_service import MAGIC_LINKS_COLLECTION
|
|
157
220
|
magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
|
|
158
221
|
is_first_login = False
|
|
222
|
+
magic_link_tenant_id = None
|
|
159
223
|
if magic_link_doc.exists:
|
|
160
224
|
magic_link_data = magic_link_doc.to_dict()
|
|
225
|
+
magic_link_tenant_id = magic_link_data.get('tenant_id')
|
|
161
226
|
pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
|
|
162
227
|
if pre_verify_user:
|
|
163
228
|
is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
|
|
@@ -168,18 +233,19 @@ async def verify_magic_link(
|
|
|
168
233
|
if not success or not user:
|
|
169
234
|
raise HTTPException(status_code=401, detail=message)
|
|
170
235
|
|
|
171
|
-
# Create session
|
|
236
|
+
# Create session with tenant context from the magic link
|
|
172
237
|
session = user_service.create_session(
|
|
173
238
|
user.email,
|
|
174
239
|
ip_address=ip_address,
|
|
175
|
-
user_agent=user_agent
|
|
240
|
+
user_agent=user_agent,
|
|
241
|
+
tenant_id=magic_link_tenant_id
|
|
176
242
|
)
|
|
177
243
|
|
|
178
244
|
# Send welcome email to first-time users
|
|
179
245
|
if is_first_login:
|
|
180
246
|
email_service.send_welcome_email(user.email, user.credits)
|
|
181
247
|
|
|
182
|
-
# Return user info
|
|
248
|
+
# Return user info with tenant_id
|
|
183
249
|
user_public = UserPublic(
|
|
184
250
|
email=user.email,
|
|
185
251
|
role=user.role,
|
|
@@ -187,6 +253,7 @@ async def verify_magic_link(
|
|
|
187
253
|
display_name=user.display_name,
|
|
188
254
|
total_jobs_created=user.total_jobs_created,
|
|
189
255
|
total_jobs_completed=user.total_jobs_completed,
|
|
256
|
+
tenant_id=user.tenant_id,
|
|
190
257
|
)
|
|
191
258
|
|
|
192
259
|
return VerifyMagicLinkResponse(
|
|
@@ -327,10 +394,327 @@ async def create_checkout(
|
|
|
327
394
|
)
|
|
328
395
|
|
|
329
396
|
|
|
397
|
+
@router.post("/made-for-you/checkout", response_model=CreateCheckoutResponse)
|
|
398
|
+
async def create_made_for_you_checkout(
|
|
399
|
+
request: MadeForYouCheckoutRequest,
|
|
400
|
+
stripe_service: StripeService = Depends(get_stripe_service),
|
|
401
|
+
):
|
|
402
|
+
"""
|
|
403
|
+
Create a Stripe checkout session for a made-for-you karaoke video order.
|
|
404
|
+
|
|
405
|
+
This is the full-service option where Nomad Karaoke handles everything:
|
|
406
|
+
- Finding or processing the audio
|
|
407
|
+
- Reviewing and correcting lyrics
|
|
408
|
+
- Selecting the best instrumental
|
|
409
|
+
- Generating the final video
|
|
410
|
+
|
|
411
|
+
$15 with 24-hour delivery guarantee.
|
|
412
|
+
No authentication required - customer email is provided in the request.
|
|
413
|
+
"""
|
|
414
|
+
if not stripe_service.is_configured():
|
|
415
|
+
raise HTTPException(status_code=503, detail="Payment processing is not available")
|
|
416
|
+
|
|
417
|
+
success, checkout_url, message = stripe_service.create_made_for_you_checkout_session(
|
|
418
|
+
customer_email=request.email,
|
|
419
|
+
artist=request.artist,
|
|
420
|
+
title=request.title,
|
|
421
|
+
source_type=request.source_type,
|
|
422
|
+
youtube_url=request.youtube_url,
|
|
423
|
+
notes=request.notes,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if not success or not checkout_url:
|
|
427
|
+
raise HTTPException(status_code=400, detail=message)
|
|
428
|
+
|
|
429
|
+
return CreateCheckoutResponse(
|
|
430
|
+
status="success",
|
|
431
|
+
checkout_url=checkout_url,
|
|
432
|
+
message=message,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
330
436
|
# =============================================================================
|
|
331
437
|
# Stripe Webhooks
|
|
332
438
|
# =============================================================================
|
|
333
439
|
|
|
440
|
+
# Admin email for made-for-you order notifications
|
|
441
|
+
ADMIN_EMAIL = "madeforyou@nomadkaraoke.com"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def _handle_made_for_you_order(
|
|
445
|
+
session_id: str,
|
|
446
|
+
metadata: dict,
|
|
447
|
+
user_service: UserService,
|
|
448
|
+
email_service: EmailService,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""
|
|
451
|
+
Handle a completed made-for-you order by creating a job and notifying Andrew.
|
|
452
|
+
|
|
453
|
+
The made-for-you flow:
|
|
454
|
+
1. Job is created with made_for_you=True, owned by admin during processing
|
|
455
|
+
2. For search orders: audio search runs, results stored, job pauses at AWAITING_AUDIO_SELECTION
|
|
456
|
+
3. Admin receives notification email with link to select audio source
|
|
457
|
+
4. Customer receives order confirmation email
|
|
458
|
+
5. Admin selects audio in UI, job proceeds through normal pipeline
|
|
459
|
+
6. On completion, ownership transfers to customer
|
|
460
|
+
|
|
461
|
+
For orders with a YouTube URL, workers are triggered immediately (no search needed).
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
session_id: Stripe checkout session ID
|
|
465
|
+
metadata: Order metadata from Stripe session
|
|
466
|
+
user_service: User service for marking session processed
|
|
467
|
+
email_service: Email service for notifications
|
|
468
|
+
"""
|
|
469
|
+
from backend.models.job import JobCreate, JobStatus
|
|
470
|
+
from backend.services.job_manager import JobManager
|
|
471
|
+
from backend.services.worker_service import get_worker_service
|
|
472
|
+
from backend.services.audio_search_service import (
|
|
473
|
+
get_audio_search_service,
|
|
474
|
+
NoResultsError,
|
|
475
|
+
AudioSearchError,
|
|
476
|
+
)
|
|
477
|
+
from backend.services.storage_service import StorageService
|
|
478
|
+
from backend.config import get_settings
|
|
479
|
+
import asyncio
|
|
480
|
+
import tempfile
|
|
481
|
+
import os
|
|
482
|
+
|
|
483
|
+
customer_email = metadata.get("customer_email", "")
|
|
484
|
+
artist = metadata.get("artist", "Unknown Artist")
|
|
485
|
+
title = metadata.get("title", "Unknown Title")
|
|
486
|
+
source_type = metadata.get("source_type", "search")
|
|
487
|
+
youtube_url = metadata.get("youtube_url")
|
|
488
|
+
notes = metadata.get("notes", "")
|
|
489
|
+
|
|
490
|
+
logger.info(
|
|
491
|
+
f"Processing made-for-you order: {artist} - {title} for {customer_email} "
|
|
492
|
+
f"(session: {session_id}, source_type: {source_type})"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
job_manager = JobManager()
|
|
497
|
+
worker_service = get_worker_service()
|
|
498
|
+
storage_service = StorageService()
|
|
499
|
+
settings = get_settings()
|
|
500
|
+
|
|
501
|
+
# Apply default theme (Nomad) - same as audio_search endpoint
|
|
502
|
+
theme_service = get_theme_service()
|
|
503
|
+
effective_theme_id = theme_service.get_default_theme_id()
|
|
504
|
+
if effective_theme_id:
|
|
505
|
+
logger.info(f"Applying default theme '{effective_theme_id}' for made-for-you order")
|
|
506
|
+
|
|
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
|
|
514
|
+
|
|
515
|
+
# Create job with admin ownership during processing
|
|
516
|
+
# CRITICAL: made_for_you=True, user_email=ADMIN_EMAIL, customer_email for delivery
|
|
517
|
+
# auto_download=False to pause at audio selection for admin to choose
|
|
518
|
+
job_create = JobCreate(
|
|
519
|
+
url=youtube_url if youtube_url else None,
|
|
520
|
+
artist=artist,
|
|
521
|
+
title=title,
|
|
522
|
+
user_email=ADMIN_EMAIL, # Admin owns during processing
|
|
523
|
+
theme_id=effective_theme_id, # Apply default theme
|
|
524
|
+
non_interactive=False, # Admin will review lyrics/instrumental
|
|
525
|
+
# Made-for-you specific fields
|
|
526
|
+
made_for_you=True, # Flag for ownership transfer on completion
|
|
527
|
+
customer_email=customer_email, # Customer email for final delivery
|
|
528
|
+
customer_notes=notes if notes else None, # Customer's special requests
|
|
529
|
+
# Audio search fields for search-based orders
|
|
530
|
+
audio_search_artist=artist if not youtube_url else None,
|
|
531
|
+
audio_search_title=title if not youtube_url else None,
|
|
532
|
+
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,
|
|
540
|
+
)
|
|
541
|
+
job = job_manager.create_job(job_create)
|
|
542
|
+
job_id = job.job_id
|
|
543
|
+
|
|
544
|
+
logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
|
|
545
|
+
|
|
546
|
+
# Prepare theme style assets for the job (same as audio_search endpoint)
|
|
547
|
+
if effective_theme_id:
|
|
548
|
+
try:
|
|
549
|
+
style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
|
|
550
|
+
job_id, effective_theme_id, None # No color overrides for made-for-you
|
|
551
|
+
)
|
|
552
|
+
theme_update = {
|
|
553
|
+
'style_params_gcs_path': style_params_path,
|
|
554
|
+
'style_assets': theme_style_assets,
|
|
555
|
+
}
|
|
556
|
+
if youtube_desc:
|
|
557
|
+
theme_update['youtube_description_template'] = youtube_desc
|
|
558
|
+
job_manager.update_job(job_id, theme_update)
|
|
559
|
+
logger.info(f"Applied theme '{effective_theme_id}' to made-for-you job {job_id}")
|
|
560
|
+
except Exception as e:
|
|
561
|
+
logger.warning(f"Failed to prepare theme for made-for-you job {job_id}: {e}")
|
|
562
|
+
|
|
563
|
+
# Mark session as processed for idempotency
|
|
564
|
+
# Note: Using internal method since this isn't a credit transaction
|
|
565
|
+
user_service._mark_stripe_session_processed(
|
|
566
|
+
stripe_session_id=session_id,
|
|
567
|
+
email=customer_email,
|
|
568
|
+
amount=0 # No credits, just tracking the session
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
# Initialize search_results for later use in email notification
|
|
572
|
+
search_results = None
|
|
573
|
+
|
|
574
|
+
# Handle based on whether we have a YouTube URL or need to search
|
|
575
|
+
if youtube_url:
|
|
576
|
+
# URL provided - trigger workers directly (no audio selection needed)
|
|
577
|
+
logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
|
|
578
|
+
await asyncio.gather(
|
|
579
|
+
worker_service.trigger_audio_worker(job_id),
|
|
580
|
+
worker_service.trigger_lyrics_worker(job_id)
|
|
581
|
+
)
|
|
582
|
+
else:
|
|
583
|
+
# No URL - use audio search flow, pause for admin selection
|
|
584
|
+
# Made-for-you jobs require admin to select audio source
|
|
585
|
+
logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
|
|
586
|
+
|
|
587
|
+
# Update job with audio search fields
|
|
588
|
+
job_manager.update_job(job_id, {
|
|
589
|
+
'audio_search_artist': artist,
|
|
590
|
+
'audio_search_title': title,
|
|
591
|
+
'auto_download': False, # Admin must select
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
# Transition to searching state
|
|
595
|
+
job_manager.transition_to_state(
|
|
596
|
+
job_id=job_id,
|
|
597
|
+
new_status=JobStatus.SEARCHING_AUDIO,
|
|
598
|
+
progress=5,
|
|
599
|
+
message=f"Searching for audio: {artist} - {title}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Perform audio search
|
|
603
|
+
audio_search_service = get_audio_search_service()
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
search_results = audio_search_service.search(artist, title)
|
|
607
|
+
except NoResultsError as e:
|
|
608
|
+
# No results found - still transition to AWAITING_AUDIO_SELECTION
|
|
609
|
+
# Admin can manually provide audio
|
|
610
|
+
logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
|
|
611
|
+
job_manager.transition_to_state(
|
|
612
|
+
job_id=job_id,
|
|
613
|
+
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
614
|
+
progress=10,
|
|
615
|
+
message=f"No automatic audio sources found. Manual intervention required."
|
|
616
|
+
)
|
|
617
|
+
search_results = None
|
|
618
|
+
except AudioSearchError as e:
|
|
619
|
+
logger.error(f"Job {job_id}: Audio search failed: {e}")
|
|
620
|
+
job_manager.transition_to_state(
|
|
621
|
+
job_id=job_id,
|
|
622
|
+
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
623
|
+
progress=10,
|
|
624
|
+
message=f"Audio search error. Manual intervention required."
|
|
625
|
+
)
|
|
626
|
+
search_results = None
|
|
627
|
+
|
|
628
|
+
if search_results:
|
|
629
|
+
# Store search results in state_data for admin to review
|
|
630
|
+
results_dicts = [r.to_dict() for r in search_results]
|
|
631
|
+
state_data_update = {
|
|
632
|
+
'audio_search_results': results_dicts,
|
|
633
|
+
'audio_search_count': len(results_dicts),
|
|
634
|
+
}
|
|
635
|
+
if audio_search_service.last_remote_search_id:
|
|
636
|
+
state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
|
|
637
|
+
job_manager.update_job(job_id, {'state_data': state_data_update})
|
|
638
|
+
|
|
639
|
+
# Transition to AWAITING_AUDIO_SELECTION for admin to choose
|
|
640
|
+
# Do NOT auto-select or download - admin must review and select
|
|
641
|
+
logger.info(f"Job {job_id}: Found {len(results_dicts)} audio sources, awaiting admin selection")
|
|
642
|
+
|
|
643
|
+
# Transition to AWAITING_AUDIO_SELECTION for admin to choose
|
|
644
|
+
# Admin will select from results in the UI, then job proceeds
|
|
645
|
+
job_manager.transition_to_state(
|
|
646
|
+
job_id=job_id,
|
|
647
|
+
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
648
|
+
progress=10,
|
|
649
|
+
message=f"Found {len(results_dicts)} audio sources. Awaiting admin selection."
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Get audio source count for admin notification
|
|
653
|
+
# (search_results may be set from the search flow above, or None for YouTube URL orders)
|
|
654
|
+
audio_source_count = len(search_results) if search_results else 0
|
|
655
|
+
|
|
656
|
+
# Generate admin login token for one-click email access (24hr expiry)
|
|
657
|
+
admin_login = user_service.create_admin_login_token(
|
|
658
|
+
email=ADMIN_EMAIL,
|
|
659
|
+
expiry_hours=24,
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Send confirmation email to customer using professional template
|
|
663
|
+
email_service.send_made_for_you_order_confirmation(
|
|
664
|
+
to_email=customer_email,
|
|
665
|
+
artist=artist,
|
|
666
|
+
title=title,
|
|
667
|
+
job_id=job_id,
|
|
668
|
+
notes=notes,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
# Send notification email to admin using professional template
|
|
672
|
+
email_service.send_made_for_you_admin_notification(
|
|
673
|
+
to_email=ADMIN_EMAIL,
|
|
674
|
+
customer_email=customer_email,
|
|
675
|
+
artist=artist,
|
|
676
|
+
title=title,
|
|
677
|
+
job_id=job_id,
|
|
678
|
+
admin_login_token=admin_login.token,
|
|
679
|
+
notes=notes,
|
|
680
|
+
audio_source_count=audio_source_count,
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
logger.info(f"Sent made-for-you order notifications for job {job_id}")
|
|
684
|
+
|
|
685
|
+
except Exception as e:
|
|
686
|
+
logger.error(f"Error processing made-for-you order: {e}", exc_info=True)
|
|
687
|
+
# Still try to notify Andrew of the failure
|
|
688
|
+
try:
|
|
689
|
+
email_service.send_email(
|
|
690
|
+
to_email=ADMIN_EMAIL,
|
|
691
|
+
subject=f"[FAILED] Made For You Order: {artist} - {title}",
|
|
692
|
+
html_content=f"""
|
|
693
|
+
<h2>Made-For-You Order Failed</h2>
|
|
694
|
+
<p>An error occurred processing this order:</p>
|
|
695
|
+
<ul>
|
|
696
|
+
<li><strong>Customer:</strong> {customer_email}</li>
|
|
697
|
+
<li><strong>Artist:</strong> {artist}</li>
|
|
698
|
+
<li><strong>Title:</strong> {title}</li>
|
|
699
|
+
<li><strong>Error:</strong> {str(e)}</li>
|
|
700
|
+
</ul>
|
|
701
|
+
<p>Please manually create this job and notify the customer.</p>
|
|
702
|
+
""",
|
|
703
|
+
text_content=f"""
|
|
704
|
+
Made-For-You Order Failed
|
|
705
|
+
|
|
706
|
+
Customer: {customer_email}
|
|
707
|
+
Artist: {artist}
|
|
708
|
+
Title: {title}
|
|
709
|
+
Error: {str(e)}
|
|
710
|
+
|
|
711
|
+
Please manually create this job and notify the customer.
|
|
712
|
+
""".strip(),
|
|
713
|
+
)
|
|
714
|
+
except Exception as email_error:
|
|
715
|
+
logger.error(f"Failed to send error notification: {email_error}")
|
|
716
|
+
|
|
717
|
+
|
|
334
718
|
@router.post("/webhooks/stripe")
|
|
335
719
|
async def stripe_webhook(
|
|
336
720
|
request: Request,
|
|
@@ -365,30 +749,41 @@ async def stripe_webhook(
|
|
|
365
749
|
if event_type == "checkout.session.completed":
|
|
366
750
|
session = event["data"]["object"]
|
|
367
751
|
session_id = session.get("id")
|
|
752
|
+
metadata = session.get("metadata", {})
|
|
368
753
|
|
|
369
754
|
# Idempotency check: Skip if this session was already processed
|
|
370
755
|
if session_id and user_service.is_stripe_session_processed(session_id):
|
|
371
756
|
logger.info(f"Skipping already processed session: {session_id}")
|
|
372
757
|
return {"status": "received", "type": event_type, "note": "already_processed"}
|
|
373
758
|
|
|
374
|
-
#
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
reason="stripe_purchase",
|
|
383
|
-
stripe_session_id=session_id,
|
|
759
|
+
# Check if this is a made-for-you order
|
|
760
|
+
if metadata.get("order_type") == "made_for_you":
|
|
761
|
+
# Handle made-for-you order - create a job
|
|
762
|
+
await _handle_made_for_you_order(
|
|
763
|
+
session_id=session_id,
|
|
764
|
+
metadata=metadata,
|
|
765
|
+
user_service=user_service,
|
|
766
|
+
email_service=email_service,
|
|
384
767
|
)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
768
|
+
else:
|
|
769
|
+
# Handle regular credit purchase
|
|
770
|
+
success, user_email, credits, _ = stripe_service.handle_checkout_completed(session)
|
|
771
|
+
|
|
772
|
+
if success and user_email and credits > 0:
|
|
773
|
+
# Add credits to user account
|
|
774
|
+
ok, new_balance, credit_msg = user_service.add_credits(
|
|
775
|
+
email=user_email,
|
|
776
|
+
amount=credits,
|
|
777
|
+
reason="stripe_purchase",
|
|
778
|
+
stripe_session_id=session_id,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
if ok:
|
|
782
|
+
# Send confirmation email
|
|
783
|
+
email_service.send_credits_added(user_email, credits, new_balance)
|
|
784
|
+
logger.info(f"Added {credits} credits to {user_email}, new balance: {new_balance}")
|
|
785
|
+
else:
|
|
786
|
+
logger.error(f"Failed to add credits: {credit_msg}")
|
|
392
787
|
|
|
393
788
|
elif event_type == "checkout.session.expired":
|
|
394
789
|
logger.info(f"Checkout session expired: {event['data']['object'].get('id')}")
|
|
@@ -663,6 +1058,7 @@ async def list_users(
|
|
|
663
1058
|
sort_by: str = "created_at",
|
|
664
1059
|
sort_order: str = "desc",
|
|
665
1060
|
include_inactive: bool = False,
|
|
1061
|
+
exclude_test: bool = True,
|
|
666
1062
|
auth_data: Tuple[str, UserType, int] = Depends(require_admin),
|
|
667
1063
|
user_service: UserService = Depends(get_user_service),
|
|
668
1064
|
):
|
|
@@ -676,6 +1072,7 @@ async def list_users(
|
|
|
676
1072
|
sort_by: Field to sort by (created_at, last_login_at, credits, email)
|
|
677
1073
|
sort_order: Sort direction (asc, desc)
|
|
678
1074
|
include_inactive: Include disabled users
|
|
1075
|
+
exclude_test: If True (default), exclude test users (e.g., @inbox.testmail.app)
|
|
679
1076
|
"""
|
|
680
1077
|
from google.cloud import firestore
|
|
681
1078
|
from google.cloud.firestore_v1 import FieldFilter
|
|
@@ -704,9 +1101,14 @@ async def list_users(
|
|
|
704
1101
|
else:
|
|
705
1102
|
query = query.order_by("created_at", direction=direction)
|
|
706
1103
|
|
|
707
|
-
# Get
|
|
1104
|
+
# Get all docs and filter in Python
|
|
708
1105
|
# Note: This is expensive for large datasets, consider caching
|
|
709
1106
|
all_docs = list(query.stream())
|
|
1107
|
+
|
|
1108
|
+
# Filter out test users if exclude_test is True
|
|
1109
|
+
if exclude_test:
|
|
1110
|
+
all_docs = [d for d in all_docs if not is_test_email(d.to_dict().get('email', ''))]
|
|
1111
|
+
|
|
710
1112
|
total_count = len(all_docs)
|
|
711
1113
|
|
|
712
1114
|
# Apply pagination manually (Firestore doesn't support offset well)
|
|
@@ -942,44 +1344,64 @@ async def list_beta_feedback(
|
|
|
942
1344
|
|
|
943
1345
|
@router.get("/admin/beta/stats")
|
|
944
1346
|
async def get_beta_stats(
|
|
1347
|
+
exclude_test: bool = True,
|
|
945
1348
|
auth_data: Tuple[str, UserType, int] = Depends(require_admin),
|
|
946
1349
|
user_service: UserService = Depends(get_user_service),
|
|
947
1350
|
):
|
|
948
1351
|
"""
|
|
949
1352
|
Get beta tester program statistics (admin only).
|
|
1353
|
+
|
|
1354
|
+
Args:
|
|
1355
|
+
exclude_test: If True (default), exclude test users from beta stats
|
|
950
1356
|
"""
|
|
951
1357
|
from google.cloud.firestore_v1 import FieldFilter
|
|
952
1358
|
from google.cloud.firestore_v1 import aggregation
|
|
953
1359
|
|
|
954
|
-
# Count beta testers by status using efficient aggregation queries
|
|
955
1360
|
users_collection = user_service.db.collection(USERS_COLLECTION)
|
|
956
1361
|
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
total_beta_testers = get_count(
|
|
965
|
-
users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
|
|
966
|
-
)
|
|
967
|
-
|
|
968
|
-
active_testers = get_count(
|
|
969
|
-
users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
|
|
970
|
-
)
|
|
971
|
-
|
|
972
|
-
pending_feedback = get_count(
|
|
973
|
-
users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
|
|
974
|
-
)
|
|
1362
|
+
if exclude_test:
|
|
1363
|
+
# Stream and filter in Python since Firestore doesn't support "not ends with"
|
|
1364
|
+
all_beta_users = []
|
|
1365
|
+
for doc in users_collection.where(filter=FieldFilter("is_beta_tester", "==", True)).stream():
|
|
1366
|
+
data = doc.to_dict()
|
|
1367
|
+
if not is_test_email(data.get("email", "")):
|
|
1368
|
+
all_beta_users.append(data)
|
|
975
1369
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1370
|
+
total_beta_testers = len(all_beta_users)
|
|
1371
|
+
active_testers = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "active")
|
|
1372
|
+
pending_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "pending_feedback")
|
|
1373
|
+
completed_feedback = sum(1 for u in all_beta_users if u.get("beta_tester_status") == "completed")
|
|
979
1374
|
|
|
980
|
-
|
|
981
|
-
|
|
1375
|
+
# Filter feedback by non-test users
|
|
1376
|
+
all_feedback = []
|
|
1377
|
+
for doc in user_service.db.collection("beta_feedback").stream():
|
|
1378
|
+
data = doc.to_dict()
|
|
1379
|
+
if not is_test_email(data.get("user_email", "")):
|
|
1380
|
+
all_feedback.append(data)
|
|
1381
|
+
feedback_docs = all_feedback
|
|
1382
|
+
else:
|
|
1383
|
+
# Use efficient aggregation queries when including test data
|
|
1384
|
+
def get_count(query) -> int:
|
|
1385
|
+
agg_query = aggregation.AggregationQuery(query)
|
|
1386
|
+
agg_query.count(alias="count")
|
|
1387
|
+
results = agg_query.get()
|
|
1388
|
+
return results[0][0].value if results else 0
|
|
1389
|
+
|
|
1390
|
+
total_beta_testers = get_count(
|
|
1391
|
+
users_collection.where(filter=FieldFilter("is_beta_tester", "==", True))
|
|
1392
|
+
)
|
|
1393
|
+
active_testers = get_count(
|
|
1394
|
+
users_collection.where(filter=FieldFilter("beta_tester_status", "==", "active"))
|
|
1395
|
+
)
|
|
1396
|
+
pending_feedback = get_count(
|
|
1397
|
+
users_collection.where(filter=FieldFilter("beta_tester_status", "==", "pending_feedback"))
|
|
1398
|
+
)
|
|
1399
|
+
completed_feedback = get_count(
|
|
1400
|
+
users_collection.where(filter=FieldFilter("beta_tester_status", "==", "completed"))
|
|
1401
|
+
)
|
|
1402
|
+
feedback_docs = [doc.to_dict() for doc in user_service.db.collection("beta_feedback").stream()]
|
|
982
1403
|
|
|
1404
|
+
# Calculate average ratings from feedback
|
|
983
1405
|
avg_overall = 0
|
|
984
1406
|
avg_ease = 0
|
|
985
1407
|
avg_accuracy = 0
|
|
@@ -987,8 +1409,7 @@ async def get_beta_stats(
|
|
|
987
1409
|
|
|
988
1410
|
if feedback_docs:
|
|
989
1411
|
total = len(feedback_docs)
|
|
990
|
-
for
|
|
991
|
-
data = doc.to_dict()
|
|
1412
|
+
for data in feedback_docs:
|
|
992
1413
|
avg_overall += data.get("overall_rating", 0)
|
|
993
1414
|
avg_ease += data.get("ease_of_use_rating", 0)
|
|
994
1415
|
avg_accuracy += data.get("lyrics_accuracy_rating", 0)
|