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# 在root目录下准备nginx-1.19.6.tar.gz、nginx-rtmp-module.zip、nginx_mod_h264_streaming-2.2.7.tar.gz并解压
 2nginx-1.19.6
 3nginx-1.19.6.tar.gz
 4nginx-rtmp-module
 5nginx-rtmp-module.zip
 6nginx_mod_h264_streaming-2.2.7
 7nginx_mod_h264_streaming-2.2.7.tar.gz
 8cd nginx-1.19.6
 9# 检查依赖库与环境、添加rtmp和h264_streaming模块
10./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
11
12# 根据提示还需要安装PCRE、OpenSSL、Zlib的库:
13apt install libpcre3 libpcre3-dev zlib1g-dev openssl libssl-dev
14
15# 再次检查依赖库与环境、添加rtmp和h264_streaming模块
16./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
17
18# 编译 + 安装
19make
20make install
21# 建立软连接
22ln -s /usr/local/nginx/sbin/nginx /usr/local/bin/nginx
23# 开启Nginx服务器
24nginx

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

问题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# 如果访问出现403,需要把user配置为root;
 3user  root;
 4...
 5
 6server {
 7        listen       80;
 8        server_name  localhost;
 9 
10        location / {
11            root   html;
12            index  index.html index.htm;
13        }
14         
15        location  ~ \.mp4$ {
16        	root   /root/videos/mp4/;
17        }
18
19        location ~ \.flv$ {
20            root   /root/videos/flv/;
21        }
22}
23...

然后输入 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

1apt install ffmpeg

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

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

2、查看本Ubuntu的代号

1lsb_release -a

对于我的Ubuntu20.04来说,代号就是focal 3、确认查看阿里云是否存在该源 http://mirrors.aliyun.com/ubuntu/dists/ 看来是存在focal的。 4、将下面的XXX全部替换为系统的代号,比如我的系统代号是focal

 1deb http://mirrors.aliyun.com/ubuntu/ XXX main restricted universe multiverse
 2deb-src http://mirrors.aliyun.com/ubuntu/ XXX main restricted universe multiverse
 3
 4deb http://mirrors.aliyun.com/ubuntu/ XXX-security main restricted universe multiverse
 5deb-src http://mirrors.aliyun.com/ubuntu/ XXX-security main restricted universe multiverse
 6
 7deb http://mirrors.aliyun.com/ubuntu/ XXX-updates main restricted universe multiverse
 8deb-src http://mirrors.aliyun.com/ubuntu/ XXX-updates main restricted universe multiverse
 9
10deb http://mirrors.aliyun.com/ubuntu/ XXX-proposed main restricted universe multiverse
11deb-src http://mirrors.aliyun.com/ubuntu/ XXX-proposed main restricted universe multiverse
12
13deb http://mirrors.aliyun.com/ubuntu/ XXX-backports main restricted universe multiverse
14deb-src http://mirrors.aliyun.com/ubuntu/ XXX-backports main restricted universe multiverse

那么替换完成后就是

 1deb http://mirrors.aliyun.com/ubuntu/ focal main restricted universe multiverse
 2deb-src http://mirrors.aliyun.com/ubuntu/ focal main restricted universe multiverse
 3
 4deb http://mirrors.aliyun.com/ubuntu/ focal-security main restricted universe multiverse
 5deb-src http://mirrors.aliyun.com/ubuntu/ focal-security main restricted universe multiverse
 6
 7deb http://mirrors.aliyun.com/ubuntu/ focal-updates main restricted universe multiverse
 8deb-src http://mirrors.aliyun.com/ubuntu/ focal-updates main restricted universe multiverse
 9
10deb http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted universe multiverse
11deb-src http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted universe multiverse
12
13deb http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse
14deb-src http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse

5、更新缓存

1apt update

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

1ffmpeg

使用ffmpeg切割媒体文件

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

1cd ~/videos/mp4
2ffmpeg -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#EXTM3U
 2#EXT-X-VERSION:3
 3#EXT-X-TARGETDURATION:36
 4#EXT-X-MEDIA-SEQUENCE:0
 5#EXTINF:35.704711,
 6test0.ts
 7#EXTINF:24.984956,
 8test1.ts
 9#EXTINF:31.450178,
