文章目录

  • 前言
  • 一、m3u8文件
  • 二、神奇的工具
  • 三、思路整理
  • 1.安装FFmpeg
  • 2.获取m3u8文件
  • 3.解析m3u8文件
  • 4.重写m3u8文件
  • 4.1生成文件列表
  • 4.2重写视频文件路径
  • 5.调用FFmpeg
  • 5.1生成文件列表
  • 5.2重写视频文件路径
  • 6.其他
  • 四、代码实现
  • 总结


前言



一、m3u8文件

android开发 m3u8 为什么选用m3u8 安卓m3u8工具_文件路径

这里就简单的引用一段话:

M3U8文件是指UTF-8编码格式的M3U文件(M3U使用Latin-1字符集编码)。M3U文件是一个记录索引的纯文本文件,打开它时播放软件并不是播放它,而是根据它的索引找到对应的音视频文件的网络地址进行在线播放

m3u8的文件格式,大体如下:

android开发 m3u8 为什么选用m3u8 安卓m3u8工具_音视频_02

二、神奇的工具

    接下来介绍下这次的重点,就是FFmpeg这个工具,官网的介绍如下:

A complete, cross-platform solution to record, convert and stream audio and video(一个完整的跨平台解决方案,用于录制、转换和流式传输音频和视频)

FFmpeg功能有很多,也很强大,我们这里只需要用到两个功能就行了,一个是根据m3u8网址下载视频,还有就是根据ts文件列表合成mp4文件

三、思路整理

1.安装FFmpeg

    这个就直接去FFmpeg官网去下载就行了。找到对应的版本解压并配置环境变量,具体参考这边

2.获取m3u8文件

    当只有一个m3u8文件的时候,只需把对应的文件路径写进去就行了,但是往往我们有很多资料(咳咳)。所以需要指定文件夹,获取到文件夹下所有的m3u8文件

3.解析m3u8文件

    获取到m3u8文件之后,就需要提取m3u8的文件信息(主要是路径),之后再把Android的路径转化成Windows下的路径,如下:

android开发 m3u8 为什么选用m3u8 安卓m3u8工具_文件路径_03


这里借用一个open-m3u8.jar去解析,具体用法可以看open-m3u8.jar的git介绍

4.重写m3u8文件

4.1生成文件列表

    调用FFmpeg之前,需要指定一个文件列表给FFmpeg作为参数进行合并,这个方法无法解决加密过的视频文件,可以自己进行解密,不过有点麻烦。文件格式大体如下:

android开发 m3u8 为什么选用m3u8 安卓m3u8工具_apache_04


大体就是读取m3u8文件列表,转成这种格式的文件

4.2重写视频文件路径

    这个方式利用FFmpeg进行处理,不论是否加密过,都可以进行合并。只需要把原先的安卓文件路径替换成当前的路径即可

android开发 m3u8 为什么选用m3u8 安卓m3u8工具_文件列表_05

5.调用FFmpeg

    生成列表文件之后,就需要调用FFmpeg命令将文件合成,这个也简单,只需要调用java自带的Runtime就行了

5.1生成文件列表

    FFmpeg命令如下

ffmpeg   -f concat -safe 0 -i 生成的列表文件  -c copy  视频名称

5.2重写视频文件路径

    FFmpeg命令如下

ffmpeg -allowed_extensions ALL -protocol_whitelist "file,http,https,rtp,udp,tcp,tls,crypto" -i  m3u8文件路径  -c copy 输出视频路径

6.其他

    上边使用合并ts的方式实现,当然还可以直接通过网络下载,命令如下:

ffmpeg -allowed_extensions ALL -protocol_whitelist "file,http,https,rtp,udp,tcp,tls,crypto" -i  m3u8文件路径  -c copy 输出视频路径

m3u8文件可以在ts目录下找,如下:

android开发 m3u8 为什么选用m3u8 安卓m3u8工具_文件列表_06


android开发 m3u8 为什么选用m3u8 安卓m3u8工具_文件路径_07


这里很明显是http协议,而不是file协议


四、代码实现

git地址

