HLS 流媒体服务与加解密

在前面一篇文章中 《流媒体协议之 HLS》 介绍了什么是流媒体,什么是 HLS 以及分析了 m3u8 文件格式内容的含义。在本篇文章中更多的是实际操作,搭建一个流媒体服务,使用 ffmpeg 切割大的视频文件,使用 ffmpeg 切割大的视频文件并加密,使用 ffmpeg 整合视频片断,以及在代码中如何实现根据 m3u8 下载并解码对应的媒体文件。演示的环境是 Ubuntu 20.04.1 LTS (GNU/Linux 5.4.0-62-generic x86_64)、nginx-1.19.6、gcc version 9.3.0、GNU Make 4.2.1O、OpenSSL 1.1.1f

编译安装 Nginx

这个时候选择编译安装的方式:
nginx 的 rtmp 模块,下载地址是 https://github.com/arut/nginx-rtmp-module
nginx_mod_h264_streaming 的下载地址是:http://h264.code-shop.com/download/nginx_mod_h264_streaming-2.2.7.tar.gz
nginx 源码包,下载地址是:http://nginx.org/download/nginx-1.19.6.tar.gz
先准备好 Nginx 源码包、rtmp 模块和 nginx_mod_h264_streaming 模块,并解压:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 在 root 目录下准备 nginx-1.19.6.tar.gz、nginx-rtmp-module.zip、nginx_mod_h264_streaming-2.2.7.tar.gz 并解压 
nginx-1.19.6
nginx-1.19.6.tar.gz
nginx-rtmp-module
nginx-rtmp-module.zip
nginx_mod_h264_streaming-2.2.7
nginx_mod_h264_streaming-2.2.7.tar.gz
cd nginx-1.19.6
# 检查依赖库与环境、添加 rtmp 和 h264_streaming 模块
./configure --prefix=/usr/local/nginx --with-http_ssl_module --add-module=/root/nginx-rtmp-module --add-module=/root/nginx_mod_h264_streaming-2.2.7

# 根据提示还需要安装 PCRE、OpenSSL、Zlib 的库:
apt install libpcre3 libpcre3-dev zlib1g-dev openssl libssl-dev

# 再次检查依赖库与环境、添加 rtmp 和 h264_streaming 模块
./configure --prefix=/usr/local/nginx --with-http_ssl_module --add-module=/root/nginx-rtmp-module --add-module=/root/nginx_mod_h264_streaming-2.2.7

# 编译 + 安装
make
make install
# 建立软连接
ln -s /usr/local/nginx/sbin/nginx/usr/local/bin/nginx
# 开启 Nginx 服务器
nginx

编译过程可能会遇到的问题:

问题 1:ngx_http_streaming_module.c:158:8: error: ngx_http_request_t has no member named zero_in_uri
解决方案:注释掉 nginx_mod_h264_streaming-2.2.7/src/ngx_http_streaming_module.c 的 158 到 161 行即可

问题 2:error: variable ‘stream_priority’ set but not used [-Werror=unused-but-set-variable]
解决方案:修改 nginx-1.10.2/objs/Makefile 文件第 2 行 CFLAGS 变量去掉 “-Werror” 字段

现在找一个 mp4 与 flv 文件分别放在 /root/videos/mp4//root/videos/flv/ 下,则配置文件修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
# 如果访问出现 403,需要把 user 配置为 root;
user root;
...

server {
listen 80;
server_name localhost;

location / {
root html;
index index.html index.htm;
}

location ~ \.mp4$ {
root /root/videos/mp4/;
}

location ~ \.flv$ {
root /root/videos/flv/;
}
}
...

然后输入 http://xx.xx.xx.xx/test.mp4 即可开始播放,就说明已经配置好了,现在你已经拥有了一个基本的视频点播站点,就像下面这样:


对于 flv 的视频,可以使用 VLC 来播放,这是下载地址 https://get.videolan.org/vlc/3.0.11.1/macosx/vlc-3.0.11.1.dmg

apt 安装 FFmpeg

1
apt install ffmpeg

如果没有更换源的话会很慢,下面可以更新一下镜像源,再执行 apt install ffmpeg
1、首先备份原来的源:

1
cp /etc/apt/sources.list/etc/apt/sources.list.bak

2、查看本 Ubuntu 的代号
1
lsb_release -a


