使用Java实现M3U8视频文件合并的完整指南

一、M3U8文件格式简介

M3U8是一种基于HTTP Live Streaming (HLS)协议的播放列表文件格式,常用于视频点播和直播领域。它本质上是M3U播放列表的UTF-8编码版本,主要包含以下内容:

  • 文件头信息:#EXTM3U标识
  • 版本信息:#EXT-X-VERSION
  • 媒体序列信息:#EXT-X-MEDIA-SEQUENCE
  • 目标时长:#EXT-X-TARGETDURATION
  • 分片列表:#EXTINF标签指示的TS文件片段

二、M3U8合并的基本原理

合并M3U8文件主要涉及以下几个步骤:

  1. 解析M3U8索引文件,获取所有TS分片URL
  2. 按顺序下载所有TS分片文件
  3. 将TS文件按正确顺序合并为完整视频文件
  4. (可选)转换为MP4等其他格式

三、Java实现M3U8合并的完整代码

1. 添加必要的依赖

首先,在pom.xml中添加以下依赖:

<dependencies>
    <!-- HTTP客户端 -->
    <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.5.13</version>
    </dependency>
    
    <!-- 文件操作 -->
    <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.11.0</version>
    </dependency>
    
    <!-- 视频处理 -->
    <dependency>
        <groupId>org.bytedeco</groupId>
        <artifactId>javacv-platform</artifactId>
        <version>1.5.7</version>
    </dependency>
</dependencies>

2. M3U8解析器实现

import org.apache.commons.io.FileUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;

