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