fix(youtube): 修复 YouTube 笔记生成失败 "Requested format is not available"

## 问题

输入 YouTube 链接生成笔记时任务必定失败:

    ERROR: [youtube] <id>: Requested format is not available

即使字幕已经抓取成功(日志里能看到"成功获取 YouTube 字幕,共 N 段"),
任务仍然在下载阶段崩掉。

## 原因

两个独立的问题叠加:

1. `requirements.txt` 把 yt-dlp 钉在 `2025.3.31`。YouTube 之后轮换过
   player,旧版 yt-dlp 解不出 nsig 签名,所有音视频格式都被丢弃,只剩下
   storyboard 图片(日志:`Only images are available for download`),
   格式选择随即抛错。

2. 有字幕时 `NoteGenerator` 走的是"只取元信息"的路径
   (`download(skip_download=True)`,只要 title/duration/cover),但
   `YoutubeDownloader.download` 无条件设置了
   `format='bestaudio[ext=m4a]/bestaudio/best'`。于是一个根本不需要媒体流的
   调用,也会因为选不出格式而失败——把一个字幕已经到手的任务整个带崩。

## 改动

- `youtube_downloader.py`:`skip_download` 时设置
  `ignore_no_formats_error=True`。yt-dlp 落后于 YouTube 时,只取元信息的路径
  降级为"没有音频",而不是让整个笔记失败。
- `youtube_downloader.py`:`ext = info.get("ext") or "m4a"`。跳过下载时
  yt-dlp 返回 `ext=None`,`dict.get` 的默认值对显式 None 不生效,
  会拼出 `xxx.None` 这样的路径。
- `requirements.txt`:`yt-dlp==2025.3.31` → `>=2026.7.4`。yt-dlp 是对抗
  YouTube 变化的滚动依赖,精确钉版本本身就是这个 bug 的成因;用 `>=` 与同文件
  的 `youtube-transcript-api>=1.0.0` 保持一致。
- `bilibili_dm_patch.py`:wrapper 改为透传 `**kwargs`。升级 yt-dlp 后
  `_real_extract` 会以 `fatal=False` 调用 `_download_playinfo`,而 wrapper
  钉死了签名,导致 **所有 B 站下载** 抛
  `TypeError: ... got an unexpected keyword argument 'fatal'`。
- 测试:新增 `test_youtube_metadata_only.py` 覆盖上面两条 YouTube 保证;
  `test_bilibili_dm_patch.py` 新增未知 kwargs 透传用例,并让 fake 响应带上
  `code` 字段(yt-dlp 2026.x 会先校验信封再返回 data)。

## 验证

- 真实跑通:YouTube(有字幕,走元信息路径)与 B 站(无字幕,走完整下载 +
  转写)均能生成笔记。
- `pytest tests/` → 46 passed。唯一失败的
  `test_task_serial_executor` 在升级前后表现一致,与本次改动无关,未作改动。
This commit is contained in:
pumpkinperson996
2026-07-25 07:37:10 -05:00
parent 6d67e5a76a
commit 3e579b1434
5 changed files with 187 additions and 5 deletions
+5 -2
View File
@@ -58,12 +58,15 @@ def apply_bilibili_dm_img_patch() -> bool:
if getattr(original, '_bili_dm_patched', False):
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
# _sign_wbi; caller-supplied query params (e.g. try_look/qn) take
# 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 {})}
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
BilibiliBaseIE._download_playinfo = _patched_download_playinfo
@@ -55,6 +55,10 @@ class YoutubeDownloader(Downloader, ABC):
if skip_download:
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)
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
@@ -63,7 +67,8 @@ class YoutubeDownloader(Downloader, ABC):
title = info.get("title")
duration = info.get("duration", 0)
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}")
return AudioDownloadResult(