import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class M3U8Merger {
    
    // 解析M3U8文件,获取所有TS片段URL
    public static List<String> parseM3U8(String m3u8Url) throws IOException, URISyntaxException {
        List<String> tsUrls = new ArrayList<>();
        
        // 获取M3U8文件内容
        String m3u8Content = downloadFileAsString(m3u8Url);
        
        // 解析TS片段
        Pattern pattern = Pattern.compile("(?m)^[^#].*\\.ts$");
        Matcher matcher = pattern.matcher(m3u8Content);
        
        // 构建完整的TS URL
        URI baseUri = new URI(m3u8Url).resolve(".");
        
        while (matcher.find()) {
            String tsPath = matcher.group();
            URI tsUri = baseUri.resolve(tsPath);
            tsUrls.add(tsUri.toString());
        }
        
        return tsUrls;
    }
    
    // 下载文件内容为字符串
    private static String downloadFileAsString(String fileUrl) throws IOException {
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            HttpGet httpGet = new HttpGet(fileUrl);
            try (CloseableHttpResponse response = httpClient.execute(httpGet);
                 InputStream inputStream = response.getEntity().getContent();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
                
                StringBuilder content = new StringBuilder();
                String line;
                while ((line = reader.readLine()) != null) {
                    content.append(line).append("\n");
                }
                return content.toString();
            }
        }
    }
    
    // 下载单个TS文件
    private static void downloadTsFile(String tsUrl, String outputPath) throws IOException {
        try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
            HttpGet httpGet = new HttpGet(tsUrl);
            try (CloseableHttpResponse response = httpClient.execute(httpGet);
                 InputStream inputStream = response.getEntity().getContent()) {
                
                FileUtils.copyInputStreamToFile(inputStream, new File(outputPath));
            }
        }
    }
    
    // 合并所有TS文件
    public static void mergeTsFiles(List<String> tsUrls, String outputDir, String outputFilename) throws IOException {
        // 确保输出目录存在
        Files.createDirectories(Paths.get(outputDir));
        
        // 临时目录存放下载的TS文件
        String tempDir = outputDir + File.separator + "temp_ts_files";
        Files.createDirectories(Paths.get(tempDir));
        
        // 下载所有TS文件
        List<String> tsFilePaths = new ArrayList<>();
        for (int i = 0; i < tsUrls.size(); i++) {
            String tsUrl = tsUrls.get(i);
            String tsPath = tempDir + File.separator + "segment_" + i + ".ts";
            downloadTsFile(tsUrl, tsPath);
            tsFilePaths.add(tsPath);
        }
        
        // 合并TS文件
        String mergedTsPath = outputDir + File.separator + "merged.ts";
        try (FileOutputStream fos = new FileOutputStream(mergedTsPath);
             BufferedOutputStream mergingStream = new BufferedOutputStream(fos)) {
            
            for (String tsFilePath : tsFilePaths) {
                Files.copy(Paths.get(tsFilePath), mergingStream);
            }
        }
        
        // 转换为MP4格式(可选)
        String finalOutputPath = outputDir + File.separator + outputFilename;
        convertToMp4(mergedTsPath, finalOutputPath);
        
        // 清理临时文件
        FileUtils.deleteDirectory(new File(tempDir));
        Files.deleteIfExists(Paths.get(mergedTsPath));
    }
    
    // 将TS文件转换为MP4(需要FFmpeg支持)
    private static void convertToMp4(String inputPath, String outputPath) throws IOException {
        try {
            ProcessBuilder pb = new ProcessBuilder(
                "ffmpeg", "-i", inputPath, "-c", "copy", outputPath);
            pb.redirectErrorStream(true);
            Process process = pb.start();
            process.waitFor();
        } catch (Exception e) {
            throw new IOException("FFmpeg转换失败,请确保已安装FFmpeg并添加到系统PATH", e);
        }
    }
    
    public static void main(String[] args) {
        try {
            // 示例使用
            String m3u8Url = "https://example.com/video/playlist.m3u8";
            String outputDir = "D:/videos";
            String outputFilename = "merged_video.mp4";
            
            System.out.println("开始解析M3U8文件...");
            List<String> tsUrls = parseM3U8(m3u8Url);
            System.out.println("找到 " + tsUrls.size() + " 个TS片段");
            
            System.out.println("开始下载并合并TS文件...");
            mergeTsFiles(tsUrls, outputDir, outputFilename);
            
            System.out.println("视频合并完成,保存为: " + outputDir + File.separator + outputFilename);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

四、代码解析与关键点说明

1. M3U8解析逻辑

  • 使用正则表达式(?m)^[^#].*\.ts$匹配不以#开头的.ts文件行
  • 正确处理相对路径,通过URI.resolve()方法构建完整的TS文件URL

2. 文件下载处理

  • 使用Apache HttpClient进行HTTP请求
  • 使用commons-io的FileUtils简化文件操作
  • 采用流式下载避免内存溢出

3. 文件合并策略

  1. 先下载所有TS片段到临时目录
  2. 按顺序将TS文件二进制合并
  3. 使用FFmpeg将合并后的TS转换为MP4格式

4. 异常处理

  • 处理网络请求异常
  • 处理文件IO异常
  • 处理FFmpeg转换异常

五、高级功能扩展

1. 多线程下载加速

// 使用ExecutorService实现多线程下载
ExecutorService executor = Executors.newFixedThreadPool(8);
List<Future<?>> futures = new ArrayList<>();

for (int i = 0; i < tsUrls.size(); i++) {
    final int index = i;
    futures.add(executor.submit(() -> {
        String tsUrl = tsUrls.get(index);
        String tsPath = tempDir + File.separator + "segment_" + index + ".ts";
        downloadTsFile(tsUrl, tsPath);
    }));
}

// 等待所有下载完成
for (Future<?> future : futures) {
    future.get();
}
executor.shutdown();

2. 断点续传支持

// 检查本地已下载的文件
File tsFile = new File(tsPath);
if (tsFile.exists() && tsFile.length() > 0) {
    System.out.println("文件已存在,跳过下载: " + tsPath);
    return;
}

// 使用Range头实现断点续传
HttpGet httpGet = new HttpGet(tsUrl);
if (tsFile.exists()) {
    long downloadedLength = tsFile.length();
    httpGet.setHeader("Range", "bytes=" + downloadedLength + "-");
}

try (CloseableHttpResponse response = httpClient.execute(httpGet);
     InputStream inputStream = response.getEntity().getContent();
     FileOutputStream fos = new FileOutputStream(tsFile, true)) {
    
    byte[] buffer = new byte[8192];
    int bytesRead;
    while ((bytesRead = inputStream.read(buffer)) != -1) {
        fos.write(buffer, 0, bytesRead);
    }
}

3. 加密TS文件的处理

如果M3U8中的TS文件是加密的,需要处理AES加密:

// 解析加密key
Pattern keyPattern = Pattern.compile("#EXT-X-KEY:METHOD=AES-128,URI=\"(.+?)\"");
Matcher keyMatcher = keyPattern.matcher(m3u8Content);
String keyUrl = null;
if (keyMatcher.find()) {
    keyUrl = keyMatcher.group(1);
}

// 下载解密key
byte[] key = null;
if (keyUrl != null) {
    try (CloseableHttpClient httpClient = HttpClients.createDefault()) {
        HttpGet httpGet = new HttpGet(keyUrl);
        try (CloseableHttpResponse response = httpClient.execute(httpGet);
             InputStream inputStream = response.getEntity().getContent()) {
            
            ByteArrayOutputStream buffer = new ByteArrayOutputStream();
            byte[] data = new byte[1024];
            int nRead;
            while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
                buffer.write(data, 0, nRead);
            }
            key = buffer.toByteArray();
        }
    }
}

// 解密TS文件
if (key != null) {
    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
    SecretKeySpec keySpec = new SecretKeySpec(key, "AES");
    IvParameterSpec ivSpec = new IvParameterSpec(new byte[16]); // 通常IV是16个0
    
    cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
    byte[] decryptedData = cipher.doFinal(Files.readAllBytes(Paths.get(tsPath)));
    Files.write(Paths.get(tsPath), decryptedData);
}

六、常见问题及解决方案

1. 网络请求失败

  • 问题:部分TS文件下载失败
  • 解决:实现重试机制,设置合理的超时时间
// 带重试的下载方法
private static void downloadWithRetry(String url, String outputPath, int maxRetries) throws IOException {
    int retryCount = 0;
    while (retryCount < maxRetries) {
        try {
            downloadTsFile(url, outputPath);
            return;
        } catch (IOException e) {
            retryCount++;
            if (retryCount == maxRetries) {
                throw e;
            }
            try {
                Thread.sleep(1000 * retryCount); // 指数退避
            } catch (InterruptedException ie) {
                Thread.currentThread().interrupt();
                throw new IOException("下载被中断", ie);
            }
        }
    }
}

2. 合并后的视频不同步

  • 问题:音视频不同步或时间戳错误
  • 解决:使用FFmpeg重新封装而非简单二进制合并
// 使用FFmpeg合并TS文件(更可靠的方法)
private static void mergeWithFFmpeg(List<String> tsFilePaths, String outputPath) throws IOException {
    // 创建文件列表
    File listFile = File.createTempFile("ffmpeg-list", ".txt");
    try (BufferedWriter writer = new BufferedWriter(new FileWriter(listFile))) {
        for (String tsPath : tsFilePaths) {
            writer.write("file '" + tsPath + "'");
            writer.newLine();
        }
    }
    
    // 执行FFmpeg命令
    try {
        ProcessBuilder pb = new ProcessBuilder(
            "ffmpeg", "-f", "concat", "-safe", "0", 
            "-i", listFile.getAbsolutePath(), 
            "-c", "copy", outputPath);
        pb.redirectErrorStream(true);
        Process process = pb.start();
        process.waitFor();
    } catch (Exception e) {
        throw new IOException("FFmpeg合并失败", e);
    } finally {
        listFile.delete();
    }
}

3. 内存不足问题

  • 问题:处理大文件时内存溢出
  • 解决:使用流式处理而非全量加载到内存
// 流式合并文件
private static void streamMergeFiles(List<String> inputFiles, String outputFile) throws IOException {
    try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(outputFile))) {
        byte[] buffer = new byte[8192];
        for (String inputFile : inputFiles) {
            try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(inputFile))) {
                int bytesRead;
                while ((bytesRead = in.read(buffer)) != -1) {
                    out.write(buffer, 0, bytesRead);
                }
            }
        }
    }
}

七、总结

本文详细介绍了如何使用Java实现M3U8视频文件的合并,包括:

  1. M3U8文件解析与TS片段URL提取
  2. 多线程下载加速实现
  3. TS文件合并与格式转换
  4. 加密视频的解密处理
  5. 常见问题的解决方案

通过这个完整的解决方案,你可以轻松地将分片的M3U8视频合并为完整的MP4文件。根据实际需求,你可以进一步扩展功能,如添加进度显示、支持更多视频格式转换等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

cyc&阿灿

喜欢的话 给个支持吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值