10test2.ts
11#EXTINF:29.614889,
12test3.ts
13#EXTINF:29.531467,
14test4.ts
15#EXTINF:28.780667,
16test5.ts
17#EXTINF:31.992422,
18test6.ts
19#EXTINF:31.241622,
20test7.ts
21#EXTINF:28.196711,
22test8.ts
23#EXTINF:29.823444,
24test9.ts
25#EXTINF:32.451244,
26test10.ts
27#EXTINF:30.824511,
28test11.ts
29#EXTINF:26.820244,
30test12.ts
31#EXTINF:13.931511,
32test13.ts
33#EXT-X-ENDLIST

修改一下Nginx的配置文件

 1server {
 2        listen       80;
 3        server_name  localhost;
 4 
 5        location / {
 6            root   html;
 7            index  index.html index.htm;
 8        }
 9         
10        location  ~ \.mp4$ {
11        	root   /root/videos/mp4/;
12        }
13
14        location ~ \.flv$ {
15            root   /root/videos/flv/;
16        }
17
18        location /media {
19            alias   /root/videos/mp4/;
20            add_header Cache-Control no-cache;
21        }
22}

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

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

 1ffmpeg -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
 2
 3cat test.m3u8
 4#EXTM3U
 5#EXT-X-VERSION:3
 6#EXT-X-TARGETDURATION:36
 7#EXT-X-MEDIA-SEQUENCE:0
 8#EXTINF:35.704711,
 9http://172.16.26.2/media/test0.ts
10#EXTINF:24.984956,
11http://172.16.26.2/media/test1.ts
12#EXTINF:31.450178,
13http://172.16.26.2/media/test2.ts
14#EXTINF:29.614889,
15http://172.16.26.2/media/test3.ts
16#EXTINF:29.531467,
17http://172.16.26.2/media/test4.ts
18#EXTINF:28.780667,
19http://172.16.26.2/media/test5.ts
20#EXTINF:31.992422,
21http://172.16.26.2/media/test6.ts
22#EXTINF:31.241622,
23http://172.16.26.2/media/test7.ts
24#EXTINF:28.196711,
25http://172.16.26.2/media/test8.ts
26#EXTINF:29.823444,
27http://172.16.26.2/media/test9.ts
28#EXTINF:32.451244,
29http://172.16.26.2/media/test10.ts
30#EXTINF:30.824511,
31http://172.16.26.2/media/test11.ts
32#EXTINF:26.820244,
33http://172.16.26.2/media/test12.ts
34#EXTINF:13.931511,
35http://172.16.26.2/media/test13.ts
36#EXT-X-ENDLIST

使用ffmpeg合并ts文件

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

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

如果是网络上的m3u8点播列表,也可以下载并合并到mp4中:

1ffmpeg -i "http://xxx.com/media/test.m3u8" "save_video.mp4"

ffmpeg切割并加密媒体文件

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

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

1openssl rand  16 > enc.key

另一个是 iv

1openssl rand -hex 16 

这里生成的IV是ef157287b9fc922ed1cc101a09e742b3

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

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

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

1ffmpeg -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#EXTM3U
 2#EXT-X-VERSION:3
 3#EXT-X-TARGETDURATION:36
 4#EXT-X-MEDIA-SEQUENCE:0
 5#EXT-X-PLAYLIST-TYPE:VOD
 6#EXT-X-KEY:METHOD=AES-128,URI="http://172.16.26.2/media/enc.key",IV=0xef157287b9fc922ed1cc101a09e742b3
 7#EXTINF:35.704711,
 8http://172.16.26.2/media/file0.ts
 9#EXTINF:24.984956,
10http://172.16.26.2/media/file1.ts
11#EXTINF:31.450178,
12http://172.16.26.2/media/file2.ts
13#EXTINF:29.614889,
14http://172.16.26.2/media/file3.ts
15#EXTINF:29.531467,
16http://172.16.26.2/media/file4.ts
17#EXTINF:28.780667,
18http://172.16.26.2/media/file5.ts
19#EXTINF:31.992422,
20http://172.16.26.2/media/file6.ts
21#EXTINF:31.241622,
22http://172.16.26.2/media/file7.ts
23#EXTINF:28.196711,
24http://172.16.26.2/media/file8.ts
25#EXTINF:29.823444,
26http://172.16.26.2/media/file9.ts
27#EXTINF:32.451244,
28http://172.16.26.2/media/file10.ts
29#EXTINF:30.824511,
30http://172.16.26.2/media/file11.ts
31#EXTINF:26.820244,
32http://172.16.26.2/media/file12.ts
33#EXTINF:13.931511,
34http://172.16.26.2/media/file13.ts
35#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加密模块的依赖

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

