本文讲的是用开源工具 yt-dlp 在本地下载,以及把它包成一个只给自己用的小服务(在线聚合下载站为什么不再推荐,见下一节)。
部署时需要的两件事另见:代理怎么搭Docker 怎么装

适用范围仅限你有权下载的内容:自己上传的视频、CC 授权作品、权利人明确允许下载的素材、以及所在司法辖区允许的个人存档。抓取受版权保护的内容并再分发是侵权;绕过平台的付费/DRM 限制通常同时违反服务条款。越过上面这两条线,后果由你自己承担。

为什么不再推荐在线下载站

原来的做法是把视频链接粘给某个在线聚合下载站,让它在服务器上解析好再丢给你一个文件。这套玩法的代价在这几年逐渐显现:

  • 可用性极差:这类站点的解析依赖 YouTube 的接口细节,平台一改就集体挂掉,域名换得比修得快。
  • 注入式广告与跳转:免费站的变现方式通常是弹窗、套娃跳转、伪装成「下载」按钮的推广;解析页正是挂马和钓鱼的重灾区,因为访问者天然准备点「下载」。
  • 隐私代价:你粘贴的是完整 URL(含视频 ID),部分站点还要求登录或装浏览器扩展。对「下载一个视频」这件事来说,这个代价不成比例。
  • 分辨率虚标:页面上写着支持 4K,实际给的是合并好的 720p 单流(带音轨)。原因见下节。
  • 随时可能消失:站点关停后,你之前的收藏、订阅、队列全部归零。

yt-dlp 把这些环节放回本地:解析规则是开源、可审计、社区维护的,下载的文件不经过第三方服务器,分辨率由你按格式表自己挑。

装好三件东西

yt-dlp 本体

1
2
3
4
5
6
7
8
# macOS(Homebrew 提供的是自带更新能力的可执行文件)
brew install yt-dlp

# 或者用 pipx 装进隔离环境,避免污染系统 Python
pipx install yt-dlp

# Windows:winget / scoop 均有;也可直接下载单文件 exe
# winget install yt-dlp.yt-dlp

官方文档把发布渠道分成三条:stable(大致按月发布)、nightly(有代码变更当晚发布)、master。README 明确写着 stable 往往滞后且容易被外部变化打断,nightly 才是对普通用户的推荐渠道。理由是这个工具维护的解析逻辑对手每天都在改:

1
2
yt-dlp -U                      # 更新到当前渠道的最新版
yt-dlp --update-to nightly # 切成 nightly 渠道

ffmpeg

1080p 几乎一定是「视频流 + 音频流分离」的 DASH 格式,合并必须靠 ffmpeg,缺了它你只能拿到无声画面或低清单流。

1
2
brew install ffmpeg            # macOS
sudo apt install ffmpeg # Debian / Ubuntu

JavaScript 运行时(新的硬性依赖)

这一条是近两年才出现的:YouTube 的播放器挑战需要用 JS 引擎来解算,yt-dlp 现在把这段逻辑放在 yt-dlp-ejs 里执行,没有可用的 JS 运行时,某些客户端会被整体跳过。官方支持的运行时按优先级为 deno(推荐,默认启用)、nodequickjsbun

1
2
brew install deno              # 推荐:默认启用的那个
# 已经有 Node 也行,但优先级低于 deno

日常使用:从格式表开始

先看能拿到什么

任何「为什么只有 720p」的问题,第一步都是看格式表:

1
yt-dlp -F "https://www.youtube.com/watch?v=VIDEO_ID"

输出的是一张表:每行一个格式,带 IDEXT、分辨率、码率、编码、协议、是否有音轨。两类格式要分清:

  • 带音轨的单流(多是 1822 这类):下载即可播放,但分辨率通常封顶在 720p,且码率较低;
  • 纯视频流137248 之类,acodec 显示为 none):分辨率高,但必须和纯音频流140 等)合并。

