如果你在手机存储里翻到一堆以.m4s结尾的文件,并且发现视频画面和声音是分开存放的,那你大概率遇到了 B 站缓存视频的标准形态:一个视频被拆成video.m4s和audio.m4s两个文件。网上很多人管这叫“加密格式”,其实不是,B站只是把 HLS 分片机制里的音视频轨单独缓存下来了。你手动播放这两个文件时,手机自带播放器通常只认其中一个轨,结果就是“有画无声”或者“有声音无画面”。
我自己第一次遇到这问题,是在手机里整理缓存空间时发现的。后来试过改名成.mp4、下载各种“全能播放器”,都不好使。真正靠谱的方案只有一条:用命令行工具把两个文件重新封装进同一个容器。如果只是合并一个视频,命令行手敲一遍很容易,但缓存了几百个视频后,一个个手动敲命令会累到怀疑人生。这篇文章就专门讲清楚:B站缓存音视频文件为什么长这样、怎么用ffmpeg批量合并、踩过哪些坑,以及电脑端和手机端哪些做法更有效率。全程基于我自己实操过的流程,可以直接照着抄。
1. 先说清楚:B站缓存文件为什么是“音画分离”的
1.1 m4s文件不是什么“加密格式”
要理解这个问题,先说一个背景。B站看视频时会根据网络状况自动选择视频流和音频流,这两条流在网络上本来就是分开传输的。视频流里面只装画面帧,音频流里面只装声音数据。播放器在播放时,会按照时间戳把两边的数据“对齐”后同步播放。用户感觉到的是完整视频,但本质上始终是两根独立管道。
当你点下“缓存”按钮时,App并没有把这两条流重新压成单个文件,而是把分离的流直接落盘保存,于是就有了video.m4s和audio.m4s。这里的m4s全称是 MPEG-4 Stream,它和常见的.mp4在文件容器层面上很接近,但缺少了完整的 moov 元数据信息。普通播放器在面对这种“半成品”文件时,很难正确识别里面是什么内容。所以绝不是文件加密了,而是封装不完整。
在很多旧版本缓存目录里,你会看到类似这样的结构:
1-123456/ ├── 64/ │ ├── audio.m4s │ └── video.m4s └── entry.jsonentry.json记录了这个视频的标题、分P序号、清晰度等信息。64是视频清晰度代码,不同版本可能会有80、112等。真正负责画面和声音的就是那两个m4s文件。
有些时候你会发现video.m4s文件名不叫 video,而是一串纯数字或随机字符。这种情况多见于较新的版本或通过某些特定方式缓存的内容。此时就需要通过文件内部信息去判断,不能只看文件名。
1.2 哪些场景下你需要批量合并
我在实际中用到的场景,主要有这么几类:
- 手机存储告急,想把缓存的视频备份到电脑或网盘,但电脑上无法直接播放音画分离的文件。
- 出现了一个视频合集或系列课程,缓存了几十集,想合并后转成 MP4 统一管理。
- 想把缓存视频导入剪辑软件,但剪辑软件无法识别
m4s分离文件。 - 只是想把某些长视频转成音频听,比如演讲、UP主的音频版内容。
不管是哪类需求,核心动作都一样:把同一目录下的video.m4s和audio.m4s当作两个输入源,交给ffmpeg做一次“重新封装”操作,输出一个标准 MP4。我推荐使用重新封装而不是重新编码,因为重新封装(-c copy)只复制数据流,几乎不浪费时间,画质音质零损失;重新编码则会经过解码再编码,极慢,还损失画质。只有极少数特殊情况才需要转码,后面会单独说明。
2. 准备工作:工具链与缓存位置
2.1 安装ffmpeg
ffmpeg是一个开源的多媒体处理命令行工具,几乎任何视频格式的剪切、合并、转换、提取音轨都靠它。它的安装方式,不同系统略有差别。
Windows:去官网下载对应的 release 版本,或者直接使用包管理器。网上的预编译版本很多,下载后把压缩包解压到任意目录,然后把bin目录加入系统环境变量Path。这样在命令行里输入ffmpeg -version才能被识别。如果不想改环境变量,也可以直接在代码里指定ffmpeg.exe的绝对路径。
macOS:用 Homebrew 一条命令搞定,brew install ffmpeg。没有 Homebrew 的话,也可以下载编译好的静态版本放到/usr/local/bin下。
Linux:一般用系统自带的包管理器,比如 Debian/Ubuntu 下执行sudo apt install ffmpeg。
我自己的习惯是永远使用官方最新稳定版。B站缓存格式偶尔会有细微变化,新版ffmpeg对 HLS 流、HEVC 编码、时间戳处理都更好。老版本偶尔会出现合并后音画不同步的问题。
2.2 找到B站缓存目录
找到了缓存文件才能谈合并。如果你用的是安卓手机,B站 App 的缓存目录通常在手机内部存储的Android/data/tv.danmaku.bili/download/下。注意:从 Android 11 开始,系统限制了对Android/data目录的直接访问,你必须用手机自带文件管理器或者电脑连接手机后才能看到完整内容。如果你用第三方文件管理器看不到这个目录,不是文件不存在,而是权限没给够。
缓存目录里面往往是一堆以数字/字母混合命名的文件夹,每个文件夹代表一个视频。文件夹名字通常是一串 ID,原始标题在entry.json文件里。
电脑端的缓存位置会不同。Windows 上常见的路径是C:\Users\<用户名>\AppData\Local\bilidown\1.x\...或C:\Users\用户名\Videos\bilibili,具体取决于你使用的是官方客户端还是第三方工具。Mac 端的路径一般在~/资源库/Containers/com.bilibili.xxx/Data/...。
如果你用的是浏览器插件来缓存视频,那情况更简单:插件往往已经帮你把音视频合并好了,甚至直接输出 MP4。插件缓存不需要我们再处理。这次讨论的主要是官方 App 和官方客户端的原始缓存。
2.3 辨别不同格式的缓存文件
不要以为 B站缓存文件只有video.m4s和audio.m4s这一种形态。我遇到过好几种:
- 标准分离型:目录下存在
video.m4s+audio.m4s,这是最常见的 HLS 分离缓存。 - 单文件型:目录下只有一个
.mp4或.flv,说明这个视频本身就是 MP4 直传或采用特殊策略缓存。这种无需合并,直接改后缀名或简单封装即可。 - 多分片型:某些低版本或特殊视频,会缓存成
*.m4s后面带序号,比如video-1.m4s、audio-1.m4s。这种其实是分片列表,需要先拼接再合并。 - 文件名无规律型:新版本缓存时,文件命名为随机字符串,但文件内部还是音视频流。此时需要读取文件头判断。
判断一个文件到底是视频还是音频,最直接的方法是看编码信息。命令行下切到文件目录,执行ffprobe(ffmpeg 工具包自带)即可:
ffprobe -show_streams 视频文件名.m4s输出内容里有一行会标明codec_type=video或codec_type=audio,以及具体的编码格式、分辨率、采样率。这比靠文件名猜要可靠得多。
3. 批量合并的核心脚本:Python + ffmpeg
3.1 单文件合并的完整命令
假设你已经定位到一个目录,里面有video.m4s和audio.m4s。最简单可靠的合并命令是:
ffmpeg -y -i video.m4s -i audio.m4s -c copy -movflags +faststart 输出.mp4我来拆解一下每个参数:
-y:如果输出文件已存在,直接覆盖,避免二次询问。-i video.m4s和-i audio.m4s:输入文件,顺序不能反,第一个输入作为视频轨,第二个输入作为音频轨。-c copy:对所有流执行“流复制”,不重新编码。-movflags +faststart:将 MP4 的索引信息移到文件头,方便在线播放和快速拖动进度条。如果要转换的 B站缓存文件数量很大,这个参数可以省略,能省一点时间;但作为成品归档,最好还是加上。
执行之后,会出现一串输出日志,等到命令运行结束,当前目录下就会多出一个标准 MP4 文件,里面同时包含画面和声音。我实测,对于一个 20 分钟左右、约 300MB 的视频,这个操作在我机器上大概需要 3 到 5 秒,几乎等于磁盘拷贝速度。
3.2 批量扫描与自动寻找audio/video
单文件能用命令行解决,批量就必须让脚本自动扫描目录。我这里最常用的是 Python,因为遍历目录和拼接命令行很灵活,可读性也好。下面的脚本可以直接保存成merge_bili.py使用:
import os import subprocess from pathlib import Path ffmpeg_path = "ffmpeg" root_dir = Path("E:/bili_cache") # 改成你的缓存根目录 output_dir = Path("E:/bili_output") # 改成你的输出目录 output_dir.mkdir(parents=True, exist_ok=True) # 只处理有这两个关键字的文件 def find_media_in_folder(folder): audio_file = None video_file = None for f in folder.rglob("*"): if not f.is_file(): continue suffix = f.suffix.lower() if suffix not in {".m4s", ".mp4", ".flv", ".m4v"}: continue name = f.stem.lower() if "audio" in name: audio_file = f elif "video" in name: video_file = f return audio_file, video_file for folder in root_dir.iterdir(): if not folder.is_dir(): continue audio, video = find_media_in_folder(folder) if audio is None or video is None: # 如果找不到标准audio/video,但目录里有两个m4s,就单独判断 m4s_files = list(folder.rglob("*.m4s")) print(f"[跳过] {folder.name}: 缺少配对文件,共找到 {len(m4s_files)} 个m4s") continue out_name = folder.name + ".mp4" out_path = output_dir / out_name cmd = [ ffmpeg_path, "-y", "-i", str(video), "-i", str(audio), "-c", "copy", "-movflags", "+faststart", str(out_path), ] print(f"[合并] {folder.name}") try: subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(f" -> 完成: {out_name}") except subprocess.CalledProcessError as e: print(f" -> 失败: {folder.name},错误码 {e.returncode}")这段脚本逻辑很简单:遍历缓存根目录下的每个文件夹,在文件夹内部递归查找文件名里带audio和video的文件,然后交给ffmpeg合并,输出到指定目录。
实际使用时,有几个细节需要根据你的缓存目录结构调整。比如某些版本的缓存,每个视频会多嵌套一层目录。如果只是root_dir.iterdir()遍历一级子目录,可能找不到对应的 m4s。这时候可以改成root_dir.rglob("*/entry.json")这种思路,先找到所有可能包含视频信息的目录再处理。
3.3 脚本中的关键细节
不要小看这些细节,我早期写的脚本就没考虑全,导致大量文件漏处理。
第一,名称匹配要宽松。有些缓存文件名是video_1.m4s、audio_1.m4s,有些是video (1).m4s,用"video" in name这种模糊匹配是最稳妥的方式。反过来,目录里可能还有cover.jpg、danmaku.xml这类无关文件,它们不会被当成媒体文件,自然不用担心误判。
第二,要处理嵌套目录。B站 App 缓存的目录结构并不是绝对统一。我在某些版本见过download/xxx下面直接就是video.m4s,也见过download/xxx/yyy/64/video.m4s。所以我用的rglob("*")会递归遍历子目录,只要一个视频目录里存在这两个文件就能找到。
第三,输出文件名不要用原始目录名直接覆盖。缓存目录是一串无意义的 ID,如果用文件夹名当输出名,你可能得到一堆1234567.mp4,后期完全分不清谁是谁。更好的方式是从entry.json里读取真正的视频标题,然后作为输出文件名。我会在稍后“进阶玩法”里展开。
第四,加异常退出判断。某些视频文件可能因为缓存不完整,音频或视频文件缺失;某些文件即使两个都在,也可能因为文件损坏导致ffmpeg合并失败。脚本里必须抓异常,否则跑到一半中断,前面所有结果都白做。我习惯把失败目录单独记录下来,最后统一处理。
4. 其他合并方案:Windows批处理与手机端工具
4.1 批处理脚本快速实现
如果你对 Python 不熟,或者电脑上不想额外装 Python,可以直接用 Windows 的批处理。B站缓存的目录结构如果比较规范,一个批处理脚本几行就能跑完。
@echo off setlocal enabledelayedexpansion set "root=D:\bilibili" cd /d "%root%" for /d %%i in (*) do ( if exist "%%i\video.m4s" if exist "%%i\audio.m4s" ( echo 正在合并 %%i ffmpeg -y -i "%%i\video.m4s" -i "%%i\audio.m4s" -c copy "%%i.mp4" ) ) echo 批量合并完成 pause这个脚本的思路是:遍历当前目录下所有子文件夹,如果某个文件夹里同时存在video.m4s和audio.m4s,就执行合并命令,输出到该文件夹下,再统一集中转移。
这种方案的优点是零依赖,系统自带批处理引擎,只要ffmpeg加入环境变量即可。缺点也很明显:对嵌套目录无能为力,也无法读取entry.json自动命名。如果你的缓存目录不规范,这个脚本会漏掉很多文件。我建议只把它作为一种应急手段,或者目录结构刚好规整时使用。
还有一个变种,使用for /r递归遍历所有子目录:
for /r "%root%" %%i in (video.m4s) do ( if exist "%%~dpiaudio.m4s" ( echo 合并 %%~dpi ffmpeg -y -i "%%i" -i "%%~dpiaudio.m4s" -c copy "%%~dpititle.mp4" ) )这里%%i会递归找到所有video.m4s,%%~dpi表示它所在目录路径,%%~dpiaudio.m4s则拼出同目录下的 audio 文件。对于标准目录结构,这个批处理能处理更多层级。不过要注意,%%~dpititle.mp4这个写法是拼到目录末尾,输出名字固定为 title.mp4,不同目录的合并结果会互相覆盖。你需要改成动态文件名,比如%%~dpi%%~ni.mp4(取目录名)。批处理在文件名处理上远不如 Python 灵活,这也是后来我放弃批处理方案的原因。
4.2 手机端直接合并(适合应急)
有些朋友问:手机上装个工具能不能直接合并?答案是能,但体验通常很一般。
手机端有几款播放器或文件管理器自带了“音视频合成”功能,能选择两个 m4s 文件进行合并。这类操作本质上也是在手机上调用 ffmpeg 内核,但受限于手机处理速度、存储 IO 和系统后台限制,合并大文件时极容易中断,而且很多工具对m4s文件的识别并不友好。我试过一次合成长视频,等了好几分钟,输出文件还是损坏的。
所以我个人非常不推荐在手机端进行大批量合并,应该把整个缓存文件夹拷贝到电脑上再处理。如果你只是外出时急需一个视频能发给别人,那可以临时用手机工具处理单个文件。但真正几百个视频的整理,请老老实实回到电脑端。
另外,有些手机端的“全能播放器”宣称能直接播放m4s缓存文件夹。它确实能播,因为播放器自己分析了目录里的两个文件并按时间戳同步播放。但这种方式没有产生实体文件,无法把成品发给别人或导入剪辑软件。如果只是自己临时看,可以凑合;如果想长期保留,还是要合并。
5. 实操中遇到的坑与排查思路
5.1 时长不一致怎么办
用ffmpeg -c copy合并时,偶尔会遇到输出文件总时长异常,比如画面和声音对不上。这通常不是因为两个文件真的一个长一个短,而是文件里的时间戳基准不一致。
这种情况可以强制让ffmpeg把音频流的时间戳对齐到视频流。命令里加上-async 1或者-af apad都不太标准,更稳妥的做法是使用:
ffmpeg -i video.m4s -i audio.m4s -c copy -copyts -avoid_negative_ts make_zero 输出.mp4-copyts保持原始时间戳,-avoid_negative_ts make_zero把负的时间戳纠正为 0。如果这样还是不同步,再考虑用-c:v copy -c:a aac -b:a 192k重新编码音频轨,但那样会慢一些。
还有一种情况是视频本身包含两个以上音轨,B站可能缓存了多个音频文件。此时find_media找出的audio_file可能只是其中一个,合并出来的成品只有一个声道。检查方式很简单:用ffprobe看音频流数量。
5.2 文件名乱码或非法字符
缓存目录名的编码有时是 UTF-8,有时是 Unicode,拷贝到 Windows 后很容易出现乱码文件名,比如“é”变成“é”。Python 脚本在遍历目录时默认使用系统编码,可能报UnicodeDecodeError。
解决方法是先给目录重命名,或者在 Python 读取时指定编码容错。更推荐的做法是直接读取entry.json中的原始标题,而不是依赖目录名。脚本中可以用下面的方式:
import json def get_title(folder): entry_path = folder / "entry.json" if not entry_path.exists(): return folder.name try: data = json.loads(entry_path.read_text(encoding="utf-8")) return data.get("title", folder.name) except Exception: return folder.nameentry.json里通常包含title(视频标题)、page_data里的part(分P标题)等。你可以把它们拼接成更有意义的输出文件名,例如{title}_{part}.mp4。
Windows 下还对文件名字符有严格限制:不能包含\ / : * ? " < > |等字符。B站视频标题经常带有冒号或问号,如果不处理,输出时会报错。脚本里要做一次清洗:
import re def clean_filename(name): return re.sub(r'[\\/:*?"<>|]', '_', name)5.3 合并后没有声音
这个问题我遇到好多次,原因多半是音频流的编码格式并不被 MP4 容器友好支持。B站缓存音频大多是 AAC,MP4 天然兼容;但有些视频源可能使用其他编码,比如 AC-3、EAC-3,复制进 MP4 后不一定会报错,但播放器解码不出来。
这种情况下需要把音频转码成 AAC。命令改成:
ffmpeg -i video.m4s -i audio.m4s -c:v copy -c:a aac -b:a 192k 输出.mp4保留了视频流无损复制,只对音频流做一次 AAC 转码,既保证兼容性也控制了体积。你也可以用-c:a copy试试原装复制,如果播放失败再转码。在批量脚本里可以加一个判断,如果首次合并失败,自动执行音频转码版本。
5.4 遇到“.m4s”实际上不是音频视频
B站缓存目录里的m4s文件不只有音视频,有些m4s可能是一段字幕流、缩略图流,或者干脆是空的占位文件。早期我写的脚本只看后缀,结果把某些占位文件当成视频文件去合并,导致大量失败。
后来我加了一道保险:先执行ffprobe判断文件类型,只有codec_type=video或codec_type=audio才参与合并。但这样会多出很多系统调用,批量处理时速度变慢。折中方案是:只处理文件名包含audio或video关键字、且文件大小大于一定阈值的文件。B站音频缓存通常在 1MB 以上,而很小的占位文件可以直接忽略。阈值设成1MB比较稳妥。
5.5 缓存未解密/非完整缓存
B站官方缓存分为“离线缓存”和“高清缓存”。某些版本的高清缓存可能采用特殊封装,文件名甚至可能是单个.mp4,而不是标准的 m4s 分离文件。如果你下载的缓存目录里没有任何m4s文件,只有一些奇怪的二进制文件,说明这个版本可能做了特殊处理。这时候单纯用ffmpeg无法直接合并,必须先把文件正确解码为可识别流。这个过程通常需要逆向研究缓存算法,我不建议个人用户折腾,最简单的方法是换用一个支持标准m4s缓存的旧版本或第三方工具。文章到这里,只想说一句:不要本末倒置,合并本身是为了方便管理,如果为了一个缓存文件耗费大量精力去研究解密,那就偏离主题了。
6. 一些进阶玩法和经验总结
6.1 无损复制与转码的选择
前面反复提到-c copy,这是最理想的模式。它的本质是拷贝流,并不解码视频。所以合并过程极快,且输出的 MP4 画质音质和原缓存完全一致。相当于你把两个包裹里的东西,没有拆封直接放到同一个箱子里面。
只有在这几种情况下,我才建议转码:
- 输出文件在播放器里卡顿或不能快进:因为原始缓存里的关键帧间隔可能比较稀疏,复制到 MP4 后仍保留稀疏特点。可以视频流转码,或者用
-c copy但加上-f mp4并设置-video_track_timescale。不过这属于优化播放体验,不是必选项。 - 播放器无法解码:比如某些视频使用 HEVC(H.265)编码,很多旧设备不支持硬解。这时可以转成 H.264,体积变大,但兼容性最好。
- 需要给成品做二次剪辑:剪辑软件对 raw 流文件的导入支持不如标准 MP4 好。如果准备导入剪辑,建议转码成 ProRes 或 DNxHD,当然这是专业流程,一般用户用不到。
6.2 自动整理成片
批量的理想化流程应该是“一键跑完,自动归档”。我在脚本里还加了一个额外功能:把entry.json里的视频标题和分P信息读取出来,作为输出文件名。这样合并完成的文件可以直接取代原来的视频在资料库中存在。
比如一个视频标题是【合集】Python入门教程,分P标题是第01讲 环境安装,那么输出文件名可以是:
【合集】Python入门教程_第01讲 环境安装.mp4这样后期找资源非常方便。脚本中读取和清洗标题的代码可以整合进主流程:
def get_display_name(folder): try: entry_path = folder / "entry.json" if entry_path.exists(): data = json.loads(entry_path.read_text(encoding="utf-8")) title = data.get("title", folder.name) page_data = data.get("page_data", {}) part = page_data.get("part", "") if part and part != title: return f"{title}_{part}" return title except Exception: pass return folder.name要注意不同版本entry.json的嵌套结构可能不同,所以我在读取时用了try/except,解析失败就回退成目录名。这个函数不算长,但能极大地提升你会发现,缓存目录里还有danmaku.xml等文件,弹幕却是 XML 格式。如果想把弹幕烧录进视频,你需要另一个工具来转换弹幕为 ASS 字幕,再用ffmpeg挂载。这个就更复杂了,属于另一个话题。普通用户别贪心,音视频合并成功就已经解决了 90% 的需求。
6.3 最后几点经验
写到最后,分享几个我在实际使用中的体会。
第一,批量合并前一定要先备份原始缓存。虽然我不会误改原始文件,但ffmpeg偶尔会因为输入文件损坏、磁盘空间不足而异常退出。万一合并过程中出现磁盘写满,可能导致输出文件不完整。把原始缓存备份到移动硬盘或者另一个分区,是最稳妥的做法。
第二,“合并”不等于“转格式”。很多人把m4s直接改成.mp4,希望蒙混过关。结果就是视频文件后缀变成了.mp4,但播放器还是识别不了。因为后缀只是外壳,播放器真正读取的是文件内部结构。只有把音视频轨道真实封装进一个 MP4 容器,问题才真正解决。
第三,某些 B站视频是有 DRM 版权限制的,官方缓存可能无法直接合并。比如大会员专享或电视剧资源,可能出现缓存内容残缺或不能播放。对于这种情况,不要尝试破解或绕过,正确的做法是老老实实在线观看,或者使用官方支持的离线观看功能,而不是强行抽取文件。技术只能解决未加保护的数据,遇到保护性内容,还是尊重规则比较好。
第四,如果你发现自己缓存了很多视频,且长期不清理,建议养成定时整理的节奏。我现在的习惯是每隔一两个月跑一次批量脚本,把清理出来的缓存目录统一转成 MP4 归档。这样既不担心没用完的手机存储,也给以后想看留下了一份备份。该删除的就删除,该留下的才保留,存储空间自然不是压力。
第五,关于脚本再补一句。上文给出的 Python 脚本里,ffmpeg_path我写的是"ffmpeg",如果你没有配置环境变量,请改为你电脑上ffmpeg.exe或者ffmpeg的绝对路径。Windows 下路径里的反斜杠记得转义,或者直接用原始字符串,比如r"D:\tools\ffmpeg\bin\ffmpeg.exe"。
文章的最后,我再分享一个小技巧:如果你只想处理某一个文件,又不想记命令行,可以用鼠标把video.m4s拖到ffmpeg.exe窗口里?不行,ffmpeg 是命令行工具,不是图形界面。正确方式是建一个右键菜单,把“合并音视频”这个命令固化下来。这样做之后,以后在文件管理器里右键点某个目录,就能直接合并里面同名文件,非常方便。但要小心配置错误导致右键菜单混乱,这个属于 Windows 操作系统的进阶玩法了,有兴趣的可以自己折腾一下,我没法在一篇文章里全讲完。
批量合并看似只是把一条命令循环执行很多遍,真正顺了之后,你会发现很多视频管理需求都能顺手解决。比如转换其他平台下载的音视频分离文件、处理录制软件的独立音视频轨、把多个片段拼接成完整视频,等等。掌握了ffmpeg的基本用法,相当于手里多了一把万能工具刀。以后看到任何奇怪的媒体文件,第一反应不再是“这个播放器不支持”,而是“让我看看它内部封装了什么”。这就是从“只会用别人工具”到“自己掌控处理流程”的转变,也是我愿意把这套流程写下来的原因。