karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1610 @@
1
+ """
2
+ Job management routes.
3
+
4
+ Handles job lifecycle endpoints including:
5
+ - Job creation and submission
6
+ - Status polling
7
+ - Human-in-the-loop interactions (lyrics review, instrumental selection)
8
+ - Job deletion and cancellation
9
+ """
10
+ import asyncio
11
+ import logging
12
+ import httpx
13
+ from typing import List, Optional, Dict, Any, Tuple
14
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends
15
+
16
+ from backend.models.job import Job, JobCreate, JobResponse, JobStatus
17
+ from backend.models.requests import (
18
+ URLSubmissionRequest,
19
+ CorrectionsSubmission,
20
+ InstrumentalSelection,
21
+ StartReviewRequest,
22
+ CancelJobRequest,
23
+ CreateCustomInstrumentalRequest,
24
+ )
25
+ from backend.services.job_manager import JobManager
26
+ from backend.services.worker_service import get_worker_service
27
+ from backend.services.storage_service import StorageService
28
+ from backend.config import get_settings
29
+ from backend.api.dependencies import require_admin, require_auth, require_instrumental_auth
30
+ from backend.services.auth_service import UserType, AuthResult
31
+ from backend.services.metrics import metrics
32
+
33
+
34
+ logger = logging.getLogger(__name__)
35
+ router = APIRouter(prefix="/jobs", tags=["jobs"])
36
+
37
+ # Initialize services
38
+ job_manager = JobManager()
39
+ worker_service = get_worker_service()
40
+ settings = get_settings()
41
+
42
+
43
+ async def _trigger_workers_parallel(job_id: str) -> None:
44
+ """
45
+ Trigger both audio and lyrics workers in parallel.
46
+
47
+ FastAPI's BackgroundTasks runs async tasks sequentially, so we use
48
+ asyncio.gather to ensure both workers start at the same time.
49
+ """
50
+ await asyncio.gather(
51
+ worker_service.trigger_audio_worker(job_id),
52
+ worker_service.trigger_lyrics_worker(job_id)
53
+ )
54
+
55
+
56
+ @router.post("", response_model=JobResponse)
57
+ async def create_job(
58
+ request: URLSubmissionRequest,
59
+ background_tasks: BackgroundTasks,
60
+ auth_result: AuthResult = Depends(require_auth)
61
+ ) -> JobResponse:
62
+ """
63
+ Create a new karaoke generation job from a URL.
64
+
65
+ This triggers the complete workflow:
66
+ 1. Job created in PENDING state
67
+ 2. Audio and lyrics workers triggered in parallel
68
+ 3. Both workers update job state as they progress
69
+ 4. When both complete, job transitions to AWAITING_REVIEW
70
+ """
71
+ try:
72
+ # Determine job owner email:
73
+ # All authentication methods must provide a user_email for job ownership
74
+ if auth_result.user_email:
75
+ # Use authenticated user's email (standard case)
76
+ user_email = auth_result.user_email
77
+ else:
78
+ # This should never happen - all auth methods now require user_email
79
+ logger.error("Authentication succeeded but no user_email provided")
80
+ raise HTTPException(
81
+ status_code=500,
82
+ detail="Authentication error: no user identity available"
83
+ )
84
+
85
+ # Admins can optionally create jobs on behalf of other users
86
+ if request.user_email and auth_result.is_admin and request.user_email != auth_result.user_email:
87
+ user_email = request.user_email
88
+ logger.info(f"Admin {auth_result.user_email} creating job on behalf of {user_email}")
89
+
90
+ # Apply YouTube upload default from settings
91
+ # Use explicit value if provided, otherwise fall back to server default
92
+ settings = get_settings()
93
+ effective_enable_youtube_upload = request.enable_youtube_upload if request.enable_youtube_upload is not None else settings.default_enable_youtube_upload
94
+
95
+ # Create job with all preferences
96
+ job_create = JobCreate(
97
+ url=str(request.url),
98
+ artist=request.artist,
99
+ title=request.title,
100
+ enable_cdg=request.enable_cdg,
101
+ enable_txt=request.enable_txt,
102
+ enable_youtube_upload=effective_enable_youtube_upload,
103
+ youtube_description=request.youtube_description,
104
+ webhook_url=request.webhook_url,
105
+ user_email=user_email
106
+ )
107
+ job = job_manager.create_job(job_create)
108
+
109
+ # Record job creation metric
110
+ metrics.record_job_created(job.job_id, source="url")
111
+
112
+ # Trigger both workers in parallel using asyncio.gather
113
+ # (FastAPI's BackgroundTasks runs async tasks sequentially)
114
+ background_tasks.add_task(_trigger_workers_parallel, job.job_id)
115
+
116
+ logger.info(f"Job {job.job_id} created, workers triggered")
117
+
118
+ return JobResponse(
119
+ status="success",
120
+ job_id=job.job_id,
121
+ message="Job created successfully. Processing started."
122
+ )
123
+ except Exception as e:
124
+ logger.error(f"Error creating job: {e}", exc_info=True)
125
+ raise HTTPException(status_code=500, detail=str(e))
126
+
127
+
128
+ # Worker triggering is now handled by WorkerService
129
+ # See backend/services/worker_service.py
130
+
131
+
132
+ @router.get("/{job_id}", response_model=Job)
133
+ async def get_job(
134
+ job_id: str,
135
+ auth_result: AuthResult = Depends(require_auth)
136
+ ) -> Job:
137
+ """Get job status and details."""
138
+ job = job_manager.get_job(job_id)
139
+ if not job:
140
+ raise HTTPException(status_code=404, detail="Job not found")
141
+
142
+ # Check ownership - users can only see their own jobs, admins can see all
143
+ if not _check_job_ownership(job, auth_result):
144
+ raise HTTPException(status_code=403, detail="You don't have permission to access this job")
145
+
146
+ # If job is complete, include download URLs
147
+ if job.status == JobStatus.COMPLETE:
148
+ job.download_urls = job_manager.get_output_urls(job_id)
149
+
150
+ return job
151
+
152
+
153
+ def _check_job_ownership(job: Job, auth_result: AuthResult) -> bool:
154
+ """
155
+ Check if the authenticated user owns the job or has admin access.
156
+
157
+ Returns:
158
+ True if user can access the job, False otherwise
159
+ """
160
+ # Admins can access all jobs
161
+ if auth_result.is_admin:
162
+ return True
163
+
164
+ # Check if user owns the job
165
+ if auth_result.user_email and job.user_email:
166
+ return auth_result.user_email.lower() == job.user_email.lower()
167
+
168
+ # If no user_email on auth (token auth without email), deny access to jobs with user_email
169
+ # This prevents token-based auth from accessing user jobs
170
+ if job.user_email:
171
+ return False
172
+
173
+ # Legacy jobs without user_email - allow access for backward compatibility
174
+ # TODO: Consider restricting this in the future
175
+ return True
176
+
177
+
178
+ @router.get("", response_model=List[Job])
179
+ async def list_jobs(
180
+ status: Optional[JobStatus] = None,
181
+ environment: Optional[str] = None,
182
+ client_id: Optional[str] = None,
183
+ created_after: Optional[str] = None,
184
+ created_before: Optional[str] = None,
185
+ limit: int = 100,
186
+ auth_result: AuthResult = Depends(require_auth)
187
+ ) -> List[Job]:
188
+ """
189
+ List jobs with optional filters.
190
+
191
+ Regular users only see their own jobs. Admins see all jobs.
192
+
193
+ Args:
194
+ status: Filter by job status (pending, complete, failed, etc.)
195
+ environment: Filter by request_metadata.environment (test/production/development)
196
+ client_id: Filter by request_metadata.client_id (customer identifier)
197
+ created_after: Filter jobs created after this ISO datetime (e.g., 2024-01-01T00:00:00Z)
198
+ created_before: Filter jobs created before this ISO datetime
199
+ limit: Maximum number of jobs to return (default 100)
200
+
201
+ Returns:
202
+ List of jobs matching filters, ordered by created_at descending
203
+ """
204
+ from datetime import datetime
205
+
206
+ try:
207
+ # Parse datetime strings if provided
208
+ created_after_dt = None
209
+ created_before_dt = None
210
+
211
+ if created_after:
212
+ try:
213
+ created_after_dt = datetime.fromisoformat(created_after.replace('Z', '+00:00'))
214
+ except ValueError as e:
215
+ raise HTTPException(status_code=400, detail=f"Invalid created_after format: {created_after}") from e
216
+
217
+ if created_before:
218
+ try:
219
+ created_before_dt = datetime.fromisoformat(created_before.replace('Z', '+00:00'))
220
+ except ValueError as e:
221
+ raise HTTPException(status_code=400, detail=f"Invalid created_before format: {created_before}") from e
222
+
223
+ # Determine user_email filter based on admin status
224
+ # Admins see all jobs, regular users only see their own
225
+ user_email_filter = None
226
+ if not auth_result.is_admin:
227
+ if auth_result.user_email:
228
+ user_email_filter = auth_result.user_email
229
+ logger.debug(f"Filtering jobs for user: {user_email_filter}")
230
+ else:
231
+ # Token-based auth without user email - show no jobs for security
232
+ logger.warning("Non-admin auth without user_email, returning empty job list")
233
+ return []
234
+
235
+ jobs = job_manager.list_jobs(
236
+ status=status,
237
+ environment=environment,
238
+ client_id=client_id,
239
+ created_after=created_after_dt,
240
+ created_before=created_before_dt,
241
+ user_email=user_email_filter,
242
+ limit=limit
243
+ )
244
+
245
+ logger.debug(f"Listed {len(jobs)} jobs for user={auth_result.user_email}, admin={auth_result.is_admin}")
246
+ return jobs
247
+ except HTTPException:
248
+ raise
249
+ except Exception as e:
250
+ logger.error(f"Error listing jobs: {e}", exc_info=True)
251
+ raise HTTPException(status_code=500, detail=str(e))
252
+
253
+
254
+ @router.delete("/{job_id}")
255
+ async def delete_job(
256
+ job_id: str,
257
+ delete_files: bool = True,
258
+ auth_result: AuthResult = Depends(require_auth)
259
+ ) -> dict:
260
+ """Delete a job and optionally its output files."""
261
+ try:
262
+ job = job_manager.get_job(job_id)
263
+ if not job:
264
+ raise HTTPException(status_code=404, detail="Job not found")
265
+
266
+ # Check ownership - users can only delete their own jobs
267
+ if not _check_job_ownership(job, auth_result):
268
+ raise HTTPException(status_code=403, detail="You don't have permission to delete this job")
269
+
270
+ job_manager.delete_job(job_id, delete_files=delete_files)
271
+
272
+ return {"status": "success", "message": f"Job {job_id} deleted"}
273
+ except HTTPException:
274
+ raise
275
+ except Exception as e:
276
+ logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
277
+ raise HTTPException(status_code=500, detail=str(e))
278
+
279
+
280
+ @router.delete("")
281
+ async def bulk_delete_jobs(
282
+ environment: Optional[str] = None,
283
+ client_id: Optional[str] = None,
284
+ status: Optional[JobStatus] = None,
285
+ created_before: Optional[str] = None,
286
+ delete_files: bool = True,
287
+ confirm: bool = False,
288
+ _auth_result: AuthResult = Depends(require_admin)
289
+ ) -> dict:
290
+ """
291
+ Delete multiple jobs matching filter criteria.
292
+
293
+ CAUTION: This is a destructive operation. Requires confirm=true.
294
+
295
+ Use cases:
296
+ - Delete all test jobs: ?environment=test&confirm=true
297
+ - Delete jobs from a specific client: ?client_id=test-runner&confirm=true
298
+ - Delete old failed jobs: ?status=failed&created_before=2024-01-01T00:00:00Z&confirm=true
299
+
300
+ Args:
301
+ environment: Delete jobs with this environment (test/production/development)
302
+ client_id: Delete jobs from this client
303
+ status: Delete jobs with this status
304
+ created_before: Delete jobs created before this ISO datetime
305
+ delete_files: Also delete GCS files (default True)
306
+ confirm: Must be True to execute deletion (safety check)
307
+
308
+ Returns:
309
+ Statistics about the deletion
310
+ """
311
+ from datetime import datetime
312
+
313
+ # Require at least one filter to prevent accidental deletion of all jobs
314
+ if not any([environment, client_id, status, created_before]):
315
+ raise HTTPException(
316
+ status_code=400,
317
+ detail="At least one filter (environment, client_id, status, created_before) is required"
318
+ )
319
+
320
+ # Require explicit confirmation
321
+ if not confirm:
322
+ # Return preview of what would be deleted
323
+ created_before_dt = None
324
+ if created_before:
325
+ try:
326
+ created_before_dt = datetime.fromisoformat(created_before.replace('Z', '+00:00'))
327
+ except ValueError:
328
+ raise HTTPException(status_code=400, detail=f"Invalid created_before format: {created_before}")
329
+
330
+ jobs = job_manager.list_jobs(
331
+ status=status,
332
+ environment=environment,
333
+ client_id=client_id,
334
+ created_before=created_before_dt,
335
+ limit=1000
336
+ )
337
+
338
+ return {
339
+ "status": "preview",
340
+ "message": "Add &confirm=true to execute deletion",
341
+ "jobs_to_delete": len(jobs),
342
+ "sample_jobs": [
343
+ {
344
+ "job_id": j.job_id,
345
+ "artist": j.artist,
346
+ "title": j.title,
347
+ "status": j.status,
348
+ "environment": j.request_metadata.get('environment'),
349
+ "client_id": j.request_metadata.get('client_id'),
350
+ "created_at": j.created_at.isoformat() if j.created_at else None
351
+ }
352
+ for j in jobs[:10] # Show first 10 as sample
353
+ ]
354
+ }
355
+
356
+ try:
357
+ # Parse datetime string
358
+ created_before_dt = None
359
+ if created_before:
360
+ try:
361
+ created_before_dt = datetime.fromisoformat(created_before.replace('Z', '+00:00'))
362
+ except ValueError:
363
+ raise HTTPException(status_code=400, detail=f"Invalid created_before format: {created_before}")
364
+
365
+ result = job_manager.delete_jobs_by_filter(
366
+ environment=environment,
367
+ client_id=client_id,
368
+ status=status,
369
+ created_before=created_before_dt,
370
+ delete_files=delete_files
371
+ )
372
+
373
+ return {
374
+ "status": "success",
375
+ "message": f"Deleted {result['jobs_deleted']} jobs",
376
+ "jobs_deleted": result['jobs_deleted'],
377
+ "files_deleted": result.get('files_deleted', 0)
378
+ }
379
+
380
+ except Exception as e:
381
+ logger.error(f"Error bulk deleting jobs: {e}", exc_info=True)
382
+ raise HTTPException(status_code=500, detail=str(e))
383
+
384
+
385
+ # ============================================================================
386
+ # Human-in-the-Loop Interaction Endpoints
387
+ # ============================================================================
388
+
389
+ @router.get("/{job_id}/review-data")
390
+ async def get_review_data(
391
+ job_id: str,
392
+ auth_result: AuthResult = Depends(require_auth)
393
+ ) -> Dict[str, Any]:
394
+ """
395
+ Get data needed for lyrics review interface.
396
+
397
+ Returns corrections JSON URL and audio URL.
398
+ Frontend loads these to render the review UI.
399
+ """
400
+ job = job_manager.get_job(job_id)
401
+ if not job:
402
+ raise HTTPException(status_code=404, detail="Job not found")
403
+
404
+ # Check ownership
405
+ if not _check_job_ownership(job, auth_result):
406
+ raise HTTPException(status_code=403, detail="You don't have permission to access this job")
407
+
408
+ if job.status not in [JobStatus.AWAITING_REVIEW, JobStatus.IN_REVIEW]:
409
+ raise HTTPException(
410
+ status_code=400,
411
+ detail=f"Job not ready for review (current status: {job.status})"
412
+ )
413
+
414
+ # Get URLs from file_urls
415
+ corrections_url = job.file_urls.get('lyrics', {}).get('corrections')
416
+
417
+ # For audio, try multiple sources in order of preference:
418
+ # 1. Explicit lyrics audio (if worker uploaded it)
419
+ # 2. Lead vocals stem (best for reviewing lyrics sync)
420
+ # 3. Input media (original audio)
421
+ audio_url = (
422
+ job.file_urls.get('lyrics', {}).get('audio') or
423
+ job.file_urls.get('stems', {}).get('lead_vocals') or
424
+ job.input_media_gcs_path
425
+ )
426
+
427
+ if not corrections_url:
428
+ raise HTTPException(
429
+ status_code=500,
430
+ detail="Corrections data not available"
431
+ )
432
+
433
+ if not audio_url:
434
+ raise HTTPException(
435
+ status_code=500,
436
+ detail="Audio not available for review"
437
+ )
438
+
439
+ # Generate signed URLs for direct access
440
+ from backend.services.storage_service import StorageService
441
+ storage = StorageService()
442
+
443
+ return {
444
+ "corrections_url": storage.generate_signed_url(corrections_url, expiration_minutes=120),
445
+ "audio_url": storage.generate_signed_url(audio_url, expiration_minutes=120),
446
+ "status": job.status,
447
+ "artist": job.artist,
448
+ "title": job.title
449
+ }
450
+
451
+
452
+ @router.post("/{job_id}/start-review")
453
+ async def start_review(
454
+ job_id: str,
455
+ request: StartReviewRequest,
456
+ auth_result: AuthResult = Depends(require_auth)
457
+ ) -> dict:
458
+ """
459
+ Mark job as IN_REVIEW (user opened review interface).
460
+
461
+ This helps track that the user is actively working on the review.
462
+ """
463
+ job = job_manager.get_job(job_id)
464
+ if not job:
465
+ raise HTTPException(status_code=404, detail="Job not found")
466
+
467
+ # Check ownership
468
+ if not _check_job_ownership(job, auth_result):
469
+ raise HTTPException(status_code=403, detail="You don't have permission to access this job")
470
+
471
+ success = job_manager.transition_to_state(
472
+ job_id=job_id,
473
+ new_status=JobStatus.IN_REVIEW,
474
+ message="User started reviewing lyrics"
475
+ )
476
+
477
+ if not success:
478
+ raise HTTPException(status_code=400, detail="Cannot start review")
479
+
480
+ return {"status": "success", "job_status": "in_review"}
481
+
482
+
483
+ @router.post("/{job_id}/corrections")
484
+ async def submit_corrections(
485
+ job_id: str,
486
+ submission: CorrectionsSubmission,
487
+ background_tasks: BackgroundTasks,
488
+ auth_result: AuthResult = Depends(require_auth)
489
+ ) -> dict:
490
+ """
491
+ Save corrected lyrics during human review.
492
+
493
+ This endpoint saves review progress but does NOT complete the review.
494
+ Call POST /{job_id}/complete-review to finish and trigger video rendering.
495
+
496
+ Can be called multiple times to save progress.
497
+ """
498
+ job = job_manager.get_job(job_id)
499
+ if not job:
500
+ raise HTTPException(status_code=404, detail="Job not found")
501
+
502
+ # Check ownership
503
+ if not _check_job_ownership(job, auth_result):
504
+ raise HTTPException(status_code=403, detail="You don't have permission to modify this job")
505
+
506
+ if job.status not in [JobStatus.AWAITING_REVIEW, JobStatus.IN_REVIEW]:
507
+ raise HTTPException(
508
+ status_code=400,
509
+ detail=f"Job not in review state (current status: {job.status})"
510
+ )
511
+
512
+ try:
513
+ # Store corrected lyrics in state_data
514
+ job_manager.update_state_data(job_id, 'corrected_lyrics', submission.corrections)
515
+ if submission.user_notes:
516
+ job_manager.update_state_data(job_id, 'review_notes', submission.user_notes)
517
+
518
+ # Transition to IN_REVIEW if not already
519
+ if job.status == JobStatus.AWAITING_REVIEW:
520
+ job_manager.transition_to_state(
521
+ job_id=job_id,
522
+ new_status=JobStatus.IN_REVIEW,
523
+ message="User is reviewing lyrics"
524
+ )
525
+
526
+ # Save updated corrections to GCS for the render worker
527
+ from backend.services.storage_service import StorageService
528
+ storage = StorageService()
529
+
530
+ corrections_gcs_path = f"jobs/{job_id}/lyrics/corrections_updated.json"
531
+ storage.upload_json(corrections_gcs_path, submission.corrections)
532
+ job_manager.update_file_url(job_id, 'lyrics', 'corrections_updated', corrections_gcs_path)
533
+
534
+ logger.info(f"Job {job_id}: Corrections saved (review in progress)")
535
+
536
+ return {
537
+ "status": "success",
538
+ "job_status": "in_review",
539
+ "message": "Corrections saved. Call /complete-review when done."
540
+ }
541
+
542
+ except Exception as e:
543
+ logger.error(f"Error saving corrections for job {job_id}: {e}", exc_info=True)
544
+ raise HTTPException(status_code=500, detail=str(e))
545
+
546
+
547
+ @router.post("/{job_id}/complete-review")
548
+ async def complete_review(
549
+ job_id: str,
550
+ background_tasks: BackgroundTasks,
551
+ auth_result: AuthResult = Depends(require_auth)
552
+ ) -> dict:
553
+ """
554
+ Complete the human review and trigger video rendering.
555
+
556
+ This is the FIRST critical human-in-the-loop completion point.
557
+ After this:
558
+ 1. Job transitions to REVIEW_COMPLETE
559
+ 2. Render video worker is triggered
560
+ 3. Worker uses OutputGenerator to create with_vocals.mkv
561
+ 4. Job transitions to AWAITING_INSTRUMENTAL_SELECTION
562
+ """
563
+ job = job_manager.get_job(job_id)
564
+ if not job:
565
+ raise HTTPException(status_code=404, detail="Job not found")
566
+
567
+ # Check ownership
568
+ if not _check_job_ownership(job, auth_result):
569
+ raise HTTPException(status_code=403, detail="You don't have permission to modify this job")
570
+
571
+ if job.status not in [JobStatus.AWAITING_REVIEW, JobStatus.IN_REVIEW]:
572
+ raise HTTPException(
573
+ status_code=400,
574
+ detail=f"Job not in review state (current status: {job.status})"
575
+ )
576
+
577
+ try:
578
+ # Transition to REVIEW_COMPLETE
579
+ job_manager.transition_to_state(
580
+ job_id=job_id,
581
+ new_status=JobStatus.REVIEW_COMPLETE,
582
+ progress=70,
583
+ message="Review complete, rendering video with corrected lyrics"
584
+ )
585
+
586
+ # Trigger render video worker
587
+ background_tasks.add_task(worker_service.trigger_render_video_worker, job_id)
588
+
589
+ logger.info(f"Job {job_id}: Review complete, triggering render video worker")
590
+
591
+ return {
592
+ "status": "success",
593
+ "job_status": "review_complete",
594
+ "message": "Review complete. Video rendering started."
595
+ }
596
+
597
+ except Exception as e:
598
+ logger.error(f"Error completing review for job {job_id}: {e}", exc_info=True)
599
+ raise HTTPException(status_code=500, detail=str(e))
600
+
601
+
602
+ @router.get("/{job_id}/instrumental-options")
603
+ async def get_instrumental_options(
604
+ job_id: str,
605
+ auth_info: Tuple[str, str] = Depends(require_instrumental_auth)
606
+ ) -> Dict[str, Any]:
607
+ """
608
+ Get instrumental audio options for user selection.
609
+
610
+ Returns signed URLs for both options:
611
+ 1. Clean instrumental (no backing vocals)
612
+ 2. Instrumental with backing vocals
613
+
614
+ Accepts either full auth token or job-specific instrumental_token.
615
+ """
616
+ job = job_manager.get_job(job_id)
617
+ if not job:
618
+ raise HTTPException(status_code=404, detail="Job not found")
619
+
620
+ if job.status != JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
621
+ raise HTTPException(
622
+ status_code=400,
623
+ detail=f"Job not ready for instrumental selection (current status: {job.status})"
624
+ )
625
+
626
+ # Get stem URLs
627
+ stems = job.file_urls.get('stems', {})
628
+ clean_url = stems.get('instrumental_clean')
629
+ backing_url = stems.get('instrumental_with_backing')
630
+
631
+ if not clean_url or not backing_url:
632
+ raise HTTPException(
633
+ status_code=500,
634
+ detail="Instrumental options not available"
635
+ )
636
+
637
+ # Generate signed URLs
638
+ from backend.services.storage_service import StorageService
639
+ storage = StorageService()
640
+
641
+ return {
642
+ "options": [
643
+ {
644
+ "id": "clean",
645
+ "label": "Clean Instrumental (no backing vocals)",
646
+ "audio_url": storage.generate_signed_url(clean_url, expiration_minutes=120),
647
+ "duration_seconds": None # TODO: Extract from audio file
648
+ },
649
+ {
650
+ "id": "with_backing",
651
+ "label": "Instrumental with Backing Vocals",
652
+ "audio_url": storage.generate_signed_url(backing_url, expiration_minutes=120),
653
+ "duration_seconds": None # TODO: Extract from audio file
654
+ }
655
+ ],
656
+ "status": job.status,
657
+ "artist": job.artist,
658
+ "title": job.title
659
+ }
660
+
661
+
662
+ @router.get("/{job_id}/instrumental-analysis")
663
+ async def get_instrumental_analysis(
664
+ job_id: str,
665
+ auth_info: Tuple[str, str] = Depends(require_instrumental_auth)
666
+ ) -> Dict[str, Any]:
667
+ """
668
+ Get audio analysis data for instrumental selection.
669
+
670
+ Returns:
671
+ - Analysis of backing vocals (audible segments, recommendation)
672
+ - Waveform image URL
673
+ - Audio stream URLs for playback
674
+
675
+ This endpoint enables intelligent instrumental selection by providing
676
+ detailed analysis of the backing vocals track.
677
+
678
+ Accepts either full auth token or job-specific instrumental_token.
679
+ """
680
+ job = job_manager.get_job(job_id)
681
+ if not job:
682
+ raise HTTPException(status_code=404, detail="Job not found")
683
+
684
+ if job.status != JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
685
+ raise HTTPException(
686
+ status_code=400,
687
+ detail=f"Job not ready for instrumental analysis (current status: {job.status})"
688
+ )
689
+
690
+ storage = StorageService()
691
+
692
+ # Get analysis from state_data (populated by render_video_worker)
693
+ analysis_data = job.state_data.get('backing_vocals_analysis', {})
694
+
695
+ # Get URLs
696
+ stems = job.file_urls.get('stems', {})
697
+ analysis_files = job.file_urls.get('analysis', {})
698
+
699
+ clean_url = stems.get('instrumental_clean')
700
+ backing_vocals_url = stems.get('backing_vocals')
701
+ with_backing_url = stems.get('instrumental_with_backing')
702
+ waveform_url = analysis_files.get('backing_vocals_waveform')
703
+ custom_instrumental_url = stems.get('instrumental_custom')
704
+
705
+ # Build response
706
+ response = {
707
+ "job_id": job_id,
708
+ "artist": job.artist,
709
+ "title": job.title,
710
+ "status": job.status,
711
+
712
+ # Analysis results
713
+ "analysis": {
714
+ "has_audible_content": analysis_data.get('has_audible_content', False),
715
+ "total_duration_seconds": analysis_data.get('total_duration_seconds', 0),
716
+ "audible_segments": analysis_data.get('audible_segments', []),
717
+ "recommended_selection": analysis_data.get('recommended_selection', 'review_needed'),
718
+ "total_audible_duration_seconds": analysis_data.get('total_audible_duration_seconds', 0),
719
+ "audible_percentage": analysis_data.get('audible_percentage', 0),
720
+ "silence_threshold_db": analysis_data.get('silence_threshold_db', -40.0),
721
+ },
722
+
723
+ # Audio URLs for playback
724
+ "audio_urls": {
725
+ "clean_instrumental": storage.generate_signed_url(clean_url, expiration_minutes=120) if clean_url else None,
726
+ "backing_vocals": storage.generate_signed_url(backing_vocals_url, expiration_minutes=120) if backing_vocals_url else None,
727
+ "with_backing": storage.generate_signed_url(with_backing_url, expiration_minutes=120) if with_backing_url else None,
728
+ "custom_instrumental": storage.generate_signed_url(custom_instrumental_url, expiration_minutes=120) if custom_instrumental_url else None,
729
+ },
730
+
731
+ # Waveform image URL
732
+ "waveform_url": storage.generate_signed_url(waveform_url, expiration_minutes=120) if waveform_url else None,
733
+
734
+ # Whether a custom instrumental has been created
735
+ "has_custom_instrumental": custom_instrumental_url is not None,
736
+ }
737
+
738
+ return response
739
+
740
+
741
+ @router.get("/{job_id}/audio-stream/{stem_type}")
742
+ async def stream_audio(
743
+ job_id: str,
744
+ stem_type: str,
745
+ auth_info: Tuple[str, str] = Depends(require_instrumental_auth)
746
+ ):
747
+ """
748
+ Stream an audio file for playback in the browser.
749
+
750
+ Supported stem_type values:
751
+ - clean_instrumental: Clean instrumental (no backing vocals)
752
+ - backing_vocals: Backing vocals only
753
+ - with_backing: Instrumental with backing vocals
754
+ - custom_instrumental: Custom instrumental (if created)
755
+
756
+ Returns audio as a streaming response with proper headers for
757
+ browser audio playback.
758
+
759
+ Accepts either full auth token or job-specific instrumental_token.
760
+ """
761
+ from fastapi.responses import StreamingResponse
762
+ import tempfile
763
+
764
+ job = job_manager.get_job(job_id)
765
+ if not job:
766
+ raise HTTPException(status_code=404, detail="Job not found")
767
+
768
+ # Map stem_type to file_urls key
769
+ stem_map = {
770
+ 'clean_instrumental': ('stems', 'instrumental_clean'),
771
+ 'backing_vocals': ('stems', 'backing_vocals'),
772
+ 'with_backing': ('stems', 'instrumental_with_backing'),
773
+ 'custom_instrumental': ('stems', 'instrumental_custom'),
774
+ }
775
+
776
+ if stem_type not in stem_map:
777
+ raise HTTPException(
778
+ status_code=400,
779
+ detail=f"Invalid stem_type. Must be one of: {list(stem_map.keys())}"
780
+ )
781
+
782
+ category, key = stem_map[stem_type]
783
+ gcs_path = job.file_urls.get(category, {}).get(key)
784
+
785
+ if not gcs_path:
786
+ raise HTTPException(
787
+ status_code=404,
788
+ detail=f"Audio file not available: {stem_type}"
789
+ )
790
+
791
+ # Determine content type
792
+ ext = gcs_path.split('.')[-1].lower()
793
+ content_types = {
794
+ 'flac': 'audio/flac',
795
+ 'mp3': 'audio/mpeg',
796
+ 'wav': 'audio/wav',
797
+ 'm4a': 'audio/mp4',
798
+ }
799
+ content_type = content_types.get(ext, 'audio/flac')
800
+
801
+ try:
802
+ storage = StorageService()
803
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}') as tmp:
804
+ tmp_path = tmp.name
805
+
806
+ storage.download_file(gcs_path, tmp_path)
807
+
808
+ def file_iterator():
809
+ try:
810
+ with open(tmp_path, 'rb') as f:
811
+ while chunk := f.read(8192):
812
+ yield chunk
813
+ finally:
814
+ import os
815
+ os.unlink(tmp_path)
816
+
817
+ filename = gcs_path.split('/')[-1]
818
+
819
+ return StreamingResponse(
820
+ file_iterator(),
821
+ media_type=content_type,
822
+ headers={
823
+ 'Content-Disposition': f'inline; filename="{filename}"',
824
+ 'Accept-Ranges': 'bytes',
825
+ }
826
+ )
827
+ except Exception as e:
828
+ logger.exception(f"Error streaming audio {gcs_path}: {e}")
829
+ raise HTTPException(status_code=500, detail=f"Error streaming audio: {e}") from e
830
+
831
+
832
+ @router.post("/{job_id}/create-custom-instrumental")
833
+ async def create_custom_instrumental(
834
+ job_id: str,
835
+ request: CreateCustomInstrumentalRequest,
836
+ auth_info: Tuple[str, str] = Depends(require_instrumental_auth)
837
+ ) -> Dict[str, Any]:
838
+ """
839
+ Create a custom instrumental by muting regions of backing vocals.
840
+
841
+ This endpoint:
842
+ 1. Takes a list of time ranges to mute in the backing vocals
843
+ 2. Creates a custom instrumental (clean + muted backing vocals)
844
+ 3. Stores the result and makes it available for selection
845
+
846
+ After calling this endpoint, the user can select "custom" in the
847
+ select-instrumental endpoint.
848
+
849
+ Accepts either full auth token or job-specific instrumental_token.
850
+ """
851
+ from backend.services.audio_editing_service import AudioEditingService
852
+ from karaoke_gen.instrumental_review import MuteRegion
853
+
854
+ job = job_manager.get_job(job_id)
855
+ if not job:
856
+ raise HTTPException(status_code=404, detail="Job not found")
857
+
858
+ if job.status != JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
859
+ raise HTTPException(
860
+ status_code=400,
861
+ detail=f"Job not ready for custom instrumental creation (current status: {job.status})"
862
+ )
863
+
864
+ # Get stem paths
865
+ stems = job.file_urls.get('stems', {})
866
+ clean_path = stems.get('instrumental_clean')
867
+ backing_path = stems.get('backing_vocals')
868
+
869
+ if not clean_path or not backing_path:
870
+ raise HTTPException(
871
+ status_code=500,
872
+ detail="Required audio files not available"
873
+ )
874
+
875
+ try:
876
+ # Convert request mute regions to model objects
877
+ mute_regions = [
878
+ MuteRegion(
879
+ start_seconds=r.start_seconds,
880
+ end_seconds=r.end_seconds
881
+ )
882
+ for r in request.mute_regions
883
+ ]
884
+
885
+ # Create custom instrumental
886
+ editing_service = AudioEditingService()
887
+
888
+ # Determine output path
889
+ output_path = f"jobs/{job_id}/stems/instrumental_custom.flac"
890
+
891
+ result = editing_service.create_custom_instrumental(
892
+ gcs_clean_instrumental_path=clean_path,
893
+ gcs_backing_vocals_path=backing_path,
894
+ mute_regions=mute_regions,
895
+ gcs_output_path=output_path,
896
+ job_id=job_id,
897
+ )
898
+
899
+ # Update job file_urls with custom instrumental
900
+ job_manager.update_file_url(job_id, 'stems', 'instrumental_custom', output_path)
901
+
902
+ # Store mute regions in state_data for reference
903
+ job_manager.update_state_data(job_id, 'custom_instrumental_mute_regions', [
904
+ {"start_seconds": r.start_seconds, "end_seconds": r.end_seconds}
905
+ for r in mute_regions
906
+ ])
907
+
908
+ logger.info(f"Job {job_id}: Custom instrumental created with {len(mute_regions)} mute regions")
909
+
910
+ # Generate signed URL for the new file
911
+ storage = StorageService()
912
+
913
+ return {
914
+ "status": "success",
915
+ "message": "Custom instrumental created successfully",
916
+ "custom_instrumental_url": storage.generate_signed_url(output_path, expiration_minutes=120),
917
+ "mute_regions_applied": len(result.mute_regions_applied),
918
+ "total_muted_duration_seconds": result.total_muted_duration_seconds,
919
+ "output_duration_seconds": result.output_duration_seconds,
920
+ }
921
+
922
+ except Exception as e:
923
+ logger.exception(f"Error creating custom instrumental for job {job_id}: {e}")
924
+ raise HTTPException(status_code=500, detail=str(e)) from e
925
+
926
+
927
+ @router.get("/{job_id}/waveform-data")
928
+ async def get_waveform_data(
929
+ job_id: str,
930
+ num_points: int = 500,
931
+ auth_info: Tuple[str, str] = Depends(require_instrumental_auth)
932
+ ) -> Dict[str, Any]:
933
+ """
934
+ Get waveform amplitude data for client-side rendering.
935
+
936
+ This endpoint returns raw amplitude data that the frontend can use
937
+ to render a waveform using Canvas or SVG, enabling interactive
938
+ features like click-to-seek.
939
+
940
+ Args:
941
+ num_points: Number of data points to return (default 500)
942
+
943
+ Returns:
944
+ {
945
+ "amplitudes": [0.1, 0.2, ...], # Normalized 0-1 values
946
+ "duration_seconds": 180.5,
947
+ "num_points": 500
948
+ }
949
+
950
+ Accepts either full auth token or job-specific instrumental_token.
951
+ """
952
+ from backend.services.audio_analysis_service import AudioAnalysisService
953
+
954
+ job = job_manager.get_job(job_id)
955
+ if not job:
956
+ raise HTTPException(status_code=404, detail="Job not found")
957
+
958
+ if job.status != JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
959
+ raise HTTPException(
960
+ status_code=400,
961
+ detail=f"Job not ready for waveform data (current status: {job.status})"
962
+ )
963
+
964
+ backing_vocals_path = job.file_urls.get('stems', {}).get('backing_vocals')
965
+ if not backing_vocals_path:
966
+ raise HTTPException(status_code=404, detail="Backing vocals file not found")
967
+
968
+ try:
969
+ analysis_service = AudioAnalysisService()
970
+ amplitudes, duration = analysis_service.get_waveform_data(
971
+ gcs_audio_path=backing_vocals_path,
972
+ job_id=job_id,
973
+ num_points=num_points,
974
+ )
975
+
976
+ return {
977
+ "amplitudes": amplitudes,
978
+ "duration_seconds": duration,
979
+ "num_points": len(amplitudes),
980
+ }
981
+
982
+ except Exception as e:
983
+ logger.exception(f"Error getting waveform data for job {job_id}: {e}")
984
+ raise HTTPException(status_code=500, detail=str(e)) from e
985
+
986
+
987
+ @router.post("/{job_id}/select-instrumental")
988
+ async def select_instrumental(
989
+ job_id: str,
990
+ selection: InstrumentalSelection,
991
+ background_tasks: BackgroundTasks,
992
+ auth_info: Tuple[str, str] = Depends(require_instrumental_auth)
993
+ ) -> dict:
994
+ """
995
+ Submit instrumental selection.
996
+
997
+ This is the SECOND critical human-in-the-loop interaction point.
998
+ After selection, the job proceeds to video generation.
999
+
1000
+ Accepts either full auth token or job-specific instrumental_token.
1001
+ """
1002
+ job = job_manager.get_job(job_id)
1003
+ if not job:
1004
+ raise HTTPException(status_code=404, detail="Job not found")
1005
+
1006
+ if job.status != JobStatus.AWAITING_INSTRUMENTAL_SELECTION:
1007
+ raise HTTPException(
1008
+ status_code=400,
1009
+ detail=f"Job not ready for instrumental selection (current status: {job.status})"
1010
+ )
1011
+
1012
+ try:
1013
+ # Store selection in state_data
1014
+ job_manager.update_state_data(job_id, 'instrumental_selection', selection.selection)
1015
+
1016
+ # Transition to INSTRUMENTAL_SELECTED
1017
+ job_manager.transition_to_state(
1018
+ job_id=job_id,
1019
+ new_status=JobStatus.INSTRUMENTAL_SELECTED,
1020
+ progress=65,
1021
+ message=f"Instrumental selected: {selection.selection}"
1022
+ )
1023
+
1024
+ # Trigger video generation worker
1025
+ background_tasks.add_task(worker_service.trigger_video_worker, job_id)
1026
+
1027
+ logger.info(f"Job {job_id}: Instrumental selected ({selection.selection}), triggering video generation")
1028
+
1029
+ return {
1030
+ "status": "success",
1031
+ "job_status": "instrumental_selected",
1032
+ "selection": selection.selection,
1033
+ "message": "Selection accepted, starting video generation"
1034
+ }
1035
+
1036
+ except Exception as e:
1037
+ logger.error(f"Error selecting instrumental for job {job_id}: {e}", exc_info=True)
1038
+ raise HTTPException(status_code=500, detail=str(e))
1039
+
1040
+
1041
+ @router.get("/{job_id}/download-urls")
1042
+ async def get_download_urls(
1043
+ job_id: str,
1044
+ auth_result: AuthResult = Depends(require_auth)
1045
+ ) -> dict:
1046
+ """
1047
+ Get download URLs for all job output files.
1048
+
1049
+ Returns a dictionary mapping file types to download URLs.
1050
+ Uses the streaming download endpoint which proxies through the backend.
1051
+ """
1052
+ job = job_manager.get_job(job_id)
1053
+ if not job:
1054
+ raise HTTPException(status_code=404, detail="Job not found")
1055
+
1056
+ # Check ownership
1057
+ if not _check_job_ownership(job, auth_result):
1058
+ raise HTTPException(status_code=403, detail="You don't have permission to access this job")
1059
+
1060
+ if job.status != JobStatus.COMPLETE:
1061
+ raise HTTPException(
1062
+ status_code=400,
1063
+ detail=f"Job not complete (current status: {job.status})"
1064
+ )
1065
+
1066
+ file_urls = job.file_urls or {}
1067
+ download_urls = {}
1068
+
1069
+ # Build download URLs using the streaming endpoint
1070
+ base_url = f"/api/jobs/{job_id}/download"
1071
+
1072
+ for category, files in file_urls.items():
1073
+ if isinstance(files, dict):
1074
+ download_urls[category] = {}
1075
+ for file_key, gcs_path in files.items():
1076
+ if gcs_path:
1077
+ download_urls[category][file_key] = f"{base_url}/{category}/{file_key}"
1078
+ elif isinstance(files, str) and files:
1079
+ # For single-file categories, use the category name as the file_key
1080
+ download_urls[category] = f"{base_url}/{category}/{category}"
1081
+
1082
+ return {
1083
+ "job_id": job_id,
1084
+ "artist": job.artist,
1085
+ "title": job.title,
1086
+ "download_urls": download_urls
1087
+ }
1088
+
1089
+
1090
+ # Map file keys to human-readable suffixes (matching Dropbox naming from karaoke_finalise.py)
1091
+ DOWNLOAD_FILENAME_SUFFIXES = {
1092
+ "lossless_4k_mp4": " (Final Karaoke Lossless 4k).mp4",
1093
+ "lossless_4k_mkv": " (Final Karaoke Lossless 4k).mkv",
1094
+ "lossy_4k_mp4": " (Final Karaoke Lossy 4k).mp4",
1095
+ "lossy_720p_mp4": " (Final Karaoke Lossy 720p).mp4",
1096
+ "cdg_zip": " (Final Karaoke CDG).zip",
1097
+ "txt_zip": " (Final Karaoke TXT).zip",
1098
+ "with_vocals": " (With Vocals).mkv",
1099
+ }
1100
+
1101
+
1102
+ @router.get("/{job_id}/download/{category}/{file_key}")
1103
+ async def download_file(
1104
+ job_id: str,
1105
+ category: str,
1106
+ file_key: str,
1107
+ auth_result: AuthResult = Depends(require_auth)
1108
+ ):
1109
+ """
1110
+ Stream download a specific file from a completed job.
1111
+
1112
+ This endpoint proxies the file from GCS through the backend,
1113
+ so no client-side authentication is required.
1114
+ """
1115
+ from fastapi.responses import StreamingResponse
1116
+ import tempfile
1117
+
1118
+ job = job_manager.get_job(job_id)
1119
+ if not job:
1120
+ raise HTTPException(status_code=404, detail="Job not found")
1121
+
1122
+ # Check ownership
1123
+ if not _check_job_ownership(job, auth_result):
1124
+ raise HTTPException(status_code=403, detail="You don't have permission to access this job")
1125
+
1126
+ if job.status != JobStatus.COMPLETE:
1127
+ raise HTTPException(
1128
+ status_code=400,
1129
+ detail=f"Job not complete (current status: {job.status})"
1130
+ )
1131
+
1132
+ file_urls = job.file_urls or {}
1133
+ category_files = file_urls.get(category)
1134
+
1135
+ if not category_files:
1136
+ raise HTTPException(status_code=404, detail=f"Category '{category}' not found")
1137
+
1138
+ if isinstance(category_files, dict):
1139
+ gcs_path = category_files.get(file_key)
1140
+ else:
1141
+ gcs_path = category_files if file_key == category else None
1142
+
1143
+ if not gcs_path:
1144
+ raise HTTPException(status_code=404, detail=f"File '{file_key}' not found in '{category}'")
1145
+
1146
+ # Determine content type based on file extension
1147
+ ext = gcs_path.split('.')[-1].lower()
1148
+ content_types = {
1149
+ 'mp4': 'video/mp4',
1150
+ 'mkv': 'video/x-matroska',
1151
+ 'mov': 'video/quicktime',
1152
+ 'flac': 'audio/flac',
1153
+ 'mp3': 'audio/mpeg',
1154
+ 'wav': 'audio/wav',
1155
+ 'ass': 'text/plain',
1156
+ 'lrc': 'text/plain',
1157
+ 'txt': 'text/plain',
1158
+ 'json': 'application/json',
1159
+ 'zip': 'application/zip',
1160
+ 'png': 'image/png',
1161
+ 'jpg': 'image/jpeg',
1162
+ 'jpeg': 'image/jpeg',
1163
+ }
1164
+ content_type = content_types.get(ext, 'application/octet-stream')
1165
+
1166
+ # Build proper filename: "Artist - Title (Final Karaoke Lossy 4k).mp4"
1167
+ # Use sanitize_filename to handle Unicode characters (curly quotes, em dashes, etc.)
1168
+ # that cause HTTP header encoding issues (Content-Disposition uses latin-1)
1169
+ from karaoke_gen.utils import sanitize_filename
1170
+ artist_clean = sanitize_filename(job.artist) if job.artist else None
1171
+ title_clean = sanitize_filename(job.title) if job.title else None
1172
+ base_name = f"{artist_clean} - {title_clean}" if artist_clean and title_clean else None
1173
+
1174
+ if base_name and file_key in DOWNLOAD_FILENAME_SUFFIXES:
1175
+ filename = f"{base_name}{DOWNLOAD_FILENAME_SUFFIXES[file_key]}"
1176
+ else:
1177
+ filename = gcs_path.split('/')[-1] # Fallback to original
1178
+
1179
+ try:
1180
+ # Download to temp file and stream
1181
+ storage = StorageService()
1182
+ with tempfile.NamedTemporaryFile(delete=False, suffix=f'.{ext}') as tmp:
1183
+ tmp_path = tmp.name
1184
+
1185
+ storage.download_file(gcs_path, tmp_path)
1186
+
1187
+ def file_iterator():
1188
+ try:
1189
+ with open(tmp_path, 'rb') as f:
1190
+ while chunk := f.read(8192):
1191
+ yield chunk
1192
+ finally:
1193
+ import os
1194
+ os.unlink(tmp_path)
1195
+
1196
+ return StreamingResponse(
1197
+ file_iterator(),
1198
+ media_type=content_type,
1199
+ headers={
1200
+ 'Content-Disposition': f'attachment; filename="{filename}"'
1201
+ }
1202
+ )
1203
+ except Exception as e:
1204
+ logger.error(f"Error downloading {gcs_path}: {e}")
1205
+ raise HTTPException(status_code=500, detail=f"Error downloading file: {e}")
1206
+
1207
+
1208
+ @router.post("/{job_id}/cancel")
1209
+ async def cancel_job(
1210
+ job_id: str,
1211
+ request: CancelJobRequest,
1212
+ auth_result: AuthResult = Depends(require_auth)
1213
+ ) -> dict:
1214
+ """
1215
+ Cancel a job.
1216
+
1217
+ Jobs can be cancelled at any stage before completion.
1218
+ """
1219
+ job = job_manager.get_job(job_id)
1220
+ if not job:
1221
+ raise HTTPException(status_code=404, detail="Job not found")
1222
+
1223
+ # Check ownership
1224
+ if not _check_job_ownership(job, auth_result):
1225
+ raise HTTPException(status_code=403, detail="You don't have permission to cancel this job")
1226
+
1227
+ success = job_manager.cancel_job(job_id, reason=request.reason)
1228
+
1229
+ if not success:
1230
+ raise HTTPException(status_code=400, detail="Cannot cancel job")
1231
+
1232
+ return {
1233
+ "status": "success",
1234
+ "job_status": "cancelled",
1235
+ "message": "Job cancelled successfully"
1236
+ }
1237
+
1238
+
1239
+ @router.post("/{job_id}/retry")
1240
+ async def retry_job(
1241
+ job_id: str,
1242
+ background_tasks: BackgroundTasks,
1243
+ auth_result: AuthResult = Depends(require_auth)
1244
+ ) -> dict:
1245
+ """
1246
+ Retry a failed or cancelled job from the last successful checkpoint.
1247
+
1248
+ This endpoint allows resuming jobs that failed or were cancelled during:
1249
+ - Audio processing (re-runs from beginning if input audio exists)
1250
+ - Video generation (re-runs video worker)
1251
+ - Encoding (re-runs video worker)
1252
+ - Packaging (re-runs video worker)
1253
+
1254
+ The retry logic determines the appropriate stage to resume from
1255
+ based on what files/state already exist.
1256
+ """
1257
+ job = job_manager.get_job(job_id)
1258
+ if not job:
1259
+ raise HTTPException(status_code=404, detail="Job not found")
1260
+
1261
+ # Check ownership
1262
+ if not _check_job_ownership(job, auth_result):
1263
+ raise HTTPException(status_code=403, detail="You don't have permission to retry this job")
1264
+
1265
+ if job.status not in [JobStatus.FAILED, JobStatus.CANCELLED]:
1266
+ raise HTTPException(
1267
+ status_code=400,
1268
+ detail=f"Only failed or cancelled jobs can be retried (current status: {job.status})"
1269
+ )
1270
+
1271
+ try:
1272
+ # Determine retry point based on what's already complete
1273
+ error_details = job.error_details or {}
1274
+ error_stage = error_details.get('stage', 'unknown')
1275
+ original_status = job.status
1276
+
1277
+ logger.info(f"Job {job_id}: Retrying from {original_status} state (error stage: '{error_stage}')")
1278
+
1279
+ # Check what state exists to determine retry point
1280
+ file_urls = job.file_urls or {}
1281
+ state_data = job.state_data or {}
1282
+
1283
+ # If we have a video with vocals and instrumental selection, retry video generation
1284
+ if (file_urls.get('videos', {}).get('with_vocals') and
1285
+ state_data.get('instrumental_selection')):
1286
+
1287
+ logger.info(f"Job {job_id}: Has rendered video and instrumental selection, retrying video generation")
1288
+
1289
+ # Clear error state and reset worker progress for idempotency
1290
+ job_manager.update_job(job_id, {
1291
+ 'error_message': None,
1292
+ 'error_details': None,
1293
+ })
1294
+ job_manager.update_state_data(job_id, 'video_progress', {'stage': 'pending'})
1295
+
1296
+ # Reset to INSTRUMENTAL_SELECTED and trigger video worker
1297
+ if not job_manager.transition_to_state(
1298
+ job_id=job_id,
1299
+ new_status=JobStatus.INSTRUMENTAL_SELECTED,
1300
+ progress=65,
1301
+ message=f"Retrying video generation from failed state"
1302
+ ):
1303
+ raise HTTPException(
1304
+ status_code=500,
1305
+ detail="Failed to transition job status for retry"
1306
+ )
1307
+
1308
+ # Trigger video generation worker
1309
+ background_tasks.add_task(worker_service.trigger_video_worker, job_id)
1310
+
1311
+ return {
1312
+ "status": "success",
1313
+ "job_status": "instrumental_selected",
1314
+ "message": "Job retry started from video generation stage",
1315
+ "retry_stage": "video_generation"
1316
+ }
1317
+
1318
+ # If we have corrections and screens but no video, retry render
1319
+ elif (file_urls.get('lyrics', {}).get('corrections') and
1320
+ file_urls.get('screens', {}).get('title')):
1321
+
1322
+ logger.info(f"Job {job_id}: Has corrections and screens, retrying from render stage")
1323
+
1324
+ # Clear error state and reset worker progress for idempotency
1325
+ job_manager.update_job(job_id, {
1326
+ 'error_message': None,
1327
+ 'error_details': None,
1328
+ })
1329
+ job_manager.update_state_data(job_id, 'render_progress', {'stage': 'pending'})
1330
+
1331
+ # Reset to REVIEW_COMPLETE and trigger render worker
1332
+ if not job_manager.transition_to_state(
1333
+ job_id=job_id,
1334
+ new_status=JobStatus.REVIEW_COMPLETE,
1335
+ progress=70,
1336
+ message=f"Retrying video render from failed state"
1337
+ ):
1338
+ raise HTTPException(
1339
+ status_code=500,
1340
+ detail="Failed to transition job status for retry"
1341
+ )
1342
+
1343
+ # Trigger render video worker
1344
+ background_tasks.add_task(worker_service.trigger_render_video_worker, job_id)
1345
+
1346
+ return {
1347
+ "status": "success",
1348
+ "job_status": "review_complete",
1349
+ "message": "Job retry started from render stage",
1350
+ "retry_stage": "render_video"
1351
+ }
1352
+
1353
+ # If we have stems and corrections, retry from screens generation
1354
+ elif (file_urls.get('stems', {}).get('instrumental_clean') and
1355
+ file_urls.get('lyrics', {}).get('corrections')):
1356
+
1357
+ logger.info(f"Job {job_id}: Has stems and corrections, retrying from screens stage")
1358
+
1359
+ # Clear error state and reset worker progress for idempotency
1360
+ job_manager.update_job(job_id, {
1361
+ 'error_message': None,
1362
+ 'error_details': None,
1363
+ })
1364
+ job_manager.update_state_data(job_id, 'screens_progress', {'stage': 'pending'})
1365
+
1366
+ # Reset to a state before screens and trigger screens worker
1367
+ if not job_manager.transition_to_state(
1368
+ job_id=job_id,
1369
+ new_status=JobStatus.LYRICS_COMPLETE,
1370
+ progress=45,
1371
+ message=f"Retrying from screens generation"
1372
+ ):
1373
+ raise HTTPException(
1374
+ status_code=500,
1375
+ detail="Failed to transition job status for retry"
1376
+ )
1377
+
1378
+ # Trigger screens worker
1379
+ background_tasks.add_task(worker_service.trigger_screens_worker, job_id)
1380
+
1381
+ return {
1382
+ "status": "success",
1383
+ "job_status": "lyrics_complete",
1384
+ "message": "Job retry started from screens generation",
1385
+ "retry_stage": "screens_generation"
1386
+ }
1387
+
1388
+ # If we have input audio (uploaded or from URL), restart from beginning
1389
+ elif job.input_media_gcs_path or job.url:
1390
+ logger.info(f"Job {job_id}: Has input audio, restarting from beginning")
1391
+
1392
+ # Clear error state and any partial progress
1393
+ job_manager.update_job(job_id, {
1394
+ 'error_message': None,
1395
+ 'error_details': None,
1396
+ 'state_data': {}, # Clear parallel worker progress
1397
+ })
1398
+
1399
+ # Reset to DOWNLOADING and trigger audio worker
1400
+ if not job_manager.transition_to_state(
1401
+ job_id=job_id,
1402
+ new_status=JobStatus.DOWNLOADING,
1403
+ progress=5,
1404
+ message=f"Restarting job from {original_status} state"
1405
+ ):
1406
+ raise HTTPException(
1407
+ status_code=500,
1408
+ detail="Failed to transition job status for retry"
1409
+ )
1410
+
1411
+ # Trigger audio worker (which kicks off parallel audio + lyrics processing)
1412
+ background_tasks.add_task(worker_service.trigger_audio_worker, job_id)
1413
+ background_tasks.add_task(worker_service.trigger_lyrics_worker, job_id)
1414
+
1415
+ return {
1416
+ "status": "success",
1417
+ "job_id": job_id,
1418
+ "job_status": "downloading",
1419
+ "message": f"Job restarted from {original_status} state",
1420
+ "retry_stage": "from_beginning"
1421
+ }
1422
+
1423
+ else:
1424
+ # No input audio available - job needs to be resubmitted
1425
+ raise HTTPException(
1426
+ status_code=400,
1427
+ detail="Cannot retry: no input audio available. Job must be resubmitted."
1428
+ )
1429
+
1430
+ except HTTPException:
1431
+ raise
1432
+ except Exception as e:
1433
+ logger.error(f"Error retrying job {job_id}: {e}", exc_info=True)
1434
+ raise HTTPException(status_code=500, detail=str(e))
1435
+
1436
+
1437
+ @router.get("/{job_id}/logs")
1438
+ async def get_worker_logs(
1439
+ job_id: str,
1440
+ since_index: int = 0,
1441
+ worker: Optional[str] = None,
1442
+ auth_result: AuthResult = Depends(require_auth)
1443
+ ) -> Dict[str, Any]:
1444
+ """
1445
+ Get worker logs for debugging.
1446
+
1447
+ This endpoint returns worker logs stored in Firestore.
1448
+ Use `since_index` for efficient polling (returns only new logs).
1449
+
1450
+ Logs are stored in a subcollection (jobs/{job_id}/logs) to avoid
1451
+ the 1MB document size limit. Older jobs may have logs in an embedded
1452
+ array (worker_logs field) - this endpoint handles both transparently.
1453
+
1454
+ Args:
1455
+ job_id: Job ID
1456
+ since_index: Return only logs after this index (for pagination/polling)
1457
+ worker: Filter by worker name (audio, lyrics, screens, video, render, distribution)
1458
+
1459
+ Returns:
1460
+ {
1461
+ "logs": [{"timestamp": "...", "level": "INFO", "worker": "audio", "message": "..."}],
1462
+ "next_index": 42, # Use this for next poll
1463
+ "total_logs": 42
1464
+ }
1465
+ """
1466
+ job = job_manager.get_job(job_id)
1467
+ if not job:
1468
+ raise HTTPException(status_code=404, detail="Job not found")
1469
+
1470
+ # Check ownership - users can only see logs for their own jobs
1471
+ if not _check_job_ownership(job, auth_result):
1472
+ raise HTTPException(status_code=403, detail="You don't have permission to access logs for this job")
1473
+
1474
+ logs = job_manager.get_worker_logs(job_id, since_index=since_index, worker=worker)
1475
+ total = job_manager.get_worker_logs_count(job_id)
1476
+
1477
+ return {
1478
+ "logs": logs,
1479
+ "next_index": since_index + len(logs),
1480
+ "total_logs": total
1481
+ }
1482
+
1483
+
1484
+ @router.post("/{job_id}/cleanup-distribution")
1485
+ async def cleanup_distribution(
1486
+ job_id: str,
1487
+ delete_job: bool = True,
1488
+ auth_result: AuthResult = Depends(require_admin)
1489
+ ) -> dict:
1490
+ """
1491
+ Clean up all distributed content for a job (YouTube, Dropbox, Google Drive).
1492
+
1493
+ This admin-only endpoint is designed for E2E test cleanup. It:
1494
+ 1. Deletes YouTube video (if uploaded)
1495
+ 2. Deletes Dropbox folder (if uploaded)
1496
+ 3. Deletes Google Drive files (if uploaded)
1497
+ 4. Optionally deletes the job itself
1498
+
1499
+ Args:
1500
+ job_id: Job ID to clean up
1501
+ delete_job: If True, also delete the job after cleaning up distribution
1502
+
1503
+ Returns:
1504
+ Cleanup results for each service
1505
+ """
1506
+ job = job_manager.get_job(job_id)
1507
+ if not job:
1508
+ raise HTTPException(status_code=404, detail="Job not found")
1509
+
1510
+ state_data = job.state_data or {}
1511
+ results = {
1512
+ "job_id": job_id,
1513
+ "youtube": {"status": "skipped", "reason": "no youtube_url in state_data"},
1514
+ "dropbox": {"status": "skipped", "reason": "no brand_code or dropbox_path"},
1515
+ "gdrive": {"status": "skipped", "reason": "no gdrive_files in state_data"},
1516
+ "job_deleted": False
1517
+ }
1518
+
1519
+ # Clean up YouTube
1520
+ youtube_url = state_data.get('youtube_url')
1521
+ if youtube_url:
1522
+ try:
1523
+ # Extract video ID from URL (format: https://youtu.be/VIDEO_ID or https://www.youtube.com/watch?v=VIDEO_ID)
1524
+ import re
1525
+ video_id_match = re.search(r'(?:youtu\.be/|youtube\.com/watch\?v=)([^&\s]+)', youtube_url)
1526
+ if video_id_match:
1527
+ video_id = video_id_match.group(1)
1528
+
1529
+ # Import and use karaoke_finalise for YouTube deletion
1530
+ from karaoke_gen.karaoke_finalise.karaoke_finalise import KaraokeFinalise
1531
+ from backend.services.youtube_service import get_youtube_service
1532
+
1533
+ youtube_service = get_youtube_service()
1534
+ if youtube_service.is_configured:
1535
+ # Create minimal KaraokeFinalise instance for deletion
1536
+ finalise = KaraokeFinalise(
1537
+ dry_run=False,
1538
+ non_interactive=True,
1539
+ enable_youtube=True,
1540
+ user_youtube_credentials=youtube_service.get_credentials_dict()
1541
+ )
1542
+ success = finalise.delete_youtube_video(video_id)
1543
+ results["youtube"] = {
1544
+ "status": "success" if success else "failed",
1545
+ "video_id": video_id
1546
+ }
1547
+ else:
1548
+ results["youtube"] = {"status": "failed", "reason": "YouTube credentials not configured"}
1549
+ else:
1550
+ results["youtube"] = {"status": "failed", "reason": f"Could not extract video ID from {youtube_url}"}
1551
+ except Exception as e:
1552
+ logger.error(f"Error cleaning up YouTube for job {job_id}: {e}", exc_info=True)
1553
+ results["youtube"] = {"status": "error", "error": str(e)}
1554
+
1555
+ # Clean up Dropbox
1556
+ brand_code = state_data.get('brand_code')
1557
+ dropbox_path = getattr(job, 'dropbox_path', None)
1558
+ if brand_code and dropbox_path:
1559
+ try:
1560
+ from backend.services.dropbox_service import get_dropbox_service
1561
+ dropbox = get_dropbox_service()
1562
+ if dropbox.is_configured:
1563
+ base_name = f"{job.artist} - {job.title}"
1564
+ folder_name = f"{brand_code} - {base_name}"
1565
+ full_path = f"{dropbox_path}/{folder_name}"
1566
+ success = dropbox.delete_folder(full_path)
1567
+ results["dropbox"] = {
1568
+ "status": "success" if success else "failed",
1569
+ "path": full_path
1570
+ }
1571
+ else:
1572
+ results["dropbox"] = {"status": "failed", "reason": "Dropbox credentials not configured"}
1573
+ except Exception as e:
1574
+ logger.error(f"Error cleaning up Dropbox for job {job_id}: {e}", exc_info=True)
1575
+ results["dropbox"] = {"status": "error", "error": str(e)}
1576
+
1577
+ # Clean up Google Drive
1578
+ gdrive_files = state_data.get('gdrive_files')
1579
+ if gdrive_files:
1580
+ try:
1581
+ from backend.services.gdrive_service import get_gdrive_service
1582
+ gdrive = get_gdrive_service()
1583
+ if gdrive.is_configured:
1584
+ # gdrive_files is a dict like {"mp4": "file_id", "mp4_720p": "file_id", "cdg": "file_id"}
1585
+ file_ids = list(gdrive_files.values()) if isinstance(gdrive_files, dict) else []
1586
+ delete_results = gdrive.delete_files(file_ids)
1587
+ all_success = all(delete_results.values())
1588
+ results["gdrive"] = {
1589
+ "status": "success" if all_success else "partial",
1590
+ "files": delete_results
1591
+ }
1592
+ else:
1593
+ results["gdrive"] = {"status": "failed", "reason": "Google Drive credentials not configured"}
1594
+ except Exception as e:
1595
+ logger.error(f"Error cleaning up Google Drive for job {job_id}: {e}", exc_info=True)
1596
+ results["gdrive"] = {"status": "error", "error": str(e)}
1597
+
1598
+ # Delete the job if requested
1599
+ if delete_job:
1600
+ try:
1601
+ job_manager.delete_job(job_id, delete_files=True)
1602
+ results["job_deleted"] = True
1603
+ logger.info(f"Deleted job {job_id} after distribution cleanup")
1604
+ except Exception as e:
1605
+ logger.error(f"Error deleting job {job_id}: {e}", exc_info=True)
1606
+ results["job_deleted"] = False
1607
+ results["job_delete_error"] = str(e)
1608
+
1609
+ return results
1610
+