maven依赖如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.hqd</groupId>
    <artifactId>simple-utils</artifactId>
    <version>1.0-SNAPSHOT</version>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <open-m3u8.version>0.2.4</open-m3u8.version>
        <commons-beanutils.version>1.7.0</commons-beanutils.version>
        <commons-lang3.version>3.4</commons-lang3.version>
        <commons-collections.version>3.2.2</commons-collections.version>
        <commons-io.version>2.4</commons-io.version>
        <junit.version>4.12</junit.version>
    </properties>

    <dependencies>
        <!-- m3u8解析工具 -->
        <dependency>
            <groupId>com.iheartradio.m3u8</groupId>
            <artifactId>open-m3u8</artifactId>
            <version>${open-m3u8.version}</version>
        </dependency>
        <!-- 工具类 -->
        <dependency>
            <groupId>commons-beanutils</groupId>
            <artifactId>commons-beanutils</artifactId>
            <version>${commons-beanutils.version}</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>${commons-lang3.version}</version>
        </dependency>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>${commons-collections.version}</version>
        </dependency>
        <dependency>
            <groupId>commons-io</groupId>
            <artifactId>commons-io</artifactId>
            <version>${commons-io.version}</version>
        </dependency>
        <!-- 测试模块jar包-->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>${junit.version}</version>
            <scope>test</scope>
        </dependency>

    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.6.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

java代码如下:

package com.hqd.test;

import com.hqd.utils.file.SimpleFileUtils;
import com.hqd.utils.file.filter.SuffixNameFilter;
import com.iheartradio.m3u8.Encoding;
import com.iheartradio.m3u8.Format;
import com.iheartradio.m3u8.PlaylistParser;
import com.iheartradio.m3u8.data.Playlist;
import com.iheartradio.m3u8.data.TrackData;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.io.FileExistsException;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;

import java.io.*;
import java.util.List;

public class FileUtilsTest {
    private final static String KEY_TAG = "#EXT-X-KEY:";
    private final static String SUFFIX_KEY = ".key";
    public static String SUFFIX_NAME_MP4 = ".mp4";
    public static String SUFFIX_NAME_TS = ".ts";
    public static String SUFFIX_NAME_TXT = ".txt";
    public static String SUFFIX_NAME_M3U8 = ".m3u8";

    /**
     * E:\Ffmpeg\bin\ffmpeg.exe -allowed_extensions ALL -protocol_whitelist "file,http,crypto,tcp" -i  G:\FFOutput\fileList.txt  -c copy G:\FFOutput\test.mp4 包含key的解析命令
     *
     * @param args
     * @throws Exception
     */
    public static void main(String[] args) throws Exception {

        mergeTSFiles("E:\\Ffmpeg\\bin\\ffmpeg", new File("G:\\FFOutput"), false);
    }

    public static void mergeTSFiles(String ffmpegPath, File rootPath) throws IOException {
        mergeTSFiles(ffmpegPath, rootPath, true);
    }

    /**
     * 修复key缺失引号问题
     *
     * @param m3u8
     * @throws IOException
     */
    private static void repairM3U8Format(File m3u8) throws IOException {
        if (SimpleFileUtils.isDir(m3u8)) {
            return;
        }
        List<String> contentLines = FileUtils.readLines(m3u8);
        for (int i = 0; i < contentLines.size(); i++) {
            String content = contentLines.get(i);
            int index = content.indexOf(KEY_TAG);
            if (index != -1) {
                if (!content.endsWith("\"")) {
                    contentLines.set(i, content.replaceFirst(SUFFIX_KEY, SUFFIX_KEY + "\""));
                }
                break;
            }
        }
        FileUtils.writeLines(m3u8, contentLines);
    }