AES128Utils.java

 1public class AES128Utils {
 2
 3    static {
 4        Security.addProvider(new BouncyCastleProvider());
 5    }
 6
 7    /**
 8     * 序号格式化为32字节长度字符串
 9     * @param index 片断序号
10     * @return 32字节长度序号
11     */
12    public static String getIvValue(int index){
13        return String.format("%032x", index);
14    }
15
16    /**
17     * 加密的TS文件加密为字节数组
18     * @param srcTsFileBytes 加密的TS文件字节数组
19     * @param keyBytes key文件的字节数组
20     * @param iv iv偏移量(m3u8文件中)
21     * @return 解密后的字节数组
22     * @throws Exception 编解码、IO异常
23     */
24    public static byte[] decryptTsFile(byte[] srcTsFileBytes, byte[] keyBytes, String iv) throws Exception{
25        Cipher cipher = Cipher.getInstance("AES/CBC/PKCS7Padding");
26        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, "AES");
27        byte[] ivByte = iv.getBytes();
28        if (ivByte.length != 16) ivByte = new byte[16];
29        AlgorithmParameterSpec paramSpec = new IvParameterSpec(ivByte);
30        cipher.init(Cipher.DECRYPT_MODE, keySpec, paramSpec);
31        return cipher.doFinal(srcTsFileBytes, 0, srcTsFileBytes.length);
32    }
33
34    /**
35     * 加密的TS文件加密为字节数组
36     * @param srcTsFile 加密的TS文件
37     * @param key key文件的字节数组
38     * @param iv iv偏移量(m3u8文件中)
39     * @return 解密后的字节数组
40     * @throws Exception 编解码、IO异常
41     */
42    public static byte[] decryptTsFile(File srcTsFile, String key, String iv) throws Exception {
43        return decryptTsFile(IOUtils.fileToByteArray(srcTsFile), key.getBytes(), iv);
44    }
45
46    /**
47     * 加密的TS文件加密为字节数组
48     * @param srcTsFile 加密的TS文件
49     * @param keyFile key文件
50     * @param iv iv偏移量(m3u8文件中)
51     * @return 解密后的字节数组
52     * @throws Exception 编解码、IO异常
53     */
54    public static byte[] decryptTsFile(File srcTsFile, File keyFile, String iv) throws Exception {
55        return decryptTsFile(IOUtils.fileToByteArray(srcTsFile), IOUtils.fileToByteArray(keyFile), iv);
56    }
57}

IOUtils.java

 1public class IOUtils {
 2    private static final String TAG = "IOUtils";
 3    /**
 4     * 合并Ts片断文件
 5     * @param tsFiles Ts文件集合
 6     * @param descFile 目标文件
 7     * @throws IOException IOException
 8     */
 9    public static void mergeTsFiles(Map<String, File> tsFiles, List<String> tsList,
10                                    File descFile, boolean deleteSrcFile) throws IOException{
11        FileOutputStream fileOutputStream = new FileOutputStream(descFile);
12        for(String name: tsList){
13            File file = tsFiles.get(name);
14            Log.i(TAG, "mergeTsFiles: key = " + name + ", path = "+ file.getAbsolutePath());
15            fileOutputStream.write(IOUtils.fileToByteArray(file));
16            fileOutputStream.flush();
17            if(deleteSrcFile) file.delete();
18        }
19        fileOutputStream.close();
20    }
21
22    /**
23     * 文件转字节数组
24     * @param srcFile 源文件
25     * @return 字节数组
26     * @throws IOException IO
27     */
28    public static byte[] fileToByteArray(File srcFile) throws IOException {
29        FileInputStream inputStream = new FileInputStream(srcFile);
30        byte[] buffer = new byte[4096];
31        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
32        int read;
33        while ((read = inputStream.read(buffer)) != -1){
34            byteArrayOutputStream.write(buffer, 0, read);
35        }
36        inputStream.close();
37        byteArrayOutputStream.close();
38        return byteArrayOutputStream.toByteArray();
39    }
40
41    /**
42     * 字节数组写入文件
43     * @param srcByte 组接数组
44     * @param descFile 目标文件
45     * @throws IOException IO
46     */
47    public static void byteArrayToFile(byte[] srcByte, File descFile) throws IOException {
48        FileOutputStream os = new FileOutputStream(descFile);
49        os.write(srcByte);
50        os.close();
51    }
52}