选格式:优先用 -S 而不是硬编码 -f

不传任何参数时,yt-dlp 的默认选择等价于 -f bestvideo*+bestaudio/best,也就是尽力拿最好的画质并合并。真要限制,推荐用排序(-S)而不是过滤(-f:过滤写死了具体格式 ID,一旦这个 ID 在该视频上不存在,整条命令直接失败;排序则是在候选里挑最符合你偏好的那个。

1
2
3
4
5
6
7
8
# 想要「不超过 1080p 的最佳画质」,用排序表达偏好
yt-dlp -S "res:1080" "URL"

# 偏好顺序更细:优先 H.264(兼容性最好)、其次按分辨率/帧率
yt-dlp -S "res:1080,codec:h264" "URL"

# 只有在确实要卡死某个条件时才用过滤表达式
yt-dlp -f "bv*[height<=1080]+ba/b[height<=1080]" "URL"

几个约定俗成的写法:bv* 是「最好的含视频流(可含音频)」,ba 是「最好的纯音频」,b 是「最好的单流」。/ 表示候选优先级,从左到右。合并容器可以指定:

1
yt-dlp -S "res:1080" --merge-output-format mp4 "URL"

输出命名与目录

默认文件名会把标题原样拼进去,中文和特殊字符容易出问题。模板变量用 -o 控制:

1
2
3
yt-dlp -S "res:1080" \
-o "%(uploader)s/%(upload_date>%Y-%m-%d)s - %(title).80s [%(id)s].%(ext)s" \
"URL"

%(title).80s 表示标题截断到 80 字符,%(upload_date>%Y-%m-%d)s 是日期格式转换,%(id)s 保留视频 ID 便于去重。音频单独抽出来(不需要 ffmpeg 也能跑,但要转码成 mp3 仍然需要):

1
yt-dlp -x --audio-format mp3 "URL"

字幕与元数据

1
2
3
4
5
6
7
8
# 下载人工字幕(中文优先,其次英文),并嵌入视频
yt-dlp --write-subs --sub-langs "zh-Hans,zh.*,en" --embed-subs -S "res:1080" "URL"

# 没有人工字幕时才考虑自动字幕
yt-dlp --write-auto-subs --sub-langs "zh-Hans,en" "URL"

# 把标题、作者、封面图写进文件,方便媒体库识别
yt-dlp --embed-metadata --embed-thumbnail -S "res:1080" "URL"

1080p 为什么经常拿不到

原因在 YouTube 侧:几道门槛叠在一起。

第一道:客户端与格式形态。 yt-dlp 通过不同的「客户端」身份去取格式表,不同客户端能看到的格式完全不同。默认值是 visionos,web;而 web 客户端目前只会提供 SABR 形态的流。如果环境里没有 JS 运行时,web 会被直接跳过——这就是「装了 yt-dlp 却拿不到高清」最常见的原因,装个 deno 往往立刻见效。想手动指定客户端:

1
yt-dlp --extractor-args "youtube:player_client=tv,web_safari" -F "URL"

web_safari 会提供 HLS 格式,tv 通常不需要 PO Token(但登录态缺失时格式可能带 DRM)。可选的客户端还有 web_embeddedmwebiosandroid_vrtv_simply 等,各自的能力和限制不同,值得在拿不到格式时逐个试试。

第二道:PO Token。 部分客户端(如 webmweb 的流媒体请求)要求请求里带一个能证明「请求来自真实客户端」的令牌。缺令牌的表现就是 403,或者干脆拿不到某些格式。这个令牌与视频 ID 绑定,也就是说每个视频都要新生成一个——所以官方已经不推荐手工抓取,而是推荐装一个 PO Token Provider 插件自动生成:

1
2
# 示例:bgutil 的 provider 插件(由 yt-dlp 维护者维护,需按其文档部署本地服务)
yt-dlp --extractor-args "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416" "URL"

第三道:登录态。 年龄限制、会员专属、部分地区限制的内容需要账号 cookie:

1
yt-dlp --cookies-from-browser chrome "URL"

注意两点:运行时要先退出浏览器(cookie 数据库被锁会读取失败);用账号 cookie 下载会把这些行为记到你的账号上,别拿主账号跑大批量任务。

403 与限流的排查顺序

按这个顺序试,基本能覆盖绝大多数失败:

  1. 先更新到 nightlyyt-dlp --update-to nightly。YouTube 的改动按周计,stable 经常已经过期。
  2. 确认 JS 运行时可用deno --versionnode -v 能跑通,且 denoPATH 里。
  3. 打印请求过程看失败在哪一步yt-dlp -v "URL",关注是取格式表失败,还是取媒体流 403。
  4. 换客户端--extractor-args "youtube:player_client=tv,web_safari"
  5. 加 cookie--cookies-from-browser firefox(参见上面的注意事项)。
  6. 上 PO Token Provider 插件:走到这一步基本能确定是令牌问题。
  7. 降速:被限流时给请求之间加间隔、限制并发分片数。
1
2
yt-dlp --sleep-requests 1 --min-sleep-interval 2 --max-sleep-interval 5 \
-N 1 --limit-rate 5M "URL"

-N 是并发下载的分片数(默认 1),调高能提速,但也更容易触发限流;--limit-rate 限制速率,长任务里能显著降低被封的概率。

包成一个只给自己用的小服务

命令行适合自己用,但如果你有 NAS、家庭服务器,或者希望手机也能用,把它包成一个 HTTP 服务更顺手。关键设计原则是不要裸奔在公网上——一个开放的 YouTube 代理会被迅速扫到并滥用,最后是出口 IP 被封。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# app.py —— 最小可用的下载服务:单用户、带令牌鉴权、串行队列
import os
import json
import uuid
import asyncio
import subprocess
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import FileResponse

TOKEN = os.environ["DL_TOKEN"] # 必须显式配置,没有默认值
OUT_DIR = Path(os.environ.get("DL_OUT", "/data")).resolve()
COOKIE_BROWSER = os.environ.get("DL_COOKIES") # 例如 "firefox",留空则不使用

JOBS: dict[str, dict] = {}
QUEUE: asyncio.Queue[str] = asyncio.Queue()


@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(worker())
yield
task.cancel()


app = FastAPI(lifespan=lifespan)


def build_cmd(url: str, job_id: str) -> list[str]:
cmd = [
"yt-dlp",
"--no-playlist", # 只下一个视频,别把整个播放列表拖下来
"-S", "res:1080",
"--merge-output-format", "mp4",
"-o", str(OUT_DIR / f"{job_id}.%(ext)s"),
"--print", "after_move:filepath", # 完成后打印最终路径,便于回调定位文件
]
if COOKIE_BROWSER:
cmd += ["--cookies-from-browser", COOKIE_BROWSER]
cmd.append(url)
return cmd


async def worker() -> None:
while True:
job_id = await QUEUE.get()
job = JOBS[job_id]
job["state"] = "running"
proc = await asyncio.create_subprocess_exec(
*build_cmd(job["url"], job_id),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
out, _ = await proc.communicate()
job["log"] = out.decode("utf-8", "replace")[-4000:]
job["state"] = "done" if proc.returncode == 0 else "failed"
QUEUE.task_done()


def auth(token: str | None) -> None:
if token != TOKEN:
raise HTTPException(status_code=401, detail="bad token")


@app.post("/jobs")
async def submit(payload: dict, x_token: str | None = Header(default=None)) -> dict:
auth(x_token)
url = str(payload.get("url", ""))
if not url.startswith(("https://www.youtube.com/", "https://youtu.be/")):
raise HTTPException(status_code=400, detail="unsupported url")
job_id = uuid.uuid4().hex[:12]
JOBS[job_id] = {"url": url, "state": "queued"}
await QUEUE.put(job_id)
return {"id": job_id}


@app.get("/jobs/{job_id}")
async def status(job_id: str, x_token: str | None = Header(default=None)) -> dict:
auth(x_token)
job = JOBS.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="no such job")
return {"id": job_id, "state": job["state"]}


@app.get("/jobs/{job_id}/file")
async def download(job_id: str, x_token: str | None = Header(default=None)):
auth(x_token)
job = JOBS.get(job_id)
if not job or job["state"] != "done":
raise HTTPException(status_code=409, detail="not ready")
path = next(OUT_DIR.glob(f"{job_id}.*"), None)
if path is None:
raise HTTPException(status_code=410, detail="file gone")
return FileResponse(path, filename=path.name)

几点说明:

  • 鉴权是必须的,且令牌从环境变量读、没有默认值——避免「忘了配就裸奔」。
  • 串行队列是刻意设计:并发下载会放大被限流的概率,家用场景下排队完全够用。
  • URL 白名单只放行 YouTube 域名,防止这个服务被当成通用 SSRF 跳板。
  • 想再收紧一层,用 docker run --cpus 1 -m 512m 限制资源,并在前面套一层带 HTTPS 的反向代理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# deno 用官方静态二进制镜像取:yt-dlp 提取 YouTube 需要 JS 运行时,且默认只认 deno
FROM denoland/deno:bin-2.9.6 AS deno

FROM python:3.13-slim
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deno /deno /usr/local/bin/deno

# 必须带 [default]:它才会装上 yt-dlp-ejs(YouTube 的 JS 挑战解算)
# 版本写死,构建可复现;升级就是改这几个版本号再 build
ARG YTDLP_SPEC="yt-dlp[default]==2026.8.19"
RUN pip install --no-cache-dir "${YTDLP_SPEC}" fastapi==0.141.1 uvicorn==0.53.0

# 不用 root 跑服务
RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin app
WORKDIR /app
COPY --chown=app:app app.py .
RUN mkdir -p /data && chown app:app /data

USER app
EXPOSE 8000
HEALTHCHECK --interval=1m --timeout=5s --start-period=20s \
CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/docs',timeout=3)" || exit 1

CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]

