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,435 @@
1
+ """
2
+ Internal API routes for worker coordination.
3
+
4
+ These endpoints are for internal use only (backend → workers).
5
+ They are protected by admin authentication.
6
+
7
+ With Cloud Tasks integration, these endpoints may be called multiple times
8
+ (retry on failure). Idempotency checks prevent duplicate processing.
9
+
10
+ Observability:
11
+ - Extracts trace context from incoming requests (propagated via Cloud Tasks)
12
+ - Creates worker spans linked to the original request trace
13
+ - All logs include job_id for easy filtering in Cloud Logging
14
+ """
15
+ import logging
16
+ import asyncio
17
+ import time
18
+ from typing import Tuple, Optional
19
+ from fastapi import APIRouter, HTTPException, BackgroundTasks, Depends, Request
20
+ from pydantic import BaseModel
21
+
22
+ from backend.workers.audio_worker import process_audio_separation
23
+ from backend.workers.lyrics_worker import process_lyrics_transcription
24
+ from backend.workers.screens_worker import generate_screens
25
+ from backend.workers.video_worker import generate_video
26
+ from backend.workers.render_video_worker import process_render_video
27
+ from backend.api.dependencies import require_admin
28
+ from backend.services.auth_service import UserType
29
+ from backend.services.job_manager import JobManager
30
+ from backend.services.tracing import (
31
+ extract_trace_context,
32
+ start_span_with_context,
33
+ add_span_attribute,
34
+ add_span_event,
35
+ )
36
+
37
+
38
+ logger = logging.getLogger(__name__)
39
+ router = APIRouter(prefix="/internal", tags=["internal"])
40
+
41
+
42
+ class WorkerRequest(BaseModel):
43
+ """Request to trigger a worker."""
44
+ job_id: str
45
+
46
+
47
+ class WorkerResponse(BaseModel):
48
+ """Response from worker trigger."""
49
+ status: str
50
+ job_id: str
51
+ message: str
52
+
53
+
54
+ def _check_worker_idempotency(job_id: str, worker_name: str) -> Optional[WorkerResponse]:
55
+ """
56
+ Check if a worker is already running or completed for this job.
57
+
58
+ This provides idempotency for Cloud Tasks retries - if a task is retried
59
+ but the worker is already running or has completed, we skip processing.
60
+
61
+ Args:
62
+ job_id: Job ID to check
63
+ worker_name: Worker name (audio, lyrics, screens, render, video)
64
+
65
+ Returns:
66
+ WorkerResponse if should skip (already running/complete), None to proceed
67
+ """
68
+ job_manager = JobManager()
69
+ job = job_manager.get_job(job_id)
70
+
71
+ if not job:
72
+ logger.warning(f"[job:{job_id}] Job not found for {worker_name} worker")
73
+ return WorkerResponse(
74
+ status="not_found",
75
+ job_id=job_id,
76
+ message=f"Job {job_id} not found"
77
+ )
78
+
79
+ # Check worker-specific progress in state_data
80
+ progress_key = f"{worker_name}_progress"
81
+ worker_progress = job.state_data.get(progress_key, {})
82
+ stage = worker_progress.get('stage')
83
+
84
+ if stage == 'running':
85
+ logger.info(f"[job:{job_id}] {worker_name.capitalize()} worker already running, skipping")
86
+ return WorkerResponse(
87
+ status="already_running",
88
+ job_id=job_id,
89
+ message=f"{worker_name.capitalize()} worker already in progress"
90
+ )
91
+
92
+ if stage == 'complete':
93
+ logger.info(f"[job:{job_id}] {worker_name.capitalize()} worker already complete, skipping")
94
+ return WorkerResponse(
95
+ status="already_complete",
96
+ job_id=job_id,
97
+ message=f"{worker_name.capitalize()} worker already completed"
98
+ )
99
+
100
+ # Mark as running before starting (for idempotency on next retry)
101
+ job_manager.update_state_data(job_id, progress_key, {'stage': 'running'})
102
+ return None
103
+
104
+
105
+ @router.post("/workers/audio", response_model=WorkerResponse)
106
+ async def trigger_audio_worker(
107
+ request: WorkerRequest,
108
+ http_request: Request,
109
+ background_tasks: BackgroundTasks,
110
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
111
+ ):
112
+ """
113
+ Trigger audio separation worker for a job.
114
+
115
+ This endpoint is called internally after job creation to start
116
+ the audio processing track (parallel with lyrics processing).
117
+
118
+ Idempotency: If worker is already running or complete, returns early.
119
+
120
+ The worker runs in the background and updates job state as it progresses.
121
+ """
122
+ job_id = request.job_id
123
+
124
+ # Extract trace context from incoming request (propagated via Cloud Tasks)
125
+ trace_context = extract_trace_context(dict(http_request.headers))
126
+
127
+ logger.info(f"[job:{job_id}] WORKER_TRIGGER worker=audio")
128
+ add_span_attribute("job_id", job_id)
129
+ add_span_attribute("worker", "audio")
130
+
131
+ # Idempotency check
132
+ skip_response = _check_worker_idempotency(job_id, "audio")
133
+ if skip_response:
134
+ add_span_event("worker_skipped", {"reason": skip_response.status})
135
+ return skip_response
136
+
137
+ # Add task to background tasks
138
+ # This allows the HTTP response to return immediately
139
+ # while the worker continues processing
140
+ background_tasks.add_task(process_audio_separation, job_id)
141
+
142
+ add_span_event("worker_started")
143
+ return WorkerResponse(
144
+ status="started",
145
+ job_id=job_id,
146
+ message="Audio separation worker started"
147
+ )
148
+
149
+
150
+ @router.post("/workers/lyrics", response_model=WorkerResponse)
151
+ async def trigger_lyrics_worker(
152
+ request: WorkerRequest,
153
+ http_request: Request,
154
+ background_tasks: BackgroundTasks,
155
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
156
+ ):
157
+ """
158
+ Trigger lyrics transcription worker for a job.
159
+
160
+ This endpoint is called internally after job creation to start
161
+ the lyrics processing track (parallel with audio processing).
162
+
163
+ Idempotency: If worker is already running or complete, returns early.
164
+
165
+ The worker runs in the background and updates job state as it progresses.
166
+ """
167
+ job_id = request.job_id
168
+
169
+ # Extract trace context from incoming request
170
+ trace_context = extract_trace_context(dict(http_request.headers))
171
+
172
+ logger.info(f"[job:{job_id}] WORKER_TRIGGER worker=lyrics")
173
+ add_span_attribute("job_id", job_id)
174
+ add_span_attribute("worker", "lyrics")
175
+
176
+ # Idempotency check
177
+ skip_response = _check_worker_idempotency(job_id, "lyrics")
178
+ if skip_response:
179
+ add_span_event("worker_skipped", {"reason": skip_response.status})
180
+ return skip_response
181
+
182
+ # Add task to background tasks
183
+ background_tasks.add_task(process_lyrics_transcription, job_id)
184
+
185
+ add_span_event("worker_started")
186
+ return WorkerResponse(
187
+ status="started",
188
+ job_id=job_id,
189
+ message="Lyrics transcription worker started"
190
+ )
191
+
192
+
193
+ @router.post("/workers/screens", response_model=WorkerResponse)
194
+ async def trigger_screens_worker(
195
+ request: WorkerRequest,
196
+ http_request: Request,
197
+ background_tasks: BackgroundTasks,
198
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
199
+ ):
200
+ """
201
+ Trigger title/end screen generation worker.
202
+
203
+ This is called automatically when both audio and lyrics are complete.
204
+
205
+ Idempotency: If worker is already running or complete, returns early.
206
+ """
207
+ job_id = request.job_id
208
+
209
+ # Extract trace context from incoming request
210
+ trace_context = extract_trace_context(dict(http_request.headers))
211
+
212
+ logger.info(f"[job:{job_id}] WORKER_TRIGGER worker=screens")
213
+ add_span_attribute("job_id", job_id)
214
+ add_span_attribute("worker", "screens")
215
+
216
+ # Idempotency check
217
+ skip_response = _check_worker_idempotency(job_id, "screens")
218
+ if skip_response:
219
+ add_span_event("worker_skipped", {"reason": skip_response.status})
220
+ return skip_response
221
+
222
+ # Add task to background tasks
223
+ background_tasks.add_task(generate_screens, job_id)
224
+
225
+ add_span_event("worker_started")
226
+ return WorkerResponse(
227
+ status="started",
228
+ job_id=job_id,
229
+ message="Screens generation worker started"
230
+ )
231
+
232
+
233
+ @router.post("/workers/video", response_model=WorkerResponse)
234
+ async def trigger_video_worker(
235
+ request: WorkerRequest,
236
+ http_request: Request,
237
+ background_tasks: BackgroundTasks,
238
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
239
+ ):
240
+ """
241
+ Trigger final video generation and encoding worker.
242
+
243
+ This is called after user selects their preferred instrumental.
244
+ This is the longest-running stage (15-20 minutes).
245
+
246
+ Idempotency: If worker is already running or complete, returns early.
247
+ """
248
+ job_id = request.job_id
249
+
250
+ # Extract trace context from incoming request
251
+ trace_context = extract_trace_context(dict(http_request.headers))
252
+
253
+ logger.info(f"[job:{job_id}] WORKER_TRIGGER worker=video")
254
+ add_span_attribute("job_id", job_id)
255
+ add_span_attribute("worker", "video")
256
+
257
+ # Idempotency check
258
+ skip_response = _check_worker_idempotency(job_id, "video")
259
+ if skip_response:
260
+ add_span_event("worker_skipped", {"reason": skip_response.status})
261
+ return skip_response
262
+
263
+ # Add task to background tasks
264
+ background_tasks.add_task(generate_video, job_id)
265
+
266
+ add_span_event("worker_started")
267
+ return WorkerResponse(
268
+ status="started",
269
+ job_id=job_id,
270
+ message="Video generation worker started"
271
+ )
272
+
273
+
274
+ @router.post("/workers/render-video", response_model=WorkerResponse)
275
+ async def trigger_render_video_worker(
276
+ request: WorkerRequest,
277
+ http_request: Request,
278
+ background_tasks: BackgroundTasks,
279
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
280
+ ):
281
+ """
282
+ Trigger render video worker (post-review).
283
+
284
+ This is called after human review is complete.
285
+ Uses OutputGenerator from LyricsTranscriber to generate the karaoke video
286
+ with the corrected lyrics.
287
+
288
+ Idempotency: If worker is already running or complete, returns early.
289
+
290
+ Output: with_vocals.mkv in GCS
291
+ Next state: AWAITING_INSTRUMENTAL_SELECTION
292
+ """
293
+ job_id = request.job_id
294
+
295
+ # Extract trace context from incoming request
296
+ trace_context = extract_trace_context(dict(http_request.headers))
297
+
298
+ logger.info(f"[job:{job_id}] WORKER_TRIGGER worker=render-video")
299
+ add_span_attribute("job_id", job_id)
300
+ add_span_attribute("worker", "render-video")
301
+
302
+ # Idempotency check
303
+ skip_response = _check_worker_idempotency(job_id, "render")
304
+ if skip_response:
305
+ add_span_event("worker_skipped", {"reason": skip_response.status})
306
+ return skip_response
307
+
308
+ # Add task to background tasks
309
+ background_tasks.add_task(process_render_video, job_id)
310
+
311
+ add_span_event("worker_started")
312
+ return WorkerResponse(
313
+ status="started",
314
+ job_id=job_id,
315
+ message="Render video worker started (post-review)"
316
+ )
317
+
318
+
319
+ @router.post("/jobs/{job_id}/check-idle-reminder")
320
+ async def check_idle_reminder(
321
+ job_id: str,
322
+ http_request: Request,
323
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
324
+ ):
325
+ """
326
+ Check if a job needs an idle reminder email.
327
+
328
+ This endpoint is called by a Cloud Tasks scheduled task 5 minutes after
329
+ a job enters a blocking state (AWAITING_REVIEW or AWAITING_INSTRUMENTAL_SELECTION).
330
+
331
+ If the job is still in the blocking state and no reminder has been sent yet,
332
+ sends a reminder email to the user.
333
+
334
+ Idempotency: Only one reminder per job (tracked via reminder_sent flag).
335
+ """
336
+ from backend.models.job import JobStatus
337
+ from backend.services.job_notification_service import get_job_notification_service
338
+
339
+ # Extract trace context from incoming request
340
+ trace_context = extract_trace_context(dict(http_request.headers))
341
+
342
+ logger.info(f"[job:{job_id}] IDLE_REMINDER_CHECK starting")
343
+ add_span_attribute("job_id", job_id)
344
+ add_span_attribute("operation", "idle_reminder_check")
345
+
346
+ job_manager = JobManager()
347
+ job = job_manager.get_job(job_id)
348
+
349
+ if not job:
350
+ logger.warning(f"[job:{job_id}] Job not found for idle reminder check")
351
+ add_span_event("job_not_found")
352
+ return {"status": "not_found", "job_id": job_id, "message": "Job not found"}
353
+
354
+ # Check if job is still in a blocking state
355
+ blocking_states = [JobStatus.AWAITING_REVIEW, JobStatus.AWAITING_INSTRUMENTAL_SELECTION]
356
+ if job.status not in [s.value for s in blocking_states]:
357
+ logger.info(f"[job:{job_id}] Job no longer in blocking state ({job.status}), skipping reminder")
358
+ add_span_event("not_blocking", {"current_status": job.status})
359
+ return {
360
+ "status": "skipped",
361
+ "job_id": job_id,
362
+ "message": f"Job not in blocking state (current: {job.status})"
363
+ }
364
+
365
+ # Normalize state_data to prevent None errors
366
+ state_data = job.state_data or {}
367
+
368
+ # Check if reminder was already sent (idempotency)
369
+ if state_data.get('reminder_sent'):
370
+ logger.info(f"[job:{job_id}] Reminder already sent, skipping")
371
+ add_span_event("already_sent")
372
+ return {"status": "already_sent", "job_id": job_id, "message": "Reminder already sent"}
373
+
374
+ # Check if user has an email
375
+ if not job.user_email:
376
+ logger.warning(f"[job:{job_id}] No user email, cannot send reminder")
377
+ add_span_event("no_email")
378
+ return {"status": "no_email", "job_id": job_id, "message": "No user email configured"}
379
+
380
+ # Determine action type
381
+ action_type = state_data.get('blocking_action_type')
382
+ if not action_type:
383
+ action_type = "lyrics" if job.status == JobStatus.AWAITING_REVIEW.value else "instrumental"
384
+
385
+ # Send the reminder email
386
+ try:
387
+ notification_service = get_job_notification_service()
388
+
389
+ success = await notification_service.send_action_reminder_email(
390
+ job_id=job.job_id,
391
+ user_email=job.user_email,
392
+ action_type=action_type,
393
+ user_name=None, # Could fetch from user service if needed
394
+ artist=job.artist,
395
+ title=job.title,
396
+ audio_hash=job.audio_hash,
397
+ review_token=job.review_token,
398
+ instrumental_token=job.instrumental_token,
399
+ )
400
+
401
+ if success:
402
+ # Mark reminder as sent (prevents duplicate sends)
403
+ job_manager.firestore.update_job(job_id, {
404
+ 'state_data': {**state_data, 'reminder_sent': True}
405
+ })
406
+ logger.info(f"[job:{job_id}] Sent {action_type} reminder email to {job.user_email}")
407
+ add_span_event("reminder_sent", {"action_type": action_type})
408
+ return {
409
+ "status": "sent",
410
+ "job_id": job_id,
411
+ "message": f"Sent {action_type} reminder to {job.user_email}"
412
+ }
413
+ else:
414
+ logger.error(f"[job:{job_id}] Failed to send reminder email")
415
+ add_span_event("send_failed")
416
+ return {"status": "failed", "job_id": job_id, "message": "Failed to send reminder"}
417
+
418
+ except Exception as e:
419
+ logger.exception(f"[job:{job_id}] Error sending reminder: {e}")
420
+ add_span_event("error", {"error": str(e)})
421
+ return {"status": "error", "job_id": job_id, "message": str(e)}
422
+
423
+
424
+ @router.get("/health")
425
+ async def internal_health(
426
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin)
427
+ ):
428
+ """
429
+ Internal health check endpoint.
430
+
431
+ Used to verify the internal API is responsive.
432
+ Requires admin authentication.
433
+ """
434
+ return {"status": "healthy", "service": "karaoke-backend-internal"}
435
+