karaoke-gen 0.90.1__py3-none-any.whl → 0.96.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/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,2076 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File upload route for local file submission with style configuration support.
|
|
3
|
+
|
|
4
|
+
Supports two upload flows:
|
|
5
|
+
1. Direct upload (original): All files sent as multipart form data to /api/jobs/upload
|
|
6
|
+
- Simple but limited by Cloud Run's 32MB request body size
|
|
7
|
+
|
|
8
|
+
2. Signed URL upload (recommended for large files):
|
|
9
|
+
- POST /api/jobs/create-with-upload-urls - Creates job, returns signed GCS upload URLs
|
|
10
|
+
- Client uploads files directly to GCS using signed URLs (no size limit)
|
|
11
|
+
- POST /api/jobs/{job_id}/uploads-complete - Validates uploads, triggers workers
|
|
12
|
+
"""
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import tempfile
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks, Request, Body, Depends
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, Field
|
|
24
|
+
|
|
25
|
+
from backend.models.job import JobCreate, JobStatus
|
|
26
|
+
from backend.models.theme import ColorOverrides
|
|
27
|
+
from backend.services.job_manager import JobManager
|
|
28
|
+
from backend.services.storage_service import StorageService
|
|
29
|
+
from backend.services.worker_service import get_worker_service
|
|
30
|
+
from backend.services.credential_manager import get_credential_manager, CredentialStatus
|
|
31
|
+
from backend.services.theme_service import get_theme_service
|
|
32
|
+
from backend.config import get_settings
|
|
33
|
+
from backend.version import VERSION
|
|
34
|
+
from backend.services.metrics import metrics
|
|
35
|
+
from backend.api.dependencies import require_auth
|
|
36
|
+
from backend.services.auth_service import UserType, AuthResult
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
router = APIRouter(tags=["jobs"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ============================================================================
|
|
43
|
+
# Pydantic models for signed URL upload flow
|
|
44
|
+
# ============================================================================
|
|
45
|
+
|
|
46
|
+
class FileUploadRequest(BaseModel):
|
|
47
|
+
"""Information about a file to be uploaded."""
|
|
48
|
+
filename: str = Field(..., description="Original filename with extension")
|
|
49
|
+
content_type: str = Field(..., description="MIME type of the file")
|
|
50
|
+
file_type: str = Field(..., description="Type of file: 'audio', 'style_params', 'style_intro_background', 'style_karaoke_background', 'style_end_background', 'style_font', 'style_cdg_instrumental_background', 'style_cdg_title_background', 'style_cdg_outro_background', 'lyrics_file'")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CreateJobFromUrlRequest(BaseModel):
|
|
54
|
+
"""Request to create a job from a YouTube/online URL."""
|
|
55
|
+
# Required fields
|
|
56
|
+
url: str = Field(..., description="YouTube or other video URL to download audio from")
|
|
57
|
+
|
|
58
|
+
# Optional fields - will be auto-detected from URL if not provided
|
|
59
|
+
artist: Optional[str] = Field(None, description="Artist name (auto-detected from URL if not provided)")
|
|
60
|
+
title: Optional[str] = Field(None, description="Song title (auto-detected from URL if not provided)")
|
|
61
|
+
|
|
62
|
+
# Theme configuration (use pre-made theme from GCS)
|
|
63
|
+
theme_id: Optional[str] = Field(None, description="Theme ID to use (e.g., 'nomad', 'default'). If set, CDG/TXT are enabled by default.")
|
|
64
|
+
color_overrides: Optional[Dict[str, str]] = Field(None, description="Color overrides: artist_color, title_color, sung_lyrics_color, unsung_lyrics_color (hex #RRGGBB)")
|
|
65
|
+
|
|
66
|
+
# Processing options (CDG/TXT require style config or theme, disabled by default unless theme is set)
|
|
67
|
+
enable_cdg: Optional[bool] = Field(None, description="Generate CDG+MP3 package. Default: True if theme_id set, False otherwise")
|
|
68
|
+
enable_txt: Optional[bool] = Field(None, description="Generate TXT+MP3 package. Default: True if theme_id set, False otherwise")
|
|
69
|
+
|
|
70
|
+
# Finalisation options
|
|
71
|
+
brand_prefix: Optional[str] = Field(None, description="Brand code prefix (e.g., NOMAD)")
|
|
72
|
+
enable_youtube_upload: Optional[bool] = Field(None, description="Upload to YouTube. None = use server default")
|
|
73
|
+
youtube_description: Optional[str] = Field(None, description="YouTube video description text")
|
|
74
|
+
discord_webhook_url: Optional[str] = Field(None, description="Discord webhook URL for notifications")
|
|
75
|
+
webhook_url: Optional[str] = Field(None, description="Generic webhook URL")
|
|
76
|
+
user_email: Optional[str] = Field(None, description="User email for notifications")
|
|
77
|
+
|
|
78
|
+
# Distribution options (native API - preferred for remote CLI)
|
|
79
|
+
dropbox_path: Optional[str] = Field(None, description="Dropbox folder path for organized output")
|
|
80
|
+
gdrive_folder_id: Optional[str] = Field(None, description="Google Drive folder ID for public share uploads")
|
|
81
|
+
|
|
82
|
+
# Legacy distribution options (rclone - deprecated)
|
|
83
|
+
organised_dir_rclone_root: Optional[str] = Field(None, description="[Deprecated] rclone remote path")
|
|
84
|
+
|
|
85
|
+
# Lyrics configuration
|
|
86
|
+
lyrics_artist: Optional[str] = Field(None, description="Override artist name for lyrics search")
|
|
87
|
+
lyrics_title: Optional[str] = Field(None, description="Override title for lyrics search")
|
|
88
|
+
subtitle_offset_ms: int = Field(0, description="Subtitle timing offset in milliseconds")
|
|
89
|
+
|
|
90
|
+
# Audio separation model configuration
|
|
91
|
+
clean_instrumental_model: Optional[str] = Field(None, description="Model for clean instrumental separation")
|
|
92
|
+
backing_vocals_models: Optional[List[str]] = Field(None, description="Models for backing vocals separation")
|
|
93
|
+
other_stems_models: Optional[List[str]] = Field(None, description="Models for other stems")
|
|
94
|
+
|
|
95
|
+
# Non-interactive mode
|
|
96
|
+
non_interactive: bool = Field(False, description="Skip interactive steps (lyrics review, instrumental selection)")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CreateJobFromUrlResponse(BaseModel):
|
|
100
|
+
"""Response from creating a job from URL."""
|
|
101
|
+
status: str
|
|
102
|
+
job_id: str
|
|
103
|
+
message: str
|
|
104
|
+
detected_artist: Optional[str]
|
|
105
|
+
detected_title: Optional[str]
|
|
106
|
+
server_version: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CreateJobWithUploadUrlsRequest(BaseModel):
|
|
110
|
+
"""Request to create a job and get signed upload URLs."""
|
|
111
|
+
# Required fields
|
|
112
|
+
artist: str = Field(..., description="Artist name")
|
|
113
|
+
title: str = Field(..., description="Song title")
|
|
114
|
+
files: List[FileUploadRequest] = Field(..., description="List of files to upload")
|
|
115
|
+
|
|
116
|
+
# Theme configuration (use pre-made theme from GCS)
|
|
117
|
+
theme_id: Optional[str] = Field(None, description="Theme ID to use (e.g., 'nomad', 'default'). If set, CDG/TXT are enabled by default.")
|
|
118
|
+
color_overrides: Optional[Dict[str, str]] = Field(None, description="Color overrides: artist_color, title_color, sung_lyrics_color, unsung_lyrics_color (hex #RRGGBB)")
|
|
119
|
+
|
|
120
|
+
# Processing options (CDG/TXT require style config or theme, disabled by default unless theme is set)
|
|
121
|
+
enable_cdg: Optional[bool] = Field(None, description="Generate CDG+MP3 package. Default: True if theme_id set, False otherwise")
|
|
122
|
+
enable_txt: Optional[bool] = Field(None, description="Generate TXT+MP3 package. Default: True if theme_id set, False otherwise")
|
|
123
|
+
|
|
124
|
+
# Finalisation options
|
|
125
|
+
brand_prefix: Optional[str] = Field(None, description="Brand code prefix (e.g., NOMAD)")
|
|
126
|
+
enable_youtube_upload: Optional[bool] = Field(None, description="Upload to YouTube. None = use server default")
|
|
127
|
+
youtube_description: Optional[str] = Field(None, description="YouTube video description text")
|
|
128
|
+
discord_webhook_url: Optional[str] = Field(None, description="Discord webhook URL for notifications")
|
|
129
|
+
webhook_url: Optional[str] = Field(None, description="Generic webhook URL")
|
|
130
|
+
user_email: Optional[str] = Field(None, description="User email for notifications")
|
|
131
|
+
|
|
132
|
+
# Distribution options (native API - preferred for remote CLI)
|
|
133
|
+
dropbox_path: Optional[str] = Field(None, description="Dropbox folder path for organized output")
|
|
134
|
+
gdrive_folder_id: Optional[str] = Field(None, description="Google Drive folder ID for public share uploads")
|
|
135
|
+
|
|
136
|
+
# Legacy distribution options (rclone - deprecated)
|
|
137
|
+
organised_dir_rclone_root: Optional[str] = Field(None, description="[Deprecated] rclone remote path")
|
|
138
|
+
|
|
139
|
+
# Lyrics configuration
|
|
140
|
+
lyrics_artist: Optional[str] = Field(None, description="Override artist name for lyrics search")
|
|
141
|
+
lyrics_title: Optional[str] = Field(None, description="Override title for lyrics search")
|
|
142
|
+
subtitle_offset_ms: int = Field(0, description="Subtitle timing offset in milliseconds")
|
|
143
|
+
|
|
144
|
+
# Audio separation model configuration
|
|
145
|
+
clean_instrumental_model: Optional[str] = Field(None, description="Model for clean instrumental separation")
|
|
146
|
+
backing_vocals_models: Optional[List[str]] = Field(None, description="Models for backing vocals separation")
|
|
147
|
+
other_stems_models: Optional[List[str]] = Field(None, description="Models for other stems")
|
|
148
|
+
|
|
149
|
+
# Existing instrumental configuration (Batch 3)
|
|
150
|
+
existing_instrumental: bool = Field(False, description="Whether an existing instrumental file is being uploaded")
|
|
151
|
+
|
|
152
|
+
# Two-phase workflow configuration (Batch 6)
|
|
153
|
+
prep_only: bool = Field(False, description="Stop after review phase, don't run finalisation")
|
|
154
|
+
keep_brand_code: Optional[str] = Field(None, description="Preserve existing brand code instead of generating new one")
|
|
155
|
+
|
|
156
|
+
# Non-interactive mode
|
|
157
|
+
non_interactive: bool = Field(False, description="Skip interactive steps (lyrics review, instrumental selection)")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class SignedUploadUrl(BaseModel):
|
|
161
|
+
"""Signed URL for uploading a file."""
|
|
162
|
+
file_type: str = Field(..., description="Type of file this URL is for")
|
|
163
|
+
gcs_path: str = Field(..., description="The GCS path where file will be stored")
|
|
164
|
+
upload_url: str = Field(..., description="Signed URL to PUT the file to")
|
|
165
|
+
content_type: str = Field(..., description="Content-Type header to use when uploading")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class CreateJobWithUploadUrlsResponse(BaseModel):
|
|
169
|
+
"""Response from creating a job with upload URLs."""
|
|
170
|
+
status: str
|
|
171
|
+
job_id: str
|
|
172
|
+
message: str
|
|
173
|
+
upload_urls: List[SignedUploadUrl]
|
|
174
|
+
server_version: str
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class UploadsCompleteRequest(BaseModel):
|
|
178
|
+
"""Request to mark uploads as complete and start processing."""
|
|
179
|
+
uploaded_files: List[str] = Field(..., description="List of file_types that were successfully uploaded")
|
|
180
|
+
|
|
181
|
+
# Initialize services
|
|
182
|
+
job_manager = JobManager()
|
|
183
|
+
storage_service = StorageService()
|
|
184
|
+
worker_service = get_worker_service()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def extract_request_metadata(request: Request, created_from: str = "upload") -> Dict[str, Any]:
|
|
188
|
+
"""
|
|
189
|
+
Extract metadata from a FastAPI Request for job tracking.
|
|
190
|
+
|
|
191
|
+
Captures:
|
|
192
|
+
- Client IP address (handles X-Forwarded-For for proxies)
|
|
193
|
+
- User-Agent header
|
|
194
|
+
- Environment from X-Environment header (test/production/development)
|
|
195
|
+
- Client ID from X-Client-ID header
|
|
196
|
+
- All custom X-* headers
|
|
197
|
+
- Server version
|
|
198
|
+
- Creation source (upload/url)
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
request: FastAPI Request object
|
|
202
|
+
created_from: How the job was created ("upload" or "url")
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Dict with metadata fields for storage in job.request_metadata
|
|
206
|
+
"""
|
|
207
|
+
headers = dict(request.headers)
|
|
208
|
+
|
|
209
|
+
# Extract client IP (check X-Forwarded-For for proxy scenarios)
|
|
210
|
+
client_ip = headers.get('x-forwarded-for', '').split(',')[0].strip()
|
|
211
|
+
if not client_ip and request.client:
|
|
212
|
+
client_ip = request.client.host
|
|
213
|
+
|
|
214
|
+
# Extract standard headers
|
|
215
|
+
user_agent = headers.get('user-agent', '')
|
|
216
|
+
environment = headers.get('x-environment', '') # test, production, development
|
|
217
|
+
client_id = headers.get('x-client-id', '') # Customer/user identifier
|
|
218
|
+
|
|
219
|
+
# Collect all X-* custom headers (excluding standard ones we already captured)
|
|
220
|
+
custom_headers = {}
|
|
221
|
+
for key, value in headers.items():
|
|
222
|
+
if key.lower().startswith('x-') and key.lower() not in ('x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host'):
|
|
223
|
+
# Normalize header name to original casing if possible
|
|
224
|
+
custom_headers[key] = value
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
'client_ip': client_ip,
|
|
228
|
+
'user_agent': user_agent,
|
|
229
|
+
'environment': environment,
|
|
230
|
+
'client_id': client_id,
|
|
231
|
+
'server_version': VERSION,
|
|
232
|
+
'created_from': created_from,
|
|
233
|
+
'custom_headers': custom_headers,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# File extension validation
|
|
238
|
+
ALLOWED_AUDIO_EXTENSIONS = {'.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac'}
|
|
239
|
+
ALLOWED_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
240
|
+
ALLOWED_FONT_EXTENSIONS = {'.ttf', '.otf', '.woff', '.woff2'}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _trigger_workers_parallel(job_id: str) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Trigger both audio and lyrics workers in parallel.
|
|
246
|
+
|
|
247
|
+
FastAPI's BackgroundTasks runs async tasks sequentially, so we use
|
|
248
|
+
asyncio.gather to ensure both workers start at the same time.
|
|
249
|
+
"""
|
|
250
|
+
await asyncio.gather(
|
|
251
|
+
worker_service.trigger_audio_worker(job_id),
|
|
252
|
+
worker_service.trigger_lyrics_worker(job_id)
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
async def _trigger_audio_worker_only(job_id: str) -> None:
|
|
257
|
+
"""
|
|
258
|
+
Trigger only the audio worker.
|
|
259
|
+
|
|
260
|
+
Used for URL jobs where audio needs to be downloaded first.
|
|
261
|
+
The audio worker will trigger the lyrics worker after download completes.
|
|
262
|
+
"""
|
|
263
|
+
await worker_service.trigger_audio_worker(job_id)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _resolve_cdg_txt_defaults(
|
|
267
|
+
theme_id: Optional[str],
|
|
268
|
+
enable_cdg: Optional[bool],
|
|
269
|
+
enable_txt: Optional[bool]
|
|
270
|
+
) -> Tuple[bool, bool]:
|
|
271
|
+
"""
|
|
272
|
+
Resolve CDG/TXT settings based on theme and explicit settings.
|
|
273
|
+
|
|
274
|
+
When a theme is selected, CDG and TXT are enabled by default.
|
|
275
|
+
Explicit True/False values always override the default.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
theme_id: Theme identifier (if any)
|
|
279
|
+
enable_cdg: Explicit CDG setting (None means use default)
|
|
280
|
+
enable_txt: Explicit TXT setting (None means use default)
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Tuple of (resolved_enable_cdg, resolved_enable_txt)
|
|
284
|
+
"""
|
|
285
|
+
# Default based on whether theme is set
|
|
286
|
+
default_enabled = theme_id is not None
|
|
287
|
+
|
|
288
|
+
# Resolve with explicit override taking precedence
|
|
289
|
+
resolved_cdg = enable_cdg if enable_cdg is not None else default_enabled
|
|
290
|
+
resolved_txt = enable_txt if enable_txt is not None else default_enabled
|
|
291
|
+
|
|
292
|
+
return resolved_cdg, resolved_txt
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@dataclass
|
|
296
|
+
class EffectiveDistributionSettings:
|
|
297
|
+
"""Settings with defaults applied from environment variables."""
|
|
298
|
+
dropbox_path: Optional[str]
|
|
299
|
+
gdrive_folder_id: Optional[str]
|
|
300
|
+
discord_webhook_url: Optional[str]
|
|
301
|
+
brand_prefix: Optional[str]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _get_effective_distribution_settings(
|
|
305
|
+
dropbox_path: Optional[str] = None,
|
|
306
|
+
gdrive_folder_id: Optional[str] = None,
|
|
307
|
+
discord_webhook_url: Optional[str] = None,
|
|
308
|
+
brand_prefix: Optional[str] = None,
|
|
309
|
+
) -> EffectiveDistributionSettings:
|
|
310
|
+
"""
|
|
311
|
+
Get distribution settings with defaults applied from environment variables.
|
|
312
|
+
|
|
313
|
+
This ensures consistent handling of defaults across all job creation endpoints.
|
|
314
|
+
Each parameter, if not provided (None), falls back to the corresponding
|
|
315
|
+
environment variable configured in settings.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
dropbox_path: Explicit Dropbox path or None for default
|
|
319
|
+
gdrive_folder_id: Explicit Google Drive folder ID or None for default
|
|
320
|
+
discord_webhook_url: Explicit Discord webhook URL or None for default
|
|
321
|
+
brand_prefix: Explicit brand prefix or None for default
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
EffectiveDistributionSettings with defaults applied
|
|
325
|
+
"""
|
|
326
|
+
settings = get_settings()
|
|
327
|
+
return EffectiveDistributionSettings(
|
|
328
|
+
dropbox_path=dropbox_path or settings.default_dropbox_path,
|
|
329
|
+
gdrive_folder_id=gdrive_folder_id or settings.default_gdrive_folder_id,
|
|
330
|
+
discord_webhook_url=discord_webhook_url or settings.default_discord_webhook_url,
|
|
331
|
+
brand_prefix=brand_prefix or settings.default_brand_prefix,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _prepare_theme_for_job(
|
|
336
|
+
job_id: str,
|
|
337
|
+
theme_id: str,
|
|
338
|
+
color_overrides: Optional[Dict[str, str]] = None
|
|
339
|
+
) -> Tuple[str, Dict[str, str], Optional[str]]:
|
|
340
|
+
"""
|
|
341
|
+
Prepare theme style files for a job.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
job_id: The job ID
|
|
345
|
+
theme_id: Theme identifier
|
|
346
|
+
color_overrides: Optional color override dict
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Tuple of (style_params_gcs_path, style_assets, youtube_description)
|
|
350
|
+
|
|
351
|
+
Raises:
|
|
352
|
+
HTTPException: If theme not found
|
|
353
|
+
"""
|
|
354
|
+
theme_service = get_theme_service()
|
|
355
|
+
|
|
356
|
+
# Verify theme exists
|
|
357
|
+
if not theme_service.theme_exists(theme_id):
|
|
358
|
+
raise HTTPException(
|
|
359
|
+
status_code=400,
|
|
360
|
+
detail=f"Theme not found: {theme_id}. Use GET /api/themes to list available themes."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Convert dict to ColorOverrides model if provided
|
|
364
|
+
color_overrides_model = None
|
|
365
|
+
if color_overrides:
|
|
366
|
+
color_overrides_model = ColorOverrides(**color_overrides)
|
|
367
|
+
|
|
368
|
+
# Prepare job style from theme
|
|
369
|
+
style_params_path, style_assets = theme_service.prepare_job_style(
|
|
370
|
+
job_id=job_id,
|
|
371
|
+
theme_id=theme_id,
|
|
372
|
+
color_overrides=color_overrides_model
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Get YouTube description template if available
|
|
376
|
+
youtube_desc = theme_service.get_youtube_description(theme_id)
|
|
377
|
+
|
|
378
|
+
logger.info(f"Prepared theme '{theme_id}' for job {job_id}")
|
|
379
|
+
|
|
380
|
+
return style_params_path, style_assets, youtube_desc
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@router.post("/jobs/upload")
|
|
384
|
+
async def upload_and_create_job(
|
|
385
|
+
request: Request,
|
|
386
|
+
background_tasks: BackgroundTasks,
|
|
387
|
+
auth_result: AuthResult = Depends(require_auth),
|
|
388
|
+
# Required fields
|
|
389
|
+
file: UploadFile = File(..., description="Audio file to process"),
|
|
390
|
+
artist: str = Form(..., description="Artist name"),
|
|
391
|
+
title: str = Form(..., description="Song title"),
|
|
392
|
+
# Style configuration files (optional)
|
|
393
|
+
style_params: Optional[UploadFile] = File(None, description="Style parameters JSON file"),
|
|
394
|
+
style_intro_background: Optional[UploadFile] = File(None, description="Intro/title screen background image"),
|
|
395
|
+
style_karaoke_background: Optional[UploadFile] = File(None, description="Karaoke video background image"),
|
|
396
|
+
style_end_background: Optional[UploadFile] = File(None, description="End screen background image"),
|
|
397
|
+
style_font: Optional[UploadFile] = File(None, description="Font file (TTF/OTF)"),
|
|
398
|
+
style_cdg_instrumental_background: Optional[UploadFile] = File(None, description="CDG instrumental background"),
|
|
399
|
+
style_cdg_title_background: Optional[UploadFile] = File(None, description="CDG title screen background"),
|
|
400
|
+
style_cdg_outro_background: Optional[UploadFile] = File(None, description="CDG outro screen background"),
|
|
401
|
+
# Theme configuration (use pre-made theme from GCS)
|
|
402
|
+
theme_id: Optional[str] = Form(None, description="Theme ID to use (e.g., 'nomad', 'default'). If set, CDG/TXT are enabled by default."),
|
|
403
|
+
color_overrides: Optional[str] = Form(None, description="JSON-encoded color overrides: artist_color, title_color, sung_lyrics_color, unsung_lyrics_color (hex #RRGGBB)"),
|
|
404
|
+
# Processing options (CDG/TXT require style config or theme, disabled by default unless theme is set)
|
|
405
|
+
enable_cdg: Optional[bool] = Form(None, description="Generate CDG+MP3 package. Default: True if theme_id set, False otherwise"),
|
|
406
|
+
enable_txt: Optional[bool] = Form(None, description="Generate TXT+MP3 package. Default: True if theme_id set, False otherwise"),
|
|
407
|
+
# Finalisation options
|
|
408
|
+
brand_prefix: Optional[str] = Form(None, description="Brand code prefix (e.g., NOMAD)"),
|
|
409
|
+
enable_youtube_upload: Optional[bool] = Form(None, description="Upload to YouTube. None = use server default"),
|
|
410
|
+
youtube_description: Optional[str] = Form(None, description="YouTube video description text"),
|
|
411
|
+
discord_webhook_url: Optional[str] = Form(None, description="Discord webhook URL for notifications"),
|
|
412
|
+
webhook_url: Optional[str] = Form(None, description="Generic webhook URL"),
|
|
413
|
+
user_email: Optional[str] = Form(None, description="User email for notifications"),
|
|
414
|
+
# Distribution options (native API - preferred for remote CLI)
|
|
415
|
+
dropbox_path: Optional[str] = Form(None, description="Dropbox folder path for organized output (e.g., /Karaoke/Tracks-Organized)"),
|
|
416
|
+
gdrive_folder_id: Optional[str] = Form(None, description="Google Drive folder ID for public share uploads"),
|
|
417
|
+
# Legacy distribution options (rclone - deprecated)
|
|
418
|
+
organised_dir_rclone_root: Optional[str] = Form(None, description="[Deprecated] rclone remote path for Dropbox upload"),
|
|
419
|
+
# Lyrics configuration (overrides for search/transcription)
|
|
420
|
+
lyrics_artist: Optional[str] = Form(None, description="Override artist name for lyrics search"),
|
|
421
|
+
lyrics_title: Optional[str] = Form(None, description="Override title for lyrics search"),
|
|
422
|
+
lyrics_file: Optional[UploadFile] = File(None, description="User-provided lyrics file (TXT, DOCX, RTF)"),
|
|
423
|
+
subtitle_offset_ms: int = Form(0, description="Subtitle timing offset in milliseconds (positive = delay)"),
|
|
424
|
+
# Audio separation model configuration
|
|
425
|
+
clean_instrumental_model: Optional[str] = Form(None, description="Model for clean instrumental separation (e.g., model_bs_roformer_ep_317_sdr_12.9755.ckpt)"),
|
|
426
|
+
backing_vocals_models: Optional[str] = Form(None, description="Comma-separated list of models for backing vocals separation"),
|
|
427
|
+
other_stems_models: Optional[str] = Form(None, description="Comma-separated list of models for other stems (bass, drums, guitar, etc.)"),
|
|
428
|
+
# Non-interactive mode
|
|
429
|
+
non_interactive: bool = Form(False, description="Skip interactive steps (lyrics review, instrumental selection)"),
|
|
430
|
+
):
|
|
431
|
+
"""
|
|
432
|
+
Upload an audio file and create a karaoke generation job with full style configuration.
|
|
433
|
+
|
|
434
|
+
This endpoint:
|
|
435
|
+
1. Validates all uploaded files
|
|
436
|
+
2. Creates a job in Firestore
|
|
437
|
+
3. Uploads all files to GCS (audio, style JSON, images, fonts)
|
|
438
|
+
4. Updates job with GCS paths
|
|
439
|
+
5. Triggers the audio and lyrics workers
|
|
440
|
+
|
|
441
|
+
Style Configuration:
|
|
442
|
+
- style_params: JSON file with style configuration (fonts, colors, regions)
|
|
443
|
+
- style_*_background: Background images for various screens
|
|
444
|
+
- style_font: Custom font file
|
|
445
|
+
|
|
446
|
+
The style_params JSON can reference the uploaded images/fonts by their original
|
|
447
|
+
filenames, and the backend will update the paths to GCS locations.
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
# Validate main audio file type
|
|
451
|
+
file_ext = Path(file.filename).suffix.lower()
|
|
452
|
+
if file_ext not in ALLOWED_AUDIO_EXTENSIONS:
|
|
453
|
+
raise HTTPException(
|
|
454
|
+
status_code=400,
|
|
455
|
+
detail=f"Invalid audio file type '{file_ext}'. Allowed: {', '.join(ALLOWED_AUDIO_EXTENSIONS)}"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# Validate style files if provided
|
|
459
|
+
if style_params and not style_params.filename.endswith('.json'):
|
|
460
|
+
raise HTTPException(status_code=400, detail="Style params must be a JSON file")
|
|
461
|
+
|
|
462
|
+
for img_file, name in [
|
|
463
|
+
(style_intro_background, "intro_background"),
|
|
464
|
+
(style_karaoke_background, "karaoke_background"),
|
|
465
|
+
(style_end_background, "end_background"),
|
|
466
|
+
(style_cdg_instrumental_background, "cdg_instrumental_background"),
|
|
467
|
+
(style_cdg_title_background, "cdg_title_background"),
|
|
468
|
+
(style_cdg_outro_background, "cdg_outro_background"),
|
|
469
|
+
]:
|
|
470
|
+
if img_file:
|
|
471
|
+
ext = Path(img_file.filename).suffix.lower()
|
|
472
|
+
if ext not in ALLOWED_IMAGE_EXTENSIONS:
|
|
473
|
+
raise HTTPException(
|
|
474
|
+
status_code=400,
|
|
475
|
+
detail=f"Invalid image file type '{ext}' for {name}. Allowed: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}"
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if style_font:
|
|
479
|
+
ext = Path(style_font.filename).suffix.lower()
|
|
480
|
+
if ext not in ALLOWED_FONT_EXTENSIONS:
|
|
481
|
+
raise HTTPException(
|
|
482
|
+
status_code=400,
|
|
483
|
+
detail=f"Invalid font file type '{ext}'. Allowed: {', '.join(ALLOWED_FONT_EXTENSIONS)}"
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
# Validate lyrics file if provided
|
|
487
|
+
ALLOWED_LYRICS_EXTENSIONS = {'.txt', '.docx', '.rtf'}
|
|
488
|
+
if lyrics_file:
|
|
489
|
+
ext = Path(lyrics_file.filename).suffix.lower()
|
|
490
|
+
if ext not in ALLOWED_LYRICS_EXTENSIONS:
|
|
491
|
+
raise HTTPException(
|
|
492
|
+
status_code=400,
|
|
493
|
+
detail=f"Invalid lyrics file type '{ext}'. Allowed: {', '.join(ALLOWED_LYRICS_EXTENSIONS)}"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
# Apply default distribution settings from environment if not provided
|
|
497
|
+
dist = _get_effective_distribution_settings(
|
|
498
|
+
dropbox_path=dropbox_path,
|
|
499
|
+
gdrive_folder_id=gdrive_folder_id,
|
|
500
|
+
discord_webhook_url=discord_webhook_url,
|
|
501
|
+
brand_prefix=brand_prefix,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if dist.dropbox_path and not dropbox_path:
|
|
505
|
+
logger.info(f"Using default dropbox_path: {dist.dropbox_path}")
|
|
506
|
+
if dist.gdrive_folder_id and not gdrive_folder_id:
|
|
507
|
+
logger.info(f"Using default gdrive_folder_id: {dist.gdrive_folder_id}")
|
|
508
|
+
if dist.discord_webhook_url and not discord_webhook_url:
|
|
509
|
+
logger.info("Using default discord_webhook_url (from env)")
|
|
510
|
+
if dist.brand_prefix and not brand_prefix:
|
|
511
|
+
logger.info(f"Using default brand_prefix: {dist.brand_prefix}")
|
|
512
|
+
|
|
513
|
+
# Apply YouTube upload default from settings
|
|
514
|
+
# Use explicit value if provided, otherwise fall back to server default
|
|
515
|
+
settings = get_settings()
|
|
516
|
+
effective_enable_youtube_upload = enable_youtube_upload if enable_youtube_upload is not None else settings.default_enable_youtube_upload
|
|
517
|
+
if effective_enable_youtube_upload and enable_youtube_upload is None:
|
|
518
|
+
logger.info("Using default enable_youtube_upload: True (from env)")
|
|
519
|
+
|
|
520
|
+
# Validate credentials for requested distribution services (including defaults)
|
|
521
|
+
# This prevents accepting jobs that will fail later due to missing credentials
|
|
522
|
+
invalid_services = []
|
|
523
|
+
credential_manager = get_credential_manager()
|
|
524
|
+
|
|
525
|
+
if effective_enable_youtube_upload:
|
|
526
|
+
result = credential_manager.check_youtube_credentials()
|
|
527
|
+
if result.status != CredentialStatus.VALID:
|
|
528
|
+
invalid_services.append(f"youtube ({result.message})")
|
|
529
|
+
|
|
530
|
+
if dist.dropbox_path:
|
|
531
|
+
result = credential_manager.check_dropbox_credentials()
|
|
532
|
+
if result.status != CredentialStatus.VALID:
|
|
533
|
+
invalid_services.append(f"dropbox ({result.message})")
|
|
534
|
+
|
|
535
|
+
if dist.gdrive_folder_id:
|
|
536
|
+
result = credential_manager.check_gdrive_credentials()
|
|
537
|
+
if result.status != CredentialStatus.VALID:
|
|
538
|
+
invalid_services.append(f"gdrive ({result.message})")
|
|
539
|
+
|
|
540
|
+
if invalid_services:
|
|
541
|
+
raise HTTPException(
|
|
542
|
+
status_code=400,
|
|
543
|
+
detail={
|
|
544
|
+
"error": "credentials_invalid",
|
|
545
|
+
"message": f"The following distribution services need re-authorization: {', '.join(invalid_services)}",
|
|
546
|
+
"invalid_services": invalid_services,
|
|
547
|
+
"auth_url": "/api/auth/status"
|
|
548
|
+
}
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Extract request metadata for tracking and filtering
|
|
552
|
+
request_metadata = extract_request_metadata(request, created_from="upload")
|
|
553
|
+
|
|
554
|
+
# Parse color_overrides from JSON if provided
|
|
555
|
+
parsed_color_overrides: Dict[str, str] = {}
|
|
556
|
+
if color_overrides:
|
|
557
|
+
try:
|
|
558
|
+
parsed_color_overrides = json.loads(color_overrides)
|
|
559
|
+
except json.JSONDecodeError as e:
|
|
560
|
+
raise HTTPException(
|
|
561
|
+
status_code=400,
|
|
562
|
+
detail=f"Invalid color_overrides JSON: {e}"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Resolve CDG/TXT defaults based on theme
|
|
566
|
+
resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
|
|
567
|
+
theme_id, enable_cdg, enable_txt
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Check if any custom style files are being uploaded (overrides theme)
|
|
571
|
+
has_custom_style_files = any([
|
|
572
|
+
style_params,
|
|
573
|
+
style_intro_background,
|
|
574
|
+
style_karaoke_background,
|
|
575
|
+
style_end_background,
|
|
576
|
+
style_font,
|
|
577
|
+
style_cdg_instrumental_background,
|
|
578
|
+
style_cdg_title_background,
|
|
579
|
+
style_cdg_outro_background,
|
|
580
|
+
])
|
|
581
|
+
|
|
582
|
+
# Parse comma-separated model lists into arrays
|
|
583
|
+
parsed_backing_vocals_models = None
|
|
584
|
+
if backing_vocals_models:
|
|
585
|
+
parsed_backing_vocals_models = [m.strip() for m in backing_vocals_models.split(',') if m.strip()]
|
|
586
|
+
|
|
587
|
+
parsed_other_stems_models = None
|
|
588
|
+
if other_stems_models:
|
|
589
|
+
parsed_other_stems_models = [m.strip() for m in other_stems_models.split(',') if m.strip()]
|
|
590
|
+
|
|
591
|
+
# Prefer authenticated user's email over form parameter
|
|
592
|
+
effective_user_email = auth_result.user_email or user_email
|
|
593
|
+
|
|
594
|
+
# Create job first to get job_id
|
|
595
|
+
job_create = JobCreate(
|
|
596
|
+
artist=artist,
|
|
597
|
+
title=title,
|
|
598
|
+
filename=file.filename,
|
|
599
|
+
theme_id=theme_id,
|
|
600
|
+
color_overrides=parsed_color_overrides,
|
|
601
|
+
enable_cdg=resolved_cdg,
|
|
602
|
+
enable_txt=resolved_txt,
|
|
603
|
+
brand_prefix=dist.brand_prefix,
|
|
604
|
+
enable_youtube_upload=effective_enable_youtube_upload,
|
|
605
|
+
youtube_description=youtube_description,
|
|
606
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
607
|
+
webhook_url=webhook_url,
|
|
608
|
+
user_email=effective_user_email,
|
|
609
|
+
# Native API distribution (preferred for remote CLI)
|
|
610
|
+
dropbox_path=dist.dropbox_path,
|
|
611
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
612
|
+
# Legacy rclone distribution (deprecated)
|
|
613
|
+
organised_dir_rclone_root=organised_dir_rclone_root,
|
|
614
|
+
# Lyrics configuration (overrides for search/transcription)
|
|
615
|
+
lyrics_artist=lyrics_artist,
|
|
616
|
+
lyrics_title=lyrics_title,
|
|
617
|
+
subtitle_offset_ms=subtitle_offset_ms,
|
|
618
|
+
# Audio separation model configuration
|
|
619
|
+
clean_instrumental_model=clean_instrumental_model,
|
|
620
|
+
backing_vocals_models=parsed_backing_vocals_models,
|
|
621
|
+
other_stems_models=parsed_other_stems_models,
|
|
622
|
+
# Request metadata for tracking and filtering
|
|
623
|
+
request_metadata=request_metadata,
|
|
624
|
+
# Non-interactive mode
|
|
625
|
+
non_interactive=non_interactive,
|
|
626
|
+
)
|
|
627
|
+
job = job_manager.create_job(job_create)
|
|
628
|
+
job_id = job.job_id
|
|
629
|
+
|
|
630
|
+
# Record job creation metric
|
|
631
|
+
metrics.record_job_created(job_id, source="upload")
|
|
632
|
+
|
|
633
|
+
logger.info(f"Created job {job_id} for {artist} - {title}")
|
|
634
|
+
|
|
635
|
+
# If theme is set and no custom style files are being uploaded, prepare theme style now
|
|
636
|
+
# This copies the theme's style_params.json to the job folder so LyricsTranscriber
|
|
637
|
+
# can access the style configuration for preview videos
|
|
638
|
+
theme_style_params_path = None
|
|
639
|
+
theme_style_assets = {}
|
|
640
|
+
theme_youtube_desc = None
|
|
641
|
+
if theme_id and not has_custom_style_files:
|
|
642
|
+
try:
|
|
643
|
+
theme_style_params_path, theme_style_assets, theme_youtube_desc = _prepare_theme_for_job(
|
|
644
|
+
job_id, theme_id, parsed_color_overrides or None
|
|
645
|
+
)
|
|
646
|
+
logger.info(f"Applied theme '{theme_id}' to job {job_id}")
|
|
647
|
+
except HTTPException:
|
|
648
|
+
raise # Re-raise validation errors (e.g., theme not found)
|
|
649
|
+
except Exception as e:
|
|
650
|
+
logger.warning(f"Failed to prepare theme '{theme_id}' for job {job_id}: {e}")
|
|
651
|
+
# Continue without theme - job can still be processed with defaults
|
|
652
|
+
|
|
653
|
+
# Upload main audio file to GCS
|
|
654
|
+
audio_gcs_path = f"uploads/{job_id}/audio/{file.filename}"
|
|
655
|
+
logger.info(f"Uploading audio to GCS: {audio_gcs_path}")
|
|
656
|
+
storage_service.upload_fileobj(
|
|
657
|
+
file.file,
|
|
658
|
+
audio_gcs_path,
|
|
659
|
+
content_type=file.content_type or 'audio/flac'
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
# Track style assets
|
|
663
|
+
style_assets = {}
|
|
664
|
+
|
|
665
|
+
# Upload style files if provided
|
|
666
|
+
if style_params:
|
|
667
|
+
style_gcs_path = f"uploads/{job_id}/style/style_params.json"
|
|
668
|
+
logger.info(f"Uploading style params to GCS: {style_gcs_path}")
|
|
669
|
+
storage_service.upload_fileobj(
|
|
670
|
+
style_params.file,
|
|
671
|
+
style_gcs_path,
|
|
672
|
+
content_type='application/json'
|
|
673
|
+
)
|
|
674
|
+
style_assets['style_params'] = style_gcs_path
|
|
675
|
+
|
|
676
|
+
# Upload background images
|
|
677
|
+
for img_file, asset_key in [
|
|
678
|
+
(style_intro_background, "intro_background"),
|
|
679
|
+
(style_karaoke_background, "karaoke_background"),
|
|
680
|
+
(style_end_background, "end_background"),
|
|
681
|
+
(style_cdg_instrumental_background, "cdg_instrumental_background"),
|
|
682
|
+
(style_cdg_title_background, "cdg_title_background"),
|
|
683
|
+
(style_cdg_outro_background, "cdg_outro_background"),
|
|
684
|
+
]:
|
|
685
|
+
if img_file:
|
|
686
|
+
gcs_path = f"uploads/{job_id}/style/{asset_key}{Path(img_file.filename).suffix.lower()}"
|
|
687
|
+
logger.info(f"Uploading {asset_key} to GCS: {gcs_path}")
|
|
688
|
+
storage_service.upload_fileobj(
|
|
689
|
+
img_file.file,
|
|
690
|
+
gcs_path,
|
|
691
|
+
content_type=img_file.content_type or 'image/png'
|
|
692
|
+
)
|
|
693
|
+
style_assets[asset_key] = gcs_path
|
|
694
|
+
|
|
695
|
+
# Upload font file
|
|
696
|
+
if style_font:
|
|
697
|
+
font_gcs_path = f"uploads/{job_id}/style/font{Path(style_font.filename).suffix.lower()}"
|
|
698
|
+
logger.info(f"Uploading font to GCS: {font_gcs_path}")
|
|
699
|
+
storage_service.upload_fileobj(
|
|
700
|
+
style_font.file,
|
|
701
|
+
font_gcs_path,
|
|
702
|
+
content_type='font/ttf'
|
|
703
|
+
)
|
|
704
|
+
style_assets['font'] = font_gcs_path
|
|
705
|
+
|
|
706
|
+
# Upload lyrics file if provided
|
|
707
|
+
lyrics_file_gcs_path = None
|
|
708
|
+
if lyrics_file:
|
|
709
|
+
lyrics_file_gcs_path = f"uploads/{job_id}/lyrics/user_lyrics{Path(lyrics_file.filename).suffix.lower()}"
|
|
710
|
+
logger.info(f"Uploading user lyrics file to GCS: {lyrics_file_gcs_path}")
|
|
711
|
+
storage_service.upload_fileobj(
|
|
712
|
+
lyrics_file.file,
|
|
713
|
+
lyrics_file_gcs_path,
|
|
714
|
+
content_type='text/plain'
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
# Update job with all GCS paths
|
|
718
|
+
update_data = {
|
|
719
|
+
'input_media_gcs_path': audio_gcs_path,
|
|
720
|
+
'filename': file.filename,
|
|
721
|
+
'enable_cdg': resolved_cdg,
|
|
722
|
+
'enable_txt': resolved_txt,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
# Handle style assets - either from custom uploads or from theme
|
|
726
|
+
if style_assets:
|
|
727
|
+
# Custom style files uploaded
|
|
728
|
+
update_data['style_assets'] = style_assets
|
|
729
|
+
if 'style_params' in style_assets:
|
|
730
|
+
update_data['style_params_gcs_path'] = style_assets['style_params']
|
|
731
|
+
elif theme_style_assets:
|
|
732
|
+
# Theme style assets (no custom uploads)
|
|
733
|
+
update_data['style_assets'] = theme_style_assets
|
|
734
|
+
if theme_style_params_path:
|
|
735
|
+
update_data['style_params_gcs_path'] = theme_style_params_path
|
|
736
|
+
|
|
737
|
+
if dist.brand_prefix:
|
|
738
|
+
update_data['brand_prefix'] = dist.brand_prefix
|
|
739
|
+
if dist.discord_webhook_url:
|
|
740
|
+
update_data['discord_webhook_url'] = dist.discord_webhook_url
|
|
741
|
+
# Use theme YouTube description if no custom one provided
|
|
742
|
+
effective_youtube_description = youtube_description or theme_youtube_desc
|
|
743
|
+
if effective_youtube_description:
|
|
744
|
+
update_data['youtube_description_template'] = effective_youtube_description
|
|
745
|
+
|
|
746
|
+
# Native API distribution (use effective values which include defaults)
|
|
747
|
+
if dist.dropbox_path:
|
|
748
|
+
update_data['dropbox_path'] = dist.dropbox_path
|
|
749
|
+
if dist.gdrive_folder_id:
|
|
750
|
+
update_data['gdrive_folder_id'] = dist.gdrive_folder_id
|
|
751
|
+
|
|
752
|
+
# Legacy rclone distribution (deprecated)
|
|
753
|
+
if organised_dir_rclone_root:
|
|
754
|
+
update_data['organised_dir_rclone_root'] = organised_dir_rclone_root
|
|
755
|
+
|
|
756
|
+
# Lyrics configuration
|
|
757
|
+
if lyrics_artist:
|
|
758
|
+
update_data['lyrics_artist'] = lyrics_artist
|
|
759
|
+
if lyrics_title:
|
|
760
|
+
update_data['lyrics_title'] = lyrics_title
|
|
761
|
+
if lyrics_file_gcs_path:
|
|
762
|
+
update_data['lyrics_file_gcs_path'] = lyrics_file_gcs_path
|
|
763
|
+
if subtitle_offset_ms != 0:
|
|
764
|
+
update_data['subtitle_offset_ms'] = subtitle_offset_ms
|
|
765
|
+
|
|
766
|
+
# Audio separation model configuration
|
|
767
|
+
if clean_instrumental_model:
|
|
768
|
+
update_data['clean_instrumental_model'] = clean_instrumental_model
|
|
769
|
+
if parsed_backing_vocals_models:
|
|
770
|
+
update_data['backing_vocals_models'] = parsed_backing_vocals_models
|
|
771
|
+
if parsed_other_stems_models:
|
|
772
|
+
update_data['other_stems_models'] = parsed_other_stems_models
|
|
773
|
+
|
|
774
|
+
job_manager.update_job(job_id, update_data)
|
|
775
|
+
|
|
776
|
+
# Verify the update
|
|
777
|
+
updated_job = job_manager.get_job(job_id)
|
|
778
|
+
if not hasattr(updated_job, 'input_media_gcs_path') or not updated_job.input_media_gcs_path:
|
|
779
|
+
import asyncio
|
|
780
|
+
await asyncio.sleep(0.5)
|
|
781
|
+
updated_job = job_manager.get_job(job_id)
|
|
782
|
+
if not hasattr(updated_job, 'input_media_gcs_path') or not updated_job.input_media_gcs_path:
|
|
783
|
+
raise HTTPException(
|
|
784
|
+
status_code=500,
|
|
785
|
+
detail="Failed to update job with GCS paths"
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
logger.info(f"All files uploaded for job {job_id}")
|
|
789
|
+
if style_assets:
|
|
790
|
+
logger.info(f"Style assets: {list(style_assets.keys())}")
|
|
791
|
+
|
|
792
|
+
# Transition job to DOWNLOADING state
|
|
793
|
+
job_manager.transition_to_state(
|
|
794
|
+
job_id=job_id,
|
|
795
|
+
new_status=JobStatus.DOWNLOADING,
|
|
796
|
+
progress=5,
|
|
797
|
+
message="Files uploaded, preparing to process"
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
# Trigger workers in parallel using asyncio.gather
|
|
801
|
+
# (FastAPI's BackgroundTasks runs async tasks sequentially)
|
|
802
|
+
background_tasks.add_task(_trigger_workers_parallel, job_id)
|
|
803
|
+
|
|
804
|
+
# Build distribution services info for response
|
|
805
|
+
distribution_services: Dict[str, Any] = {}
|
|
806
|
+
|
|
807
|
+
if dist.dropbox_path:
|
|
808
|
+
dropbox_result = credential_manager.check_dropbox_credentials()
|
|
809
|
+
distribution_services["dropbox"] = {
|
|
810
|
+
"enabled": True,
|
|
811
|
+
"path": dist.dropbox_path,
|
|
812
|
+
"credentials_valid": dropbox_result.status == CredentialStatus.VALID,
|
|
813
|
+
"using_default": dropbox_path is None,
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if dist.gdrive_folder_id:
|
|
817
|
+
gdrive_result = credential_manager.check_gdrive_credentials()
|
|
818
|
+
distribution_services["gdrive"] = {
|
|
819
|
+
"enabled": True,
|
|
820
|
+
"folder_id": dist.gdrive_folder_id,
|
|
821
|
+
"credentials_valid": gdrive_result.status == CredentialStatus.VALID,
|
|
822
|
+
"using_default": gdrive_folder_id is None,
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if effective_enable_youtube_upload:
|
|
826
|
+
youtube_result = credential_manager.check_youtube_credentials()
|
|
827
|
+
distribution_services["youtube"] = {
|
|
828
|
+
"enabled": True,
|
|
829
|
+
"credentials_valid": youtube_result.status == CredentialStatus.VALID,
|
|
830
|
+
"using_default": enable_youtube_upload is None,
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if dist.discord_webhook_url:
|
|
834
|
+
distribution_services["discord"] = {
|
|
835
|
+
"enabled": True,
|
|
836
|
+
"using_default": discord_webhook_url is None,
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
"status": "success",
|
|
841
|
+
"job_id": job_id,
|
|
842
|
+
"message": "Files uploaded successfully. Processing started.",
|
|
843
|
+
"filename": file.filename,
|
|
844
|
+
"style_assets_uploaded": list(style_assets.keys()) if style_assets else [],
|
|
845
|
+
"server_version": VERSION,
|
|
846
|
+
"distribution_services": distribution_services,
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
except HTTPException:
|
|
850
|
+
raise
|
|
851
|
+
except Exception as e:
|
|
852
|
+
logger.error(f"Error uploading files: {e}", exc_info=True)
|
|
853
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# ============================================================================
|
|
857
|
+
# Signed URL Upload Flow - For large files that exceed Cloud Run's 32MB limit
|
|
858
|
+
# ============================================================================
|
|
859
|
+
|
|
860
|
+
# Valid file types and their expected extensions
|
|
861
|
+
VALID_FILE_TYPES = {
|
|
862
|
+
'audio': ALLOWED_AUDIO_EXTENSIONS,
|
|
863
|
+
'style_params': {'.json'},
|
|
864
|
+
'style_intro_background': ALLOWED_IMAGE_EXTENSIONS,
|
|
865
|
+
'style_karaoke_background': ALLOWED_IMAGE_EXTENSIONS,
|
|
866
|
+
'style_end_background': ALLOWED_IMAGE_EXTENSIONS,
|
|
867
|
+
'style_font': ALLOWED_FONT_EXTENSIONS,
|
|
868
|
+
'style_cdg_instrumental_background': ALLOWED_IMAGE_EXTENSIONS,
|
|
869
|
+
'style_cdg_title_background': ALLOWED_IMAGE_EXTENSIONS,
|
|
870
|
+
'style_cdg_outro_background': ALLOWED_IMAGE_EXTENSIONS,
|
|
871
|
+
'lyrics_file': {'.txt', '.docx', '.rtf'},
|
|
872
|
+
'existing_instrumental': ALLOWED_AUDIO_EXTENSIONS, # Batch 3: user-provided instrumental
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
# Valid file types for finalise-only mode (Batch 6)
|
|
876
|
+
ALLOWED_VIDEO_EXTENSIONS = {'.mkv', '.mov', '.mp4'}
|
|
877
|
+
FINALISE_ONLY_FILE_TYPES = {
|
|
878
|
+
'audio': ALLOWED_AUDIO_EXTENSIONS, # Original audio
|
|
879
|
+
'with_vocals': ALLOWED_VIDEO_EXTENSIONS, # Karaoke video from prep
|
|
880
|
+
'title_screen': ALLOWED_VIDEO_EXTENSIONS, # Title screen video
|
|
881
|
+
'end_screen': ALLOWED_VIDEO_EXTENSIONS, # End screen video
|
|
882
|
+
'title_jpg': ALLOWED_IMAGE_EXTENSIONS, # Title screen JPG
|
|
883
|
+
'title_png': ALLOWED_IMAGE_EXTENSIONS, # Title screen PNG
|
|
884
|
+
'end_jpg': ALLOWED_IMAGE_EXTENSIONS, # End screen JPG
|
|
885
|
+
'end_png': ALLOWED_IMAGE_EXTENSIONS, # End screen PNG
|
|
886
|
+
'instrumental_clean': ALLOWED_AUDIO_EXTENSIONS, # Clean instrumental
|
|
887
|
+
'instrumental_backing': ALLOWED_AUDIO_EXTENSIONS, # Instrumental with backing vocals
|
|
888
|
+
'lrc': {'.lrc'}, # LRC lyrics
|
|
889
|
+
'ass': {'.ass'}, # ASS subtitles
|
|
890
|
+
'corrections': {'.json'}, # Corrections JSON
|
|
891
|
+
'style_params': {'.json'}, # Style params
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
# Pydantic models for finalise-only flow (Batch 6)
|
|
895
|
+
class FinaliseOnlyFileRequest(BaseModel):
|
|
896
|
+
"""Information about a prep output file to be uploaded for finalise-only mode."""
|
|
897
|
+
filename: str = Field(..., description="Original filename with extension")
|
|
898
|
+
content_type: str = Field(..., description="MIME type of the file")
|
|
899
|
+
file_type: str = Field(..., description="Type of prep file: 'with_vocals', 'title_screen', 'end_screen', 'instrumental_clean', 'instrumental_backing', 'lrc', etc.")
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class CreateFinaliseOnlyJobRequest(BaseModel):
|
|
903
|
+
"""Request to create a finalise-only job with prep output files."""
|
|
904
|
+
artist: str = Field(..., description="Artist name")
|
|
905
|
+
title: str = Field(..., description="Song title")
|
|
906
|
+
files: List[FinaliseOnlyFileRequest] = Field(..., description="List of prep output files to upload")
|
|
907
|
+
|
|
908
|
+
# Theme configuration (use pre-made theme from GCS)
|
|
909
|
+
theme_id: Optional[str] = Field(None, description="Theme ID to use (e.g., 'nomad', 'default'). If set, CDG/TXT are enabled by default.")
|
|
910
|
+
color_overrides: Optional[Dict[str, str]] = Field(None, description="Color overrides: artist_color, title_color, sung_lyrics_color, unsung_lyrics_color (hex #RRGGBB)")
|
|
911
|
+
|
|
912
|
+
# Processing options (CDG/TXT require style config or theme, disabled by default unless theme is set)
|
|
913
|
+
enable_cdg: Optional[bool] = Field(None, description="Generate CDG+MP3 package. Default: True if theme_id set, False otherwise")
|
|
914
|
+
enable_txt: Optional[bool] = Field(None, description="Generate TXT+MP3 package. Default: True if theme_id set, False otherwise")
|
|
915
|
+
|
|
916
|
+
# Finalisation options
|
|
917
|
+
brand_prefix: Optional[str] = Field(None, description="Brand code prefix (e.g., NOMAD)")
|
|
918
|
+
keep_brand_code: Optional[str] = Field(None, description="Preserve existing brand code from folder name")
|
|
919
|
+
enable_youtube_upload: Optional[bool] = Field(None, description="Upload to YouTube. None = use server default")
|
|
920
|
+
youtube_description: Optional[str] = Field(None, description="YouTube video description text")
|
|
921
|
+
discord_webhook_url: Optional[str] = Field(None, description="Discord webhook URL for notifications")
|
|
922
|
+
|
|
923
|
+
# Distribution options
|
|
924
|
+
dropbox_path: Optional[str] = Field(None, description="Dropbox folder path for organized output")
|
|
925
|
+
gdrive_folder_id: Optional[str] = Field(None, description="Google Drive folder ID for public share uploads")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
async def _validate_audio_durations(
|
|
931
|
+
storage: StorageService,
|
|
932
|
+
audio_gcs_path: str,
|
|
933
|
+
instrumental_gcs_path: str,
|
|
934
|
+
tolerance_seconds: float = 0.5
|
|
935
|
+
) -> Tuple[bool, float, float]:
|
|
936
|
+
"""
|
|
937
|
+
Validate that audio and instrumental files have matching durations.
|
|
938
|
+
|
|
939
|
+
Downloads both files to temp directory and checks their durations using pydub.
|
|
940
|
+
|
|
941
|
+
Args:
|
|
942
|
+
storage: StorageService instance
|
|
943
|
+
audio_gcs_path: GCS path to main audio file
|
|
944
|
+
instrumental_gcs_path: GCS path to instrumental file
|
|
945
|
+
tolerance_seconds: Maximum allowed difference in seconds (default 0.5s)
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
Tuple of (is_valid, audio_duration, instrumental_duration)
|
|
949
|
+
"""
|
|
950
|
+
from pydub import AudioSegment
|
|
951
|
+
|
|
952
|
+
temp_dir = tempfile.mkdtemp(prefix="duration_check_")
|
|
953
|
+
try:
|
|
954
|
+
# Download audio file
|
|
955
|
+
audio_local = os.path.join(temp_dir, "audio" + Path(audio_gcs_path).suffix)
|
|
956
|
+
storage.download_file(audio_gcs_path, audio_local)
|
|
957
|
+
|
|
958
|
+
# Download instrumental file
|
|
959
|
+
instrumental_local = os.path.join(temp_dir, "instrumental" + Path(instrumental_gcs_path).suffix)
|
|
960
|
+
storage.download_file(instrumental_gcs_path, instrumental_local)
|
|
961
|
+
|
|
962
|
+
# Get durations using pydub (returns milliseconds)
|
|
963
|
+
audio_segment = AudioSegment.from_file(audio_local)
|
|
964
|
+
audio_duration = len(audio_segment) / 1000.0 # Convert to seconds
|
|
965
|
+
|
|
966
|
+
instrumental_segment = AudioSegment.from_file(instrumental_local)
|
|
967
|
+
instrumental_duration = len(instrumental_segment) / 1000.0 # Convert to seconds
|
|
968
|
+
|
|
969
|
+
# Check if durations are within tolerance
|
|
970
|
+
difference = abs(audio_duration - instrumental_duration)
|
|
971
|
+
is_valid = difference <= tolerance_seconds
|
|
972
|
+
|
|
973
|
+
return is_valid, audio_duration, instrumental_duration
|
|
974
|
+
|
|
975
|
+
finally:
|
|
976
|
+
# Clean up temp files
|
|
977
|
+
import shutil
|
|
978
|
+
if os.path.exists(temp_dir):
|
|
979
|
+
shutil.rmtree(temp_dir)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _get_gcs_path_for_file(job_id: str, file_type: str, filename: str) -> str:
|
|
983
|
+
"""Generate the GCS path for a file based on its type."""
|
|
984
|
+
ext = Path(filename).suffix.lower()
|
|
985
|
+
|
|
986
|
+
if file_type == 'audio':
|
|
987
|
+
return f"uploads/{job_id}/audio/{filename}"
|
|
988
|
+
elif file_type == 'style_params':
|
|
989
|
+
return f"uploads/{job_id}/style/style_params.json"
|
|
990
|
+
elif file_type.startswith('style_'):
|
|
991
|
+
# Map style_intro_background -> intro_background, etc.
|
|
992
|
+
asset_key = file_type.replace('style_', '')
|
|
993
|
+
return f"uploads/{job_id}/style/{asset_key}{ext}"
|
|
994
|
+
elif file_type == 'lyrics_file':
|
|
995
|
+
return f"uploads/{job_id}/lyrics/user_lyrics{ext}"
|
|
996
|
+
elif file_type == 'existing_instrumental':
|
|
997
|
+
# Batch 3: user-provided instrumental file
|
|
998
|
+
return f"uploads/{job_id}/audio/existing_instrumental{ext}"
|
|
999
|
+
else:
|
|
1000
|
+
return f"uploads/{job_id}/other/{filename}"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
@router.post("/jobs/create-with-upload-urls", response_model=CreateJobWithUploadUrlsResponse)
|
|
1004
|
+
async def create_job_with_upload_urls(
|
|
1005
|
+
request: Request,
|
|
1006
|
+
body: CreateJobWithUploadUrlsRequest,
|
|
1007
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
1008
|
+
):
|
|
1009
|
+
"""
|
|
1010
|
+
Create a karaoke generation job and return signed URLs for direct file upload to GCS.
|
|
1011
|
+
|
|
1012
|
+
This is the first step of the two-step upload flow for large files:
|
|
1013
|
+
1. Call this endpoint with job metadata and list of files to upload
|
|
1014
|
+
2. Upload each file directly to GCS using the returned signed URLs
|
|
1015
|
+
3. Call POST /api/jobs/{job_id}/uploads-complete to start processing
|
|
1016
|
+
|
|
1017
|
+
Benefits of this flow:
|
|
1018
|
+
- No file size limits (GCS supports up to 5TB)
|
|
1019
|
+
- Faster uploads (direct to storage, no proxy)
|
|
1020
|
+
- Works with any HTTP client (no HTTP/2 required)
|
|
1021
|
+
- Resumable uploads possible with GCS
|
|
1022
|
+
"""
|
|
1023
|
+
try:
|
|
1024
|
+
# Validate files list
|
|
1025
|
+
if not body.files:
|
|
1026
|
+
raise HTTPException(status_code=400, detail="At least one file is required")
|
|
1027
|
+
|
|
1028
|
+
# Check that audio file is included
|
|
1029
|
+
audio_files = [f for f in body.files if f.file_type == 'audio']
|
|
1030
|
+
if not audio_files:
|
|
1031
|
+
raise HTTPException(status_code=400, detail="An audio file is required")
|
|
1032
|
+
if len(audio_files) > 1:
|
|
1033
|
+
raise HTTPException(status_code=400, detail="Only one audio file is allowed")
|
|
1034
|
+
|
|
1035
|
+
# Validate file types and extensions
|
|
1036
|
+
for file_info in body.files:
|
|
1037
|
+
if file_info.file_type not in VALID_FILE_TYPES:
|
|
1038
|
+
raise HTTPException(
|
|
1039
|
+
status_code=400,
|
|
1040
|
+
detail=f"Invalid file_type: '{file_info.file_type}'. Valid types: {list(VALID_FILE_TYPES.keys())}"
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
ext = Path(file_info.filename).suffix.lower()
|
|
1044
|
+
allowed_extensions = VALID_FILE_TYPES[file_info.file_type]
|
|
1045
|
+
if ext not in allowed_extensions:
|
|
1046
|
+
raise HTTPException(
|
|
1047
|
+
status_code=400,
|
|
1048
|
+
detail=f"Invalid file extension '{ext}' for file_type '{file_info.file_type}'. Allowed: {allowed_extensions}"
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# Apply default distribution settings from environment if not provided
|
|
1052
|
+
dist = _get_effective_distribution_settings(
|
|
1053
|
+
dropbox_path=body.dropbox_path,
|
|
1054
|
+
gdrive_folder_id=body.gdrive_folder_id,
|
|
1055
|
+
discord_webhook_url=body.discord_webhook_url,
|
|
1056
|
+
brand_prefix=body.brand_prefix,
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
# Apply YouTube upload default from settings
|
|
1060
|
+
# Use explicit value if provided, otherwise fall back to server default
|
|
1061
|
+
settings = get_settings()
|
|
1062
|
+
effective_enable_youtube_upload = body.enable_youtube_upload if body.enable_youtube_upload is not None else settings.default_enable_youtube_upload
|
|
1063
|
+
|
|
1064
|
+
# Validate credentials for requested distribution services
|
|
1065
|
+
invalid_services = []
|
|
1066
|
+
credential_manager = get_credential_manager()
|
|
1067
|
+
|
|
1068
|
+
if effective_enable_youtube_upload:
|
|
1069
|
+
result = credential_manager.check_youtube_credentials()
|
|
1070
|
+
if result.status != CredentialStatus.VALID:
|
|
1071
|
+
invalid_services.append(f"youtube ({result.message})")
|
|
1072
|
+
|
|
1073
|
+
if dist.dropbox_path:
|
|
1074
|
+
result = credential_manager.check_dropbox_credentials()
|
|
1075
|
+
if result.status != CredentialStatus.VALID:
|
|
1076
|
+
invalid_services.append(f"dropbox ({result.message})")
|
|
1077
|
+
|
|
1078
|
+
if dist.gdrive_folder_id:
|
|
1079
|
+
result = credential_manager.check_gdrive_credentials()
|
|
1080
|
+
if result.status != CredentialStatus.VALID:
|
|
1081
|
+
invalid_services.append(f"gdrive ({result.message})")
|
|
1082
|
+
|
|
1083
|
+
if invalid_services:
|
|
1084
|
+
raise HTTPException(
|
|
1085
|
+
status_code=400,
|
|
1086
|
+
detail={
|
|
1087
|
+
"error": "credentials_invalid",
|
|
1088
|
+
"message": f"The following distribution services need re-authorization: {', '.join(invalid_services)}",
|
|
1089
|
+
"invalid_services": invalid_services,
|
|
1090
|
+
"auth_url": "/api/auth/status"
|
|
1091
|
+
}
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
# Extract request metadata for tracking
|
|
1095
|
+
request_metadata = extract_request_metadata(request, created_from="signed_url_upload")
|
|
1096
|
+
|
|
1097
|
+
# Get original audio filename
|
|
1098
|
+
audio_file = audio_files[0]
|
|
1099
|
+
|
|
1100
|
+
# Resolve CDG/TXT defaults based on theme
|
|
1101
|
+
resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
|
|
1102
|
+
body.theme_id, body.enable_cdg, body.enable_txt
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
# Check if style_params is being uploaded (overrides theme)
|
|
1106
|
+
has_style_params_upload = any(f.file_type == 'style_params' for f in body.files)
|
|
1107
|
+
|
|
1108
|
+
# Prefer authenticated user's email over request body
|
|
1109
|
+
effective_user_email = auth_result.user_email or body.user_email
|
|
1110
|
+
|
|
1111
|
+
# Create job
|
|
1112
|
+
job_create = JobCreate(
|
|
1113
|
+
artist=body.artist,
|
|
1114
|
+
title=body.title,
|
|
1115
|
+
filename=audio_file.filename,
|
|
1116
|
+
theme_id=body.theme_id,
|
|
1117
|
+
color_overrides=body.color_overrides or {},
|
|
1118
|
+
enable_cdg=resolved_cdg,
|
|
1119
|
+
enable_txt=resolved_txt,
|
|
1120
|
+
brand_prefix=dist.brand_prefix,
|
|
1121
|
+
enable_youtube_upload=effective_enable_youtube_upload,
|
|
1122
|
+
youtube_description=body.youtube_description,
|
|
1123
|
+
youtube_description_template=body.youtube_description, # video_worker reads this field
|
|
1124
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
1125
|
+
webhook_url=body.webhook_url,
|
|
1126
|
+
user_email=effective_user_email,
|
|
1127
|
+
dropbox_path=dist.dropbox_path,
|
|
1128
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
1129
|
+
organised_dir_rclone_root=body.organised_dir_rclone_root,
|
|
1130
|
+
lyrics_artist=body.lyrics_artist,
|
|
1131
|
+
lyrics_title=body.lyrics_title,
|
|
1132
|
+
subtitle_offset_ms=body.subtitle_offset_ms,
|
|
1133
|
+
clean_instrumental_model=body.clean_instrumental_model,
|
|
1134
|
+
backing_vocals_models=body.backing_vocals_models,
|
|
1135
|
+
other_stems_models=body.other_stems_models,
|
|
1136
|
+
request_metadata=request_metadata,
|
|
1137
|
+
non_interactive=body.non_interactive,
|
|
1138
|
+
)
|
|
1139
|
+
job = job_manager.create_job(job_create)
|
|
1140
|
+
job_id = job.job_id
|
|
1141
|
+
|
|
1142
|
+
# Record job creation metric
|
|
1143
|
+
metrics.record_job_created(job_id, source="upload")
|
|
1144
|
+
|
|
1145
|
+
logger.info(f"Created job {job_id} for {body.artist} - {body.title} (signed URL upload flow)")
|
|
1146
|
+
|
|
1147
|
+
# If theme is set and no style_params uploaded, prepare theme style now
|
|
1148
|
+
if body.theme_id and not has_style_params_upload:
|
|
1149
|
+
style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(
|
|
1150
|
+
job_id, body.theme_id, body.color_overrides
|
|
1151
|
+
)
|
|
1152
|
+
# Update job with theme style data
|
|
1153
|
+
update_data = {
|
|
1154
|
+
'style_params_gcs_path': style_params_path,
|
|
1155
|
+
'style_assets': style_assets,
|
|
1156
|
+
}
|
|
1157
|
+
if youtube_desc and not body.youtube_description:
|
|
1158
|
+
update_data['youtube_description_template'] = youtube_desc
|
|
1159
|
+
job_manager.update_job(job_id, update_data)
|
|
1160
|
+
logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
|
|
1161
|
+
|
|
1162
|
+
# Generate signed upload URLs for each file
|
|
1163
|
+
upload_urls = []
|
|
1164
|
+
for file_info in body.files:
|
|
1165
|
+
gcs_path = _get_gcs_path_for_file(job_id, file_info.file_type, file_info.filename)
|
|
1166
|
+
|
|
1167
|
+
# Generate signed upload URL (valid for 60 minutes)
|
|
1168
|
+
signed_url = storage_service.generate_signed_upload_url(
|
|
1169
|
+
gcs_path,
|
|
1170
|
+
content_type=file_info.content_type,
|
|
1171
|
+
expiration_minutes=60
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
upload_urls.append(SignedUploadUrl(
|
|
1175
|
+
file_type=file_info.file_type,
|
|
1176
|
+
gcs_path=gcs_path,
|
|
1177
|
+
upload_url=signed_url,
|
|
1178
|
+
content_type=file_info.content_type
|
|
1179
|
+
))
|
|
1180
|
+
|
|
1181
|
+
logger.info(f"Generated signed upload URL for {file_info.file_type}: {gcs_path}")
|
|
1182
|
+
|
|
1183
|
+
return CreateJobWithUploadUrlsResponse(
|
|
1184
|
+
status="success",
|
|
1185
|
+
job_id=job_id,
|
|
1186
|
+
message="Job created. Upload files to the provided URLs, then call /api/jobs/{job_id}/uploads-complete",
|
|
1187
|
+
upload_urls=upload_urls,
|
|
1188
|
+
server_version=VERSION
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
except HTTPException:
|
|
1192
|
+
raise
|
|
1193
|
+
except Exception as e:
|
|
1194
|
+
logger.error(f"Error creating job with upload URLs: {e}", exc_info=True)
|
|
1195
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
@router.post("/jobs/{job_id}/uploads-complete")
|
|
1199
|
+
async def mark_uploads_complete(
|
|
1200
|
+
job_id: str,
|
|
1201
|
+
background_tasks: BackgroundTasks,
|
|
1202
|
+
body: UploadsCompleteRequest,
|
|
1203
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
1204
|
+
):
|
|
1205
|
+
"""
|
|
1206
|
+
Mark file uploads as complete and start job processing.
|
|
1207
|
+
|
|
1208
|
+
This is the second step of the signed URL upload flow:
|
|
1209
|
+
1. Create job with POST /api/jobs/create-with-upload-urls
|
|
1210
|
+
2. Upload files directly to GCS using signed URLs
|
|
1211
|
+
3. Call this endpoint to validate uploads and start processing
|
|
1212
|
+
|
|
1213
|
+
The endpoint will:
|
|
1214
|
+
- Verify the job exists and is in PENDING state
|
|
1215
|
+
- Validate that required files (audio) were uploaded
|
|
1216
|
+
- Update job with GCS paths
|
|
1217
|
+
- Trigger audio and lyrics workers
|
|
1218
|
+
"""
|
|
1219
|
+
try:
|
|
1220
|
+
# Get job and verify it exists
|
|
1221
|
+
job = job_manager.get_job(job_id)
|
|
1222
|
+
if not job:
|
|
1223
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
1224
|
+
|
|
1225
|
+
# Verify job is in pending state
|
|
1226
|
+
if job.status != JobStatus.PENDING:
|
|
1227
|
+
raise HTTPException(
|
|
1228
|
+
status_code=400,
|
|
1229
|
+
detail=f"Job {job_id} is not in pending state (current: {job.status}). Cannot complete uploads."
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
# Validate required files
|
|
1233
|
+
if 'audio' not in body.uploaded_files:
|
|
1234
|
+
raise HTTPException(status_code=400, detail="Audio file upload is required")
|
|
1235
|
+
|
|
1236
|
+
# Build GCS paths for uploaded files and validate they exist
|
|
1237
|
+
update_data = {}
|
|
1238
|
+
style_assets = {}
|
|
1239
|
+
|
|
1240
|
+
for file_type in body.uploaded_files:
|
|
1241
|
+
if file_type not in VALID_FILE_TYPES:
|
|
1242
|
+
logger.warning(f"Unknown file_type in uploaded_files: {file_type}")
|
|
1243
|
+
continue
|
|
1244
|
+
|
|
1245
|
+
# Determine the GCS path - we need to find the actual file
|
|
1246
|
+
prefix = f"uploads/{job_id}/"
|
|
1247
|
+
if file_type == 'audio':
|
|
1248
|
+
prefix = f"uploads/{job_id}/audio/"
|
|
1249
|
+
elif file_type == 'style_params':
|
|
1250
|
+
prefix = f"uploads/{job_id}/style/style_params"
|
|
1251
|
+
elif file_type.startswith('style_'):
|
|
1252
|
+
asset_key = file_type.replace('style_', '')
|
|
1253
|
+
prefix = f"uploads/{job_id}/style/{asset_key}"
|
|
1254
|
+
elif file_type == 'lyrics_file':
|
|
1255
|
+
prefix = f"uploads/{job_id}/lyrics/user_lyrics"
|
|
1256
|
+
elif file_type == 'existing_instrumental':
|
|
1257
|
+
prefix = f"uploads/{job_id}/audio/existing_instrumental"
|
|
1258
|
+
|
|
1259
|
+
# List files with this prefix to find the actual uploaded file
|
|
1260
|
+
files = storage_service.list_files(prefix)
|
|
1261
|
+
if not files:
|
|
1262
|
+
raise HTTPException(
|
|
1263
|
+
status_code=400,
|
|
1264
|
+
detail=f"File for '{file_type}' was not uploaded to GCS. Expected prefix: {prefix}"
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
# Use the first (and should be only) file found
|
|
1268
|
+
gcs_path = files[0]
|
|
1269
|
+
|
|
1270
|
+
# Update appropriate field based on file type
|
|
1271
|
+
if file_type == 'audio':
|
|
1272
|
+
update_data['input_media_gcs_path'] = gcs_path
|
|
1273
|
+
update_data['filename'] = Path(gcs_path).name
|
|
1274
|
+
elif file_type == 'style_params':
|
|
1275
|
+
update_data['style_params_gcs_path'] = gcs_path
|
|
1276
|
+
style_assets['style_params'] = gcs_path
|
|
1277
|
+
elif file_type.startswith('style_'):
|
|
1278
|
+
asset_key = file_type.replace('style_', '')
|
|
1279
|
+
style_assets[asset_key] = gcs_path
|
|
1280
|
+
elif file_type == 'lyrics_file':
|
|
1281
|
+
update_data['lyrics_file_gcs_path'] = gcs_path
|
|
1282
|
+
elif file_type == 'existing_instrumental':
|
|
1283
|
+
update_data['existing_instrumental_gcs_path'] = gcs_path
|
|
1284
|
+
|
|
1285
|
+
# Validate existing instrumental duration if provided (Batch 3)
|
|
1286
|
+
audio_gcs_path = update_data.get('input_media_gcs_path')
|
|
1287
|
+
instrumental_gcs_path = update_data.get('existing_instrumental_gcs_path')
|
|
1288
|
+
|
|
1289
|
+
if audio_gcs_path and instrumental_gcs_path:
|
|
1290
|
+
logger.info(f"Validating instrumental duration for job {job_id}")
|
|
1291
|
+
try:
|
|
1292
|
+
duration_valid, audio_duration, instrumental_duration = await _validate_audio_durations(
|
|
1293
|
+
storage_service, audio_gcs_path, instrumental_gcs_path
|
|
1294
|
+
)
|
|
1295
|
+
if not duration_valid:
|
|
1296
|
+
raise HTTPException(
|
|
1297
|
+
status_code=400,
|
|
1298
|
+
detail={
|
|
1299
|
+
"error": "duration_mismatch",
|
|
1300
|
+
"message": f"Instrumental duration ({instrumental_duration:.2f}s) does not match audio duration ({audio_duration:.2f}s). "
|
|
1301
|
+
f"Difference must be within 0.5 seconds.",
|
|
1302
|
+
"audio_duration": audio_duration,
|
|
1303
|
+
"instrumental_duration": instrumental_duration,
|
|
1304
|
+
"difference": abs(audio_duration - instrumental_duration),
|
|
1305
|
+
}
|
|
1306
|
+
)
|
|
1307
|
+
logger.info(f"Duration validation passed: audio={audio_duration:.2f}s, instrumental={instrumental_duration:.2f}s")
|
|
1308
|
+
except HTTPException:
|
|
1309
|
+
raise
|
|
1310
|
+
except Exception as e:
|
|
1311
|
+
logger.warning(f"Duration validation failed with error: {e}. Proceeding without validation.")
|
|
1312
|
+
# Don't block the job if we can't validate - the video worker will fail more gracefully
|
|
1313
|
+
|
|
1314
|
+
# Add style assets to update if any
|
|
1315
|
+
if style_assets:
|
|
1316
|
+
update_data['style_assets'] = style_assets
|
|
1317
|
+
|
|
1318
|
+
# Update job with GCS paths
|
|
1319
|
+
job_manager.update_job(job_id, update_data)
|
|
1320
|
+
|
|
1321
|
+
logger.info(f"Validated uploads for job {job_id}: {body.uploaded_files}")
|
|
1322
|
+
|
|
1323
|
+
# Transition job to DOWNLOADING state
|
|
1324
|
+
job_manager.transition_to_state(
|
|
1325
|
+
job_id=job_id,
|
|
1326
|
+
new_status=JobStatus.DOWNLOADING,
|
|
1327
|
+
progress=5,
|
|
1328
|
+
message="Files uploaded, preparing to process"
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
# Trigger workers in parallel
|
|
1332
|
+
background_tasks.add_task(_trigger_workers_parallel, job_id)
|
|
1333
|
+
|
|
1334
|
+
# Get distribution services info for response
|
|
1335
|
+
credential_manager = get_credential_manager()
|
|
1336
|
+
distribution_services: Dict[str, Any] = {}
|
|
1337
|
+
|
|
1338
|
+
# Get fresh job data
|
|
1339
|
+
updated_job = job_manager.get_job(job_id)
|
|
1340
|
+
|
|
1341
|
+
if updated_job.dropbox_path:
|
|
1342
|
+
dropbox_result = credential_manager.check_dropbox_credentials()
|
|
1343
|
+
distribution_services["dropbox"] = {
|
|
1344
|
+
"enabled": True,
|
|
1345
|
+
"path": updated_job.dropbox_path,
|
|
1346
|
+
"credentials_valid": dropbox_result.status == CredentialStatus.VALID,
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if updated_job.gdrive_folder_id:
|
|
1350
|
+
gdrive_result = credential_manager.check_gdrive_credentials()
|
|
1351
|
+
distribution_services["gdrive"] = {
|
|
1352
|
+
"enabled": True,
|
|
1353
|
+
"folder_id": updated_job.gdrive_folder_id,
|
|
1354
|
+
"credentials_valid": gdrive_result.status == CredentialStatus.VALID,
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if updated_job.enable_youtube_upload:
|
|
1358
|
+
youtube_result = credential_manager.check_youtube_credentials()
|
|
1359
|
+
distribution_services["youtube"] = {
|
|
1360
|
+
"enabled": True,
|
|
1361
|
+
"credentials_valid": youtube_result.status == CredentialStatus.VALID,
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
if updated_job.discord_webhook_url:
|
|
1365
|
+
distribution_services["discord"] = {
|
|
1366
|
+
"enabled": True,
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return {
|
|
1370
|
+
"status": "success",
|
|
1371
|
+
"job_id": job_id,
|
|
1372
|
+
"message": "Uploads validated. Processing started.",
|
|
1373
|
+
"files_validated": body.uploaded_files,
|
|
1374
|
+
"style_assets": list(style_assets.keys()) if style_assets else [],
|
|
1375
|
+
"server_version": VERSION,
|
|
1376
|
+
"distribution_services": distribution_services,
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
except HTTPException:
|
|
1380
|
+
raise
|
|
1381
|
+
except Exception as e:
|
|
1382
|
+
logger.error(f"Error completing uploads for job {job_id}: {e}", exc_info=True)
|
|
1383
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
# ============================================================================
|
|
1387
|
+
# URL-based Job Creation - For YouTube and other online video URLs
|
|
1388
|
+
# ============================================================================
|
|
1389
|
+
|
|
1390
|
+
def _validate_url(url: Optional[str]) -> bool:
|
|
1391
|
+
"""
|
|
1392
|
+
Validate that a URL is a supported video/audio URL.
|
|
1393
|
+
|
|
1394
|
+
Supports YouTube, Vimeo, SoundCloud, and other platforms supported by yt-dlp.
|
|
1395
|
+
|
|
1396
|
+
Args:
|
|
1397
|
+
url: The URL to validate. Can be None or non-string.
|
|
1398
|
+
|
|
1399
|
+
Returns:
|
|
1400
|
+
True if valid URL, False otherwise.
|
|
1401
|
+
"""
|
|
1402
|
+
# Handle None and non-string inputs safely
|
|
1403
|
+
if url is None or not isinstance(url, str):
|
|
1404
|
+
return False
|
|
1405
|
+
|
|
1406
|
+
# Handle empty string
|
|
1407
|
+
if not url:
|
|
1408
|
+
return False
|
|
1409
|
+
|
|
1410
|
+
# Basic URL validation
|
|
1411
|
+
if not url.startswith(('http://', 'https://')):
|
|
1412
|
+
return False
|
|
1413
|
+
|
|
1414
|
+
# List of known supported domains (subset - yt-dlp supports many more)
|
|
1415
|
+
supported_domains = [
|
|
1416
|
+
'youtube.com', 'youtu.be', 'www.youtube.com', 'm.youtube.com',
|
|
1417
|
+
'vimeo.com', 'www.vimeo.com',
|
|
1418
|
+
'soundcloud.com', 'www.soundcloud.com',
|
|
1419
|
+
'dailymotion.com', 'www.dailymotion.com',
|
|
1420
|
+
'twitch.tv', 'www.twitch.tv',
|
|
1421
|
+
'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com',
|
|
1422
|
+
'facebook.com', 'www.facebook.com', 'fb.watch',
|
|
1423
|
+
'instagram.com', 'www.instagram.com',
|
|
1424
|
+
'tiktok.com', 'www.tiktok.com',
|
|
1425
|
+
]
|
|
1426
|
+
|
|
1427
|
+
from urllib.parse import urlparse
|
|
1428
|
+
parsed = urlparse(url)
|
|
1429
|
+
domain = parsed.netloc.lower()
|
|
1430
|
+
|
|
1431
|
+
# Remove port if present
|
|
1432
|
+
if ':' in domain:
|
|
1433
|
+
domain = domain.split(':')[0]
|
|
1434
|
+
|
|
1435
|
+
# Check if domain matches any supported domain
|
|
1436
|
+
for supported in supported_domains:
|
|
1437
|
+
if domain == supported or domain.endswith('.' + supported):
|
|
1438
|
+
return True
|
|
1439
|
+
|
|
1440
|
+
# For other URLs, let yt-dlp try (it supports many more sites)
|
|
1441
|
+
return True
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
@router.post("/jobs/create-from-url", response_model=CreateJobFromUrlResponse)
|
|
1445
|
+
async def create_job_from_url(
|
|
1446
|
+
request: Request,
|
|
1447
|
+
background_tasks: BackgroundTasks,
|
|
1448
|
+
body: CreateJobFromUrlRequest,
|
|
1449
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
1450
|
+
):
|
|
1451
|
+
"""
|
|
1452
|
+
Create a karaoke generation job from a YouTube or other online video URL.
|
|
1453
|
+
|
|
1454
|
+
The backend will:
|
|
1455
|
+
1. Validate the URL
|
|
1456
|
+
2. Extract metadata (artist/title) from the URL if not provided
|
|
1457
|
+
3. Create the job
|
|
1458
|
+
4. Trigger the audio worker to download and process
|
|
1459
|
+
|
|
1460
|
+
This is an alternative to file upload for cases where the audio
|
|
1461
|
+
source is a YouTube video or other online content.
|
|
1462
|
+
|
|
1463
|
+
Note: YouTube rate limiting may cause occasional download failures.
|
|
1464
|
+
The backend will retry automatically.
|
|
1465
|
+
"""
|
|
1466
|
+
try:
|
|
1467
|
+
# Validate URL
|
|
1468
|
+
if not _validate_url(body.url):
|
|
1469
|
+
raise HTTPException(
|
|
1470
|
+
status_code=400,
|
|
1471
|
+
detail="Invalid URL. Please provide a valid YouTube, Vimeo, SoundCloud, or other supported video URL."
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
# Apply default distribution settings from environment if not provided
|
|
1475
|
+
dist = _get_effective_distribution_settings(
|
|
1476
|
+
dropbox_path=body.dropbox_path,
|
|
1477
|
+
gdrive_folder_id=body.gdrive_folder_id,
|
|
1478
|
+
discord_webhook_url=body.discord_webhook_url,
|
|
1479
|
+
brand_prefix=body.brand_prefix,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
# Apply YouTube upload default from settings
|
|
1483
|
+
# Use explicit value if provided, otherwise fall back to server default
|
|
1484
|
+
settings = get_settings()
|
|
1485
|
+
effective_enable_youtube_upload = body.enable_youtube_upload if body.enable_youtube_upload is not None else settings.default_enable_youtube_upload
|
|
1486
|
+
|
|
1487
|
+
# Validate credentials for requested distribution services
|
|
1488
|
+
invalid_services = []
|
|
1489
|
+
credential_manager = get_credential_manager()
|
|
1490
|
+
|
|
1491
|
+
if effective_enable_youtube_upload:
|
|
1492
|
+
result = credential_manager.check_youtube_credentials()
|
|
1493
|
+
if result.status != CredentialStatus.VALID:
|
|
1494
|
+
invalid_services.append(f"youtube ({result.message})")
|
|
1495
|
+
|
|
1496
|
+
if dist.dropbox_path:
|
|
1497
|
+
result = credential_manager.check_dropbox_credentials()
|
|
1498
|
+
if result.status != CredentialStatus.VALID:
|
|
1499
|
+
invalid_services.append(f"dropbox ({result.message})")
|
|
1500
|
+
|
|
1501
|
+
if dist.gdrive_folder_id:
|
|
1502
|
+
result = credential_manager.check_gdrive_credentials()
|
|
1503
|
+
if result.status != CredentialStatus.VALID:
|
|
1504
|
+
invalid_services.append(f"gdrive ({result.message})")
|
|
1505
|
+
|
|
1506
|
+
if invalid_services:
|
|
1507
|
+
raise HTTPException(
|
|
1508
|
+
status_code=400,
|
|
1509
|
+
detail={
|
|
1510
|
+
"error": "credentials_invalid",
|
|
1511
|
+
"message": f"The following distribution services need re-authorization: {', '.join(invalid_services)}",
|
|
1512
|
+
"invalid_services": invalid_services,
|
|
1513
|
+
"auth_url": "/api/auth/status"
|
|
1514
|
+
}
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
# Extract request metadata for tracking
|
|
1518
|
+
request_metadata = extract_request_metadata(request, created_from="url")
|
|
1519
|
+
|
|
1520
|
+
# Use provided artist/title or leave as None (will be auto-detected by audio worker)
|
|
1521
|
+
artist = body.artist
|
|
1522
|
+
title = body.title
|
|
1523
|
+
|
|
1524
|
+
# Resolve CDG/TXT defaults based on theme
|
|
1525
|
+
resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
|
|
1526
|
+
body.theme_id, body.enable_cdg, body.enable_txt
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
# Prefer authenticated user's email over request body
|
|
1530
|
+
effective_user_email = auth_result.user_email or body.user_email
|
|
1531
|
+
|
|
1532
|
+
# Create job with URL
|
|
1533
|
+
job_create = JobCreate(
|
|
1534
|
+
url=body.url,
|
|
1535
|
+
artist=artist,
|
|
1536
|
+
title=title,
|
|
1537
|
+
filename=None, # No file uploaded
|
|
1538
|
+
theme_id=body.theme_id,
|
|
1539
|
+
color_overrides=body.color_overrides or {},
|
|
1540
|
+
enable_cdg=resolved_cdg,
|
|
1541
|
+
enable_txt=resolved_txt,
|
|
1542
|
+
brand_prefix=dist.brand_prefix,
|
|
1543
|
+
enable_youtube_upload=effective_enable_youtube_upload,
|
|
1544
|
+
youtube_description=body.youtube_description,
|
|
1545
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
1546
|
+
webhook_url=body.webhook_url,
|
|
1547
|
+
user_email=effective_user_email,
|
|
1548
|
+
dropbox_path=dist.dropbox_path,
|
|
1549
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
1550
|
+
organised_dir_rclone_root=body.organised_dir_rclone_root,
|
|
1551
|
+
lyrics_artist=body.lyrics_artist,
|
|
1552
|
+
lyrics_title=body.lyrics_title,
|
|
1553
|
+
subtitle_offset_ms=body.subtitle_offset_ms,
|
|
1554
|
+
clean_instrumental_model=body.clean_instrumental_model,
|
|
1555
|
+
backing_vocals_models=body.backing_vocals_models,
|
|
1556
|
+
other_stems_models=body.other_stems_models,
|
|
1557
|
+
request_metadata=request_metadata,
|
|
1558
|
+
non_interactive=body.non_interactive,
|
|
1559
|
+
)
|
|
1560
|
+
job = job_manager.create_job(job_create)
|
|
1561
|
+
job_id = job.job_id
|
|
1562
|
+
|
|
1563
|
+
# Record job creation metric
|
|
1564
|
+
metrics.record_job_created(job_id, source="url")
|
|
1565
|
+
|
|
1566
|
+
# If theme is set, prepare theme style now
|
|
1567
|
+
if body.theme_id:
|
|
1568
|
+
style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(
|
|
1569
|
+
job_id, body.theme_id, body.color_overrides
|
|
1570
|
+
)
|
|
1571
|
+
# Update job with theme style data
|
|
1572
|
+
update_data = {
|
|
1573
|
+
'style_params_gcs_path': style_params_path,
|
|
1574
|
+
'style_assets': style_assets,
|
|
1575
|
+
}
|
|
1576
|
+
if youtube_desc and not body.youtube_description:
|
|
1577
|
+
update_data['youtube_description_template'] = youtube_desc
|
|
1578
|
+
job_manager.update_job(job_id, update_data)
|
|
1579
|
+
logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
|
|
1580
|
+
|
|
1581
|
+
logger.info(f"Created URL-based job {job_id} for URL: {body.url}")
|
|
1582
|
+
if artist:
|
|
1583
|
+
logger.info(f" Artist: {artist}")
|
|
1584
|
+
if title:
|
|
1585
|
+
logger.info(f" Title: {title}")
|
|
1586
|
+
|
|
1587
|
+
# Transition job to DOWNLOADING state
|
|
1588
|
+
job_manager.transition_to_state(
|
|
1589
|
+
job_id=job_id,
|
|
1590
|
+
new_status=JobStatus.DOWNLOADING,
|
|
1591
|
+
progress=5,
|
|
1592
|
+
message="Starting audio download from URL"
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
# For URL jobs, trigger ONLY audio worker first
|
|
1596
|
+
# The audio worker will trigger lyrics worker after download completes
|
|
1597
|
+
# This prevents race condition where lyrics worker times out waiting for audio
|
|
1598
|
+
background_tasks.add_task(_trigger_audio_worker_only, job_id)
|
|
1599
|
+
|
|
1600
|
+
return CreateJobFromUrlResponse(
|
|
1601
|
+
status="success",
|
|
1602
|
+
job_id=job_id,
|
|
1603
|
+
message="Job created. Audio will be downloaded from URL.",
|
|
1604
|
+
detected_artist=artist,
|
|
1605
|
+
detected_title=title,
|
|
1606
|
+
server_version=VERSION
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
except HTTPException:
|
|
1610
|
+
raise
|
|
1611
|
+
except Exception as e:
|
|
1612
|
+
logger.error(f"Error creating job from URL: {e}", exc_info=True)
|
|
1613
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
# ============================================================================
|
|
1617
|
+
# Finalise-Only Upload Flow (Batch 6)
|
|
1618
|
+
# ============================================================================
|
|
1619
|
+
|
|
1620
|
+
def _get_gcs_path_for_finalise_file(job_id: str, file_type: str, filename: str) -> str:
|
|
1621
|
+
"""Generate the GCS path for a finalise-only file based on its type."""
|
|
1622
|
+
ext = Path(filename).suffix.lower()
|
|
1623
|
+
|
|
1624
|
+
# Map file types to appropriate GCS paths
|
|
1625
|
+
if file_type == 'audio':
|
|
1626
|
+
return f"uploads/{job_id}/audio/{filename}"
|
|
1627
|
+
elif file_type == 'with_vocals':
|
|
1628
|
+
return f"jobs/{job_id}/videos/with_vocals{ext}"
|
|
1629
|
+
elif file_type == 'title_screen':
|
|
1630
|
+
return f"jobs/{job_id}/screens/title{ext}"
|
|
1631
|
+
elif file_type == 'end_screen':
|
|
1632
|
+
return f"jobs/{job_id}/screens/end{ext}"
|
|
1633
|
+
elif file_type == 'title_jpg':
|
|
1634
|
+
return f"jobs/{job_id}/screens/title.jpg"
|
|
1635
|
+
elif file_type == 'title_png':
|
|
1636
|
+
return f"jobs/{job_id}/screens/title.png"
|
|
1637
|
+
elif file_type == 'end_jpg':
|
|
1638
|
+
return f"jobs/{job_id}/screens/end.jpg"
|
|
1639
|
+
elif file_type == 'end_png':
|
|
1640
|
+
return f"jobs/{job_id}/screens/end.png"
|
|
1641
|
+
elif file_type == 'instrumental_clean':
|
|
1642
|
+
return f"jobs/{job_id}/stems/instrumental_clean{ext}"
|
|
1643
|
+
elif file_type == 'instrumental_backing':
|
|
1644
|
+
return f"jobs/{job_id}/stems/instrumental_with_backing{ext}"
|
|
1645
|
+
elif file_type == 'lrc':
|
|
1646
|
+
return f"jobs/{job_id}/lyrics/karaoke.lrc"
|
|
1647
|
+
elif file_type == 'ass':
|
|
1648
|
+
return f"jobs/{job_id}/lyrics/karaoke.ass"
|
|
1649
|
+
elif file_type == 'corrections':
|
|
1650
|
+
return f"jobs/{job_id}/lyrics/corrections.json"
|
|
1651
|
+
elif file_type == 'style_params':
|
|
1652
|
+
return f"uploads/{job_id}/style/style_params.json"
|
|
1653
|
+
else:
|
|
1654
|
+
return f"uploads/{job_id}/other/{filename}"
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
@router.post("/jobs/create-finalise-only")
|
|
1658
|
+
async def create_finalise_only_job(
|
|
1659
|
+
request: Request,
|
|
1660
|
+
body: CreateFinaliseOnlyJobRequest,
|
|
1661
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
1662
|
+
):
|
|
1663
|
+
"""
|
|
1664
|
+
Create a finalise-only job for continuing from a local prep phase.
|
|
1665
|
+
|
|
1666
|
+
This endpoint is used when:
|
|
1667
|
+
1. User previously ran --prep-only which created local prep outputs
|
|
1668
|
+
2. User optionally made manual edits to stems, lyrics, etc.
|
|
1669
|
+
3. User now wants cloud to handle finalisation (encoding, distribution)
|
|
1670
|
+
|
|
1671
|
+
Required files:
|
|
1672
|
+
- with_vocals: The karaoke video from prep (*.mkv or *.mov)
|
|
1673
|
+
- title_screen: Title screen video
|
|
1674
|
+
- end_screen: End screen video
|
|
1675
|
+
- instrumental_clean or instrumental_backing: At least one instrumental
|
|
1676
|
+
|
|
1677
|
+
The endpoint returns signed URLs for uploading all the prep files.
|
|
1678
|
+
"""
|
|
1679
|
+
try:
|
|
1680
|
+
# Validate files list
|
|
1681
|
+
if not body.files:
|
|
1682
|
+
raise HTTPException(status_code=400, detail="At least one file is required")
|
|
1683
|
+
|
|
1684
|
+
# Validate file types
|
|
1685
|
+
file_types = {f.file_type for f in body.files}
|
|
1686
|
+
|
|
1687
|
+
# Check required files for finalise-only
|
|
1688
|
+
if 'with_vocals' not in file_types:
|
|
1689
|
+
raise HTTPException(
|
|
1690
|
+
status_code=400,
|
|
1691
|
+
detail="with_vocals video is required for finalise-only mode"
|
|
1692
|
+
)
|
|
1693
|
+
if 'title_screen' not in file_types:
|
|
1694
|
+
raise HTTPException(
|
|
1695
|
+
status_code=400,
|
|
1696
|
+
detail="title_screen video is required for finalise-only mode"
|
|
1697
|
+
)
|
|
1698
|
+
if 'end_screen' not in file_types:
|
|
1699
|
+
raise HTTPException(
|
|
1700
|
+
status_code=400,
|
|
1701
|
+
detail="end_screen video is required for finalise-only mode"
|
|
1702
|
+
)
|
|
1703
|
+
if 'instrumental_clean' not in file_types and 'instrumental_backing' not in file_types:
|
|
1704
|
+
raise HTTPException(
|
|
1705
|
+
status_code=400,
|
|
1706
|
+
detail="At least one instrumental (clean or backing) is required"
|
|
1707
|
+
)
|
|
1708
|
+
|
|
1709
|
+
# Validate file extensions
|
|
1710
|
+
for file_info in body.files:
|
|
1711
|
+
if file_info.file_type not in FINALISE_ONLY_FILE_TYPES:
|
|
1712
|
+
raise HTTPException(
|
|
1713
|
+
status_code=400,
|
|
1714
|
+
detail=f"Invalid file_type: '{file_info.file_type}'. Valid types: {list(FINALISE_ONLY_FILE_TYPES.keys())}"
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
ext = Path(file_info.filename).suffix.lower()
|
|
1718
|
+
allowed_extensions = FINALISE_ONLY_FILE_TYPES[file_info.file_type]
|
|
1719
|
+
if ext not in allowed_extensions:
|
|
1720
|
+
raise HTTPException(
|
|
1721
|
+
status_code=400,
|
|
1722
|
+
detail=f"Invalid file extension '{ext}' for file_type '{file_info.file_type}'. Allowed: {allowed_extensions}"
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
# Apply default distribution settings
|
|
1726
|
+
dist = _get_effective_distribution_settings(
|
|
1727
|
+
dropbox_path=body.dropbox_path,
|
|
1728
|
+
gdrive_folder_id=body.gdrive_folder_id,
|
|
1729
|
+
discord_webhook_url=body.discord_webhook_url,
|
|
1730
|
+
brand_prefix=body.brand_prefix,
|
|
1731
|
+
)
|
|
1732
|
+
|
|
1733
|
+
# Apply YouTube upload default from settings
|
|
1734
|
+
# Use explicit value if provided, otherwise fall back to server default
|
|
1735
|
+
settings = get_settings()
|
|
1736
|
+
effective_enable_youtube_upload = body.enable_youtube_upload if body.enable_youtube_upload is not None else settings.default_enable_youtube_upload
|
|
1737
|
+
|
|
1738
|
+
# Validate distribution credentials if services are requested
|
|
1739
|
+
invalid_services = []
|
|
1740
|
+
credential_manager = get_credential_manager()
|
|
1741
|
+
|
|
1742
|
+
if effective_enable_youtube_upload:
|
|
1743
|
+
result = credential_manager.check_youtube_credentials()
|
|
1744
|
+
if result.status != CredentialStatus.VALID:
|
|
1745
|
+
invalid_services.append(f"youtube ({result.message})")
|
|
1746
|
+
|
|
1747
|
+
if dist.dropbox_path:
|
|
1748
|
+
result = credential_manager.check_dropbox_credentials()
|
|
1749
|
+
if result.status != CredentialStatus.VALID:
|
|
1750
|
+
invalid_services.append(f"dropbox ({result.message})")
|
|
1751
|
+
|
|
1752
|
+
if dist.gdrive_folder_id:
|
|
1753
|
+
result = credential_manager.check_gdrive_credentials()
|
|
1754
|
+
if result.status != CredentialStatus.VALID:
|
|
1755
|
+
invalid_services.append(f"gdrive ({result.message})")
|
|
1756
|
+
|
|
1757
|
+
if invalid_services:
|
|
1758
|
+
raise HTTPException(
|
|
1759
|
+
status_code=400,
|
|
1760
|
+
detail={
|
|
1761
|
+
"error": "credentials_invalid",
|
|
1762
|
+
"message": f"The following distribution services need re-authorization: {', '.join(invalid_services)}",
|
|
1763
|
+
"invalid_services": invalid_services,
|
|
1764
|
+
"auth_url": "/api/auth/status"
|
|
1765
|
+
}
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
# Extract request metadata
|
|
1769
|
+
request_metadata = extract_request_metadata(request, created_from="finalise_only_upload")
|
|
1770
|
+
|
|
1771
|
+
# Resolve CDG/TXT defaults based on theme
|
|
1772
|
+
resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
|
|
1773
|
+
body.theme_id, body.enable_cdg, body.enable_txt
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
# Check if style_params is being uploaded (overrides theme)
|
|
1777
|
+
has_style_params_upload = any(f.file_type == 'style_params' for f in body.files)
|
|
1778
|
+
|
|
1779
|
+
# Use authenticated user's email
|
|
1780
|
+
effective_user_email = auth_result.user_email
|
|
1781
|
+
|
|
1782
|
+
# Create job with finalise_only=True
|
|
1783
|
+
job_create = JobCreate(
|
|
1784
|
+
artist=body.artist,
|
|
1785
|
+
title=body.title,
|
|
1786
|
+
filename="finalise_only", # No single audio file - using prep outputs
|
|
1787
|
+
theme_id=body.theme_id,
|
|
1788
|
+
color_overrides=body.color_overrides or {},
|
|
1789
|
+
enable_cdg=resolved_cdg,
|
|
1790
|
+
enable_txt=resolved_txt,
|
|
1791
|
+
brand_prefix=dist.brand_prefix,
|
|
1792
|
+
enable_youtube_upload=effective_enable_youtube_upload,
|
|
1793
|
+
youtube_description=body.youtube_description,
|
|
1794
|
+
discord_webhook_url=dist.discord_webhook_url,
|
|
1795
|
+
dropbox_path=dist.dropbox_path,
|
|
1796
|
+
gdrive_folder_id=dist.gdrive_folder_id,
|
|
1797
|
+
user_email=effective_user_email,
|
|
1798
|
+
finalise_only=True,
|
|
1799
|
+
keep_brand_code=body.keep_brand_code,
|
|
1800
|
+
request_metadata=request_metadata,
|
|
1801
|
+
)
|
|
1802
|
+
job = job_manager.create_job(job_create)
|
|
1803
|
+
job_id = job.job_id
|
|
1804
|
+
|
|
1805
|
+
# Record job creation metric
|
|
1806
|
+
metrics.record_job_created(job_id, source="finalise")
|
|
1807
|
+
|
|
1808
|
+
logger.info(f"Created finalise-only job {job_id} for {body.artist} - {body.title}")
|
|
1809
|
+
|
|
1810
|
+
# If theme is set and no style_params uploaded, prepare theme style now
|
|
1811
|
+
if body.theme_id and not has_style_params_upload:
|
|
1812
|
+
style_params_path, style_assets, youtube_desc = _prepare_theme_for_job(
|
|
1813
|
+
job_id, body.theme_id, body.color_overrides
|
|
1814
|
+
)
|
|
1815
|
+
# Update job with theme style data
|
|
1816
|
+
update_data = {
|
|
1817
|
+
'style_params_gcs_path': style_params_path,
|
|
1818
|
+
'style_assets': style_assets,
|
|
1819
|
+
}
|
|
1820
|
+
if youtube_desc and not body.youtube_description:
|
|
1821
|
+
update_data['youtube_description_template'] = youtube_desc
|
|
1822
|
+
job_manager.update_job(job_id, update_data)
|
|
1823
|
+
logger.info(f"Applied theme '{body.theme_id}' to job {job_id}")
|
|
1824
|
+
|
|
1825
|
+
# Generate signed upload URLs for each file
|
|
1826
|
+
upload_urls = []
|
|
1827
|
+
for file_info in body.files:
|
|
1828
|
+
gcs_path = _get_gcs_path_for_finalise_file(job_id, file_info.file_type, file_info.filename)
|
|
1829
|
+
|
|
1830
|
+
# Generate signed upload URL (valid for 60 minutes)
|
|
1831
|
+
signed_url = storage_service.generate_signed_upload_url(
|
|
1832
|
+
gcs_path,
|
|
1833
|
+
content_type=file_info.content_type,
|
|
1834
|
+
expiration_minutes=60
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
upload_urls.append(SignedUploadUrl(
|
|
1838
|
+
file_type=file_info.file_type,
|
|
1839
|
+
gcs_path=gcs_path,
|
|
1840
|
+
upload_url=signed_url,
|
|
1841
|
+
content_type=file_info.content_type
|
|
1842
|
+
))
|
|
1843
|
+
|
|
1844
|
+
logger.info(f"Generated signed upload URL for {file_info.file_type}: {gcs_path}")
|
|
1845
|
+
|
|
1846
|
+
return CreateJobWithUploadUrlsResponse(
|
|
1847
|
+
status="success",
|
|
1848
|
+
job_id=job_id,
|
|
1849
|
+
message="Finalise-only job created. Upload files to the provided URLs, then call /api/jobs/{job_id}/finalise-uploads-complete",
|
|
1850
|
+
upload_urls=upload_urls,
|
|
1851
|
+
server_version=VERSION
|
|
1852
|
+
)
|
|
1853
|
+
|
|
1854
|
+
except HTTPException:
|
|
1855
|
+
raise
|
|
1856
|
+
except Exception as e:
|
|
1857
|
+
logger.error(f"Error creating finalise-only job: {e}", exc_info=True)
|
|
1858
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
@router.post("/jobs/{job_id}/finalise-uploads-complete")
|
|
1862
|
+
async def mark_finalise_uploads_complete(
|
|
1863
|
+
job_id: str,
|
|
1864
|
+
background_tasks: BackgroundTasks,
|
|
1865
|
+
body: UploadsCompleteRequest,
|
|
1866
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
1867
|
+
):
|
|
1868
|
+
"""
|
|
1869
|
+
Mark finalise-only file uploads as complete and start video generation.
|
|
1870
|
+
|
|
1871
|
+
This is called after uploading prep outputs for a finalise-only job.
|
|
1872
|
+
The job will transition directly to AWAITING_INSTRUMENTAL_SELECTION.
|
|
1873
|
+
"""
|
|
1874
|
+
try:
|
|
1875
|
+
# Get job and verify it exists
|
|
1876
|
+
job = job_manager.get_job(job_id)
|
|
1877
|
+
if not job:
|
|
1878
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
1879
|
+
|
|
1880
|
+
# Verify job is in pending state and is finalise-only
|
|
1881
|
+
if job.status != JobStatus.PENDING:
|
|
1882
|
+
raise HTTPException(
|
|
1883
|
+
status_code=400,
|
|
1884
|
+
detail=f"Job {job_id} is not in pending state (current: {job.status}). Cannot complete uploads."
|
|
1885
|
+
)
|
|
1886
|
+
|
|
1887
|
+
if not job.finalise_only:
|
|
1888
|
+
raise HTTPException(
|
|
1889
|
+
status_code=400,
|
|
1890
|
+
detail=f"Job {job_id} is not a finalise-only job. Use /api/jobs/{{job_id}}/uploads-complete instead."
|
|
1891
|
+
)
|
|
1892
|
+
|
|
1893
|
+
# Validate required files were uploaded
|
|
1894
|
+
required_files = {'with_vocals', 'title_screen', 'end_screen'}
|
|
1895
|
+
uploaded_set = set(body.uploaded_files)
|
|
1896
|
+
|
|
1897
|
+
missing_files = required_files - uploaded_set
|
|
1898
|
+
if missing_files:
|
|
1899
|
+
raise HTTPException(
|
|
1900
|
+
status_code=400,
|
|
1901
|
+
detail=f"Missing required files: {missing_files}"
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
# Require at least one instrumental
|
|
1905
|
+
if 'instrumental_clean' not in uploaded_set and 'instrumental_backing' not in uploaded_set:
|
|
1906
|
+
raise HTTPException(
|
|
1907
|
+
status_code=400,
|
|
1908
|
+
detail="At least one instrumental (clean or backing) is required"
|
|
1909
|
+
)
|
|
1910
|
+
|
|
1911
|
+
# Build file_urls structure from uploaded files
|
|
1912
|
+
file_urls: Dict[str, Dict[str, str]] = {
|
|
1913
|
+
'videos': {},
|
|
1914
|
+
'screens': {},
|
|
1915
|
+
'stems': {},
|
|
1916
|
+
'lyrics': {},
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
for file_type in body.uploaded_files:
|
|
1920
|
+
if file_type not in FINALISE_ONLY_FILE_TYPES:
|
|
1921
|
+
logger.warning(f"Unknown file_type in uploaded_files: {file_type}")
|
|
1922
|
+
continue
|
|
1923
|
+
|
|
1924
|
+
# Determine the GCS path - we need to find the actual file
|
|
1925
|
+
if file_type == 'audio':
|
|
1926
|
+
prefix = f"uploads/{job_id}/audio/"
|
|
1927
|
+
elif file_type == 'with_vocals':
|
|
1928
|
+
prefix = f"jobs/{job_id}/videos/with_vocals"
|
|
1929
|
+
elif file_type == 'title_screen':
|
|
1930
|
+
prefix = f"jobs/{job_id}/screens/title"
|
|
1931
|
+
elif file_type == 'end_screen':
|
|
1932
|
+
prefix = f"jobs/{job_id}/screens/end"
|
|
1933
|
+
elif file_type == 'title_jpg':
|
|
1934
|
+
prefix = f"jobs/{job_id}/screens/title.jpg"
|
|
1935
|
+
elif file_type == 'title_png':
|
|
1936
|
+
prefix = f"jobs/{job_id}/screens/title.png"
|
|
1937
|
+
elif file_type == 'end_jpg':
|
|
1938
|
+
prefix = f"jobs/{job_id}/screens/end.jpg"
|
|
1939
|
+
elif file_type == 'end_png':
|
|
1940
|
+
prefix = f"jobs/{job_id}/screens/end.png"
|
|
1941
|
+
elif file_type == 'instrumental_clean':
|
|
1942
|
+
prefix = f"jobs/{job_id}/stems/instrumental_clean"
|
|
1943
|
+
elif file_type == 'instrumental_backing':
|
|
1944
|
+
prefix = f"jobs/{job_id}/stems/instrumental_with_backing"
|
|
1945
|
+
elif file_type == 'lrc':
|
|
1946
|
+
prefix = f"jobs/{job_id}/lyrics/karaoke.lrc"
|
|
1947
|
+
elif file_type == 'ass':
|
|
1948
|
+
prefix = f"jobs/{job_id}/lyrics/karaoke.ass"
|
|
1949
|
+
elif file_type == 'corrections':
|
|
1950
|
+
prefix = f"jobs/{job_id}/lyrics/corrections"
|
|
1951
|
+
elif file_type == 'style_params':
|
|
1952
|
+
prefix = f"uploads/{job_id}/style/style_params"
|
|
1953
|
+
else:
|
|
1954
|
+
continue
|
|
1955
|
+
|
|
1956
|
+
# List files with this prefix to find the actual uploaded file
|
|
1957
|
+
files = storage_service.list_files(prefix)
|
|
1958
|
+
if not files:
|
|
1959
|
+
raise HTTPException(
|
|
1960
|
+
status_code=400,
|
|
1961
|
+
detail=f"File for '{file_type}' was not uploaded to GCS. Expected prefix: {prefix}"
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
gcs_path = files[0]
|
|
1965
|
+
|
|
1966
|
+
# Map to file_urls structure
|
|
1967
|
+
if file_type == 'audio':
|
|
1968
|
+
pass # Handled separately
|
|
1969
|
+
elif file_type == 'with_vocals':
|
|
1970
|
+
file_urls['videos']['with_vocals'] = gcs_path
|
|
1971
|
+
elif file_type == 'title_screen':
|
|
1972
|
+
file_urls['screens']['title'] = gcs_path
|
|
1973
|
+
elif file_type == 'end_screen':
|
|
1974
|
+
file_urls['screens']['end'] = gcs_path
|
|
1975
|
+
elif file_type == 'title_jpg':
|
|
1976
|
+
file_urls['screens']['title_jpg'] = gcs_path
|
|
1977
|
+
elif file_type == 'title_png':
|
|
1978
|
+
file_urls['screens']['title_png'] = gcs_path
|
|
1979
|
+
elif file_type == 'end_jpg':
|
|
1980
|
+
file_urls['screens']['end_jpg'] = gcs_path
|
|
1981
|
+
elif file_type == 'end_png':
|
|
1982
|
+
file_urls['screens']['end_png'] = gcs_path
|
|
1983
|
+
elif file_type == 'instrumental_clean':
|
|
1984
|
+
file_urls['stems']['instrumental_clean'] = gcs_path
|
|
1985
|
+
elif file_type == 'instrumental_backing':
|
|
1986
|
+
file_urls['stems']['instrumental_with_backing'] = gcs_path
|
|
1987
|
+
elif file_type == 'lrc':
|
|
1988
|
+
file_urls['lyrics']['lrc'] = gcs_path
|
|
1989
|
+
elif file_type == 'ass':
|
|
1990
|
+
file_urls['lyrics']['ass'] = gcs_path
|
|
1991
|
+
elif file_type == 'corrections':
|
|
1992
|
+
file_urls['lyrics']['corrections'] = gcs_path
|
|
1993
|
+
|
|
1994
|
+
# Get audio path if uploaded
|
|
1995
|
+
audio_gcs_path = None
|
|
1996
|
+
if 'audio' in body.uploaded_files:
|
|
1997
|
+
audio_files = storage_service.list_files(f"uploads/{job_id}/audio/")
|
|
1998
|
+
if audio_files:
|
|
1999
|
+
audio_gcs_path = audio_files[0]
|
|
2000
|
+
|
|
2001
|
+
# Update job with file paths
|
|
2002
|
+
update_data = {
|
|
2003
|
+
'file_urls': file_urls,
|
|
2004
|
+
'finalise_only': True,
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
if audio_gcs_path:
|
|
2008
|
+
update_data['input_media_gcs_path'] = audio_gcs_path
|
|
2009
|
+
|
|
2010
|
+
# Mark parallel processing as complete (we're skipping it)
|
|
2011
|
+
update_data['state_data'] = {
|
|
2012
|
+
'audio_complete': True,
|
|
2013
|
+
'lyrics_complete': True,
|
|
2014
|
+
'finalise_only': True,
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
job_manager.update_job(job_id, update_data)
|
|
2018
|
+
|
|
2019
|
+
logger.info(f"Validated finalise-only uploads for job {job_id}: {body.uploaded_files}")
|
|
2020
|
+
|
|
2021
|
+
# Transition directly to AWAITING_INSTRUMENTAL_SELECTION
|
|
2022
|
+
job_manager.transition_to_state(
|
|
2023
|
+
job_id=job_id,
|
|
2024
|
+
new_status=JobStatus.AWAITING_INSTRUMENTAL_SELECTION,
|
|
2025
|
+
progress=80,
|
|
2026
|
+
message="Prep files uploaded - select your instrumental"
|
|
2027
|
+
)
|
|
2028
|
+
|
|
2029
|
+
# Get distribution services info for response
|
|
2030
|
+
credential_manager = get_credential_manager()
|
|
2031
|
+
distribution_services: Dict[str, Any] = {}
|
|
2032
|
+
|
|
2033
|
+
updated_job = job_manager.get_job(job_id)
|
|
2034
|
+
|
|
2035
|
+
if updated_job.dropbox_path:
|
|
2036
|
+
dropbox_result = credential_manager.check_dropbox_credentials()
|
|
2037
|
+
distribution_services["dropbox"] = {
|
|
2038
|
+
"enabled": True,
|
|
2039
|
+
"path": updated_job.dropbox_path,
|
|
2040
|
+
"credentials_valid": dropbox_result.status == CredentialStatus.VALID,
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
if updated_job.gdrive_folder_id:
|
|
2044
|
+
gdrive_result = credential_manager.check_gdrive_credentials()
|
|
2045
|
+
distribution_services["gdrive"] = {
|
|
2046
|
+
"enabled": True,
|
|
2047
|
+
"folder_id": updated_job.gdrive_folder_id,
|
|
2048
|
+
"credentials_valid": gdrive_result.status == CredentialStatus.VALID,
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
if updated_job.enable_youtube_upload:
|
|
2052
|
+
youtube_result = credential_manager.check_youtube_credentials()
|
|
2053
|
+
distribution_services["youtube"] = {
|
|
2054
|
+
"enabled": True,
|
|
2055
|
+
"credentials_valid": youtube_result.status == CredentialStatus.VALID,
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if updated_job.discord_webhook_url:
|
|
2059
|
+
distribution_services["discord"] = {
|
|
2060
|
+
"enabled": True,
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
return {
|
|
2064
|
+
"status": "success",
|
|
2065
|
+
"job_id": job_id,
|
|
2066
|
+
"message": "Finalise-only uploads validated. Select instrumental to continue.",
|
|
2067
|
+
"files_validated": body.uploaded_files,
|
|
2068
|
+
"server_version": VERSION,
|
|
2069
|
+
"distribution_services": distribution_services,
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
except HTTPException:
|
|
2073
|
+
raise
|
|
2074
|
+
except Exception as e:
|
|
2075
|
+
logger.error(f"Error completing finalise-only uploads for job {job_id}: {e}", exc_info=True)
|
|
2076
|
+
raise HTTPException(status_code=500, detail=str(e)) from e
|