同目录再放一个 .dockerignore,否则你下载到 ./data 的视频会被当成构建上下文整包发给 Docker 守护进程:

1
2
3
4
data/
__pycache__/
*.pyc
.env
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 容器以 uid 10001 运行,宿主目录属主要对得上
mkdir -p data && sudo chown -R 10001:10001 data

docker build -t ytdlp-service .
docker run -d --name ytdlp --restart unless-stopped \
-p 127.0.0.1:8000:8000 \
--log-opt max-size=10m --log-opt max-file=3 \
--memory 512m --memory-swap 512m --cpus 1 --pids-limit 256 \
-e DL_TOKEN="换成足够长的随机串" \
-e DL_OUT=/data \
-v "$PWD/data:/data" \
ytdlp-service

# 宿主机需要代理才能出网时,容器不会继承守护进程的代理,要在这里注入
# (172.17.0.1 是默认 bridge 网关,用 ip -4 addr show docker0 确认;
# 代理服务本身怎么配见 /2478192043.html)
docker run -d --name ytdlp --restart unless-stopped \
-p 127.0.0.1:8000:8000 \
-e HTTP_PROXY=http://172.17.0.1:10809 \
-e HTTPS_PROXY=http://172.17.0.1:10809 \
-e NO_PROXY=localhost,127.0.0.1,172.17.0.0/16 \
-e DL_TOKEN="换成足够长的随机串" -e DL_OUT=/data \
-v "$PWD/data:/data" \
ytdlp-service

