sathvik77 commited on
Commit
abae7c8
·
1 Parent(s): 4ae09a8

feat(backend): youtube karaoke pipeline orchestrator

Browse files
tests/test_youtube_pipeline.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import MagicMock
2
+ from pathlib import Path
3
+ from youtube_pipeline import run_pipeline, PipelineDeps
4
+ from youtube import YoutubeFetchError, VideoTooLong
5
+
6
+
7
+ def _deps(tmp_path, existing_song=None):
8
+ repo = MagicMock()
9
+ repo.get_song.return_value = existing_song
10
+ storage = MagicMock()
11
+ storage.public_url.side_effect = lambda k: f"https://pub.example.com/{k}"
12
+
13
+ def fake_fetch_meta(url, max_duration_sec):
14
+ return {
15
+ "youtube_id": "abc12345678",
16
+ "title": "T", "artist": "A",
17
+ "duration_sec": 100, "thumbnail_url": "https://thumb",
18
+ }
19
+
20
+ def fake_download_audio(url, yt_id, out_dir):
21
+ p = Path(out_dir) / f"{yt_id}.m4a"
22
+ p.write_bytes(b"audio")
23
+ return p
24
+
25
+ def fake_separate(audio_path):
26
+ out = audio_path.parent / "no_vocals.mp3"
27
+ out.write_bytes(b"instrumental")
28
+ return out
29
+
30
+ return PipelineDeps(
31
+ repo=repo,
32
+ storage=storage,
33
+ fetch_metadata=fake_fetch_meta,
34
+ download_audio=fake_download_audio,
35
+ separate_vocals=fake_separate,
36
+ work_dir=tmp_path,
37
+ max_duration_sec=600,
38
+ ), repo, storage
39
+
40
+
41
+ def test_pipeline_cache_hit_skips_download(tmp_path):
42
+ cached = {
43
+ "youtube_id": "abc", "slug": "t-a", "status": "ready",
44
+ "r2_key": "karaoke/abc.mp3",
45
+ }
46
+ deps, repo, storage = _deps(tmp_path, existing_song=cached)
47
+ deps.repo.get_song.return_value = cached
48
+
49
+ result = run_pipeline("https://youtu.be/abc12345678", "job1", deps)
50
+
51
+ assert result["slug"] == "t-a"
52
+ deps.storage.upload_file.assert_not_called()
53
+ repo.update_job.assert_called_with("job1", status="ready", error=None)
54
+
55
+
56
+ def test_pipeline_full_path_uploads_and_persists(tmp_path):
57
+ deps, repo, storage = _deps(tmp_path, existing_song=None)
58
+
59
+ result = run_pipeline("https://youtu.be/abc12345678", "job1", deps)
60
+
61
+ storage.upload_file.assert_called_once()
62
+ args, kwargs = storage.upload_file.call_args
63
+ assert kwargs.get("key", args[1]).startswith("karaoke/")
64
+ repo.upsert_song.assert_called_once()
65
+ upserted = repo.upsert_song.call_args[0][0]
66
+ assert upserted["youtube_id"] == "abc12345678"
67
+ assert upserted["status"] == "ready"
68
+ assert upserted["r2_key"].endswith(".mp3")
69
+ assert result["slug"] == upserted["slug"]
70
+
71
+
72
+ def test_pipeline_fetch_error_marks_failed(tmp_path):
73
+ deps, repo, storage = _deps(tmp_path)
74
+ def boom(*a, **k): raise YoutubeFetchError("blocked")
75
+ deps = deps._replace(fetch_metadata=boom)
76
+
77
+ result = run_pipeline("https://youtu.be/abc12345678", "job1", deps)
78
+
79
+ assert result["status"] == "failed_fetch"
80
+ repo.update_job.assert_called_with("job1", status="failed_fetch", error="blocked")
81
+ storage.upload_file.assert_not_called()
82
+
83
+
84
+ def test_pipeline_too_long_marks_failed(tmp_path):
85
+ deps, repo, storage = _deps(tmp_path)
86
+ def boom(*a, **k): raise VideoTooLong("9999s")
87
+ deps = deps._replace(fetch_metadata=boom)
88
+
89
+ result = run_pipeline("https://youtu.be/abc12345678", "job1", deps)
90
+
91
+ assert result["status"] == "failed_fetch"
youtube_pipeline.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from typing import Any, Callable, NamedTuple
3
+ from db import SongRepo, slugify
4
+ from youtube import (
5
+ extract_youtube_id, YoutubeFetchError, VideoTooLong, InvalidYoutubeUrl,
6
+ )
7
+
8
+
9
+ class PipelineDeps(NamedTuple):
10
+ repo: SongRepo
11
+ storage: Any # R2Storage
12
+ fetch_metadata: Callable[..., dict]
13
+ download_audio: Callable[..., Path]
14
+ separate_vocals: Callable[[Path], Path]
15
+ work_dir: Path
16
+ max_duration_sec: int
17
+
18
+
19
+ def run_pipeline(url: str, job_id: str, deps: PipelineDeps) -> dict:
20
+ try:
21
+ youtube_id = extract_youtube_id(url)
22
+ except InvalidYoutubeUrl:
23
+ deps.repo.update_job(job_id, status="failed_fetch", error="invalid url")
24
+ return {"status": "failed_fetch", "error": "invalid url"}
25
+
26
+ # Cache hit
27
+ cached = deps.repo.get_song(youtube_id)
28
+ if cached and cached.get("status") == "ready" and cached.get("r2_key"):
29
+ deps.repo.update_job(job_id, status="ready", error=None)
30
+ return {"status": "ready", "slug": cached["slug"], "youtube_id": youtube_id}
31
+
32
+ # Fetch metadata
33
+ deps.repo.update_job(job_id, status="fetching")
34
+ try:
35
+ meta = deps.fetch_metadata(url, max_duration_sec=deps.max_duration_sec)
36
+ except (YoutubeFetchError, VideoTooLong) as e:
37
+ deps.repo.update_job(job_id, status="failed_fetch", error=str(e))
38
+ return {"status": "failed_fetch", "error": str(e)}
39
+
40
+ # Download
41
+ work = deps.work_dir / youtube_id
42
+ work.mkdir(parents=True, exist_ok=True)
43
+ try:
44
+ audio_path = deps.download_audio(url, youtube_id, work)
45
+ except YoutubeFetchError as e:
46
+ deps.repo.update_job(job_id, status="failed_fetch", error=str(e))
47
+ return {"status": "failed_fetch", "error": str(e)}
48
+
49
+ # Separate
50
+ deps.repo.update_job(job_id, status="separating")
51
+ try:
52
+ instrumental_path = deps.separate_vocals(audio_path)
53
+ except Exception as e:
54
+ deps.repo.update_job(job_id, status="failed_separation", error=str(e)[:200])
55
+ return {"status": "failed_separation", "error": str(e)}
56
+
57
+ # Upload
58
+ deps.repo.update_job(job_id, status="uploading")
59
+ key = f"karaoke/{youtube_id}.mp3"
60
+ try:
61
+ deps.storage.upload_file(instrumental_path, key, content_type="audio/mpeg")
62
+ except Exception as e:
63
+ deps.repo.update_job(job_id, status="failed_other", error=str(e)[:200])
64
+ return {"status": "failed_other", "error": str(e)}
65
+
66
+ # Persist
67
+ slug = slugify(meta["title"], meta["artist"])
68
+ deps.repo.upsert_song({
69
+ "youtube_id": youtube_id,
70
+ "slug": slug,
71
+ "title": meta["title"],
72
+ "artist": meta["artist"],
73
+ "duration_sec": meta["duration_sec"],
74
+ "r2_key": key,
75
+ "thumbnail_url": meta["thumbnail_url"],
76
+ "status": "ready",
77
+ })
78
+ deps.repo.update_job(job_id, status="ready", error=None)
79
+ return {"status": "ready", "slug": slug, "youtube_id": youtube_id}