对于我的 Ubuntu20.04 来说,代号就是 focal
3、确认查看阿里云是否存在该源
http://mirrors.aliyun.com/ubuntu/dists/ 看来是存在 focal 的。
4、将下面的 XXX 全部替换为系统的代号,比如我的系统代号是 focal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
deb http://mirrors.aliyun.com/ubuntu/ XXX main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ XXX main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ XXX-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ XXX-security main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ XXX-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ XXX-updates main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ XXX-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ XXX-proposed main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/ XXX-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ XXX-backports main restricted universe multiverse

那么替换完成后就是
1
2
3
4
5
6
7
8
9
10
11
12
13
14
deb http://mirrors.aliyun.com/ubuntu/focal main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/focal main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/focal-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/focal-security main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/focal-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/focal-updates main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/focal-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/focal-proposed main restricted universe multiverse

deb http://mirrors.aliyun.com/ubuntu/focal-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/focal-backports main restricted universe multiverse

5、更新缓存
1
apt update

ffmpeg 安装成功后即可查看 ffmpeg 的版本:
1
ffmpeg

使用 ffmpeg 切割媒体文件

进入到 test.mp4 存在的目录,执行如下命令完成对 test.mp4 的切割:

1
2
cd ~/videos/mp4
ffmpeg -i test.mp4 -c:v libx264 -c:a copy -f hls -threads 8 -hls_time 30 -hls_list_size 0 test.m3u8

hls_time: 设置每片的长度,单位是秒,默认值为 2 秒。
hls_list_size: 设置播放列表保存的最多条目,设置为 0 会保存有所片信息,默认值为 5。
hls_wrap: 设置多少片之后开始覆盖,如果设置为 0 则不会覆盖,默认值为 0。这个选项能够避免在磁盘上存储过多的片,而且能够限制写入磁盘的最多的片的数量。
start_number: 设置播放列表中 sequence number 的值为 number,默认值为 0
hls_base_url: 参数用于为 M3U8 列表的文件路径设置前置基本路径参数,因为在 FFmpeg 中生成 M3U8 时写入的 TS 切片路径默认为 M3U8 生成的路径相同,但是实际上 TS 所存储的路径既可以为本地绝对路径,也可以为相对路径,还可以为网络路径,因此使用 hls_base_url 参数可以达到该效果
切割完成后,可以看到文件夹下的 ts 片断和对应的 m3u8 文件:

这些 ts 片断都是可以直接播放的,而且可以看到对应的 m3u8 文件如下,关于 m3u8 文件属性的内容在 《流媒体协议之 HLS》 中已经介绍过了。

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
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:36
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:35.704711,
test0.ts
#EXTINF:24.984956,
test1.ts
#EXTINF:31.450178,
test2.ts
#EXTINF:29.614889,
test3.ts
#EXTINF:29.531467,
test4.ts
#EXTINF:28.780667,
test5.ts
#EXTINF:31.992422,
test6.ts
#EXTINF:31.241622,
test7.ts
#EXTINF:28.196711,
test8.ts
#EXTINF:29.823444,
test9.ts
#EXTINF:32.451244,
test10.ts
#EXTINF:30.824511,
test11.ts
#EXTINF:26.820244,
test12.ts
#EXTINF:13.931511,
test13.ts
#EXT-X-ENDLIST

修改一下 Nginx 的配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server {
listen 80;
server_name localhost;

location / {
root html;
index index.html index.htm;
}

location ~ \.mp4$ {
root /root/videos/mp4/;
}

location ~ \.flv$ {
root /root/videos/flv/;
}

location /media {
alias /root/videos/mp4/;
add_header Cache-Control no-cache;
}
}

这样通过 VLC 打开网络串流,输入 m3u8 的地址:http://172.16.26.2/media/test.m3u8 即可播放对应的媒体资源:

如果加上 hls_base_url 参数生成的 m3u8 文件如下:

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
ffmpeg -i test.mp4 -c:v libx264 -c:a copy -f hls -threads 8 -hls_time 30 -hls_list_size 0 -hls_base_url http://172.16.26.2/media/test.m3u8