# 确认容器里三件套都在
docker exec ytdlp yt-dlp --version
docker exec ytdlp deno --version
docker exec ytdlp python -c "import yt_dlp_ejs; print('ejs ok')"

几个和「能跑起来」直接相关的点:

  • JS 运行时不是可选项。yt-dlp 从 PyPI 装的时候,[default] 这个附加依赖才会带上 yt-dlp-ejs 与解析挑战所需的依赖;裸写 pip install yt-dlp 装不上 ejs,容器里也没有任何 JS 引擎,YouTube 的提取会退化或失败。上面三条 docker exec 就是用来确认这件事的。
  • --restart unless-stopped:进程崩了或机器重启会自动拉起,手动 stop 之后不会被强行拉回。已经在跑的容器可以用 docker update --restart unless-stopped ytdlp 补上。
  • 日志要设上限json-file 驱动默认不限制大小,而这个服务会持续往 stdout 写下载日志。
  • 不要写 VOLUME ["/data"]:它只会在你忘记挂载时悄悄建一个匿名卷,数据藏在那里很难找。-v 是必须的。
  • -p 127.0.0.1:8000:8000 只监听本机回环地址,需要外网访问时通过反向代理加认证暴露,不要直接 -p 8000:8000
  • 容器里的 yt-dlp 随镜像固化,升级方式就是重建镜像:改上面的版本号(或把 YTDLP_SPEC 换成 nightly 的 --pre 安装方式)再 docker build。YouTube 侧的改动按周计,建议每月重建一次。
  • 资源限制不是可选项。这个服务会替别人下载文件、调用外部命令,一旦被滥用(有人扫到端口、或者一个超长视频占满内存),代价是整台机器:--memory--memory-swap 相等表示禁用 swap,--cpus 限核数,--pids-limit 防 fork bomb。设备紧张时还可以加 --read-only --tmpfs /tmp--security-opt no-new-privileges
  • 代理有三层,别只看一层:守护进程拉镜像、容器内进程出网、docker buildRUN 步骤。上面第二条 docker run 解决的是中间那层,另外两层的配置见 sing-box 代理与 Docker 那节
  • 数据都在 -v 挂载的宿主目录里,备份直接 tar 这个目录即可;如果改用命名卷,回收前先看 docker volume ls,匿名卷要用 docker volume prune 才清得掉。

