feat: use yt-dlp as primary transcript fetcher, fallback to youtube-transcript-api

- Add _ytdlp_transcript() — fetch transcripts via yt-dlp Python API
  (supports json3/srv3/vtt/srt subtitle formats, cookies.txt auth)
- Add _parse_subtitles() — parse subtitle formats into plain text
- Keep _yttranscript_api_transcript() as fallback
- download_transcript() tries yt-dlp first, then youtube-transcript-api
- Add yt-dlp to requirements.txt
- Update tests to mock both paths (all 23 pass)
- Document Google Data API v3 caption limitations in architecture.md
  (captions.list works, captions.download=403 for non-owned videos)
This commit is contained in:
Gan, Jimmy
2026-07-12 01:36:53 +08:00
parent fd16217076
commit f1ada7d277
4 changed files with 147 additions and 11 deletions
+8 -4
View File
@@ -43,7 +43,8 @@ async def test_download_transcript_success():
mock_trans = MockTranscript()
mock_list = MockTranscriptList(mock_trans)
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list):
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list):
text = summarizer.download_transcript("test_vid")
assert text == "Welcome to this video tutorial. Today we are learning about Svelte 5 and runes."
@@ -60,7 +61,8 @@ async def test_summarize_video_success(test_db):
mock_list = MockTranscriptList(mock_trans)
mock_response = _mock_deepseek_response("**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line.")
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
summary = await summarizer.summarize_video("summarize_vid")
@@ -106,7 +108,8 @@ async def test_summarize_video_deepseek_error_leaves_null(test_db):
"429 Rate Limited", request=MagicMock(), response=mock_response
)
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response):
result = await summarizer.summarize_video("err_vid")
@@ -133,7 +136,8 @@ async def test_summarize_all_pending(test_db):
mock_list = MockTranscriptList(mock_trans)
mock_response = _mock_deepseek_response("Fresh summary")
with patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
with patch.object(summarizer, "_ytdlp_transcript", return_value=None), \
patch("youtube_transcript_api.YouTubeTranscriptApi.list", return_value=mock_list), \
patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post:
await summarizer.summarize_all_pending()