cat test.m3u8
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:36
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:35.704711,
http://172.16.26.2/media/test0.ts
#EXTINF:24.984956,
http://172.16.26.2/media/test1.ts
#EXTINF:31.450178,
http://172.16.26.2/media/test2.ts
#EXTINF:29.614889,
http://172.16.26.2/media/test3.ts
#EXTINF:29.531467,
http://172.16.26.2/media/test4.ts
#EXTINF:28.780667,
http://172.16.26.2/media/test5.ts
#EXTINF:31.992422,
http://172.16.26.2/media/test6.ts
#EXTINF:31.241622,
http://172.16.26.2/media/test7.ts
#EXTINF:28.196711,
http://172.16.26.2/media/test8.ts
#EXTINF:29.823444,
http://172.16.26.2/media/test9.ts
#EXTINF:32.451244,
http://172.16.26.2/media/test10.ts
#EXTINF:30.824511,
http://172.16.26.2/media/test11.ts
#EXTINF:26.820244,
http://172.16.26.2/media/test12.ts
#EXTINF:13.931511,
http://172.16.26.2/media/test13.ts
#EXT-X-ENDLIST

使用 ffmpeg 合并 ts 文件

使用 ffmpeg 也可以通过 m3u8 索引文件把所有的 ts 片段文件合并:

1
ffmpeg -i ./test.m3u8 -acodec copy -vcodec copy output.mp4

如果是网络上的 m3u8 点播列表,也可以下载并合并到 mp4 中:
1
ffmpeg -i "http://xxx.com/media/test.m3u8" "save_video.mp4"

ffmpeg 切割并加密媒体文件

将一个 mp4 视频文件切割为多个 ts 片段,并在切割过程中对每一个片段使用 AES-128 加密,最后生成一个 m3u8 的视频索引文件:

加密用的 key,通过 OpenSSL 生成一个 enc.key 文件

1
openssl rand  16 > enc.key

另一个是 iv

1
openssl rand -hex 16 

这里生成的 IV 是 ef157287b9fc922ed1cc101a09e742b3

新建一个文件 enc.keyinfo 内容格式如下:

1
2
3
http://172.16.26.2/media/enc.key
enc.key
ef157287b9fc922ed1cc101a09e742b3

因为 enc.key 直接放在了 /root/vides/mp4/ 目录下,所以通过 http://172.16.26.2/media/enc.key 这个地址完全可以访问到这个 enc.key 文件。

-y \
1
ffmpeg -y -i test.mp4 -hls_time 30 -hls_key_info_file enc.keyinfo -hls_playlist_type vod -hls_segment_filename "file% d.ts" -hls_base_url http://172.16.26.2/media/test.m3u8

上述命令中 -hls_time 30 即每个片段 30s,-hls_playlist_type vod 表示这是一个点播播放列表,hls_segment_filename"file% d.ts" 规定了片断的文件名。生成的 m3u8 文件如下:

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
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:36
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-KEY:METHOD=AES-128,URI="http://172.16.26.2/media/enc.key",IV=0xef157287b9fc922ed1cc101a09e742b3
#EXTINF:35.704711,
http://172.16.26.2/media/file0.ts
#EXTINF:24.984956,
http://172.16.26.2/media/file1.ts
#EXTINF:31.450178,
http://172.16.26.2/media/file2.ts
#EXTINF:29.614889,
http://172.16.26.2/media/file3.ts
#EXTINF:29.531467,
http://172.16.26.2/media/file4.ts
#EXTINF:28.780667,
http://172.16.26.2/media/file5.ts
#EXTINF:31.992422,
http://172.16.26.2/media/file6.ts
#EXTINF:31.241622,
http://172.16.26.2/media/file7.ts
#EXTINF:28.196711,
http://172.16.26.2/media/file8.ts
#EXTINF:29.823444,
http://172.16.26.2/media/file9.ts
#EXTINF:32.451244,
http://172.16.26.2/media/file10.ts
#EXTINF:30.824511,
http://172.16.26.2/media/file11.ts
#EXTINF:26.820244,
http://172.16.26.2/media/file12.ts
#EXTINF:13.931511,
http://172.16.26.2/media/file13.ts
#EXT-X-ENDLIST

这样通过加密生成的每个 ts 片断都需要解密才能播放。HTTP Live Streaming 中内容加密有两种,一种是对 TS 切片文件直接加密;另一种是对 H.264 编码文件中类型为 1 和 5 的 NAL 单元进行加密,其它类型的 NAL 单元不加密。HLS 中媒体分块如果是加密的,其加密密钥通过 M3U8 文件中的 #EXT-X-KEY 来指定,密钥文件由客户端从服务器请求认证获得。一个播放列表可以有一个以上的 #EXT-X-KEY,同一个媒体段也可以有多个不同 KEYFORMAT 属性值的 #EXT-X-KEY,在本例中使用的是对每个 TS 片断进行加密。