替代方案

  • cobalt:另一个开源下载工具,前端 + API 一体,有公开实例,也支持自托管(官方仓库的 docs/run-an-instance.md 有部署与「如何保护实例」的说明)。它的定位是「下载自由且公开可访问的内容」,服务端不缓存文件、相当于一个带解析能力的代理。想要开箱即用又不想自建解析逻辑,它是目前体验最接近「粘链接、拿文件」的方案。
  • NewPipe(Android):手机端开源客户端,内置下载与后台播放,不需要 Google 服务框架。
  • JDownloader:老牌桌面下载管理器,站点支持面广,适合批量任务与网盘,代价是界面和依赖都偏重。

维护提示

这类工具的成本主要在持续更新:YouTube 侧的改动是常态,nightly 渠道的存在就是为了跟上它。建议把更新做成例行任务(比如每周 yt-dlp --update-to nightly),或者在自建服务里直接用镜像重建来承担更新。遇到突然大批量 403,先更新再排查。

总结

  • 在线聚合下载站是「可用性、隐私、安全」三重折价,能用开源工具就别用它们。
  • yt-dlp 装三件套:本体(建议 nightly)+ ffmpeg(合并音视频)+ JS 运行时(deno,新的硬性依赖)。
  • 选格式用 -S "res:1080" 这类排序表达偏好,只有必须卡死条件时才用 -f 过滤;-F 是排障第一步。
  • 拿不到 1080p 通常是撞在客户端、PO Token、登录态三道门槛之一。
  • 要长期用就自建一个带鉴权、只监听回环、串行队列的小服务,别把解析能力裸奔在公网上。

参考资料

系列索引:网络与自建服务