karaoke-gen 0.99.3__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 +512 -1
- backend/api/routes/audio_search.py +13 -2
- backend/api/routes/file_upload.py +42 -1
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +9 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +167 -245
- backend/main.py +6 -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/firestore_service.py +6 -0
- backend/services/job_manager.py +32 -1
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- 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 +146 -1
- backend/tests/test_made_for_you.py +2086 -0
- backend/tests/test_models.py +139 -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
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
- 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.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.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
|
|
|
@@ -60,8 +80,8 @@ class CreateCheckoutResponse(BaseModel):
|
|
|
60
80
|
message: str
|
|
61
81
|
|
|
62
82
|
|
|
63
|
-
class
|
|
64
|
-
"""Request to create a
|
|
83
|
+
class MadeForYouCheckoutRequest(BaseModel):
|
|
84
|
+
"""Request to create a made-for-you karaoke video order."""
|
|
65
85
|
email: EmailStr
|
|
66
86
|
artist: str
|
|
67
87
|
title: str
|
|
@@ -112,7 +132,11 @@ async def send_magic_link(
|
|
|
112
132
|
|
|
113
133
|
The user will receive an email with a link that logs them in.
|
|
114
134
|
Links expire after 15 minutes and can only be used once.
|
|
135
|
+
|
|
136
|
+
For white-label tenants, validates email domain against tenant config.
|
|
115
137
|
"""
|
|
138
|
+
from backend.middleware.tenant import get_tenant_from_request, get_tenant_config_from_request
|
|
139
|
+
|
|
116
140
|
# Check if email service is configured
|
|
117
141
|
if not email_service.is_configured():
|
|
118
142
|
logger.error("Email service not configured - cannot send magic links")
|
|
@@ -123,19 +147,39 @@ async def send_magic_link(
|
|
|
123
147
|
|
|
124
148
|
email = request.email.lower()
|
|
125
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
|
+
|
|
126
164
|
# Get client info for security logging
|
|
127
165
|
ip_address = http_request.client.host if http_request.client else None
|
|
128
166
|
user_agent = http_request.headers.get("user-agent")
|
|
129
167
|
|
|
130
|
-
# Create magic link token
|
|
168
|
+
# Create magic link token with tenant context
|
|
131
169
|
magic_link = user_service.create_magic_link(
|
|
132
170
|
email,
|
|
133
171
|
ip_address=ip_address,
|
|
134
|
-
user_agent=user_agent
|
|
172
|
+
user_agent=user_agent,
|
|
173
|
+
tenant_id=tenant_id
|
|
135
174
|
)
|
|
136
175
|
|
|
137
|
-
#
|
|
138
|
-
|
|
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)
|
|
139
183
|
|
|
140
184
|
if not sent:
|
|
141
185
|
logger.error(f"Failed to send magic link email to {email}")
|
|
@@ -159,18 +203,26 @@ async def verify_magic_link(
|
|
|
159
203
|
Verify a magic link token and create a session.
|
|
160
204
|
|
|
161
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.
|
|
162
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
|
+
|
|
163
212
|
# Get client info
|
|
164
213
|
ip_address = http_request.client.host if http_request.client else None
|
|
165
214
|
user_agent = http_request.headers.get("user-agent")
|
|
166
215
|
|
|
167
216
|
# Check if this is a first login BEFORE verification (which sets last_login_at)
|
|
168
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
|
|
169
219
|
from backend.services.user_service import MAGIC_LINKS_COLLECTION
|
|
170
220
|
magic_link_doc = user_service.db.collection(MAGIC_LINKS_COLLECTION).document(token).get()
|
|
171
221
|
is_first_login = False
|
|
222
|
+
magic_link_tenant_id = None
|
|
172
223
|
if magic_link_doc.exists:
|
|
173
224
|
magic_link_data = magic_link_doc.to_dict()
|
|
225
|
+
magic_link_tenant_id = magic_link_data.get('tenant_id')
|
|
174
226
|
pre_verify_user = user_service.get_user(magic_link_data.get('email', ''))
|
|
175
227
|
if pre_verify_user:
|
|
176
228
|
is_first_login = pre_verify_user.total_jobs_created == 0 and not pre_verify_user.last_login_at
|
|
@@ -181,18 +233,19 @@ async def verify_magic_link(
|
|
|
181
233
|
if not success or not user:
|
|
182
234
|
raise HTTPException(status_code=401, detail=message)
|
|
183
235
|
|
|
184
|
-
# Create session
|
|
236
|
+
# Create session with tenant context from the magic link
|
|
185
237
|
session = user_service.create_session(
|
|
186
238
|
user.email,
|
|
187
239
|
ip_address=ip_address,
|
|
188
|
-
user_agent=user_agent
|
|
240
|
+
user_agent=user_agent,
|
|
241
|
+
tenant_id=magic_link_tenant_id
|
|
189
242
|
)
|
|
190
243
|
|
|
191
244
|
# Send welcome email to first-time users
|
|
192
245
|
if is_first_login:
|
|
193
246
|
email_service.send_welcome_email(user.email, user.credits)
|
|
194
247
|
|
|
195
|
-
# Return user info
|
|
248
|
+
# Return user info with tenant_id
|
|
196
249
|
user_public = UserPublic(
|
|
197
250
|
email=user.email,
|
|
198
251
|
role=user.role,
|
|
@@ -200,6 +253,7 @@ async def verify_magic_link(
|
|
|
200
253
|
display_name=user.display_name,
|
|
201
254
|
total_jobs_created=user.total_jobs_created,
|
|
202
255
|
total_jobs_completed=user.total_jobs_completed,
|
|
256
|
+
tenant_id=user.tenant_id,
|
|
203
257
|
)
|
|
204
258
|
|
|
205
259
|
return VerifyMagicLinkResponse(
|
|
@@ -340,13 +394,13 @@ async def create_checkout(
|
|
|
340
394
|
)
|
|
341
395
|
|
|
342
396
|
|
|
343
|
-
@router.post("/
|
|
344
|
-
async def
|
|
345
|
-
request:
|
|
397
|
+
@router.post("/made-for-you/checkout", response_model=CreateCheckoutResponse)
|
|
398
|
+
async def create_made_for_you_checkout(
|
|
399
|
+
request: MadeForYouCheckoutRequest,
|
|
346
400
|
stripe_service: StripeService = Depends(get_stripe_service),
|
|
347
401
|
):
|
|
348
402
|
"""
|
|
349
|
-
Create a Stripe checkout session for a
|
|
403
|
+
Create a Stripe checkout session for a made-for-you karaoke video order.
|
|
350
404
|
|
|
351
405
|
This is the full-service option where Nomad Karaoke handles everything:
|
|
352
406
|
- Finding or processing the audio
|
|
@@ -360,7 +414,7 @@ async def create_done_for_you_checkout(
|
|
|
360
414
|
if not stripe_service.is_configured():
|
|
361
415
|
raise HTTPException(status_code=503, detail="Payment processing is not available")
|
|
362
416
|
|
|
363
|
-
success, checkout_url, message = stripe_service.
|
|
417
|
+
success, checkout_url, message = stripe_service.create_made_for_you_checkout_session(
|
|
364
418
|
customer_email=request.email,
|
|
365
419
|
artist=request.artist,
|
|
366
420
|
title=request.title,
|
|
@@ -383,22 +437,28 @@ async def create_done_for_you_checkout(
|
|
|
383
437
|
# Stripe Webhooks
|
|
384
438
|
# =============================================================================
|
|
385
439
|
|
|
386
|
-
# Admin email for
|
|
387
|
-
ADMIN_EMAIL = "
|
|
440
|
+
# Admin email for made-for-you order notifications
|
|
441
|
+
ADMIN_EMAIL = "madeforyou@nomadkaraoke.com"
|
|
388
442
|
|
|
389
443
|
|
|
390
|
-
async def
|
|
444
|
+
async def _handle_made_for_you_order(
|
|
391
445
|
session_id: str,
|
|
392
446
|
metadata: dict,
|
|
393
447
|
user_service: UserService,
|
|
394
448
|
email_service: EmailService,
|
|
395
449
|
) -> None:
|
|
396
450
|
"""
|
|
397
|
-
Handle a completed
|
|
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
|
|
398
460
|
|
|
399
|
-
For orders with a YouTube URL,
|
|
400
|
-
For orders without a URL (search mode), the audio search flow is used to find and
|
|
401
|
-
download the best matching audio source automatically.
|
|
461
|
+
For orders with a YouTube URL, workers are triggered immediately (no search needed).
|
|
402
462
|
|
|
403
463
|
Args:
|
|
404
464
|
session_id: Stripe checkout session ID
|
|
@@ -415,6 +475,7 @@ async def _handle_done_for_you_order(
|
|
|
415
475
|
AudioSearchError,
|
|
416
476
|
)
|
|
417
477
|
from backend.services.storage_service import StorageService
|
|
478
|
+
from backend.config import get_settings
|
|
418
479
|
import asyncio
|
|
419
480
|
import tempfile
|
|
420
481
|
import os
|
|
@@ -427,7 +488,7 @@ async def _handle_done_for_you_order(
|
|
|
427
488
|
notes = metadata.get("notes", "")
|
|
428
489
|
|
|
429
490
|
logger.info(
|
|
430
|
-
f"Processing
|
|
491
|
+
f"Processing made-for-you order: {artist} - {title} for {customer_email} "
|
|
431
492
|
f"(session: {session_id}, source_type: {source_type})"
|
|
432
493
|
)
|
|
433
494
|
|
|
@@ -435,37 +496,58 @@ async def _handle_done_for_you_order(
|
|
|
435
496
|
job_manager = JobManager()
|
|
436
497
|
worker_service = get_worker_service()
|
|
437
498
|
storage_service = StorageService()
|
|
499
|
+
settings = get_settings()
|
|
438
500
|
|
|
439
501
|
# Apply default theme (Nomad) - same as audio_search endpoint
|
|
440
502
|
theme_service = get_theme_service()
|
|
441
503
|
effective_theme_id = theme_service.get_default_theme_id()
|
|
442
504
|
if effective_theme_id:
|
|
443
|
-
logger.info(f"Applying default theme '{effective_theme_id}' for
|
|
444
|
-
|
|
445
|
-
#
|
|
446
|
-
|
|
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
|
|
447
518
|
job_create = JobCreate(
|
|
448
519
|
url=youtube_url if youtube_url else None,
|
|
449
520
|
artist=artist,
|
|
450
521
|
title=title,
|
|
451
|
-
user_email=
|
|
522
|
+
user_email=ADMIN_EMAIL, # Admin owns during processing
|
|
452
523
|
theme_id=effective_theme_id, # Apply default theme
|
|
453
|
-
non_interactive=False, #
|
|
454
|
-
#
|
|
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
|
|
455
530
|
audio_search_artist=artist if not youtube_url else None,
|
|
456
531
|
audio_search_title=title if not youtube_url else None,
|
|
457
|
-
auto_download=
|
|
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,
|
|
458
540
|
)
|
|
459
541
|
job = job_manager.create_job(job_create)
|
|
460
542
|
job_id = job.job_id
|
|
461
543
|
|
|
462
|
-
logger.info(f"Created
|
|
544
|
+
logger.info(f"Created made-for-you job {job_id} for {_mask_email(customer_email)} (owned by {_mask_email(ADMIN_EMAIL)})")
|
|
463
545
|
|
|
464
546
|
# Prepare theme style assets for the job (same as audio_search endpoint)
|
|
465
547
|
if effective_theme_id:
|
|
466
548
|
try:
|
|
467
549
|
style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
|
|
468
|
-
job_id, effective_theme_id, None # No color overrides for
|
|
550
|
+
job_id, effective_theme_id, None # No color overrides for made-for-you
|
|
469
551
|
)
|
|
470
552
|
theme_update = {
|
|
471
553
|
'style_params_gcs_path': style_params_path,
|
|
@@ -474,9 +556,9 @@ async def _handle_done_for_you_order(
|
|
|
474
556
|
if youtube_desc:
|
|
475
557
|
theme_update['youtube_description_template'] = youtube_desc
|
|
476
558
|
job_manager.update_job(job_id, theme_update)
|
|
477
|
-
logger.info(f"Applied theme '{effective_theme_id}' to
|
|
559
|
+
logger.info(f"Applied theme '{effective_theme_id}' to made-for-you job {job_id}")
|
|
478
560
|
except Exception as e:
|
|
479
|
-
logger.warning(f"Failed to prepare theme for
|
|
561
|
+
logger.warning(f"Failed to prepare theme for made-for-you job {job_id}: {e}")
|
|
480
562
|
|
|
481
563
|
# Mark session as processed for idempotency
|
|
482
564
|
# Note: Using internal method since this isn't a credit transaction
|
|
@@ -486,23 +568,27 @@ async def _handle_done_for_you_order(
|
|
|
486
568
|
amount=0 # No credits, just tracking the session
|
|
487
569
|
)
|
|
488
570
|
|
|
571
|
+
# Initialize search_results for later use in email notification
|
|
572
|
+
search_results = None
|
|
573
|
+
|
|
489
574
|
# Handle based on whether we have a YouTube URL or need to search
|
|
490
575
|
if youtube_url:
|
|
491
|
-
# URL provided - trigger workers directly
|
|
576
|
+
# URL provided - trigger workers directly (no audio selection needed)
|
|
492
577
|
logger.info(f"Job {job_id}: YouTube URL provided, triggering workers")
|
|
493
578
|
await asyncio.gather(
|
|
494
579
|
worker_service.trigger_audio_worker(job_id),
|
|
495
580
|
worker_service.trigger_lyrics_worker(job_id)
|
|
496
581
|
)
|
|
497
582
|
else:
|
|
498
|
-
# No URL - use audio search flow
|
|
583
|
+
# No URL - use audio search flow, pause for admin selection
|
|
584
|
+
# Made-for-you jobs require admin to select audio source
|
|
499
585
|
logger.info(f"Job {job_id}: No URL, using audio search for '{artist} - {title}'")
|
|
500
586
|
|
|
501
587
|
# Update job with audio search fields
|
|
502
588
|
job_manager.update_job(job_id, {
|
|
503
589
|
'audio_search_artist': artist,
|
|
504
590
|
'audio_search_title': title,
|
|
505
|
-
'auto_download':
|
|
591
|
+
'auto_download': False, # Admin must select
|
|
506
592
|
})
|
|
507
593
|
|
|
508
594
|
# Transition to searching state
|
|
@@ -519,7 +605,8 @@ async def _handle_done_for_you_order(
|
|
|
519
605
|
try:
|
|
520
606
|
search_results = audio_search_service.search(artist, title)
|
|
521
607
|
except NoResultsError as e:
|
|
522
|
-
# No results found - transition to AWAITING_AUDIO_SELECTION
|
|
608
|
+
# No results found - still transition to AWAITING_AUDIO_SELECTION
|
|
609
|
+
# Admin can manually provide audio
|
|
523
610
|
logger.warning(f"Job {job_id}: No audio sources found for '{artist} - {title}'")
|
|
524
611
|
job_manager.transition_to_state(
|
|
525
612
|
job_id=job_id,
|
|
@@ -527,7 +614,6 @@ async def _handle_done_for_you_order(
|
|
|
527
614
|
progress=10,
|
|
528
615
|
message=f"No automatic audio sources found. Manual intervention required."
|
|
529
616
|
)
|
|
530
|
-
# Don't fail the job - Andrew can manually provide audio
|
|
531
617
|
search_results = None
|
|
532
618
|
except AudioSearchError as e:
|
|
533
619
|
logger.error(f"Job {job_id}: Audio search failed: {e}")
|
|
@@ -540,7 +626,7 @@ async def _handle_done_for_you_order(
|
|
|
540
626
|
search_results = None
|
|
541
627
|
|
|
542
628
|
if search_results:
|
|
543
|
-
# Store search results in state_data
|
|
629
|
+
# Store search results in state_data for admin to review
|
|
544
630
|
results_dicts = [r.to_dict() for r in search_results]
|
|
545
631
|
state_data_update = {
|
|
546
632
|
'audio_search_results': results_dicts,
|
|
@@ -550,225 +636,61 @@ async def _handle_done_for_you_order(
|
|
|
550
636
|
state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
|
|
551
637
|
job_manager.update_job(job_id, {'state_data': state_data_update})
|
|
552
638
|
|
|
553
|
-
#
|
|
554
|
-
|
|
555
|
-
|
|
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")
|
|
556
642
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
# Transition to downloading state
|
|
643
|
+
# Transition to AWAITING_AUDIO_SELECTION for admin to choose
|
|
644
|
+
# Admin will select from results in the UI, then job proceeds
|
|
560
645
|
job_manager.transition_to_state(
|
|
561
646
|
job_id=job_id,
|
|
562
|
-
new_status=JobStatus.
|
|
647
|
+
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
563
648
|
progress=10,
|
|
564
|
-
message=f"
|
|
565
|
-
state_data_updates={
|
|
566
|
-
'selected_audio_index': best_index,
|
|
567
|
-
'selected_audio_provider': selected.get('provider'),
|
|
568
|
-
}
|
|
649
|
+
message=f"Found {len(results_dicts)} audio sources. Awaiting admin selection."
|
|
569
650
|
)
|
|
570
651
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
gcs_destination = f"uploads/{job_id}/audio/"
|
|
584
|
-
|
|
585
|
-
if source_id and source_name:
|
|
586
|
-
result = audio_search_service.download_by_id(
|
|
587
|
-
source_name=source_name,
|
|
588
|
-
source_id=source_id,
|
|
589
|
-
output_dir="",
|
|
590
|
-
target_file=target_file,
|
|
591
|
-
download_url=download_url,
|
|
592
|
-
gcs_path=gcs_destination,
|
|
593
|
-
)
|
|
594
|
-
else:
|
|
595
|
-
result = audio_search_service.download(
|
|
596
|
-
result_index=best_index,
|
|
597
|
-
output_dir="",
|
|
598
|
-
gcs_path=gcs_destination,
|
|
599
|
-
remote_search_id=remote_search_id,
|
|
600
|
-
)
|
|
601
|
-
|
|
602
|
-
# Extract GCS path
|
|
603
|
-
if result.filepath.startswith("gs://"):
|
|
604
|
-
parts = result.filepath.replace("gs://", "").split("/", 1)
|
|
605
|
-
audio_gcs_path = parts[1] if len(parts) == 2 else result.filepath
|
|
606
|
-
filename = os.path.basename(result.filepath)
|
|
607
|
-
else:
|
|
608
|
-
filename = os.path.basename(result.filepath)
|
|
609
|
-
audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
|
|
610
|
-
else:
|
|
611
|
-
# Local download (YouTube or local torrent)
|
|
612
|
-
temp_dir = tempfile.mkdtemp(prefix=f"audio_download_{job_id}_")
|
|
613
|
-
import shutil
|
|
614
|
-
|
|
615
|
-
try:
|
|
616
|
-
if source_id and source_name and is_remote_enabled:
|
|
617
|
-
result = audio_search_service.download_by_id(
|
|
618
|
-
source_name=source_name,
|
|
619
|
-
source_id=source_id,
|
|
620
|
-
output_dir=temp_dir,
|
|
621
|
-
target_file=target_file,
|
|
622
|
-
download_url=download_url,
|
|
623
|
-
)
|
|
624
|
-
elif source_name == 'YouTube' and download_url:
|
|
625
|
-
# YouTube download
|
|
626
|
-
from backend.workers.audio_worker import download_from_url
|
|
627
|
-
local_path = await download_from_url(
|
|
628
|
-
download_url,
|
|
629
|
-
temp_dir,
|
|
630
|
-
selected.get('artist'),
|
|
631
|
-
selected.get('title')
|
|
632
|
-
)
|
|
633
|
-
if not local_path or not os.path.exists(local_path):
|
|
634
|
-
raise Exception(f"Failed to download from YouTube: {download_url}")
|
|
635
|
-
|
|
636
|
-
class DownloadResult:
|
|
637
|
-
def __init__(self, filepath):
|
|
638
|
-
self.filepath = filepath
|
|
639
|
-
result = DownloadResult(local_path)
|
|
640
|
-
else:
|
|
641
|
-
result = audio_search_service.download(
|
|
642
|
-
result_index=best_index,
|
|
643
|
-
output_dir=temp_dir,
|
|
644
|
-
remote_search_id=remote_search_id,
|
|
645
|
-
)
|
|
646
|
-
|
|
647
|
-
# Upload to GCS
|
|
648
|
-
filename = os.path.basename(result.filepath)
|
|
649
|
-
audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
|
|
650
|
-
|
|
651
|
-
with open(result.filepath, 'rb') as f:
|
|
652
|
-
storage_service.upload_fileobj(f, audio_gcs_path, content_type='audio/flac')
|
|
653
|
-
finally:
|
|
654
|
-
# Always cleanup temp directory
|
|
655
|
-
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
656
|
-
|
|
657
|
-
# Update job with GCS path
|
|
658
|
-
job_manager.update_job(job_id, {
|
|
659
|
-
'input_media_gcs_path': audio_gcs_path,
|
|
660
|
-
'filename': filename,
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
# Transition to DOWNLOADING and trigger workers
|
|
664
|
-
job_manager.transition_to_state(
|
|
665
|
-
job_id=job_id,
|
|
666
|
-
new_status=JobStatus.DOWNLOADING,
|
|
667
|
-
progress=15,
|
|
668
|
-
message="Audio downloaded, starting processing"
|
|
669
|
-
)
|
|
670
|
-
|
|
671
|
-
# Trigger workers
|
|
672
|
-
await asyncio.gather(
|
|
673
|
-
worker_service.trigger_audio_worker(job_id),
|
|
674
|
-
worker_service.trigger_lyrics_worker(job_id)
|
|
675
|
-
)
|
|
676
|
-
|
|
677
|
-
logger.info(f"Job {job_id}: Audio downloaded and workers triggered")
|
|
678
|
-
|
|
679
|
-
except Exception as download_error:
|
|
680
|
-
logger.error(f"Job {job_id}: Audio download failed: {download_error}")
|
|
681
|
-
# Don't fail job - transition to awaiting selection so Andrew can handle
|
|
682
|
-
job_manager.transition_to_state(
|
|
683
|
-
job_id=job_id,
|
|
684
|
-
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
685
|
-
progress=10,
|
|
686
|
-
message=f"Auto-download failed: {download_error}. Manual intervention required."
|
|
687
|
-
)
|
|
688
|
-
|
|
689
|
-
# Send confirmation email to customer
|
|
690
|
-
email_service.send_email(
|
|
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(
|
|
691
664
|
to_email=customer_email,
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
<ul>
|
|
697
|
-
<li><strong>Artist:</strong> {artist}</li>
|
|
698
|
-
<li><strong>Title:</strong> {title}</li>
|
|
699
|
-
{f'<li><strong>Notes:</strong> {notes}</li>' if notes else ''}
|
|
700
|
-
</ul>
|
|
701
|
-
<p>Our team will review and create your video within <strong>24 hours</strong>.</p>
|
|
702
|
-
<p>You'll receive another email with download links when your video is ready.</p>
|
|
703
|
-
<p>If you have any questions, reply to this email or contact us at support@nomadkaraoke.com</p>
|
|
704
|
-
<p>Thanks for using Nomad Karaoke!</p>
|
|
705
|
-
""",
|
|
706
|
-
text_content=f"""
|
|
707
|
-
Thank you for your order!
|
|
708
|
-
|
|
709
|
-
We've received your request for a karaoke video:
|
|
710
|
-
- Artist: {artist}
|
|
711
|
-
- Title: {title}
|
|
712
|
-
{f'- Notes: {notes}' if notes else ''}
|
|
713
|
-
|
|
714
|
-
Our team will review and create your video within 24 hours.
|
|
715
|
-
You'll receive another email with download links when your video is ready.
|
|
716
|
-
|
|
717
|
-
If you have any questions, reply to this email or contact us at support@nomadkaraoke.com
|
|
718
|
-
|
|
719
|
-
Thanks for using Nomad Karaoke!
|
|
720
|
-
""".strip(),
|
|
665
|
+
artist=artist,
|
|
666
|
+
title=title,
|
|
667
|
+
job_id=job_id,
|
|
668
|
+
notes=notes,
|
|
721
669
|
)
|
|
722
670
|
|
|
723
|
-
# Send notification email to
|
|
724
|
-
email_service.
|
|
671
|
+
# Send notification email to admin using professional template
|
|
672
|
+
email_service.send_made_for_you_admin_notification(
|
|
725
673
|
to_email=ADMIN_EMAIL,
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
<li><strong>Title:</strong> {title}</li>
|
|
734
|
-
<li><strong>Source:</strong> {source_type}</li>
|
|
735
|
-
{f'<li><strong>YouTube URL:</strong> <a href="{youtube_url}">{youtube_url}</a></li>' if youtube_url else ''}
|
|
736
|
-
{f'<li><strong>Notes:</strong> {notes}</li>' if notes else ''}
|
|
737
|
-
</ul>
|
|
738
|
-
<p><strong>Job ID:</strong> {job_id}</p>
|
|
739
|
-
<p>View job in admin: <a href="https://gen.nomadkaraoke.com/admin/jobs/{job_id}">Admin Link</a></p>
|
|
740
|
-
<p>View job as customer: <a href="https://gen.nomadkaraoke.com/jobs/{job_id}">Customer Link</a></p>
|
|
741
|
-
<p><strong>Deadline:</strong> 24 hours from now</p>
|
|
742
|
-
""",
|
|
743
|
-
text_content=f"""
|
|
744
|
-
New Done-For-You Order
|
|
745
|
-
|
|
746
|
-
Customer: {customer_email}
|
|
747
|
-
Artist: {artist}
|
|
748
|
-
Title: {title}
|
|
749
|
-
Source: {source_type}
|
|
750
|
-
{f'YouTube URL: {youtube_url}' if youtube_url else ''}
|
|
751
|
-
{f'Notes: {notes}' if notes else ''}
|
|
752
|
-
|
|
753
|
-
Job ID: {job_id}
|
|
754
|
-
Admin: https://gen.nomadkaraoke.com/admin/jobs/{job_id}
|
|
755
|
-
Customer: https://gen.nomadkaraoke.com/jobs/{job_id}
|
|
756
|
-
|
|
757
|
-
Deadline: 24 hours from now
|
|
758
|
-
""".strip(),
|
|
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,
|
|
759
681
|
)
|
|
760
682
|
|
|
761
|
-
logger.info(f"Sent
|
|
683
|
+
logger.info(f"Sent made-for-you order notifications for job {job_id}")
|
|
762
684
|
|
|
763
685
|
except Exception as e:
|
|
764
|
-
logger.error(f"Error processing
|
|
686
|
+
logger.error(f"Error processing made-for-you order: {e}", exc_info=True)
|
|
765
687
|
# Still try to notify Andrew of the failure
|
|
766
688
|
try:
|
|
767
689
|
email_service.send_email(
|
|
768
690
|
to_email=ADMIN_EMAIL,
|
|
769
|
-
subject=f"[FAILED]
|
|
691
|
+
subject=f"[FAILED] Made For You Order: {artist} - {title}",
|
|
770
692
|
html_content=f"""
|
|
771
|
-
<h2>
|
|
693
|
+
<h2>Made-For-You Order Failed</h2>
|
|
772
694
|
<p>An error occurred processing this order:</p>
|
|
773
695
|
<ul>
|
|
774
696
|
<li><strong>Customer:</strong> {customer_email}</li>
|
|
@@ -779,7 +701,7 @@ Deadline: 24 hours from now
|
|
|
779
701
|
<p>Please manually create this job and notify the customer.</p>
|
|
780
702
|
""",
|
|
781
703
|
text_content=f"""
|
|
782
|
-
|
|
704
|
+
Made-For-You Order Failed
|
|
783
705
|
|
|
784
706
|
Customer: {customer_email}
|
|
785
707
|
Artist: {artist}
|
|
@@ -834,10 +756,10 @@ async def stripe_webhook(
|
|
|
834
756
|
logger.info(f"Skipping already processed session: {session_id}")
|
|
835
757
|
return {"status": "received", "type": event_type, "note": "already_processed"}
|
|
836
758
|
|
|
837
|
-
# Check if this is a
|
|
838
|
-
if metadata.get("order_type") == "
|
|
839
|
-
# Handle
|
|
840
|
-
await
|
|
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(
|
|
841
763
|
session_id=session_id,
|
|
842
764
|
metadata=metadata,
|
|
843
765
|
user_service=user_service,
|