使用 FFmpeg 从 Bilibili 缓存目录提取视频文件

Bideo:编写 Python 脚本自动化调用 FFmpeg 从 Bilibili / B 站缓存目录提取视频文件。

0x01. B 站缓存目录

首先需要实现从 B 站下载视频,这里直接安装 B 站客户端,使用客户端自带的缓存功能,这对于专辑下载尤其方便。

B 站缓存目录的位置可以自行在客户端设置,缓存目录下的子目录以数字形式命名,每一个子目录代表一个视频内容。

1
2
3
4
5
6
7
8
12/01/2024  12:10 AM             4,884 .playurl
12/01/2024 12:10 AM 1,197 .videoInfo
12/01/2024 12:10 AM 17,175,348 500001648072961-1-30080.m4s
12/01/2024 12:10 AM 1,254,614 500001648072961-1-30280.m4s
12/01/2024 12:10 AM 3,017 dm1
12/01/2024 12:10 AM 174,951 group.jpg
12/01/2024 12:10 AM 174,951 image.jpg
12/01/2024 12:10 AM 1,197 videoInfo.json

其中,2m4s 文件是音视频文件,这里简单认为大的是视频文件,小的是音频文件。但是需要注意,这两个文件的头部都插入了 90,即 000000000,这部分数据需要自己清理。

另外,视频文件的标题可以从 videoInfo.json 文件中获取。

现在,我们已经讲清楚了 Bilibili 缓存目录的结构,写个简单的 Python 脚本即可进行遍历和预处理音视频文件。

0x02. FFmpeg 合并

直接使用如下命令,可以实现对音视频文件的合并,这里不会对音视频进行重新编码,因此合并的速度非常快。

1
ffmpeg -i video.mp4 -i audio.mp3 -c:v copy -c:a copy output.mp4

0x03. 完整 Python 脚本

给脚本取了个骚气的名字 Bideo(Bilibili Video),最新版本可以从 GitHub 仓库下载:https://github.com/secdroid/Bideo

使用 Bideo 从 Bilibili 下载缓存提取和合并音视频文件

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
#!/usr/bin/env python
#-*- coding:utf-8 -*-
import os
import sys
import json
import shutil
import subprocess

def get_video_list(cache_dir):
videos = []
items = os.listdir(cache_dir)
for item in items:
filepath = os.path.join(cache_dir, item)
if os.path.isdir(filepath):
videos.append(filepath)
return videos

def get_video_title(video_dir):
jsonfile = os.path.join(video_dir, 'videoInfo.json')
with open(jsonfile, 'r', encoding='utf-8') as file:
data = json.load(file)
return data['tabName']

def fix_file_header(infile, outfile):
data = b''
with open(infile, 'rb') as f:
data = f.read()
with open(outfile, 'wb') as f:
f.write(data[9:])

def get_playable_avfiles(video_dir):
avfiles = []
for filename in os.listdir(video_dir):
_, ext = os.path.splitext(filename)
if ext.lower() != '.m4s':
continue
filepath = os.path.join(video_dir, filename)
avfiles.append((filepath, os.stat(filepath).st_size))

audiofile = avfiles[0][0]
videofile = avfiles[1][0]
if avfiles[0][1] > avfiles[1][1]:
audiofile, videofile = videofile, audiofile

real_audio = os.path.join(video_dir, 'audio.m4s')
fix_file_header(audiofile, real_audio)
real_video = os.path.join(video_dir, 'video.m4s')
fix_file_header(videofile, real_video)
return real_audio, real_video

def combine_avfiles(ffmpeg, audio, video, outfile):
cmdline = [ffmpeg, '-i', audio, '-i', video, '-c:v', 'copy', '-c:a', 'copy', outfile]
p = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_1, _2 = p.communicate()
os.remove(audio)
os.remove(video)

def extract_videos(ffmpeg, cache_dir):
ffmpeg = os.path.abspath(ffmpeg)
cache_dir = os.path.abspath(cache_dir)
outdir = cache_dir + '_output'
if not os.path.exists(outdir):
os.makedirs(outdir)
print('Final video files will be write to directory: %s' % outdir)

videos = get_video_list(cache_dir)
for i, video_dir in enumerate(videos):
sys.stdout.write('[%02d/%02d] Processing %s' % (i + 1, len(videos), os.path.basename(video_dir)))
audiofile, videofile = get_playable_avfiles(video_dir)
final_name = get_video_title(video_dir) + '.mp4'
sys.stdout.write(' -> %s\n' % final_name)
outfile = os.path.join(video_dir, final_name)
combine_avfiles(ffmpeg, audiofile, videofile, outfile)
shutil.move(outfile, os.path.join(outdir, final_name))

if __name__ == '__main__':
if len(sys.argv) != 3:
print('Usage: %s <ffmpeg path> <bilibili cache directory>\n' % os.path.basename(sys.argv[0]))
else:
extract_videos(sys.argv[1], sys.argv[2])