Rthur2003 commited on
Commit
3518200
·
1 Parent(s): 56fa34f

feat: health check ve audio analysis API routes eklendi

Browse files
Files changed (1) hide show
  1. app/routes/analyze.py +200 -73
app/routes/analyze.py CHANGED
@@ -14,6 +14,7 @@ Endpoints:
14
  POST /api/analyze one request, waits for the result
15
  POST /api/analyze/jobs starts a background job, returns its id
16
  GET /api/analyze/jobs/{job_id} per-step progress, then the result
 
17
 
18
  If the audio can't be decoded or a link can't be downloaded, the
19
  response has an error code and no result.
@@ -22,12 +23,14 @@ response has an error code and no result.
22
  from __future__ import annotations
23
 
24
  import asyncio
 
25
  import io
26
  import tempfile
27
  import time
28
  import uuid
29
  from collections import defaultdict
30
  from concurrent.futures import ThreadPoolExecutor
 
31
  from pathlib import Path
32
  from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
33
 
@@ -52,15 +55,22 @@ from .analyze_schemas import (
52
  )
53
  from ..services.analysis_jobs import (
54
  FILE_STEPS,
 
55
  URL_STEPS,
56
  STEP_DONE,
57
  STEP_FAILED,
58
  STEP_RUNNING,
59
  STEP_SKIPPED,
 
 
60
  Progress,
 
 
61
  job_store,
 
62
  )
63
  from ..services.feature_extractor import (
 
64
  extract_features,
65
  AudioFeatures,
66
  )
@@ -95,8 +105,8 @@ URL_SOURCES = ("youtube", "tiktok", "instagram", "soundcloud", "twitter")
95
  YOUTUBE_CLIP_SEC = 120.0
96
  YOUTUBE_MAX_BYTES = 100 * 1024 * 1024
97
 
98
- # cpu-basic has 2 vCPUs; more concurrent jobs only queue in the pool.
99
- MAX_RUNNING_JOBS = 4
100
 
101
  # Rate limiter for public heavy endpoints
102
  _analyze_rate_store: dict[str, list[float]] = defaultdict(list)
@@ -153,7 +163,7 @@ xai_service = get_xai_service()
153
  meta_classifier_service = MetaClassifierService()
154
 
155
  # Thread pool for CPU-bound audio analysis (feature extraction + vocal)
156
- _analysis_pool = ThreadPoolExecutor(max_workers=3)
157
  # Downloads wait on the network; keep them off the analysis workers.
158
  _download_pool = ThreadPoolExecutor(max_workers=2)
159
 
@@ -219,51 +229,77 @@ async def _run_pipeline(
219
  source: Dict[str, Any],
220
  start_time: float,
221
  progress: Progress,
 
222
  warnings: Optional[List[str]] = None,
223
  total_duration: Optional[float] = None,
224
  ) -> AnalyzeResponse:
225
- """Run every layer on one piece of audio and build the response."""
 
 
 
 
226
  warnings = list(warnings or [])
227
  file_size = len(content)
228
  logger.info(f"[{request_id}] Audio: {filename} ({file_size / 1024 / 1024:.2f}MB)")
229
  loop = asyncio.get_running_loop()
 
230
 
231
- # ── Feature extraction + vocal analysis + CLAP + wav2vec2 in parallel ──
232
- progress.running("features", "vocals", "clap", "wav2vec2")
233
- feat_future = loop.run_in_executor(_analysis_pool, lambda: extract_features(io.BytesIO(content)))
234
- vocal_future = loop.run_in_executor(_analysis_pool, lambda: analyze_vocals(io.BytesIO(content)))
235
- clap_future = loop.run_in_executor(_analysis_pool, lambda: clap_service.predict(io.BytesIO(content)))
236
- wav2vec2_future = loop.run_in_executor(_analysis_pool, lambda: wav2vec2_service.predict(io.BytesIO(content)))
237
- _track(feat_future, progress, "features")
238
- _track(vocal_future, progress, "vocals")
239
- _track(clap_future, progress, "clap", ok=lambda r: bool(r.available))
240
- _track(wav2vec2_future, progress, "wav2vec2", ok=lambda r: bool(r.available))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
 
242
  # Feature extraction is required for everything below.
243
  try:
