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.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {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