在上面的示例 m3u8 文件中,#EXT-X-KEY 有一个属性 URI,其实这个 URI 就是秘钥的地址,在实际音视频版权保护的案例中,TS 切片文件的加解密是非常重要的一环,因为客户端只有在拿到了 key 文件之后才能对 TS 切片文件进行解密,所以在 URI 上面做文章就很关键,这里只是用了一个简单的 HTTP URL 表示了 key 文件的地址,实际场景中需要配合用户 Token 等一系列校验过程才能使客户端拿到真正的 key,另外如果 key 文件本身也是加密的话还需要对 Key 文件本身进行解密,如果把解密的代码放到 SO 库里(也就是 C/C++ 编写的库),那么要破译 Key 就更难了。所以为了防盗链还是有很多的方法流程的。

代码中解密 TS 文件

在很多播放器内就内置了解密 m3u8 文件的功能,但是必须是在本例中这样直接给出 key 的 URL 才可以。对于这样的直接给出 Key 的地址的情况,只需要根据对应的 Key 做解密操作就行了。上面的每一个 TS 文件未解密都不能播放,因此每个 TS 文件都需要进行解密。下面是我写的关于 TS 文件 AES128 加解密的代码:

先引入 Java 实现 AES 加密模块的依赖

1
implementation group: 'org.bouncycastle', name: 'bcprov-jdk16', version: '1.46'

AES128Utils.java
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
public class AES128Utils {

static {
Security.addProvider (new BouncyCastleProvider());
}

/**
* 序号格式化为 32 字节长度字符串
* @param index 片断序号
* @return 32 字节长度序号
*/
public static String getIvValue(int index){
return String.format ("%032x", index);
}

/**
* 加密的 TS 文件加密为字节数组
* @param srcTsFileBytes 加密的 TS 文件字节数组
* @param keyBytes key 文件的字节数组
* @param iv iv 偏移量(m3u8 文件中)
* @return 解密后的字节数组
* @throws Exception 编解码、IO 异常
*/
public static byte[] decryptTsFile (byte[] srcTsFileBytes, byte[] keyBytes, String iv) throws Exception{
Cipher cipher = Cipher.getInstance ("AES/CBC/PKCS7Padding");
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
byte[] ivByte = iv.getBytes ();
if (ivByte.length != 16) ivByte = new byte[16];
AlgorithmParameterSpec paramSpec = new IvParameterSpec(ivByte);
cipher.init (Cipher.DECRYPT_MODE, keySpec, paramSpec);
return cipher.doFinal (srcTsFileBytes, 0, srcTsFileBytes.length);
}

/**
* 加密的 TS 文件加密为字节数组
* @param srcTsFile 加密的 TS 文件
* @param key key 文件的字节数组
* @param iv iv 偏移量(m3u8 文件中)
* @return 解密后的字节数组
* @throws Exception 编解码、IO 异常
*/
public static byte[] decryptTsFile (File srcTsFile, String key, String iv) throws Exception {
return decryptTsFile (IOUtils.fileToByteArray (srcTsFile), key.getBytes (), iv);
}

/**
* 加密的 TS 文件加密为字节数组
* @param srcTsFile 加密的 TS 文件
* @param keyFile key 文件
* @param iv iv 偏移量(m3u8 文件中)
* @return 解密后的字节数组
* @throws Exception 编解码、IO 异常
*/
public static byte[] decryptTsFile (File srcTsFile, File keyFile, String iv) throws Exception {
return decryptTsFile (IOUtils.fileToByteArray (srcTsFile), IOUtils.fileToByteArray (keyFile), iv);
}
}