    public static void mergeTSFiles(String ffmpegPath, File rootPath, boolean isAddSuffix) throws IOException {
        if (SimpleFileUtils.isFile(rootPath)) {
            return;
        }
        if (StringUtils.isBlank(ffmpegPath)) {
            ffmpegPath = "ffmpeg";
        }
        List<File> m3u8Files = SimpleFileUtils.searchFile(rootPath, SUFFIX_NAME_M3U8, false, false, true);
        for (File f : m3u8Files) {
            try {
                File copyFile = new File(f.getParent(), String.format("%s_copy%s", SimpleFileUtils.getFileName(f), SUFFIX_NAME_M3U8));
                FileUtils.copyFile(f, copyFile);
                repairM3U8Format(copyFile);
                Playlist playlist = new PlaylistParser(new FileInputStream(copyFile), Format.EXT_M3U, Encoding.UTF_8).parse();
                List<TrackData> tracks = playlist.getMediaPlaylist().getTracks();
                String tsPath = getTsPath(tracks);
                String tsRootPath = getTsAbsolutePath(tracks);
                if (StringUtils.isBlank(tsPath) || StringUtils.isBlank(tsRootPath)) {
                    continue;
                }
                File tsFullPath = new File(rootPath, tsPath);
                if (SimpleFileUtils.isFile(tsFullPath)) {
                    throw new FileNotFoundException(tsFullPath.toString());
                }
                String tsFullPathStr = tsFullPath.getAbsolutePath();
                if (isAddSuffix) {
                    SimpleFileUtils.addDirFilesSuffix(tsFullPath, SUFFIX_NAME_TS);
                }
                String content = FileUtils.readFileToString(copyFile);
                content = content.replace(tsRootPath, "file:" + tsFullPath.toString().replace("\\", "/"));
                FileUtils.write(copyFile, content);
                String[] cmd = {"cmd", "/C",
                        String.format("start  %s  -allowed_extensions ALL -protocol_whitelist \"file,http,https,rtp,udp,tcp,tls,crypto\" -i %s -c copy %s", ffmpegPath,
                                "\"" + copyFile.getAbsolutePath() + "\"",
                                "\"" + tsFullPathStr.substring(0, tsFullPathStr.lastIndexOf('.')) + SUFFIX_NAME_MP4 + "\"")
                };
                Runtime.getRuntime().exec(cmd);
            } catch (Exception e) {
                throw new IOException(e);
            }
        }
    }

    /**
     * 合并所有m3u8,无法处理加密过的视频
     *
     * @param ffmpegPath
     * @param rootPath
     * @param isAddSuffix
     * @throws IOException
     */
    @Deprecated
    public static void mergeAllTSFiles(String ffmpegPath, File rootPath, boolean isAddSuffix) throws IOException {
        if (SimpleFileUtils.isFile(rootPath)) {
            return;
        }
        if (StringUtils.isBlank(ffmpegPath)) {
            ffmpegPath = "ffmpeg";
        }
        List<File> m3u8Files = SimpleFileUtils.searchFile(rootPath, SUFFIX_NAME_M3U8, false, false, true);
        for (File f : m3u8Files) {
            PlaylistParser parser = new PlaylistParser(new FileInputStream(f), Format.EXT_M3U, Encoding.UTF_8);
            try {
                Playlist playlist = parser.parse();
                List<TrackData> tracks = playlist.getMediaPlaylist().getTracks();
                String tsPath = getTsPath(tracks);
                if (StringUtils.isBlank(tsPath)) {
                    continue;
                }
                File tsFullPath = new File(rootPath, tsPath);
                if (SimpleFileUtils.isFile(tsFullPath)) {
                    throw new FileNotFoundException(tsFullPath.toString());
                }
                String tsFullPathStr = tsFullPath.getAbsolutePath();
                if (isAddSuffix) {
                    SimpleFileUtils.addDirFilesSuffix(tsFullPath, SUFFIX_NAME_TS);
                }
                String listFilePath = writeFileListText(rootPath, tsPath, tracks, isAddSuffix);
                String[] cmd = {"cmd", "/C",
                        String.format("start  %s  -f concat -safe 0 -i %s -c copy %s -y", ffmpegPath,
                                "\"" + listFilePath + "\"",
                                "\"" + tsFullPathStr.substring(0, tsFullPathStr.lastIndexOf('.')) + SUFFIX_NAME_MP4 + "\"")
                };
                Runtime.getRuntime().exec(cmd);
            } catch (Exception e) {
                throw new IOException(e);
            }
        }

    }

    /**
     * 获取ts文件路径名
     *
     * @param trackDataList
     * @return
     */
    private static String getTsPath(List<TrackData> trackDataList) {
        if (CollectionUtils.isNotEmpty(trackDataList)) {
            String uri = trackDataList.get(0).getUri();
            if (StringUtils.isNotBlank(uri) && uri.startsWith("file://")) {
                int end = uri.lastIndexOf('/');
                if (end != -1) {
                    int start = uri.substring(0, end).lastIndexOf('/');
                    if (start != -1) {
                        return uri.substring(start + 1, end);
                    }
                }
            }
        }
        return null;
    }