M3u8Parser.java

 1public class M3u8Parser {
 2    private String baseUrl;
 3    private static final String TAG = "M3u8Parser";
 4    private final File m3u8File;
 5    List<String> tsList = new ArrayList<>();
 6    private Activity context;
 7    private File cacheDir;
 8    private File keyFile;
 9
10    public M3u8Parser(String baseUrl, File m3u8File, Activity context) {
11        this.baseUrl = baseUrl;
12        this.m3u8File = m3u8File;
13        this.context = context;
14        cacheDir = context.getExternalCacheDir();
15    }
16
17    public void initParser() throws IOException {
18        BufferedReader bufferedReader = new BufferedReader(new FileReader(m3u8File));
19        String line;
20        while((line = bufferedReader.readLine()) != null){
21            if(line.startsWith("#EXT-X-KEY")){
22                String[] split = line.split(",");
23                if(split.length == 3){
24                    String keyUrl = split[1].substring(5, split[1].length() - 1);
25                    System.out.println(keyUrl);
26                    NetWorkUtils.doGet(keyUrl, new NetWorkUtils.ResultListener() {
27                        @Override
28                        public void success(File keyFile) {
29                            Log.i(TAG, "success: " + keyFile.getAbsolutePath());
30                            M3u8Parser.this.keyFile = keyFile;
31                        }
32
33                        @Override
34                        public void failed(IOException e) {
35                            Log.e(TAG, "failed: ", e);
36                        }
37                    });
38                }
39            }else if(!line.startsWith("#")){
40                tsList.add(line);
41            }
42        }
43    }
44
45    public void startDownload(DownloadListener downloadListener) {
46        CountDownLatch countDownLatch = new CountDownLatch(tsList.size());
47        Map<String, File> downloadTsFiles = new HashMap<>();
48        int index = 0;
49        for(String ts: tsList){
50            String url = baseUrl + ts;
51            Log.i(TAG, "startDownload: ts = " + ts);
52            Log.i(TAG, "startDownload: url = " + url);
53            int finalIndex = index;
54            NetWorkUtils.doGet(url, new NetWorkUtils.ResultListener() {
55                @Override
56                public void success(File downloadFile) {
57                    String iv = AES128Utils.getIvValue(finalIndex);
58                    try {
59                        // 下载后直接解码
60                        byte[] bytes = AES128Utils.decryptTsFile(downloadFile, keyFile, iv);
61                        IOUtils.byteArrayToFile(bytes, downloadFile);
62                    } catch (Exception e) {
63                        e.printStackTrace();
64                    }
65                    downloadTsFiles.put(ts, downloadFile);
66                    countDownLatch.countDown();
67                }
68
69                @Override
70                public void failed(IOException e) {
71                    Log.e(TAG, "TS文件下载失败", e);
72                }
73            });
74            index++;
75        }
76        try {
77            countDownLatch.await(1200, TimeUnit.SECONDS);
78        } catch (InterruptedException e) {
79            e.printStackTrace();
80        }
81
82        try {
83            File descFile = new File(cacheDir, "main.ts");
84            IOUtils.mergeTsFiles(downloadTsFiles, tsList, descFile, false);
85            context.runOnUiThread(()->{
86                Toast.makeText(context, "缓存完成:" + descFile.getAbsolutePath(), Toast.LENGTH_SHORT).show();
87            });
88            downloadListener.finishDownload();
89        } catch (IOException e) {
90            e.printStackTrace();
91        }
92    }
93
94    public interface DownloadListener {
95        void finishDownload();
96    }
97}

参考资料

1、 Example Playlists for HTTP Live Streaming

2、 HTTP Live Streaming

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