央视频下载器(Electron 重写版)(参考了 https://github.com/letr007/CCTVVideoDownloader/tree/main)
用 Electron + React + Vite + TypeScript 重写的央视网视频下载器,对应原 C++/Qt6 项目 CCTVVideoDownloader。
仅供技术研究 / 学习交流,请遵守网站条款与所在地法律法规,勿用于侵权传播。
- 导入央视网栏目 / 专辑 / 单集 / 4K 专区链接(忠实移植原项目的页面解析与抓取策略)
- 按月份范围浏览节目,支持完整 / 看点 / 片段
- 筛选条件:关键词(标题 / 简介 / GUID)、节目形态、频道、月份区间、时长区间、 热度等级、排序字段(时间 / 热度 / 标题 / 时长)+ 升降序、只看已选,可一键重置
- 地址库:解析成功的链接自动落盘保存,可在左侧栏随时切换、搜索、删除,并记录最近使用时间与抓取条数
- 内置「栏目大全」:左栏直接列出央视官方栏目索引(344 个,来自
api.cntv.cn/lanmu/columnSearch), 支持按栏目名 / 频道检索、按分类过滤;点一下就进列表,不必再自己找栏目页链接 - 全站搜索:关键词走央视搜索接口
search.cctv.com/ifsearch.php,搜的是全站、全部时间段, 而不是在已加载的列表里做本地过滤。左边选中了栏目时,栏目名会作为 AND 条件并入查询串 (即「在这个栏目里搜」),没选栏目就是全站搜。结果只保留视频条目,触底按page翻页。 栏目作用域看得见、点得掉:左栏与筛选栏都会显示「限栏目:X」标签,关掉即回到全站; 从地址栏 / 地址库 / 示例解析新目标时会自动解除,不会让上一次的栏目一直绑着搜索。 关键词提交后不会从输入框里消失(方便接着补充 / 改字再搜一次);在搜索态里改词重搜, 已选的时间范围 / 视频时长 / 频道不会被重置(换个词不该把你刚挑好的条件冲掉)。 输入框里的词与当前结果不一致时,结果头部会直接给一个「搜索「新词」」按钮,不让界面说谎 - 热播榜:左栏「热播」页签,取央视官方热搜榜(
topwords_js/newhotword.js), 热词 + 每个词挂的几条视频;点词直接发起全站搜索,点条目直接预览 - 预览:卡片上的「播放」按钮(或点封面)打开预览窗,加载央视官方播放页在线播放,不落盘。
打开时只显示封面,点了播放才加载页面(官方页会 autoplay,不替用户做这个决定)。
搜索 / 热播榜结果自带播放页地址;栏目列表只有 32 位 guid,会走
zy.api.cntv.cn/video/videoinfoByGuid反查 - 结果性能:列表走服务端分页(
p/n),一次请求 = 一页,滚动到底部自动续取下一页; 渲染层改为响应式卡片网格(VideoGrid,对齐主流视频站结果页):列数随窗口宽度自适应, 滚动触底用 IntersectionObserver 续取,遮罩 / 操作默认隐藏、hover 才浮现,多选走卡片角标。 - 多路下载:全局并发闸门(可在设置里限制同时下载路数)+ 按任务取消 + 进度批处理
- 下载挂件(右下角浮层):加入下载后不再自动弹开队列抽屉,改由右下角挂件做反馈—— 滑入 + 呼吸光环 + 进度环,悬停就地展开最多 3 个在跑任务的明细进度条, 点一下才打开完整队列。边挑边下不再被打断:连下好几个视频时不会反复有抽屉盖住列表
- 下载队列:抽屉式队列,支持过滤(全部 / 进行中 / 已下载 / 失败,各带计数 + 关键词)
与虚拟滚动(自研
VirtualList,万级条目照样流畅);保存目录可在队列内直接「更改」, 无需跳去设置页;失败任务可就地重试。队列里只有一套「已下载」口径 = 磁盘上有这个文件: 刚下完的那条会就地变成文件行(下完即「已下载」,不再有单独一个「已完成」页签), 上次运行下载的文件也列在同一处;每行可直接 播放 / 在文件资源管理器中打开文件位置 / 删除 - 断点续传 / 启动恢复:关掉软件再打开,没下完的任务能接着下。下载按分片落盘到
.task_<guid>/shard_NNNNNN.ts,中途取消或失败保留分片(只清半成品中间文件), 重启后抽屉顶部出现「可继续」区,一键继续或全部继续(也可「放弃」并删掉分片)。 已存在的分片会跳过重新下载,只补缺失的那几片 - 本地下载库:左栏「已下载」页签,以磁盘为单一事实来源列出保存目录下的视频
(默认封装为 MP4)。启动即扫描,下载完成后自动重扫,计数显示在面板标题
已下载 · N 个文件与左栏该页签的说明行里,表头标注扫描时间; 支持单文件删除(移入回收站,可恢复)、用系统默认程序播放、打开所在文件夹、 在文件资源管理器中打开文件位置(直接选中该文件); 顶部「刷新」重扫目录,防止文件在外部被删后列表仍残留; 下载队列里也可对已下载的视频一键删除文件(同步摘掉队列里那条记录) - 已下载识别:列表卡片上,保存目录里已有成片的视频会打上绿色「已下载」标, 点下载前就能看出来。真的点到已下载的,会先弹确认——默认只下没下过的, 也可勾选「连同这些一起重新下载」,不会在你不知情时重复下一遍
- 保存目录默认为
~/Downloads/CCTV,可在设置里用系统目录选择框自定义,也可在下载队列 / 本地下载库里一键「更改目录」/「打开目录」 - H5E 视频解密(纯 TypeScript 移植自原项目)、TS 合并、MP4 封装(ffmpeg 已内置,无需用户安装)
- 附带命令行工具
cctv-dl
界面基于 Ant Design 6(暗色主题 + zh_CN 语言包,日期使用 dayjs zh-cn)。
窗口不显示原生菜单栏(已在 main/index.ts 里 Menu.setApplicationMenu(null) 移除),
所有功能入口都在应用内 UI;复制/粘贴/刷新/DevTools 等快捷键不受影响。
src/
main/core/ 主进程核心(Node 上下文)
crypto/ cctvH5eDecrypt.ts —— H5E 解密(TEA-16 / type1/type5 网格 / EPB / AF)
tsmerger.ts TS 分片合并 + 连续性计数修正
downloadEngine.ts 并发下载 + 超时重试 + 分片级续传(`resume`,原子写 `.part` → rename)
contentresolver.ts m3u8 解析 / 清晰度选择
ffmpegLocator.ts 定位内置 ffmpeg(打包 resources → node_modules → PATH)
mediaFinalizer.ts ffmpeg 封装为 MP4(内置 ffmpeg,用户无需安装)
downloadCoordinator.ts 全流程编排(状态机;取消/失败保留分片以便续传)
downloadManager.ts 多路下载总闸:并发队列 / 进度节流 / 按任务取消
taskStore.ts 未完成任务清单(意图落盘 + 以磁盘分片对账,淘汰僵尸条目)
localFiles.ts 本地文件操作(扫描 / 回收站删除 / 打开 / 定位所在位置);shell 由主进程注入
addressStore.ts 地址库持久化
chunkWriter.ts 带背压的块写入(正确摘除监听器)
apiservice.ts 央视网页面 / 视频信息抓取
contentparse.ts 页面类型分类
videoPaginator.ts 列表分页会话(栏目 / 专辑 / v.cctv 统一成「下一页」)
columnDirectory.ts 央视栏目大全(344 个,1 小时缓存)
searchService.ts 全站搜索 + 热播榜(含 guid 补全,已单测)
config.ts 设置持久化
main/index.ts 主进程入口(窗口 + IPC)
renderer/ React 界面(Ant Design 6)
theme.ts 设计令牌(色板 / 间距 / 圆角 / 动效)
antdTheme.ts ThemeConfig:令牌 → antd v6 主题
filters.ts 筛选 / 排序纯函数(已单测)
hooks.ts 进度批处理 / 输入防抖
localIndex.ts 已下载索引:扫描结果 → 「标题 → 磁盘文件」查找表(已单测)
queueSummary.ts 队列进度口径唯一实现(挂件与抽屉共用,已单测)
queueRows.ts 队列行模型:任务 ↔ 磁盘文件配对、「已下载」口径与过滤(已单测)
searchParams.ts 搜索条件推进规则:改词重搜保留条件 / 待提交关键词(已单测)
components/
AddressSider 地址库侧栏
ColumnSider 栏目大全侧栏(344 个,本地检索 + 分类)
HotSider 热播榜侧栏(热词 + 视频,点词即搜 / 点条目即预览)
FilterBar 高频筛选 + 「更多」渐进披露 + 关键词提交搜索
ListToolbar 统计与批量操作(全选 / 反选 / 清空 / 下载已选)
VideoGrid 结果卡片网格(响应式缩略图卡 + 触底加载 + 多选 + 预览 / 下载 + 已下载标)
PreviewModal 预览窗(webview 播官方页,点击才加载,不落盘)
DownloadDrawer 下载队列(任务行 + 已下载行 / 过滤 / 虚拟滚动 / 可继续区)
DownloadPod 右下角下载挂件(引导动画 + 悬停明细,点开才是队列)
LocalFilesView 本地下载库(扫保存目录 + 删除 / 播放 / 打开位置 / 打开 + 刷新)
SettingsDrawer 设置(保存目录 / 清晰度 / 封装 MP4 / 并发)
Hint 受控提示浮层(唯一出口)
cli/cctv-dl.ts 命令行工具
shared/ 跨进程类型与 IPC 契约
heat.ts 本地热度模型(连续合成打分 + 列表内分位划档,已单测)
mediaName.ts 落盘命名规则唯一实现(主进程写文件 / 渲染层认文件共用,已单测)
yarn install # 或 npm install(自动补齐内置 ffmpeg,见下)
yarn dev # 启动 Electron + React 开发
yarn typecheck # tsc --noEmit
yarn test # vitest 单测(H5E 解密 / TS 合并 / 页面解析 / 续传 / 待续清单)
yarn build # 只构建 dist/(electron-vite,不出安装包)
yarn ensure:ffmpeg # 手动补齐内置 ffmpeg(install 失败时用)
yarn pack:dir # 出未压缩绿色版目录(自动校验内置 ffmpeg 是否落位)
yarn dist # 出 Windows 安装包 .exe(打包目录在工作区外,见「打包成 exe」)- 面板必须靠
flex: 1撑高,而 antd 6 的.ant-layout-sider-children是块容器 (v5 里是 flex column)。styles.css里有一条覆盖把它恢复成 flex 列 —— 别删。 删掉的后果不是报错,而是左栏长列表滚不动:滚动容器会自己长到内容高度、永不溢出。 - 页签文案 ≤ 3 个汉字,且不要把计数写进标签:252px 侧栏四等分后只有约 42px 文字宽,
超出的会被
ellipsis悄悄吃掉(曾经显示成「地址…」「本地…」)。计数放各面板标题里。
一条命令即可(dist 脚本内部已先跑 electron-vite build):
yarn dist只想构建产物、不出安装包时用 yarn build;想只要绿色版目录时用 yarn pack:dir。
产物分两处(为什么分开见下面「②」):
① 项目内 release/ —— 回拷回来的成品
| 文件 | 说明 |
|---|---|
央视频下载器-0.1.0-win-x64.exe |
安装程序(NSIS,可选安装目录、带卸载器)。这就是要发给用户的 exe,约 95MB |
② 工作区外的打包目录 E:\cctv-dist\release\(可用 CCTV_OUT 环境变量改)
| 文件 | 说明 |
|---|---|
win-unpacked/ |
免安装的绿色版目录,双击里面的 央视频下载器.exe 直接跑(调试打包态问题用它) |
builder-debug.yml |
electron-builder 的最终配置快照 |
win-unpacked/必须待在工作区之外:它的resources/app.asar只要被放在工作区内, 就会被 WorkBuddy 的文件索引长期锁住,导致此后每次打包都失败(原因见下面「②」)。
安装包的大部分体积来自 Electron 运行时本身,不是我们的代码:
| 组成 | 压缩后 | 备注 |
|---|---|---|
| 主程序 exe(Electron 运行时) | ~50MB | 换不掉,Electron 应用都这量级 |
resources/ffmpeg/ffmpeg.exe |
~21MB | 内置 ffmpeg 的代价,用户因此免装 |
resources/app.asar |
~2.6MB | 我们自己的代码 |
| 其余(icu/pak/locales…) | ~20MB | Electron 自带 |
配置里做了两处必须保留的瘦身,改动前先读 electron-builder.yml 的注释:
react/react-dom/antd放在 devDependencies。渲染层已被 Vite 完整打包进dist/renderer/,主进程与预加载也用不到它们 —— 放dependencies会被 electron-builder 整个塞进app.asar(实测白占 43MB)。files里排除node_modules/ffmpeg-static/**。electron-builder 的 smartUnpack 会自动把 里面的ffmpeg.exe解包一份到app.asar.unpacked/,和extraResources复制的那份 重复(各约 20.7MB)。排除后安装包 121MB → 95MB。
① TLS 拦截导致下载依赖失败(首次打包时)
electron-builder 需要下载 Electron 二进制和 nsis/winCodeSign 工具链。镜像已写进
electron-builder.yml 的 electronDownload.mirror(npmmirror),但如果本机开了代理、
触发了 TLS 吊销检查,仍可能报 unable to verify the first certificate。补一个环境变量即可:
NODE_TLS_REJECT_UNAUTHORIZED=0 yarn dist
# 需要额外指定工具链镜像时再叠加:
# ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/② 打包报 EBUSY: resource busy or locked, unlink ...app.asar
真凶是 WorkBuddy / CodeBuddy 自己 —— 不是杀软、也不是 Windows 搜索。
(早期本文档曾误判为 Defender MsMpEng + SearchIndexer,那是错的,已被实测推翻。)
WorkBuddy 会对工作区目录做文件树索引(~/.workbuddy/file-tree-manifests/<会话>.json;
本机该项目的清单有 9.7MB,其中 release 路径出现 1126 次、app.asar 出现 124 次)。
索引时打开的 app.asar 句柄会被长期持有、不释放,于是 electron-builder 每次打包
开头「清掉上次的 win-unpacked/」必然撞锁:
EBUSY: resource busy or locked, unlink '...\release\win-unpacked\resources\app.asar'
更关键的是它自锁成死循环:删不掉的 app.asar 留下一个只剩它的 win-unpacked
空壳,下次打包还得删它 → 继续失败。所以「换个目录打一次」只能救一次,之后照旧复发。
确认锁源(不需要管理员权限,用 Windows Restart Manager API 直接问系统):
# 项目自带这个探针:scripts/who-locks.ps1
powershell -ExecutionPolicy Bypass -File scripts/who-locks.ps1
# 实测输出:
# === E:\...\release\win-unpacked\resources\app.asar ===
# PID=11332 APP=WorkBuddy TYPE=RmUnknownApp
# PID=54552 APP=CodeBuddy CN TYPE=RmMainWindow
# 也可以直接指向一个目录:-Path E:\cctv-dist\release\win-unpacked解法:把打包工作目录移出工作区。 yarn dist / yarn pack:dir 现在走
scripts/pack-out.mjs,它把 electron-builder 的 directories.output 指到
<盘根>\cctv-dist\release(工作区之外 → 索引器扫不到 → 无锁 → 每轮都能正常清理旧产物),
打完后只把成品 央视频下载器-*.exe 回拷进项目 release/。
yarn dist # 正常就用这个
CCTV_OUT=D:\builds yarn dist # 想换输出根
yarn dist:inplace # 应急/对照:老路子(产物留在项目内,会被索引锁住)两个注意点:
- 已经形成的残骸只能等 WorkBuddy 重启后再删:
release/win-unpacked/resources/app.asar的句柄不释放,rm/rename/chmod一律 EBUSY(绕开 safe-delete 也无效)。 它不影响后续打包(产物已不写在那里),重启后手动删掉即可。 - 在 WorkBuddy 里让我(Agent)代跑打包时,可能还会撞上宿主的 safe-delete 批量守卫
(
[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":79,"threshold":50}—— 一次删 79 个文件超过阈值 50 需人工确认)。你自己在终端跑yarn dist没有这个 shim, 不受影响。
拿到的成品 exe 不受影响 —— 锁定只妨碍「删除旧产物」,不影响产物本身可用。
这是「下载能出 mp4」的唯一前提。yarn pack:dir / yarn dist 会自动校验并打印:
[pack] ✔ 内置 ffmpeg 落位:79.0MB ffmpeg version 6.1.1-essentials_build-www.gyan.dev ...
想手动实跑工作区外那一份(绿色版目录里):
# 路径随 CCTV_OUT 变化,默认是盘根的 cctv-dist
node -e "const{execFileSync}=require('child_process');console.log(\
execFileSync('E:/cctv-dist/release/win-unpacked/resources/ffmpeg/ffmpeg.exe',['-version'],{encoding:'utf8'}).split('\n')[0])"
# 期望:ffmpeg version 6.1.1-essentials_build-www.gyan.dev ...
# 确认安装包里也确实有它(用 electron-builder 自带的 7za 解包看清单)
# 关键条目应为 resources\ffmpeg\ffmpeg.exe 约 82,797,568 字节- 没设置应用图标:打包日志有
default Electron icon is used,装完显示的是 Electron 默认图标。 放一个build/icon.ico(256×256,含多尺寸)即可自动生效,无需改配置。 - 没做代码签名:没有证书,Windows SmartScreen 首次运行会提示「未知发布者」。
- 没裁剪多语言包:
locales/有 55 个.pak(约 7MB),只留zh-CN/en-US还能再瘦一点。
.npmrc已配置electron_mirror(npmmirror),保证 Electron 二进制能在国内网络下载; 本机首次安装若失败,先删除node_modules与锁文件再重装。
ffmpeg 的二进制约 82MB,不存在于 npm 包里 —— ffmpeg-static 是在安装时由 postinstall
脚本从 GitHub Releases 下载的。国内网络/代理环境常见的 TLS 拦截
(UNABLE_TO_VERIFY_LEAF_SIGNATURE)会让这一步失败,结果就是「装了包却没有 exe」,
表现为下载出来的视频永远是 .ts 而不是 .mp4。
为此项目挂了兜底脚本 scripts/ensure-ffmpeg.mjs(package.json 的 postinstall):
- 装完依赖自动检查
node_modules/ffmpeg-static/ffmpeg.exe是否存在且完整(>20MB); - 缺失就用
curl --ssl-no-revoke -C -下载(绕开本机 TLS 吊销检查,支持断点续传); - 失败不阻断 install,并打印手动补救命令。
# 手动触发 / 重试(支持断点续传,可反复跑)
yarn ensure:ffmpeg就位确认:该文件应存在且约 82MB。
node -e "const fs=require('fs');const p='node_modules/ffmpeg-static/ffmpeg.exe';\
console.log(fs.existsSync(p)?fs.statSync(p).size+' bytes OK':'MISSING')"若你本机已装 ffmpeg 且不想用内置的,可设环境变量
CCTV_FFMPEG_PATH指定路径, 定位器会优先采用(见src/main/core/ffmpegLocator.ts)。
ffmpeg-static 上游 tarball 内是无扩展名的 ffmpeg,是它的 postinstall 才改名成 ffmpeg.exe(win32)。
所以 yml 要列两个 from 候选,且 to 必须与 from 一一对应
(ffmpeg.exe → ffmpeg/ffmpeg.exe、ffmpeg → ffmpeg/ffmpeg)。
早期把两个 from 都映到同一个 to: ffmpeg/ffmpeg,win 包内文件名就成了无扩展名的 ffmpeg,
而定位器查的是 ffmpeg.exe → 定位落空、等同于没内置,而且只在打包态复现、dev 态永远发现不了。
不存在的 from 只会打一条警告并跳过,不需要按平台写分支。
首次打包的 TLS 麻烦、以及连续打包报 EBUSY 的原因与解法,见上面「打包成 exe」一节。
# 构建后(或在 dev 环境用 tsx / node)
URL='https://tv.cctv.com/2023/07/13/VIDE7YKAg3fuVdQB8M5yWgO8230713.shtml'
node dist/main/cctv-dl.js list "$URL"
node dist/main/cctv-dl.js download "$URL" --select 3
node dist/main/cctv-dl.js download --guid <32hex> --title "标题" --quality 850
# 其他常用选项
# --from / --to 月份范围,格式 YYYYMM(默认 200001 ~ 当月)
# --include-highlights 一并列出精彩片段
# --quality 清晰度,兼容两套写法:0/1/2/3/4/5 或 4000/2000/1200/850/450
# --output 保存目录 --threads 并发数 --no-mp4 保留 TS注意:
--guid模式下不要把 guid 写成位置参数,必须用--guid <值>的形式。
搜索这块有几条「不改会很难受」的规则,写在 renderer/searchParams.ts 里并有单测压着:
- 关键词提交后留在输入框里,不清空。真实的用法往往是「搜一次 → 改一个字再搜」, 清空等于逼你重打一遍。
- 因此列表头上会出现「搜索「新词」」按钮:只要输入框里的词和当前结果不是同一个, 它就在那儿,点一下就重搜。不允许出现「输入框是新词、列表是旧词的结果、界面一句提示都没有」。
- 在搜索态里改词重搜,不会重置你已挑好的条件。原来的行为是:搜「新闻」→ 选「一周内」, 然后把关键词改成「新闻联播」再搜 —— 时间范围会被悄悄改回「全部时间段」。 现在只有从列表态发起的新搜索才把条件归零。
- 结果头部如实显示当前条件(如
全站 · 一周内 · 5-30分钟 · CCTV-1),改了下拉就跟着变。 原来这里写死「全站 · 全部时间段」,改成「一周内」后它还在说全部时间段。 - 搜索态下月份区间不生效(搜索走的是全站接口,时间范围用左边的下拉),这一点在控件上 有悬停说明 —— 一个月份选择器摆在那儿、点了没反应又不解释,等于坏掉。
- 搜索态下关键词不参与本地过滤:查询串里可能带着栏目名(AND 语义), 本地按整串再匹配一次会把标题里只有关键词的条目误删。
起因:原来每加入一个任务就自动弹出下载队列抽屉(宽 480,从右侧盖住列表)。想连着挑好几个视频下载时, 抽屉会一次次弹开挡住正要点的卡片——「边挑边下」根本做不了。
现在:加入下载不再打开抽屉,反馈全部交给右下角的挂件 DownloadPod(renderer/components/DownloadPod.tsx)。
它不参与布局、不挡内容,只做三件事:
| 能力 | 怎么做 |
|---|---|
| 一眼看清 | 进度环(整体进度 = 所有未取消任务的平均完成度)+ 「N 个下载中 · M 个已下载」+ 当前正在下的标题 |
| 悬停展开 | 鼠标移上去就地列出最多 3 个在跑任务的明细进度条——多数情况不用开抽屉了 |
| 点开队列 | 只有点它才打开完整队列;右上角 ✕ 可临时收起(有新任务时会再次出现) |
四段动画都在 CSS 里(styles.css 的「下载挂件」段落),并整体尊重 prefers-reduced-motion:
- 入场:从右下滑入 + 轻弹
- 持续:呼吸光环向外扩散 + 外圈短弧旋转(进度卡住时也看得出「在干活」)
- 加入:
addSignal自增触发一次脉冲 + 浮出引导气泡「已加入下载队列 · 点这里随时查看」 - 结束:打勾转绿,停留约 4.6 秒后自己淡出
为什么引导用
addSignal而不是「任务数变多了」:分片有并发上限,排队中的任务轮到它开始跑时 任务数同样会增加,但那不是一次用户操作,弹引导气泡会莫名其妙。只有用户真的点了下载才自增该信号。一个必须兜住的空隙:从点击下载到主进程推回第一条进度之间有 100–250ms 的节流延迟, 这段时间队列里还是空的。若不处理,恰恰在最需要反馈的那一刻挂件不出现——所以引导期内即使
active === 0也强制按进度态渲染(显示「正在加入下载队列…」)。
队列跑完后(含取消 / 失败)会自动重取一次「可继续」清单:待续清单是主进程在任务中断的那一刻才落盘的, 启动时读到的快照并不包含它们,不刷新的话挂件会一直显示过期的「N 个任务可继续」。
判据:保存目录里存在「以该视频标题命名」的成片(.mp4 / .ts)。不用数据库、不记指纹,
磁盘就是唯一事实来源——重启后内存全丢,只有磁盘可信。
关键在命名口径只有一份实现:src/shared/mediaName.ts。主进程写文件时用它,
渲染层反过来判断「这个标题对应的文件在不在」时也用它。两处各写一份必然漂移,
表现就是「明明下载过了却认不出来」,而且不报错、不影响下载,属于最难排查的一类。
App 会带出一份「标题 → 磁盘文件」的查找表(renderer/localIndex.ts,由扫描结果现算),
在四个时机重建:
| 时机 | 为什么必须重建 |
|---|---|
| 启动 | 上次会话留下的文件要能认出来 |
| 保存目录变更 | 换目录等于换了一整批文件 |
| 有任务封装完成 | 新成片刚落盘,不重建就还是旧快照 |
| 本地库删除了文件 | 不重建的话列表里那条会一直标着「已下载」 |
为什么是精确匹配,不做模糊:标题相近的两个节目必须能区分。若用模糊匹配, 会出现「把 A 当成已下载而跳过 B 的下载」——这是静默的数据错误。 唯一不变量是
keyOfFileName(sanitizeTitle(标题) + '.mp4') === downloadKey(标题),shared/mediaName.test.ts用真实标题形态(全角冒号、书名号、多个空格、超长标题)逐条压。
界面上分两层:看(卡片封面左下角常驻绿色「已下载」标,扫列表时一眼可见) 和拦(真点到已下载的会先弹确认,默认只下没下过的,也可勾选「连同这些一起重新下载」)。 刻意不默默跳过——用户完全可能就是想重下一遍。
顺带修掉一个同源的静默 bug:taskStore 判断「成片是否已存在」原来用的是
「标题前 12 个字符做 includes」的模糊匹配,同栏目相邻集数会互相误判——
新闻联播 20230916 下完之后,新闻联播 20230917 那条未完成的任务也被当成已完成而从续传清单剔除,
用户于是「点了继续却没片可续」。现已改为与落盘同规则的精确比对。
队列里只有一套已下载口径,就是「保存目录里存在这个文件」。因此队列里没有单独的 「已完成」页签 —— 一个任务跑完,它就不再是一条进度,而是一个躺在盘上的文件:
| 行 | 何时出现 | 长什么样 |
|---|---|---|
| 任务行 | 排队 / 解析 / 下载 / 合并 / 解密 / 封装 中,以及失败 / 已取消 | 标题 + 状态标 + 进度条 |
| 已下载行 | 对应的文件在保存目录里(刚下完的、上次下载的一视同仁) | 文件名 + MP4/TS 标 + 「已下载」标 + 体积 · 修改时间 · 完整路径 + ▶ / 📂 / 🗑 |
页签与指标:全部 N(任务 + 已下载,同一部片只列一次)/ 进行中 N / 已下载 N / 失败 N。
几个容易踩的点,都在 src/renderer/queueRows.ts 里(纯函数 + 单测,不写在组件里):
- 配对键必须是落盘命名的那一套(
shared/mediaName)。在组件里自己拼文件名,两边口径一漂移就会 同一部片列两遍、或配不上对 —— 而且不报错,只表现成「列表看起来怪怪的」。 - 配不上对时退回任务行,不能让那一行消失。磁盘扫描失败、或文件被人在应用外删掉时, 完成任务找不到文件;这时候仍然把它列出来(状态标还是「已下载」,可从队列里删), 否则用户会看到「刚下完的东西不见了」。
- 两部片片名相近不能互相吞。去重只做归一化(大小写 / 连续空白 / 非法字符),
绝不做模糊匹配 ——
第 16 集与第 17 集必须能分开。 - 页签计数与列表条数同源(同一份
filterQueueRows),否则会出现「标着 3 条、点进去 2 条」。
其他:
- 与左栏「已下载」用的是同一份
localFiles(同一份磁盘事实),在一处删掉,另一处同步消失; - 打开抽屉、切到「已下载」时都会自动重扫 —— 在应用外(资源管理器)删过的文件不会继续显示;
- 删除走回收站(
deleteLocalFile,带防目录穿越),动的是磁盘文件; 如果那一行还挂着本次的任务记录,会一并摘掉 —— 不摘就会剩一条「文件没了、记录还在」的 幽灵行,下次打开队列那条还标着「已下载」(绿标说谎)。左栏「已下载」里删除同样会清队列。
「已下载」的每一行(队列里与左栏「已下载」里都有)右侧有三颗按钮:
| 图标 | 动作 | 走什么 |
|---|---|---|
| ▶ | 用系统默认播放器播放 | shell.openPath |
| 📂 | 在文件资源管理器中打开文件位置(打开所在目录并选中该文件) | shell.showItemInFolder |
| 🗑 | 移入回收站 | shell.trashItem |
两个细节:
- 定位前会先确认文件还在。
shell.showItemInFolder对不存在的路径不报错,只是什么都不做 —— 不先自检,用户点了「打开文件位置」就会毫无反应,且拿不到任何提示。现在会明确提示「文件已不存在,请刷新列表」。 - 路径必须先过落在保存目录内的校验(与其他文件操作同一道防线),拒绝定位任意路径。
目标:关掉软件再打开,没下完的任务能接着下,不用从头再来。
两条恢复线索都以磁盘为单一事实来源(进程重启后内存全丢,只有磁盘可信):
| 线索 | 数据来源 | 界面上在哪 |
|---|---|---|
| 已完成 | 保存目录下的 .mp4 / .ts |
左栏「已下载」+ 本地下载库 |
| 未完成 | .task_<guid>/shard_*.ts 分片目录 |
队列抽屉顶部「可继续」区 + 队列角标 |
开始下载 → 建 .task_<guid>/ → 逐片写 shard_NNNNNN.ts
├─ 成功完成 → 清理整个目录
├─ 用户取消 → 保留分片,记入待续清单(提示「已暂停,分片已保留,可继续下载」)
└─ 下载失败 → 保留分片,只删 merged.ts / decrypted.ts 这类中间文件
早期版本在 catch 里无条件清目录,等于取消即前功尽弃、根本没法续传;
现在只在成功路径清理,并区分「取消」与「失败」给不同提示。
续传的逻辑是「分片文件存在就跳过下载」,所以必须保证存在的分片一定是完整的:
- 下载先写
shard_NNNNNN.ts.part,写完再rename成正式名(同目录 rename 是原子操作); - 失败时删掉
.part,不让半截文件冒充成果; - 跳过判定额外要求
size > 0,双重保险。
否则取消 / 断网会留下残缺但存在的分片,续传时被当成已完成合并进成片 —— 用户拿到一个能播但中间花屏的视频,这类 bug 极难排查。
- 只存任务意图(guid / 标题 / 清晰度 / 目录 / 输出格式),不存进度; 「下到第几片」一律从磁盘现算,避免清单与实际分片不一致。
- 落盘
userData/pending-downloads.json,与addressStore.ts同款实现。 - 启动时三重过滤并顺手淘汰僵尸条目:目录里有分片 → 保存目录里没产出成片 → 条目仍在清单里。
- 排队中就被取消的任务不记入清单(还没开始下载,没有分片可续)。
LIST_RESUMABLE 只读返回清单,是否继续由用户点(「继续」/「全部继续」)。
一开软件就偷偷占满带宽是坏体验,而且用户可能已经不需要那些文件了。
队列按钮角标优先级:正在下载数 > 可继续数(后者用 warning 色区分)。
分片 URL 必须稳定:同一 guid + 清晰度 解析出的 m3u8 分片顺序与数量不能变。
若央视改了切片策略(分片数变化),旧分片会与新列表错位 ——
届时应按「分片数不一致就整目录重下」处理(当前未做)。
- H5E 解密:已完整移植算法(纯 TS,无原生编译),逻辑与原
cctv_h5e_decrypt.hpp一致。 额外把原 C++tests/regression_core_tests.cpp中的 H5E 用例逐条移植为cctvH5eDecrypt.regression.test.ts(含 flip-mask 黄金值、classic/new-mode 网格、 type25 双向切换、跨 EPB 的 RBSP 对齐),确保 TS 版与 C++ 行为逐字节一致。 - 央视接口:
contentparse.ts/apiservice.ts/contentresolver.ts已按原项目contentparse.cpp/apiservice.cpp/contentresolver.cpp忠实移植真实端点,并用真实页面 实测通过(页面字段解析 → 策略决策 → 列表抓取 → H5E 清单解析)。端点包括:zy.api.cntv.cn/video/videoinfoByGuid、api.cntv.cn/NewVideo/getVideoListByColumn|ByAlbumIdNew、api.cntv.cn/NewVideoset/getVideoAlbumInfoByVideoId、media.app.cctv.com/vapi/video/vplist.do、vdn.apps.cntv.cn/api/getHttpVideoInfo.do。央视接口仍可能随时间漂移,届时按原项目同样思路校准即可。 一处有意的改进:标题取完整commentTitle(原项目只取首个空格前片段,会导致同专辑内重名)。 - 封装:
ffmpeg随应用内置(依赖ffmpeg-static,打包时经extraResources释放到resources/ffmpeg/),运行时由ffmpegLocator.ts依次在「打包 resources → node_modules →CCTV_FFMPEG_PATH→ PATH」中定位,用户无需自行安装。仅当内置二进制缺失且系统也没有时才回落保留 TS (等价于原--no-mp4)。设置页会显示内置 ffmpeg 的可用状态。 - 热度是本地估算,不是官方数据:央视网公开接口不返回播放量——实测
zy.api.cntv.cn/video/videoinfoByGuid的pageview、vplist.do的pv恒为空字符串, 无法获取站方播放数据。因此shared/heat.ts按列表可观测信号(节目形态 / 封面 / 简介长度 / 时长 / 新鲜度)合成 0–100 的本地估算分,再按当前列表内的相对百分位 划分高 / 中 / 低三档(各约三分之一)。绝对分可跨列表比较、用于排序;等级用于组内粗筛。 界面上已如实标注「本地估算」。 - 内存安全(相对原实现的重要改动):长视频可达数 GB,原始"整文件读进内存"的写法会 OOM。
本项目全程流式处理:
- 分片下载直接流式落盘,不整片驻留内存;
- 合并按分片读取 →
TSMerger归一化 → 立即写盘(仍保留跨分片 continuity counter 修正); - H5E 解密按块读取并按 PES 边界切分(
TsH5eDecryptor),未闭合的 PES 原样带到下一块, 既不会重复解密也不会漏解密——decryptChunked.test.ts在多种块大小下与整体解密逐字节比对。
- 多路下载节流:主进程对同一任务 120ms 内只保留最新进度,再由 100ms 定时器打包成一次 IPC 推送;渲染层再用 ref 缓冲 + 150ms 合并成一次 setState,避免高频刷新拖死界面。
- 列表走服务端分页(
p/n),不再逐月盲扫:栏目列表接口getVideoListByColumn在d为空时支持p(页码)/n(每页条数)跨全部月份分页,一次请求 = 一页。 实测同一栏目:旧做法要从 200001 逐月请求到当月,拿 8015 条耗时 49.4s; 新做法首屏 50 条只要 0.37s,触底才取下一页(main/core/videoPaginator.ts的会话式 Source)。 代价是不填月份时只能按时间倒序翻到接口上限(约 1000 条)——要够到更早的内容, 就指定起止月做逐月取(单次上限 24 个月),界面上也写明了这一点。 - 搜索请求可取消(快速切换不再串数据):渲染进程每次发起「解析 + 抓取」都领一个自增
requestId(renderer/requestSeq.ts)。主进程用main/core/searchRegistry.ts登记该请求的AbortController,新请求到来时先abort掉所有序号更小的在飞请求——底层 HTTP 真的掐断, 翻页循环也会立刻停手(APIService.scoped(signal));渲染侧再用代号把过期结果整体丢弃。 于是在左侧地址库连点、或搜索框连续回车时,只有最后一次的结果能落到界面上,列表不会串成上一个地址的数据。 - 测试目录:原 C++ 项目引用了
tests/,本重写版用 Vitest 提供核心模块单测。