8

HLS流媒体服务与加解密

 3 years ago
source link: https://zouchanglin.cn/901333507.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在前面一篇文章中《流媒体协议之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模块,并解压:

# 在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/ 下,则配置文件修改如下:

...
# 如果访问出现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 即可开始播放,就说明已经配置好了,现在你已经拥有了一个基本的视频点播站点,就像下面这样:

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

apt安装ffmpeg

apt install ffmpeg

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

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

2、查看本Ubuntu的代号

lsb_release -a
eQZvqu2.png!mobile

对于我的Ubuntu20.04来说,代号就是focal

3、确认查看阿里云是否存在该源

http://mirrors.aliyun.com/ubuntu/dists/ 看来是存在focal的。

4、将下面的XXX全部替换为系统的代号,比如我的系统代号是focal

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

那么替换完成后就是

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、更新缓存

apt update

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

ffmpeg

3I7bie7.png!mobile

使用ffmpeg切割媒体文件

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

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文件:

UZRnUfj.png!mobile

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

#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的配置文件

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 即可播放对应的媒体资源:

JVv6Rjj.png!mobile

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

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片段文件合并:

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

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

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

ffmpeg切割并加密媒体文件

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

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

openssl rand  16 > enc.key

另一个是 iv

openssl rand -hex 16

这里生成的IV是 ef157287b9fc922ed1cc101a09e742b3

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

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 \
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文件如下:

#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加密模块的依赖

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

AES128Utils.java

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

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

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文件下载失败", 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();
    }
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK