Skip to content

Repository files navigation

央视频下载器(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.tsMenu.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」)

改左栏(Sider)时的两条硬约束

  1. 面板必须靠 flex: 1 撑高,而 antd 6 的 .ant-layout-sider-children 是块容器 (v5 里是 flex column)。styles.css 里有一条覆盖把它恢复成 flex 列 —— 别删。 删掉的后果不是报错,而是左栏长列表滚不动:滚动容器会自己长到内容高度、永不溢出。
  2. 页签文案 ≤ 3 个汉字,且不要把计数写进标签:252px 侧栏四等分后只有约 42px 文字宽, 超出的会被 ellipsis 悄悄吃掉(曾经显示成「地址…」「本地…」)。计数放各面板标题里。

打包成 exe

一条命令即可(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 的文件索引长期锁住,导致此后每次打包都失败(原因见下面「②」)。

关于体积(为什么是 95MB)

安装包的大部分体积来自 Electron 运行时本身,不是我们的代码:

组成 压缩后 备注
主程序 exe(Electron 运行时) ~50MB 换不掉,Electron 应用都这量级
resources/ffmpeg/ffmpeg.exe ~21MB 内置 ffmpeg 的代价,用户因此免装
resources/app.asar ~2.6MB 我们自己的代码
其余(icu/pak/locales…) ~20MB Electron 自带

配置里做了两处必须保留的瘦身,改动前先读 electron-builder.yml 的注释:

  1. react / react-dom / antd 放在 devDependencies。渲染层已被 Vite 完整打包进 dist/renderer/,主进程与预加载也用不到它们 —— 放 dependencies 会被 electron-builder 整个塞进 app.asar(实测白占 43MB)。
  2. 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.ymlelectronDownload.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 不受影响 —— 锁定只妨碍「删除旧产物」,不影响产物本身可用。

自检:确认内置 ffmpeg 真的进了安装包

这是「下载能出 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 是怎么来的(用户无需手动下载)

ffmpeg 的二进制约 82MB,不存在于 npm 包里 —— ffmpeg-static 是在安装时由 postinstall 脚本从 GitHub Releases 下载的。国内网络/代理环境常见的 TLS 拦截 (UNABLE_TO_VERIFY_LEAF_SIGNATURE)会让这一步失败,结果就是「装了包却没有 exe」, 表现为下载出来的视频永远是 .ts 而不是 .mp4

为此项目挂了兜底脚本 scripts/ensure-ffmpeg.mjspackage.jsonpostinstall):

  • 装完依赖自动检查 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)。

extraResourcesto 必须带平台扩展名(只在打包态复现)

ffmpeg-static 上游 tarball 内是无扩展名ffmpeg,是它的 postinstall 才改名成 ffmpeg.exe(win32)。 所以 yml 要列两个 from 候选,且 to 必须与 from 一一对应ffmpeg.exe → ffmpeg/ffmpeg.exeffmpeg → 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 里并有单测压着:

  1. 关键词提交后留在输入框里,不清空。真实的用法往往是「搜一次 → 改一个字再搜」, 清空等于逼你重打一遍。
  2. 因此列表头上会出现「搜索「新词」」按钮:只要输入框里的词和当前结果不是同一个, 它就在那儿,点一下就重搜。不允许出现「输入框是新词、列表是旧词的结果、界面一句提示都没有」。
  3. 在搜索态里改词重搜,不会重置你已挑好的条件。原来的行为是:搜「新闻」→ 选「一周内」, 然后把关键词改成「新闻联播」再搜 —— 时间范围会被悄悄改回「全部时间段」。 现在只有从列表态发起的新搜索才把条件归零。
  4. 结果头部如实显示当前条件(如 全站 · 一周内 · 5-30分钟 · CCTV-1),改了下拉就跟着变。 原来这里写死「全站 · 全部时间段」,改成「一周内」后它还在说全部时间段。
  5. 搜索态下月份区间不生效(搜索走的是全站接口,时间范围用左边的下拉),这一点在控件上 有悬停说明 —— 一个月份选择器摆在那儿、点了没反应又不解释,等于坏掉。
  6. 搜索态下关键词不参与本地过滤:查询串里可能带着栏目名(AND 语义), 本地按整串再匹配一次会把标题里只有关键词的条目误删。

下载反馈:为什么不再自动打开队列

起因:原来每加入一个任务就自动弹出下载队列抽屉(宽 480,从右侧盖住列表)。想连着挑好几个视频下载时, 抽屉会一次次弹开挡住正要点的卡片——「边挑边下」根本做不了。

现在:加入下载不再打开抽屉,反馈全部交给右下角的挂件 DownloadPodrenderer/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 极难排查。

待续清单(main/core/taskStore.ts

  • 只存任务意图(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/videoinfoByGuidapi.cntv.cn/NewVideo/getVideoListByColumn|ByAlbumIdNewapi.cntv.cn/NewVideoset/getVideoAlbumInfoByVideoIdmedia.app.cctv.com/vapi/video/vplist.dovdn.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/videoinfoByGuidpageviewvplist.dopv 恒为空字符串, 无法获取站方播放数据。因此 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),不再逐月盲扫:栏目列表接口 getVideoListByColumnd 为空时支持 p(页码)/ n(每页条数)跨全部月份分页,一次请求 = 一页。 实测同一栏目:旧做法要从 200001 逐月请求到当月,拿 8015 条耗时 49.4s; 新做法首屏 50 条只要 0.37s,触底才取下一页(main/core/videoPaginator.ts 的会话式 Source)。 代价是不填月份时只能按时间倒序翻到接口上限(约 1000 条)——要够到更早的内容, 就指定起止月做逐月取(单次上限 24 个月),界面上也写明了这一点。
  • 搜索请求可取消(快速切换不再串数据):渲染进程每次发起「解析 + 抓取」都领一个自增 requestIdrenderer/requestSeq.ts)。主进程用 main/core/searchRegistry.ts 登记该请求的 AbortController,新请求到来时先 abort 掉所有序号更小的在飞请求——底层 HTTP 真的掐断, 翻页循环也会立刻停手(APIService.scoped(signal));渲染侧再用代号把过期结果整体丢弃。 于是在左侧地址库连点、或搜索框连续回车时,只有最后一次的结果能落到界面上,列表不会串成上一个地址的数据。
  • 测试目录:原 C++ 项目引用了 tests/,本重写版用 Vitest 提供核心模块单测。

About

央视频下载器

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages