B站缓存m4s文件合并成MP4:ffmpeg批量转存与重封装指南
前阵子有朋友找我说B站客户端缓存了一大堆视频打算拷到电脑上慢慢看结果打开缓存目录一看全是一堆 m4s 后缀的文件播放器一个都不认识视频根本放不出来。这个需求其实特别典型B站视频缓存的机制会把音频和视频分开存储想离线转存成 MP4就得先把音视频流正确识别出来再批量合并成普通文件。这篇文章我就围绕 B站视频缓存的目录结构、合并原理、批量脚本和实际踩坑经历展开给想批量导出B站缓存的朋友一条可以直接照做的路线。这篇内容适合下面几类人经常用B站客户端缓存视频、想转成MP4收藏的批量缓存了整季动画或合集需要一次性合并几十个文件的以及只想搞清楚 m4s 到底是什么、能不能无损合并的好奇党。我尽量把原理和实操都讲透不让你只会复制命令出了问题也能自己判断。1. 为什么缓存目录里躺着一堆“打不开”的m4s文件1.1 一次典型的缓存目录长什么样以安卓客户端为例B站缓存默认存放在Android/data/tv.danmaku.bili/download/下面。打开这个目录你会看到一串数字命名的文件夹比如18828234、18828235这种一个文件夹通常对应一个视频也可能是合集里的某一P。进到其中一个文件夹常见的内容大概是这样文件/目录作用备注entry.json或info.json记录视频标题、分P信息、cid老版本叫 entry.json新版本叫 info.jsonvideo.m4s纯视频流无画面但有声音说明你拿反了audio.m4s纯音频流和视频流成对出现30280.m4s旧版视频流命名数字是视频轨道标识30232.m4s/30236.m4s旧版音频流命名取决于音频编码格式danmaku.xml弹幕文件有的版本有有的没有这里要特别注意不同版本、不同设备的B站客户端缓存文件命名并不统一。有些叫video.m4s/audio.m4s这种“见名知意”的名字有些则是0.m4s、1.m4s这种纯数字。你要是靠文件名猜哪个是视频、哪个是音频非常容易翻车。1.2 音视频分离不是bug而是流媒体的常规操作很多人第一次看到音视频分离第一反应是“缓存出错了吧”。其实不是这是流媒体网站非常常规的做法。B站播放视频时走的通常是分片流媒体协议。视频轨和音频轨被拆成一个个小分片按照播放进度按需下载。这样有几个好处一是可以根据当前网速自适应切换清晰度只重新请求视频轨音频轨不用跟着换二是客户端做“跳过片头”“分段缓冲”时可以单独控制音视频的加载节奏三是CDN分发时小分片更容易做缓存和加速。所以你在下载缓存时客户端为了省事直接把这些分片原样存到了本地。它自己播放时能通过内部的解码器把两个文件“合成”到播放时间轴上但一旦脱离客户端普通播放器根本不知道要怎么把这两个裸流弄到一起。1.3 m4s到底是什么为什么播放器不认m4s 这个后缀看起来神秘其实它的全称是 MPEG-DASH Segment也就是分片流媒体协议里的一个媒体分片。它的内部容器结构和 MP4 很接近但有些分片为了减少体积会把 MP4 的索引信息拆掉一部分只保留裸的 H.264/H.265 视频流或者 AAC 音频流。为啥普通播放器打不开因为很多播放器看到m4s后缀就直接不认了或者虽然认了但找不到完整的索引信息不知道这段流有多长、从哪开始是画面。再加上音视频分离存储就算你强行打开视频流也只会看到一堆花屏或者根本没有画面的画面轨。不过好消息是ffmpeg 这类专业工具对 m4s 的兼容性很好。它不需要后缀名直接按二进制内容去解析能正确地解复用出视频流和音频流。这也是为什么我们后面合并时文件名是video.m4s还是30280.m4s其实不重要重要的是流内部的轨道类型。2. 手动合并单个视频ffprobe识别流类型ffmpeg完成重封装2.1 用ffprobe确认哪个文件是视频轨哪个是音频轨先别急着复制合并命令。我见过太多人写了个脚本却合并出一堆“只有画面没声音”的文件就是因为文件名判断错了。最稳的做法是先用 ffprobe 这个工具去探测每个文件的流类型。ffprobe 是 ffmpeg 自带的小工具安装 ffmpeg 的时候会一起装上。Windows 用户可以去 ffmpeg 官网下载编译好的二进制macOS 用 Homebrew 装ffmpeg就行Linux 用户一般apt install ffmpeg或yum install ffmpeg。装好之后进入缓存子目录对两个 m4s 分别执行ffprobe -v error -select_streams v:0 -show_entries streamcodec_name,width,height -of csvp0 30280.m4s如果这个文件是视频流输出会类似h264,1920,1080表示它是 H.264 编码、1920x1080 分辨率。再执行音频探测ffprobe -v error -select_streams a:0 -show_entries streamcodec_name,sample_rate,channels -of csvp0 30232.m4s如果输出类似aac,44100,2说明它是 AAC 音频、44.1kHz 采样率、双声道。一套命令下来谁是视频谁是音频就一目了然了。这里有一个经验不要只靠video.m4s和audio.m4s这种名字做判断。不同客户端版本的命名规则差异大尤其旧版缓存里的30280.m4s是视频30232.m4s是音频但有些人反过来试文件名没变内容却可能因为版本不同完全相反。用 ffprobe 探测一次后面所有脚本都能自动判断。2.2 合并命令与关键参数梳理确认好两个文件之后合并其实就一行命令ffmpeg -y -i 30280.m4s -i 30232.m4s -c copy -map 0:v:0 -map 1:a:0 output.mp4逐个拆解一下这个命令的每个参数因为你会反复用到它参数作用为什么这样写-y覆盖输出文件批量合并时经常要重跑不用它的话每次都要交互确认-i 30280.m4s第一个输入视频流文件-i 30232.m4s第二个输入音频流文件-c copy流复制不重新编码原样把视频流和音频流封装进新容器速度最快、画质无损-map 0:v:0取第一个输入的第一个视频流显式指定防止ffmpeg自作主张选了别的流-map 1:a:0取第二个输入的第一个音频流和上面同理显式指定最安全output.mp4输出文件后缀按需求定mp4基本通用重点说下-c copy。很多教程让你重新编码什么-c:v libx264、-c:a aac但缓存文件的视频流本来就是 H.264/H.265音频流本来就是 AAC重新编码毫无意义白白消耗CPU和时间还可能损失画质。-c copy是“重封装”而不是“重编码”它只是把流从 m4s 容器里取出来按 MP4 的规则重新打包所以合并一个 2GB 的视频通常几秒钟就完成了而且画质没有任何损失。还有个不常见但偶尔会救命的参数-bsf:a aac_adtstoasc。如果你遇到合并出来的 MP4 在手机里播放有声音但没有开始了几秒或者某些播放器提示音频格式异常可以在-c copy后面加一段ffmpeg -y -i 30280.m4s -i 30232.m4s -c copy -map 0:v:0 -map 1:a:0 -bsf:a aac_adtstoasc output.mp4这个参数是把裸 AAC 流里缺失的 AudioSpecificConfig 信息补回 MP4 的音频轨道。B站旧版缓存里的音频流有些是直接从 ADTS 分片里拆出来的不补这层信息部分播放器会读取失败。2.3 合并完成后如何快速验证结果合并完别急着关终端先验证一下。用 ffprobe 看下输出文件ffprobe -v error -show_entries formatduration,size -show_entries streamcodec_type,codec_name -of json output.mp4重点检查三件事第一总时长是否和B站播放器里显示的时长一致第二文件里是否同时存在video和audio两条流第三输出文件的大小是否约等于原视频流加原音频流的大小之和。如果大小明显偏小说明可能只封装了视频没带音频或者中途有流被丢掉了。这一步看似多余但批量处理时特别有用。如果你等所有文件合并完才发现某个文件没声音回头的成本比单独多花两秒检查要大得多。3. 批量合并缓存目录从排序、重命名到自动配对的完整链路3.1 批量处理前先解决排序和命名的硬伤手动合并没问题之后就要上批量了。批量之前有一个很坑的细节文件排序。如果你在终端里用ls查看缓存目录会发现数字命名的文件夹按字典序排列顺序是1、10、11、2、20、3……这不是什么玄学就是字符串排序的结果。而B站缓存目录恰恰喜欢用数字或数字开头的字符串命名所以你很可能把第10集当成第2集合并输出文件顺序全乱。Python 里的sorted()默认也是按字符串排序同样有这个问题。正确做法是用自然排序也就是“人类理解的数字大小顺序”。bash 里可以这样ls -1 | sort -VPython 里可以自己写个简易键sorted(目录列表, keylambda x: int(.join(ch for ch in x if ch.isdigit()) or 0))或者直接用现成的natsort库from natsort import natsorted。还要处理输出文件的命名。B站缓存文件夹名是纯数字合并完后如果直接叫18828234.mp4你根本不知道这是哪一集。所以批量前最好从entry.json或info.json里把视频标题读出来作为文件名。假设你已经装了jq一行命令就能提取jq -r .title entry.json这个字段在旧版 entry.json 里是视频标题在新版 info.json 里结构可能略有不同但通常都叫title。我一般把输出文件名格式定为“原缓存编号-视频标题.mp4”这样即使标题里有特殊字符文件也不会互相覆盖。3.2 一个bash脚本搞定整目录合并下面是我在 Linux/macOS 上用的合并脚本逻辑不复杂遍历每个子目录找到里面的 m4s 文件用 ffprobe 判断流类型然后分别传给 ffmpeg 合并。#!/bin/bash # 批量合并B站缓存脚本运行位置为download目录上一级 for dir in */; do [ -d $dir ] || continue video_file audio_file # 找出目录里所有 m4s 文件 for f in $dir*.m4s; do [ -f $f ] || continue # 探测第一个视频流和第一个音频流 vtype$(ffprobe -v error -select_streams v:0 -show_entries streamcodec_type -of csvp0 $f 2/dev/null) atype$(ffprobe -v error -select_streams a:0 -show_entries streamcodec_type -of csvp0 $f 2/dev/null) if [ $vtype video ] [ -z $video_file ]; then video_file$f elif [ $atype audio ] [ -z $audio_file ]; then audio_file$f fi done if [ -n $video_file ] [ -n $audio_file ]; then output${dir%/}.mp4 ffmpeg -y -i $video_file -i $audio_file -c copy -map 0:v:0 -map 1:a:0 $output 2/dev/null echo 已生成$output else echo 跳过 $dir缺少视频或音频流 fi done注意我刻意没有靠文件名匹配而是用ffprobe对每个 m4s 逐一遍历判断流类型。这样不管你的缓存是30280.m4s还是video.m4s是0.m4s还是1.m4s都能正确配对。2/dev/null是屏蔽掉 ffprobe 和 ffmpeg 的那些烦人提示只保留关键信息如果你调试脚本想要更多日志可以把这行去掉。3.3 用Python处理更复杂的目录结构和多P合集bash 脚本处理平铺的一层子目录够了但B站缓存的结构并不总是平的。有些合集类视频顶层有个大目录里面每个P又是一个子目录子目录里才是 m4s 文件。这时候用 Python 写遍历更舒服逻辑也更清楚。下面这个Python脚本能递归找所有 m4s 文件并且支持单个目录里出现多段视频分片的情况import subprocess import os from pathlib import Path def stream_type(file_path: Path) - str: 返回 video / audio / unknown for kind, label in [(v:0, video), (a:0, audio)]: result subprocess.run( [ffprobe, -v, error, -select_streams, kind, -show_entries, streamcodec_type, -of, csvp0, str(file_path)], capture_outputTrue, textTrue ) if result.returncode 0 and result.stdout.strip() label: return label return unknown def natural_key(name: str): # 简单自然排序把字符串里的数字转成整数参与比较 import re return [int(x) if x.isdigit() else x.lower() for x in re.split(r(\d), name)] def merge_dir(dir_path: Path): m4s_files [f for f in dir_path.iterdir() if f.suffix.lower() .m4s] m4s_files.sort(keylambda x: natural_key(x.stem)) videos [] audios [] for f in m4s_files: t stream_type(f) if t video: videos.append(f) elif t audio: audios.append(f) if not videos or not audios: print(f跳过 {dir_path}: 无效文件组合) return # 同一个目录如果有多段视频按顺序拼进一个输入列表 video_src \n.join(ffile {v.as_posix()} for v in videos) audio_src \n.join(ffile {a.as_posix()} for a in audios) video_list dir_path / video_list.txt audio_list dir_path / audio_list.txt video_list.write_text(video_src, encodingutf-8) audio_list.write_text(audio_src, encodingutf-8) output dir_path / f{dir_path.name}.mp4 cmd [ ffmpeg, -y, -f, concat, -safe, 0, -i, str(video_list), -f, concat, -safe, 0, -i, str(audio_list), -c, copy, -map, 0:v:0, -map, 1:a:0, str(output) ] subprocess.run(cmd, capture_outputTrue) video_list.unlink() audio_list.unlink() print(f已生成{output}) root Path(r你的缓存根目录) for dir_path in sorted([p for p in root.iterdir() if p.is_dir()], keylambda p: natural_key(p.name)): merge_dir(dir_path)这段代码里有个小技巧如果一个缓存目录里拆出了多段视频分片和音频分片我会分别写两个 concat list让 ffmpeg 先各自拼接再合二为一。这种情况在手机缓存大视频时偶尔会出现在PC客户端缓存里倒是不太常见但写进去之后脚本的容错性会高很多。4. 我在批量合并中最常遇到的三个异常4.1 合并后没有声音先查音频流是否存在症状很统一ffmpeg 命令跑完文件也生成出来了播放时画面流畅但就是没声音。别急着怀疑合并命令先回到最原始的两个输入文件用 ffprobe 看音频文件ffprobe -v error -show_entries streamcodec_name -of csvp0 30232.m4s如果输出为空说明这个文件压根不是音频流你识别错了。这种情况多发生在旧版缓存里因为旧版音频文件有30232.m4s和30236.m4s两种命名前者可能是 AAC 音频后者可能是 MP3 或其他格式但有些版本把音频和视频编号调换过导致你按文件名找音频时拿到了视频文件。另一种可能是音频流存在但文件不完整。你用 ffprobe 能看到流的编码信息但拉到文件末尾时会报错。这种情况多半是B站客户端缓存时没下载完整常见于缓存中途断网或者用户手动清过空间。解决办法只有一个回到客户端重新缓存该视频。这个坑提醒我批量合并前最好先遍历所有文件做一次完整性的快速检查优先用-v error级别的 ffprobe有报错就单独拎处理。4.2 明明有多个P脚本却只合并了第一P很多人下的合集是一个大目录套多个小目录比如download/18830300/下面有1/、2/、3/...每个小目录对应一P。如果你把脚本直接放在18830300这层跑它会把每一P当成独立视频合并出一堆单P的MP4这当然不算错误但如果你想要的是“整季合成一个文件”脚本就不够用了。我的处理思路是这样先合并每一P为独立MP4再把这些MP4按顺序拼接成一个完整的视频文件。不要试图一步到位让 ffmpeg 跨多个子目录去自动配对那太容易出错了。逐P合并的好处是就算某一P的缓存损坏你也知道是哪一P的问题不至于一整季全盘失败。逐P合并后生成一个list.txt每一行写file 1.mp4 file 2.mp4 file 3.mp4然后执行ffmpeg -f concat -safe 0 -i list.txt -c copy season_all.mp4因为每P合并时都已经-c copy重封装成标准MP4拼接时同样可以不重新编码速度很快。如果有些P是从B站客户端下载的高码率4K HDR有些P是普通1080P拼接时编码参数不一样-c copy会失败。这时候就只能老老实实重新编码拼接或者放弃合并成一个大文件保留分PMP4。4.3 混进非缓存文件封面、弹幕、临时文件干扰脚本B站缓存目录里不只是 m4s 文件还有.jpg封面图、.xml弹幕和一堆无扩展名或临时后缀的文件。如果你脚本里直接“把目录下所有非目录文件都当输入”封面图会被 ffprobe 识别为图片流有的甚至被当作视频流然后合并命令就报错或者输出奇怪的文件。我的建议是for 循环里只挑.m4s后缀这是最安全的。如果某个版本缓存的音/视频文件压根没有.m4s后缀那就用file命令先看一下文件类型再做判断。另外处理完成后生成的那些list.txt、video_list.txt之类的工作文件要记得清理不然下次脚本再跑这些文本文件也会被*.m4s通配符扫到幸好文本文件没有.m4s后缀。这些细节看起来不起眼但在几百个文件的批次里每一条都能让你浪费半小时排查时间。5. 顺手把这些也处理掉弹幕、外挂字幕和多段拼接5.1 把B站弹幕xml保留或转换为ass字幕B站缓存的弹幕文件通常是danmaku.xml内容是一大堆d p时间戳,模式,...弹幕文本/d的记录。如果你经常看带弹幕的视频建议把它转成ass字幕文件播放时加载到视频里就能看到弹幕飘过。转换工具很多社区里最常见的是 danmaku2ass 一类的小工具用的时候给两个参数输入xml和输出ass路径。不过我不建议每转一个文件都手动敲命令可以把它接在批量脚本后面比如 bash 脚本里在 ffmpeg 合并完之后自动判断if [ -f $dir/danmaku.xml ]; then danmaku2ass -o ${output%.mp4}.ass $dir/danmaku.xml fi要注意B站弹幕的xml格式里没有字体、字号这些样式信息转换工具默认给的样式可能偏大或偏小看的时候在播放器里调整字号更靠谱。如果你是用弹幕播放器看本地视频直接加载danmaku.xml文件也行不一定非要转ass。5.2 多段缓存视频拼接成一个完整文件前面在Python脚本里提过 concat 方式这里再展开说一下。B站缓存有时候会把一个视频分割成多个分片比如每段几十MB目录里就有0.m4s、1.m4s、2.m4s这种多个视频流文件。这种情况下你没法只用第一个分片和单个音频组合得到完整视频。正确做法是先把所有视频分片拼接成一个完整视频流再把所有音频分片拼接成一个完整音频流最后合成。concat 的输入文件列表里文件路径要写绝对路径或者相对脚本运行目录的路径否则会报错。另外-safe 0这个参数很重要因为 ffmpeg 出于安全默认不允许 concat 读取相对路径之外的绝对路径不加上它很多人的脚本会直接卡在这一步。5.3 关于移动端缓存的几点差异提醒安卓和iOS的B站缓存还是有几处不太一样的。安卓新版App缓存路径一般在/Android/data/tv.danmaku.bili/download/但Android 11之后系统对/Android/data目录访问权限收紧了你插数据线到电脑上不一定能看到需要在手机上用文件管理器单独授权或者用支持访问Android/data的文件管理器App。iOS端几乎没办法直接从文件系统导出缓存通常只能通过备份机制或者第三方工具弄出来而且iOS缓存目录结构跟安卓完全不同里面的文件命名也更乱。所以如果你要批量合并最好是在安卓端缓存然后把整个download目录完整拷到电脑上再处理。还有一个细节B站PC客户端也会缓存视频但PC客户端的缓存路径和文件结构又跟移动端不太一样。PC端一般按xxx/download/或自定义目录存放里面的文件可能是video.m4s、audio.m4s的标准命名也可能直接带.mp4后缀。判断方法一样用 ffprobe 探测最保险。我自己现在的例行流程是先把整个缓存目录从手机拷到电脑跑一遍ffprobe标注所有文件类型再跑批量合并脚本最后统一用重命名把输出整理成“视频标题-集数-清晰度”的格式。合并本身很快瓶颈从来不在ffmpeg而在文件命名和目录层级。这套思路不但适用于B站缓存其他把音视频分开存储的App缓存导出后也基本是一通百通。遇到异常文件记住先探测、再合并、后验证能省下大量重复劳动。