    /**
     * 获取ts全路径
     *
     * @param trackDataList
     * @return
     */
    private static String getTsAbsolutePath(List<TrackData> trackDataList) {
        if (CollectionUtils.isNotEmpty(trackDataList)) {
            String uri = trackDataList.get(0).getUri();
            if (StringUtils.isNotBlank(uri) && uri.startsWith("file://")) {
                int end = uri.lastIndexOf('/');
                if (end != -1) {
                    return uri.substring(0, end);
                } else {
                    return uri;
                }
            }
        }
        return null;
    }

    private static String writeFileListText(File savePath, String name, List<TrackData> trackDataList, boolean isContainsSuffix) throws IOException {
        if (StringUtils.isBlank(name)) {
            return null;
        }
        if (CollectionUtils.isNotEmpty(trackDataList)) {
            FileUtils.forceMkdir(savePath);
            File listFileText = new File(savePath, name.substring(0, name.lastIndexOf('.')) + "_list" + SUFFIX_NAME_TXT);
            if (listFileText.exists()) {
                throw new IOException(String.format("文件'%s'已存在", listFileText));
            }
            StringBuilder sb = new StringBuilder();
            File tsPath = new File(savePath, name);
            if (tsPath.exists()) {
                trackDataList.stream().forEach((trackData) -> {
                    String uri = trackData.getUri();
                    if (StringUtils.isNotBlank(uri) && uri.startsWith("file://")) {
                        int index = uri.lastIndexOf('/');
                        if (index != -1) {
                            sb.append("file  ")
                                    .append("'")
                                    .append(tsPath.getAbsolutePath() + File.separator + uri.substring(index + 1));
                            if (isContainsSuffix) {
                                sb.append(SUFFIX_NAME_TS);
                            }
                            sb.append("'").append(System.lineSeparator());
                        }
                    }
                });
                try (BufferedWriter bw = new BufferedWriter(new FileWriter(listFileText))) {
                    bw.write(sb.toString());
                } catch (IOException e) {
                    throw e;
                }
                return listFileText.getAbsolutePath();
            }
        }
        return null;
    }

    /**
     * 合并ts文件
     *
     * @param ffmpegPath ffmpeg路径
     * @param listFile   列表文件路径
     * @param savePath   保存路径
     * @param videoName  生成视频名称
     * @throws IOException
     */
    public static void mergeTSFiles(String ffmpegPath, File listFile, File savePath, String videoName) throws IOException {
        if (SimpleFileUtils.isDir(listFile)) {
            return;
        }
        if (savePath.isFile()) {
            return;
        }
        if (!savePath.exists()) {
            savePath.mkdirs();
        }
        File mp4 = new File(savePath, videoName + SUFFIX_NAME_MP4);
        if (mp4.exists()) {
            throw new FileExistsException(String.format("文件'%s'已存在", mp4));
        }
        String[] cmd = {"cmd", "/C",
                String.format("start %s -f concat -safe 0 -i %s -c copy %s -y",
                        ffmpegPath, listFile.getAbsolutePath(),
                        mp4.getAbsolutePath())
        };
        Runtime.getRuntime().exec(cmd);
    }


    /**
     * 生成ts合并列表
     *
     * @param file   目标文件夹
     * @param suffix 后缀名
     * @throws IOException
     */
    public static String writeFileListText(File file, String savePath, String suffix) throws IOException {
        if (StringUtils.isBlank(suffix) || SimpleFileUtils.isFile(file)) {
            return null;
        }
        File path = new File(savePath);
        if (!path.exists()) {
            path.mkdirs();
        }
        File fileList = new File(path, file.getName() + "_list" + SUFFIX_NAME_TXT);
        if (fileList.exists()) {
            throw new FileExistsException(String.format("文件'%s'已存在", fileList));
        }
        File[] files = file.listFiles(new SuffixNameFilter(suffix));
        try (BufferedWriter bw = new BufferedWriter(new FileWriter(fileList))) {
            for (File f : files) {
                bw.write("file  '" + f.getPath() + "'");
                bw.newLine();
            }
        }
        return fileList.getPath();
    }

}

总结

    这里只是简单做个合并ts的小工具,如果有错误的地方欢迎大家指出(〃‘▽’〃)