244
- features: AudioFeatures = await feat_future
245
  logger.info(
246
  f"[{request_id}] Features: "
247
  f"S={features.spectral_regularity:.3f} "
248
  f"T={features.temporal_patterns:.3f} "
249
  f"H={features.harmonic_structure:.3f}"
250
  )
251
- except ValueError as e:
252
- logger.warning(f"[{request_id}] Feature extraction rejected the audio: {e}")
253
- for step in ("fst", "xai", "meta"):
254
- progress.set(step, STEP_SKIPPED)
255
- message = str(e).lower()
256
- code = "audio_too_short" if "short" in message else "audio_silent" if "silent" in message else "audio_decode_failed"
257
- return AnalyzeResponse(errors=[code], warnings=_unique(warnings))
258
  except Exception as e:
259
  logger.error(f"[{request_id}] Feature extraction error: {e}", exc_info=True)
260
- for step in ("fst", "xai", "meta"):
261
- progress.set(step, STEP_SKIPPED)
262
  return AnalyzeResponse(errors=["audio_decode_failed"], warnings=_unique(warnings))
263
 
264
  vocals: Optional[VocalFeatures] = None
265
  try:
266
- vocals = await vocal_future
267
  if vocals.has_vocals:
268
  logger.info(
269
  f"[{request_id}] Vocals: "
@@ -278,7 +314,7 @@ async def _run_pipeline(
278
 
279
  clap_result = None
280
  try:
281
- clap_result = await clap_future
282
  if clap_result.available:
283
  logger.info(
284
  f"[{request_id}] CLAP ({clap_result.classifier_used}): "
@@ -293,7 +329,7 @@ async def _run_pipeline(
293
 
294
  wav2vec2_result = None
295
  try:
296
- wav2vec2_result = await wav2vec2_future
297
  if wav2vec2_result.available:
298
  logger.info(f"[{request_id}] wav2vec2: p_ai={wav2vec2_result.p_ai:.3f}")
299
  else:
@@ -303,11 +339,10 @@ async def _run_pipeline(
303
  logger.warning(f"[{request_id}] wav2vec2 failed: {e}")
304
  warnings.append("wav2vec2_unavailable")
305
 
306
- # ── FST detection (external Space) ──
307
  fst_result = None
308
- progress.set("fst", STEP_RUNNING)
309
  try:
310
- fst_result = await fst_service.predict(io.BytesIO(content))
311
  if fst_result.available:
312
  progress.set("fst", STEP_DONE)
313
  logger.info(
@@ -322,6 +357,7 @@ async def _run_pipeline(
322
  progress.set("fst", STEP_FAILED)
323
  logger.warning(f"[{request_id}] FST failed: {e}")
324
  warnings.append("fst_analysis_unavailable")
 
325
 
326
  # ── Score fusion ──
327
  fusion: FusionResult = fuse_scores(
@@ -339,7 +375,11 @@ async def _run_pipeline(
339
  xai_payload: Optional[XAIExplanation] = None
340
  progress.set("xai", STEP_RUNNING)
341
  try:
342
- xai_result = xai_service.predict(features, vocals) if xai_service.available else None
 
 
 
 
343
  if xai_result is not None:
344
  xai_dict = xai_service.to_dict(xai_result)
345
  xai_payload = XAIExplanation(
@@ -377,9 +417,12 @@ async def _run_pipeline(
377
  meta_payload: Optional[MetaClassifierExplanation] = None
378
  progress.set("meta", STEP_RUNNING)
379
  try:
380
- meta_result = meta_classifier_service.predict(
381
- features, vocals=vocals,
382
- wav2vec2=wav2vec2_result, clap=clap_result, fst=fst_result,
 
 
 
383
  )
384
  if meta_result.model_version == MODEL_VERSION_TRAINED:
385
  meta_payload = MetaClassifierExplanation(
@@ -531,7 +574,7 @@ def _download_youtube_sync(
531
 
532
 
533
  async def _analyze_url(
534
- request_id: str, url: str, start_time: float, progress: Progress,
535
  ) -> AnalyzeResponse:
536
  """Download a YouTube link's audio and run the same pipeline as a file."""
537
  try:
@@ -541,13 +584,15 @@ async def _analyze_url(
541
  progress.set("download", STEP_FAILED)
542
  return AnalyzeResponse(errors=["invalid_youtube_url"])
543
 
544
- progress.set("download", STEP_RUNNING)
 
 
 
 
 
545
  loop = asyncio.get_running_loop()
546
  try:
547
- content, ext, title, duration, dl_warnings = await loop.run_in_executor(
548
- _download_pool, _download_youtube_sync,
549
- parsed.normalized_url, parsed.video_id, parsed.start_time_sec,
550
- )
551
  except YouTubeDownloadError as exc:
552
  progress.set("download", STEP_FAILED)
553
  logger.warning(f"[{request_id}] YouTube download failed ({exc.error_code}): {exc}")
@@ -560,6 +605,7 @@ async def _analyze_url(
560
  logger.error(f"[{request_id}] YouTube download error: {exc}", exc_info=True)
561
  return AnalyzeResponse(errors=["youtube_analysis_failed"])
562
  progress.set("download", STEP_DONE)
 
563
 
564
  if len(content) < MIN_UPLOAD_BYTES:
565
  return AnalyzeResponse(errors=["youtube_analysis_failed"])
@@ -581,12 +627,21 @@ async def _analyze_url(
581
  source=source,
582
  start_time=start_time,
583
  progress=progress,
 
584
  warnings=dl_warnings,
585
  total_duration=duration,
586
  )
587
 
588
 
589
- Work = Callable[[Progress], Awaitable[AnalyzeResponse]]
 
 
 
 
 
 
 
 
590
 
591
 
592
  async def _plan(
@@ -594,17 +649,21 @@ async def _plan(
594
  source_type: str,
595
  url: Optional[str],
596
  file: Optional[UploadFile],
597
- ) -> Tuple[Optional[AnalyzeResponse], Optional[Tuple[List[str], Work]]]:
598
  """Validate a request. Returns an immediate error response, or the
599
- steps plus the work to run (uploads are read here, while the request
600
- is still open)."""
601
  start_time = time.monotonic()
602
  logger.info(f"[{request_id}] Analysis: sourceType={source_type}")
603
 
604
  if source_type in URL_SOURCES:
605
  if not url:
606
  return AnalyzeResponse(errors=["missing_url"]), None
607
- return None, (URL_STEPS, lambda p: _analyze_url(request_id, url, start_time, p))
 
 
 
 
 
608
 
609
  if source_type == "file":
610
  if not file:
@@ -619,17 +678,26 @@ async def _plan(
619
  "fileSizeBytes": len(content),
620
  "mimeType": content_type,
621
  }
622
- return None, (FILE_STEPS, lambda p: _run_pipeline(
 
623
  request_id, content,
624
  filename=filename, content_type=content_type,
625
- source=source, start_time=start_time, progress=p,
626
- ))
627
 
628
  if source_type in ("spotify", "apple"):
629
  return AnalyzeResponse(errors=["unsupported_source"]), None
630
  return AnalyzeResponse(errors=["invalid_source_type"]), None
631
 
632
 
 
 
 
 
 
 
 
 
633
  # ── endpoints ─────────────────────────────────────────────────────────
634
 
635
  @router.post(
@@ -638,21 +706,33 @@ async def _plan(
638
  dependencies=[Depends(_analyze_rate_limit)],
639
  )
640
  async def analyze(
 
641
  sourceType: str = Form(...),
642
  url: Optional[str] = Form(None),
643
- file: Optional[UploadFile] = File(None)
644
  ) -> AnalyzeResponse:
645
- """Unified analysis endpoint (waits for the result)."""
646
  request_id = str(uuid.uuid4())[:8]
 
 
 
 
 
 
 
 
 
 
 
647
  try:
648
- immediate, plan = await _plan(request_id, sourceType, url, file)
649
- if immediate is not None or plan is None:
650
- return immediate or AnalyzeResponse(errors=["internal_error"])
651
- steps, work = plan
652
- return await work(Progress(steps))
653
  except Exception as e:
654
  logger.error(f"[{request_id}] Error: {e}", exc_info=True)
655
  return AnalyzeResponse(errors=["internal_error"])
 
 
656
 
657
 
658
  @router.post(
@@ -664,7 +744,7 @@ async def start_analysis_job(
664
  url: Optional[str] = Form(None),
665
  file: Optional[UploadFile] = File(None)
666
  ) -> JSONResponse:
667
- """Start an analysis in the background; poll GET /api/analyze/jobs/{jobId}."""
668
  request_id = str(uuid.uuid4())[:8]
669
  immediate, plan = await _plan(request_id, sourceType, url, file)
670
  if immediate is not None or plan is None:
@@ -674,33 +754,80 @@ async def start_analysis_job(
674
  "steps": [], "elapsedSec": 0, "response": response.model_dump(),
675
  })
676
 
677
- if job_store.running_count() >= MAX_RUNNING_JOBS:
678
- raise HTTPException(
679
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
680
- detail={"code": "server_busy", "message": "Too many analyses running. Try again shortly."},
681
- )
682
-
683
- steps, work = plan
684
-
685
- async def run(progress: Progress) -> Dict[str, Any]:
686
  try:
687
- return (await work(progress)).model_dump()
 
 
688
  except Exception as e:
689
  logger.error(f"[{request_id}] Job error: {e}", exc_info=True)
690
  return AnalyzeResponse(errors=["internal_error"]).model_dump()
691
 
692
- job = job_store.start(sourceType, steps, run)
693
- logger.info(f"[{request_id}] Job {job.id} started")
 
 
 
694
  return JSONResponse(status_code=202, content=job.to_dict())
695
 
696
 
697
- @router.get("/api/analyze/jobs/{job_id}")
698
- async def get_analysis_job(job_id: str) -> Dict[str, Any]:
699
- """Per-step progress of a job; `response` is filled once it settles."""
700
- job = job_store.get(job_id)
701
  if job is None:
702
  raise HTTPException(
703
  status_code=status.HTTP_404_NOT_FOUND,
704
  detail={"code": "job_not_found", "message": "Unknown or expired job."},
705
  )
706
- return job.to_dict()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  POST /api/analyze one request, waits for the result
15
  POST /api/analyze/jobs starts a background job, returns its id
16
  GET /api/analyze/jobs/{job_id} per-step progress, then the result
17
+ DELETE /api/analyze/jobs/{job_id} cancel
18
 
19
  If the audio can't be decoded or a link can't be downloaded, the
20
  response has an error code and no result.
 
23
  from __future__ import annotations
24
 
25
  import asyncio
26
+ import hashlib
27
  import io
28
  import tempfile
29
  import time
30
  import uuid
31
  from collections import defaultdict
32
  from concurrent.futures import ThreadPoolExecutor
33
+ from dataclasses import dataclass
34
  from pathlib import Path
35
  from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
36
 
 
55
  )
56
  from ..services.analysis_jobs import (
57
  FILE_STEPS,
58
+ MAX_PENDING,
59
  URL_STEPS,
60
  STEP_DONE,
61
  STEP_FAILED,
62
  STEP_RUNNING,
63
  STEP_SKIPPED,
64
+ Job,
65
+ JobCancelled,
66
  Progress,
67
+ QueueFull,
68
+ cpu_gate,
69
  job_store,
70
+ result_cache,
71
  )
72
  from ..services.feature_extractor import (
73
+ decode_clip,
74
  extract_features,
75
  AudioFeatures,
76
  )
 
105
  YOUTUBE_CLIP_SEC = 120.0
106
  YOUTUBE_MAX_BYTES = 100 * 1024 * 1024
107
 
108
+ # Bump when the pipeline's output changes, so results cached from the old one aren't served.
109
+ PIPELINE_VERSION = "2"
110
 
111
  # Rate limiter for public heavy endpoints
112
  _analyze_rate_store: dict[str, list[float]] = defaultdict(list)
 
163
  meta_classifier_service = MetaClassifierService()
164
 
165
  # Thread pool for CPU-bound audio analysis (feature extraction + vocal)
166
+ _analysis_pool = ThreadPoolExecutor(max_workers=4)
167
  # Downloads wait on the network; keep them off the analysis workers.
168
  _download_pool = ThreadPoolExecutor(max_workers=2)
169
 
 
229
  source: Dict[str, Any],
230
  start_time: float,
231
  progress: Progress,
232
+ ticket: str,
233
  warnings: Optional[List[str]] = None,
234
  total_duration: Optional[float] = None,
235
  ) -> AnalyzeResponse:
236
+ """Run every layer on one piece of audio and build the response.
237
+
238
+ The CPU-heavy part waits its turn in `cpu_gate` (ticket = job id); the
239
+ external FST call starts alongside it and never holds the CPU slot.
240
+ """
241
  warnings = list(warnings or [])
242
  file_size = len(content)
243
  logger.info(f"[{request_id}] Audio: {filename} ({file_size / 1024 / 1024:.2f}MB)")
244
  loop = asyncio.get_running_loop()
245
+ fst_task: Optional["asyncio.Task[Any]"] = None
246
 
247
+ try:
248
+ async with cpu_gate.slot(ticket, progress):
249
+ # Network, not CPU: runs while the models work.
250
+ progress.set("fst", STEP_RUNNING)
251
+ fst_task = asyncio.create_task(fst_service.predict(content, suffix=Path(filename).suffix.lower() or ".wav"))
252
+
253
+ # Decode once; every layer reads the same 60 s clip.
254
+ progress.running("features", "vocals", "clap", "wav2vec2")
255
+ try:
256
+ clip = await loop.run_in_executor(_analysis_pool, decode_clip, content)
257
+ except ValueError as e:
258
+ logger.warning(f"[{request_id}] Audio rejected: {e}")
259
+ message = str(e).lower()
260
+ code = "audio_too_short" if "short" in message else "audio_silent" if "silent" in message else "audio_decode_failed"
261
+ progress.skip_pending()
262
+ return AnalyzeResponse(errors=[code], warnings=_unique(warnings))
263
+ except Exception as e:
264
+ logger.error(f"[{request_id}] Decode error: {e}", exc_info=True)
265
+ progress.skip_pending()
266
+ return AnalyzeResponse(errors=["audio_decode_failed"], warnings=_unique(warnings))
267
+ progress.check()
268
+
269
+ feat_future = loop.run_in_executor(_analysis_pool, lambda: extract_features(io.BytesIO(clip)))
270
+ vocal_future = loop.run_in_executor(_analysis_pool, lambda: analyze_vocals(io.BytesIO(clip)))
271
+ clap_future = loop.run_in_executor(_analysis_pool, lambda: clap_service.predict(io.BytesIO(clip)))
272
+ wav2vec2_future = loop.run_in_executor(_analysis_pool, lambda: wav2vec2_service.predict(io.BytesIO(clip)))
273
+ _track(feat_future, progress, "features")
274
+ _track(vocal_future, progress, "vocals")
275
+ _track(clap_future, progress, "clap", ok=lambda r: bool(r.available))
276
+ _track(wav2vec2_future, progress, "wav2vec2", ok=lambda r: bool(r.available))
277
+ # Hold the slot until every thread is done, even if one fails.
278
+ await asyncio.gather(feat_future, vocal_future, clap_future, wav2vec2_future, return_exceptions=True)
279
+ progress.check()
280
+ except BaseException:
281
+ if fst_task is not None:
282
+ fst_task.cancel()
283
+ raise
284
 
285
  # Feature extraction is required for everything below.
286
  try:
287
+ features: AudioFeatures = feat_future.result()
288
  logger.info(
289
  f"[{request_id}] Features: "
290
  f"S={features.spectral_regularity:.3f} "
291
  f"T={features.temporal_patterns:.3f} "
292
  f"H={features.harmonic_structure:.3f}"
293
  )
 
 
 
 
 
 
 
294
  except Exception as e:
295
  logger.error(f"[{request_id}] Feature extraction error: {e}", exc_info=True)
296
+ fst_task.cancel()
297
+ progress.skip_pending()
298
  return AnalyzeResponse(errors=["audio_decode_failed"], warnings=_unique(warnings))
299
 
300
  vocals: Optional[VocalFeatures] = None
301
  try:
302
+ vocals = vocal_future.result()
303
  if vocals.has_vocals:
304
  logger.info(
305
  f"[{request_id}] Vocals: "
 
314
 
315
  clap_result = None
316
  try:
317
+ clap_result = clap_future.result()
318
  if clap_result.available:
319
  logger.info(
320
  f"[{request_id}] CLAP ({clap_result.classifier_used}): "
 
329
 
330
  wav2vec2_result = None
331
  try:
332
+ wav2vec2_result = wav2vec2_future.result()
333
  if wav2vec2_result.available:
334
  logger.info(f"[{request_id}] wav2vec2: p_ai={wav2vec2_result.p_ai:.3f}")
335
  else:
 
339
  logger.warning(f"[{request_id}] wav2vec2 failed: {e}")
340
  warnings.append("wav2vec2_unavailable")
341
 
342
+ # ── FST detection (external Space), started with the CPU stage ──
343
  fst_result = None
 
344
  try:
345
+ fst_result = await fst_task
346
  if fst_result.available:
347
  progress.set("fst", STEP_DONE)
348
  logger.info(
 
357
  progress.set("fst", STEP_FAILED)
358
  logger.warning(f"[{request_id}] FST failed: {e}")
359
  warnings.append("fst_analysis_unavailable")
360
+ progress.check()
361
 
362
  # ── Score fusion ──
363
  fusion: FusionResult = fuse_scores(
 
375
  xai_payload: Optional[XAIExplanation] = None
376
  progress.set("xai", STEP_RUNNING)
377
  try:
378
+ # 11 models + SHAP: short, but CPU; keep it off the event loop.
379
+ xai_result = (
380
+ await loop.run_in_executor(_analysis_pool, xai_service.predict, features, vocals)
381
+ if xai_service.available else None
382
+ )
383
  if xai_result is not None:
384
  xai_dict = xai_service.to_dict(xai_result)
385
  xai_payload = XAIExplanation(
 
417
  meta_payload: Optional[MetaClassifierExplanation] = None
418
  progress.set("meta", STEP_RUNNING)
419
  try:
420
+ meta_result = await loop.run_in_executor(
421
+ _analysis_pool,
422
+ lambda: meta_classifier_service.predict(
423
+ features, vocals=vocals,
424
+ wav2vec2=wav2vec2_result, clap=clap_result, fst=fst_result,
425
+ ),
426
  )
427
  if meta_result.model_version == MODEL_VERSION_TRAINED:
428
  meta_payload = MetaClassifierExplanation(
 
574
 
575
 
576
  async def _analyze_url(
577
+ request_id: str, url: str, start_time: float, progress: Progress, ticket: str,
578
  ) -> AnalyzeResponse:
579
  """Download a YouTube link's audio and run the same pipeline as a file."""
580
  try:
 
584
  progress.set("download", STEP_FAILED)
585
  return AnalyzeResponse(errors=["invalid_youtube_url"])
586
 
587
+ def download() -> Tuple[bytes, str, Optional[str], Optional[float], List[str]]:
588
+ # Marked running once a download thread is free, not while waiting for one.
589
+ progress.phase = "running"
590
+ progress.set("download", STEP_RUNNING)
591
+ return _download_youtube_sync(parsed.normalized_url, parsed.video_id, parsed.start_time_sec)
592
+
593
  loop = asyncio.get_running_loop()
594
  try:
595
+ content, ext, title, duration, dl_warnings = await loop.run_in_executor(_download_pool, download)
 
 
 
596
  except YouTubeDownloadError as exc:
597
  progress.set("download", STEP_FAILED)
598
  logger.warning(f"[{request_id}] YouTube download failed ({exc.error_code}): {exc}")
 
605
  logger.error(f"[{request_id}] YouTube download error: {exc}", exc_info=True)
606
  return AnalyzeResponse(errors=["youtube_analysis_failed"])
607
  progress.set("download", STEP_DONE)
608
+ progress.check()
609
 
610
  if len(content) < MIN_UPLOAD_BYTES:
611
  return AnalyzeResponse(errors=["youtube_analysis_failed"])
 
627
  source=source,
628
  start_time=start_time,
629
  progress=progress,
630
+ ticket=ticket,
631
  warnings=dl_warnings,
632
  total_duration=duration,
633
  )
634
 
635
 
636
+ Work = Callable[[Progress, str], Awaitable[AnalyzeResponse]]
637
+
638
+
639
+ @dataclass
640
+ class Plan:
641
+ steps: List[str]
642
+ work: Work
643
+ # Same file or same link gives the same key: shared while in flight, cached after.
644
+ key: Optional[str]
645
 
646
 
647
  async def _plan(
 
649
  source_type: str,
650
  url: Optional[str],
651
  file: Optional[UploadFile],
652
+ ) -> Tuple[Optional[AnalyzeResponse], Optional[Plan]]:
653
  """Validate a request. Returns an immediate error response, or the
654
+ plan to run (uploads are read here, while the request is still open)."""
 
655
  start_time = time.monotonic()
656
  logger.info(f"[{request_id}] Analysis: sourceType={source_type}")
657
 
658
  if source_type in URL_SOURCES:
659
  if not url:
660
  return AnalyzeResponse(errors=["missing_url"]), None
661
+ try:
662
+ parsed = parse_youtube_url(url)
663
+ except ValueError:
664
+ return AnalyzeResponse(errors=["invalid_youtube_url"]), None
665
+ key = f"yt:{parsed.video_id}:{parsed.start_time_sec or 0}:{PIPELINE_VERSION}"
666
+ return None, Plan(URL_STEPS, lambda p, t: _analyze_url(request_id, url, start_time, p, t), key)
667
 
668
  if source_type == "file":
669
  if not file:
 
678
  "fileSizeBytes": len(content),
679
  "mimeType": content_type,
680
  }
681
+ key = f"file:{hashlib.sha256(content).hexdigest()}:{PIPELINE_VERSION}"
682
+ return None, Plan(FILE_STEPS, lambda p, t: _run_pipeline(
683
  request_id, content,
684
  filename=filename, content_type=content_type,
685
+ source=source, start_time=start_time, progress=p, ticket=t,
686
+ ), key)
687
 
688
  if source_type in ("spotify", "apple"):
689
  return AnalyzeResponse(errors=["unsupported_source"]), None
690
  return AnalyzeResponse(errors=["invalid_source_type"]), None
691
 
692
 
693
+ def _busy() -> HTTPException:
694
+ return HTTPException(
695
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
696
+ detail={"code": "server_busy", "message": "Too many analyses waiting. Try again shortly."},
697
+ headers={"Retry-After": "30"},
698
+ )
699
+
700
+
701
  # ── endpoints ─────────────────────────────────────────────────────────
702
 
703
  @router.post(
 
706
  dependencies=[Depends(_analyze_rate_limit)],
707
  )
708
  async def analyze(
709
+ request: Request,
710
  sourceType: str = Form(...),
711
  url: Optional[str] = Form(None),
712
+ file: Optional[UploadFile] = File(None),
713
  ) -> AnalyzeResponse:
714
+ """Unified analysis endpoint (waits for the result, same queue as jobs)."""
715
  request_id = str(uuid.uuid4())[:8]
716
+ immediate, plan = await _plan(request_id, sourceType, url, file)
717
+ if immediate is not None or plan is None:
718
+ return immediate or AnalyzeResponse(errors=["internal_error"])
719
+ cached = result_cache.get(plan.key)
720
+ if cached is not None:
721
+ return AnalyzeResponse(**cached)
722
+ if cpu_gate.waiting + job_store.pending_count() >= MAX_PENDING:
723
+ raise _busy()
724
+
725
+ progress = Progress(plan.steps)
726
+ progress.gone = request.is_disconnected
727
  try:
728
+ response = await plan.work(progress, f"sync-{request_id}")
729
+ except JobCancelled as stop:
730
+ return AnalyzeResponse(errors=[stop.code])
 
 
731
  except Exception as e:
732
  logger.error(f"[{request_id}] Error: {e}", exc_info=True)
733
  return AnalyzeResponse(errors=["internal_error"])
734
+ result_cache.put(plan.key, response.model_dump())
735
+ return response
736
 
737
 
738
  @router.post(
 
744
  url: Optional[str] = Form(None),
745
  file: Optional[UploadFile] = File(None)
746
  ) -> JSONResponse:
747
+ """Queue an analysis; poll GET /api/analyze/jobs/{jobId}."""
748
  request_id = str(uuid.uuid4())[:8]
749
  immediate, plan = await _plan(request_id, sourceType, url, file)
750
  if immediate is not None or plan is None:
 
754
  "steps": [], "elapsedSec": 0, "response": response.model_dump(),
755
  })
756
 
757
+ async def run(job: Job) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
758
  try:
759
+ return (await plan.work(job.progress, job.id)).model_dump()
760
+ except JobCancelled:
761
+ raise
762
  except Exception as e:
763
  logger.error(f"[{request_id}] Job error: {e}", exc_info=True)
764
  return AnalyzeResponse(errors=["internal_error"]).model_dump()
765
 
766
+ try:
767
+ job = job_store.start(sourceType, plan.steps, run, key=plan.key)
768
+ except QueueFull:
769
+ raise _busy() from None
770
+ logger.info(f"[{request_id}] Job {job.id} {'from cache' if job.cached else 'queued'}")
771
  return JSONResponse(status_code=202, content=job.to_dict())
772
 
773
 
774
+ def _job_or_404(job: Optional[Job]) -> Job:
 
 
 
775
  if job is None:
776
  raise HTTPException(
777
  status_code=status.HTTP_404_NOT_FOUND,
778
  detail={"code": "job_not_found", "message": "Unknown or expired job."},
779
  )
780
+ return job
781
+
782
+
783
+ @router.get("/api/analyze/jobs/{job_id}")
784
+ async def get_analysis_job(job_id: str) -> Dict[str, Any]:
785
+ """Per-step progress of a job; `response` is filled once it settles.
786
+ Polling is also what keeps a queued job alive."""
787
+ return _job_or_404(job_store.get(job_id)).to_dict()
788
+
789
+
790
+ @router.delete("/api/analyze/jobs/{job_id}")
791
+ async def cancel_analysis_job(job_id: str) -> Dict[str, Any]:
792
+ """Cancel a job: dropped if still queued, stopped at the next step if running."""
793
+ return _job_or_404(job_store.cancel(job_id)).to_dict()
794
+
795
+
796
+ # ── warm-up ───────────────────────────────────────────────────────────
797
+
798
+ warm_state: Dict[str, Any] = {"done": False, "seconds": None}
799
+
800
+
801
+ async def warm_up() -> None:
802
+ """Load every model and let librosa compile its numba code on a short
803
+ synthetic signal, so the first visitor after a restart doesn't pay for it.
804
+ Holds the CPU slot while it runs; visitors arriving meanwhile queue."""
805
+ import numpy as np
806
+ import soundfile as sf
807
+
808
+ started = time.monotonic()
809
+ sr = 22050
810
+ t = np.arange(sr * 8) / sr
811
+ rng = np.random.default_rng(7)
812
+ y = (0.3 * np.sin(2 * np.pi * 220 * t) * (1 + 0.3 * np.sin(2 * np.pi * 2 * t))
813
+ + 0.15 * np.sin(2 * np.pi * 330 * t) + 0.02 * rng.standard_normal(t.size)).astype(np.float32)
814
+ buf = io.BytesIO()
815
+ sf.write(buf, y, sr, format="WAV", subtype="PCM_16")
816
+ loop = asyncio.get_running_loop()
817
+ try:
818
+ async with cpu_gate.slot("warm-up"):
819
+ clip = await loop.run_in_executor(_analysis_pool, decode_clip, buf.getvalue())
820
+ features, vocals, *_ = await asyncio.gather(
821
+ loop.run_in_executor(_analysis_pool, lambda: extract_features(io.BytesIO(clip))),
822
+ loop.run_in_executor(_analysis_pool, lambda: analyze_vocals(io.BytesIO(clip))),
823
+ loop.run_in_executor(_analysis_pool, lambda: clap_service.predict(io.BytesIO(clip))),
824
+ loop.run_in_executor(_analysis_pool, lambda: wav2vec2_service.predict(io.BytesIO(clip))),
825
+ )
826
+ if xai_service.available:
827
+ await loop.run_in_executor(_analysis_pool, xai_service.predict, features, vocals)
828
+ await loop.run_in_executor(_analysis_pool, lambda: meta_classifier_service.predict(features, vocals=vocals))
829
+ warm_state["done"] = True
830
+ warm_state["seconds"] = round(time.monotonic() - started, 1)
831
+ logger.info(f"Warm-up finished in {warm_state['seconds']}s")
832
+ except Exception as e: # noqa: BLE001 — a failed warm-up must not take the API down
833
+ logger.warning(f"Warm-up failed: {e}", exc_info=True)