一、M3U8文件格式简介
M3U8是一种基于HTTP Live Streaming (HLS)协议的播放列表文件格式,常用于视频点播和直播领域。它本质上是M3U播放列表的UTF-8编码版本,主要包含以下内容:
- 文件头信息:
#EXTM3U
标识 - 版本信息:
#EXT-X-VERSION
- 媒体序列信息:
#EXT-X-MEDIA-SEQUENCE
- 目标时长:
#EXT-X-TARGETDURATION
- 分片列表:
#EXTINF
标签指示的TS文件片段
二、M3U8合并的基本原理
合并M3U8文件主要涉及以下几个步骤:
- 解析M3U8索引文件,获取所有TS分片URL
- 按顺序下载所有TS分片文件
- 将TS文件按正确顺序合并为完整视频文件
- (可选)转换为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. 文件合并策略
- 先下载所有TS片段到临时目录
- 按顺序将TS文件二进制合并
- 使用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视频文件的合并,包括:
- M3U8文件解析与TS片段URL提取
- 多线程下载加速实现
- TS文件合并与格式转换
- 加密视频的解密处理
- 常见问题的解决方案
通过这个完整的解决方案,你可以轻松地将分片的M3U8视频合并为完整的MP4文件。根据实际需求,你可以进一步扩展功能,如添加进度显示、支持更多视频格式转换等。