mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-09-04 23:26:51 +08:00
Merge pull request #427 from pumpkinperson996/fix/youtube-format-not-available
fix(youtube): 修复 YouTube 笔记生成失败 "Requested format is not available"
This commit is contained in:
@@ -58,12 +58,15 @@ def apply_bilibili_dm_img_patch() -> bool:
|
|||||||
if getattr(original, '_bili_dm_patched', False):
|
if getattr(original, '_bili_dm_patched', False):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _patched_download_playinfo(self, bvid, cid, headers=None, query=None):
|
def _patched_download_playinfo(self, bvid, cid, headers=None, query=None, **kwargs):
|
||||||
# dm_* are merged into the query that the original method signs via
|
# dm_* are merged into the query that the original method signs via
|
||||||
# _sign_wbi; caller-supplied query params (e.g. try_look/qn) take
|
# _sign_wbi; caller-supplied query params (e.g. try_look/qn) take
|
||||||
# precedence over the injected dummies.
|
# precedence over the injected dummies.
|
||||||
|
# **kwargs stays open on purpose: yt-dlp keeps adding parameters to
|
||||||
|
# _download_playinfo (2026.x added `fatal`), and a wrapper that pins the
|
||||||
|
# signature turns every such addition into a TypeError at download time.
|
||||||
merged_query = {**build_dm_img_params(), **(query or {})}
|
merged_query = {**build_dm_img_params(), **(query or {})}
|
||||||
return original(self, bvid, cid, headers=headers, query=merged_query)
|
return original(self, bvid, cid, headers=headers, query=merged_query, **kwargs)
|
||||||
|
|
||||||
_patched_download_playinfo._bili_dm_patched = True
|
_patched_download_playinfo._bili_dm_patched = True
|
||||||
BilibiliBaseIE._download_playinfo = _patched_download_playinfo
|
BilibiliBaseIE._download_playinfo = _patched_download_playinfo
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class YoutubeDownloader(Downloader, ABC):
|
|||||||
|
|
||||||
if skip_download:
|
if skip_download:
|
||||||
ydl_opts['skip_download'] = True
|
ydl_opts['skip_download'] = True
|
||||||
|
# 只取元信息时并不需要媒体流。yt-dlp 版本落后于 YouTube player 时,
|
||||||
|
# nsig 解析失败会导致所有音视频格式被丢弃,此时格式选择会抛
|
||||||
|
# "Requested format is not available",把一个已经拿到字幕的任务带崩。
|
||||||
|
ydl_opts['ignore_no_formats_error'] = True
|
||||||
|
|
||||||
_apply_proxy(ydl_opts)
|
_apply_proxy(ydl_opts)
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
@@ -63,7 +67,8 @@ class YoutubeDownloader(Downloader, ABC):
|
|||||||
title = info.get("title")
|
title = info.get("title")
|
||||||
duration = info.get("duration", 0)
|
duration = info.get("duration", 0)
|
||||||
cover_url = info.get("thumbnail")
|
cover_url = info.get("thumbnail")
|
||||||
ext = info.get("ext", "m4a")
|
# skip_download 时 yt-dlp 返回 ext=None,默认值不会生效,避免拼出 "xxx.None"
|
||||||
|
ext = info.get("ext") or "m4a"
|
||||||
audio_path = os.path.join(output_dir, f"{video_id}.{ext}")
|
audio_path = os.path.join(output_dir, f"{video_id}.{ext}")
|
||||||
|
|
||||||
return AudioDownloadResult(
|
return AudioDownloadResult(
|
||||||
|
|||||||
@@ -125,5 +125,5 @@ webencodings==0.5.1
|
|||||||
websockets==15.0.1
|
websockets==15.0.1
|
||||||
yarl==1.19.0
|
yarl==1.19.0
|
||||||
youtube-transcript-api>=1.0.0
|
youtube-transcript-api>=1.0.0
|
||||||
yt-dlp==2025.3.31
|
yt-dlp>=2026.7.4
|
||||||
zopfli==0.2.3.post1
|
zopfli==0.2.3.post1
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ class ApplyPatchTest(unittest.TestCase):
|
|||||||
|
|
||||||
def fake_download_json(url, video_id, **kwargs):
|
def fake_download_json(url, video_id, **kwargs):
|
||||||
# Avoid any network; the real playurl call would 412 without dm_*.
|
# Avoid any network; the real playurl call would 412 without dm_*.
|
||||||
return {"data": {"ok": True}}
|
# yt-dlp >= 2026.x checks the envelope's `code` before returning
|
||||||
|
# `data`, so the fake has to look like a real playurl response.
|
||||||
|
return {"code": 0, "data": {"ok": True}}
|
||||||
|
|
||||||
ie = BilibiliBaseIE(YoutubeDL({"quiet": True}))
|
ie = BilibiliBaseIE(YoutubeDL({"quiet": True}))
|
||||||
ie._sign_wbi = fake_sign_wbi
|
ie._sign_wbi = fake_sign_wbi
|
||||||
@@ -89,6 +91,32 @@ class ApplyPatchTest(unittest.TestCase):
|
|||||||
# the original method still builds its base params
|
# the original method still builds its base params
|
||||||
self.assertEqual(captured["bvid"], "BV1X9L16oEgB")
|
self.assertEqual(captured["bvid"], "BV1X9L16oEgB")
|
||||||
|
|
||||||
|
def test_patch_forwards_unknown_kwargs_to_original(self):
|
||||||
|
"""
|
||||||
|
yt-dlp's real call site passes kwargs the wrapper never declared —
|
||||||
|
`_real_extract` calls `_download_playinfo(..., fatal=False)` since
|
||||||
|
2026.x. A wrapper with a pinned signature raises TypeError there and
|
||||||
|
breaks every Bilibili download, so unknown kwargs must pass through.
|
||||||
|
"""
|
||||||
|
from yt_dlp import YoutubeDL
|
||||||
|
from yt_dlp.extractor.bilibili import BilibiliBaseIE
|
||||||
|
|
||||||
|
bilibili_dm_patch.apply_bilibili_dm_img_patch()
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_download_json(url, video_id, **kwargs):
|
||||||
|
return {"code": 0, "data": {"ok": True}}
|
||||||
|
|
||||||
|
ie = BilibiliBaseIE(YoutubeDL({"quiet": True}))
|
||||||
|
ie._sign_wbi = lambda params, video_id: seen.update(params) or params
|
||||||
|
ie._download_json = fake_download_json
|
||||||
|
|
||||||
|
# Must not raise TypeError on a kwarg the wrapper does not name.
|
||||||
|
ie._download_playinfo("BV1X9L16oEgB", 4242, headers={}, query={}, fatal=False)
|
||||||
|
|
||||||
|
self.assertTrue(REQUIRED_KEYS.issubset(seen.keys()))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Coverage for the YouTube "metadata only" download path.
|
||||||
|
|
||||||
|
Background: when a YouTube video already has subtitles, NoteGenerator skips the
|
||||||
|
audio download and calls `YoutubeDownloader.download(skip_download=True)` purely
|
||||||
|
to read title/duration/cover. That call used to still request
|
||||||
|
`format='bestaudio[ext=m4a]/bestaudio/best'`.
|
||||||
|
|
||||||
|
Whenever the installed yt-dlp lags behind YouTube's player, nsig extraction
|
||||||
|
fails, every audio/video format is dropped (only storyboard images remain) and
|
||||||
|
format selection raises "Requested format is not available" — killing a task
|
||||||
|
whose transcript had already been fetched successfully.
|
||||||
|
|
||||||
|
These tests pin the two guarantees of that path:
|
||||||
|
1. skip_download implies ignore_no_formats_error, so a formatless extraction
|
||||||
|
degrades to "no audio" instead of failing the whole note.
|
||||||
|
2. ext falls back to m4a, since yt-dlp reports ext=None when skipping the
|
||||||
|
download (dict.get's default does not fire on an explicit None).
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
MODULE_PATH = ROOT / "app" / "downloaders" / "youtube_downloader.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _stub(name, **attrs):
|
||||||
|
module = types.ModuleType(name)
|
||||||
|
for key, value in attrs.items():
|
||||||
|
setattr(module, key, value)
|
||||||
|
sys.modules.setdefault(name, module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class _Downloader:
|
||||||
|
def __init__(self):
|
||||||
|
self.cache_data = "/tmp"
|
||||||
|
|
||||||
|
|
||||||
|
class _AudioDownloadResult:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_youtube_downloader():
|
||||||
|
"""Load the module with its app-level dependencies stubbed out."""
|
||||||
|
_stub("app")
|
||||||
|
_stub("app.downloaders")
|
||||||
|
_stub("app.models")
|
||||||
|
_stub("app.services")
|
||||||
|
_stub("app.utils")
|
||||||
|
_stub(
|
||||||
|
"app.downloaders.base",
|
||||||
|
Downloader=_Downloader,
|
||||||
|
DownloadQuality=str,
|
||||||
|
YDL_RETRY_OPTS={"retries": 3, "fragment_retries": 3, "socket_timeout": 30},
|
||||||
|
)
|
||||||
|
_stub("app.downloaders.youtube_subtitle", YouTubeSubtitleFetcher=object)
|
||||||
|
_stub("app.models.notes_model", AudioDownloadResult=_AudioDownloadResult)
|
||||||
|
_stub("app.models.transcriber_model", TranscriptResult=object)
|
||||||
|
_stub(
|
||||||
|
"app.services.proxy_config_manager",
|
||||||
|
ProxyConfigManager=type(
|
||||||
|
"ProxyConfigManager", (), {"get_proxy_url": lambda self: None}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_stub("app.utils.path_helper", get_data_dir=lambda: "/tmp")
|
||||||
|
_stub("app.utils.url_parser", extract_video_id=lambda url, platform: "vid")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("youtube_downloader", MODULE_PATH)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError("youtube_downloader module spec not found")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeYoutubeDL:
|
||||||
|
"""Records the opts it was constructed with; mimics a formatless extraction."""
|
||||||
|
|
||||||
|
captured_opts = None
|
||||||
|
|
||||||
|
def __init__(self, opts):
|
||||||
|
type(self).captured_opts = opts
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc_info):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extract_info(self, url, download=True):
|
||||||
|
# What yt-dlp yields for a metadata-only extraction: no media, so no ext.
|
||||||
|
return {
|
||||||
|
"id": "CJ4ndXv3CkY",
|
||||||
|
"title": "example",
|
||||||
|
"duration": 2231,
|
||||||
|
"thumbnail": "https://example.invalid/t.jpg",
|
||||||
|
"ext": None,
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class YoutubeMetadataOnlyTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
try:
|
||||||
|
cls.module = _load_youtube_downloader()
|
||||||
|
except Exception as exc: # pragma: no cover - env without yt-dlp
|
||||||
|
raise unittest.SkipTest(f"youtube_downloader not importable: {exc}")
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._real_ydl = self.module.yt_dlp.YoutubeDL
|
||||||
|
self.module.yt_dlp.YoutubeDL = _FakeYoutubeDL
|
||||||
|
_FakeYoutubeDL.captured_opts = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.module.yt_dlp.YoutubeDL = self._real_ydl
|
||||||
|
|
||||||
|
def test_skip_download_tolerates_missing_formats(self):
|
||||||
|
self.module.YoutubeDownloader().download(
|
||||||
|
"https://www.youtube.com/watch?v=CJ4ndXv3CkY",
|
||||||
|
output_dir="/tmp",
|
||||||
|
skip_download=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(_FakeYoutubeDL.captured_opts.get("ignore_no_formats_error"))
|
||||||
|
|
||||||
|
def test_missing_ext_falls_back_to_m4a(self):
|
||||||
|
result = self.module.YoutubeDownloader().download(
|
||||||
|
"https://www.youtube.com/watch?v=CJ4ndXv3CkY",
|
||||||
|
output_dir="/tmp",
|
||||||
|
skip_download=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(result.file_path.endswith(".m4a"), result.file_path)
|
||||||
|
self.assertNotIn("None", result.file_path)
|
||||||
|
|
||||||
|
def test_full_download_still_selects_an_audio_format(self):
|
||||||
|
self.module.YoutubeDownloader().download(
|
||||||
|
"https://www.youtube.com/watch?v=CJ4ndXv3CkY",
|
||||||
|
output_dir="/tmp",
|
||||||
|
)
|
||||||
|
opts = _FakeYoutubeDL.captured_opts
|
||||||
|
self.assertIn("bestaudio", opts.get("format", ""))
|
||||||
|
self.assertNotIn("ignore_no_formats_error", opts)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user