IOUtils.java

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
public class IOUtils {
private static final String TAG = "IOUtils";
/**
* 合并 Ts 片断文件
* @param tsFiles Ts 文件集合
* @param descFile 目标文件
* @throws IOException IOException
*/
public static void mergeTsFiles(Map<String, File> tsFiles, List<String> tsList,
File descFile, boolean deleteSrcFile) throws IOException{
FileOutputStream fileOutputStream = new FileOutputStream(descFile);
for(String name: tsList){
File file = tsFiles.get (name);
Log.i (TAG, "mergeTsFiles: key = " + name + ", path = "+ file.getAbsolutePath ());
fileOutputStream.write (IOUtils.fileToByteArray (file));
fileOutputStream.flush ();
if(deleteSrcFile) file.delete ();
}
fileOutputStream.close ();
}

/**
* 文件转字节数组
* @param srcFile 源文件
* @return 字节数组
* @throws IOException IO
*/
public static byte[] fileToByteArray (File srcFile) throws IOException {
FileInputStream inputStream = new FileInputStream(srcFile);
byte[] buffer = new byte[4096];
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
int read;
while ((read = inputStream.read (buffer)) != -1){
byteArrayOutputStream.write (buffer, 0, read);
}
inputStream.close ();
byteArrayOutputStream.close ();
return byteArrayOutputStream.toByteArray ();
}

/**
* 字节数组写入文件
* @param srcByte 组接数组
* @param descFile 目标文件
* @throws IOException IO
*/
public static void byteArrayToFile(byte[] srcByte, File descFile) throws IOException {
FileOutputStream os = new FileOutputStream(descFile);
os.write (srcByte);
os.close ();
}
}

M3u8Parser.java

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
public class M3u8Parser {
private String baseUrl;
private static final String TAG = "M3u8Parser";
private final File m3u8File;
List<String> tsList = new ArrayList<>();
private Activity context;
private File cacheDir;
private File keyFile;

public M3u8Parser(String baseUrl, File m3u8File, Activity context) {
this.baseUrl = baseUrl;
this.m3u8File = m3u8File;
this.context = context;
cacheDir = context.getExternalCacheDir ();
}

public void initParser() throws IOException {
BufferedReader bufferedReader = new BufferedReader(new FileReader(m3u8File));
String line;
while((line = bufferedReader.readLine ()) != null){
if(line.startsWith ("#EXT-X-KEY")){
String [] split = line.split (",");
if(split.length == 3){
String keyUrl = split [1].substring (5, split [1].length () - 1);
System.out.println (keyUrl);
NetWorkUtils.doGet (keyUrl, new NetWorkUtils.ResultListener () {
@Override
public void success(File keyFile) {
Log.i (TAG, "success: " + keyFile.getAbsolutePath ());
M3u8Parser.this.keyFile = keyFile;
}

@Override
public void failed(IOException e) {
Log.e (TAG, "failed: ", e);
}
});
}
}else if(!line.startsWith ("#")){
tsList.add (line);
}
}
}

public void startDownload(DownloadListener downloadListener) {
CountDownLatch countDownLatch = new CountDownLatch(tsList.size ());
Map<String, File> downloadTsFiles = new HashMap<>();
int index = 0;
for(String ts: tsList){
String url = baseUrl + ts;
Log.i (TAG, "startDownload: ts = " + ts);
Log.i (TAG, "startDownload: url = " + url);
int finalIndex = index;
NetWorkUtils.doGet (url, new NetWorkUtils.ResultListener () {
@Override
public void success(File downloadFile) {
String iv = AES128Utils.getIvValue (finalIndex);
try {
// 下载后直接解码
byte[] bytes = AES128Utils.decryptTsFile (downloadFile, keyFile, iv);
IOUtils.byteArrayToFile (bytes, downloadFile);
} catch (Exception e) {
e.printStackTrace ();
}
downloadTsFiles.put (ts, downloadFile);
countDownLatch.countDown ();
}

@Override
public void failed(IOException e) {
Log.e (TAG, "TS 文件下载失败 & quot;, e);
}
});
index++;
}
try {
countDownLatch.await (1200, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace ();
}

try {
File descFile = new File(cacheDir, "main.ts");
IOUtils.mergeTsFiles (downloadTsFiles, tsList, descFile, false);
context.runOnUiThread (()->{
Toast.makeText (context, " 缓存完成:" + descFile.getAbsolutePath (), Toast.LENGTH_SHORT).show ();
});
downloadListener.finishDownload ();
} catch (IOException e) {
e.printStackTrace ();
}
}

public interface DownloadListener {
void finishDownload();
}
}

参考资料

1、Example Playlists for HTTP Live Streaming

2、HTTP Live Streaming

3、知识付费 —— 移动端音视频加密、防盗播实现方案