目录
前言
很多网站为了防止视频被轻易的下载,从而将一个完整的视频切片分成了很多小段的 ts 格式视频,网站先一个链接请求来获取 m3u8 文件,该文件中含有完整视频所有的ts 切片信息,现在写了一个工具类可以方便的通过 m3u8 链接将所有 ts 切片视频下载并合并为一个 mp4 格式视频。
一、工具类
java
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class M3u8TsDownLoadUtils {
//连接超时时间15秒
private static final int CONNECT_TIMEOUT = 15000;
//读取数据超时时间60秒
private static final int READ_TIMEOUT = 60000;
//每个ts下载失败时的最大重试次数为3次
private static final int MAX_RETRY = 5;
//默认请求头
private static final Map<String, String> DEFAULT_HEADERS;
static {
Map<String, String> m = new LinkedHashMap<>();
m.put("User-Agent", "Mozilla/5.0");
m.put("Accept", "*/*");
m.put("Accept-Language", "zh-CN,zh;q=0.9");
DEFAULT_HEADERS = Collections.unmodifiableMap(m);
}
/**
* 根据mm3us链接下载ts视频
*
* m3u8Url m3u8链接,如:https://vip.dytt-hot.com/20250602/92117_4692c37d/3000k/hls/mixed.m3u8
* destDir 下载输出目录,如:C:\\Program Files\\ffmpeg\\ffmpegMedia\\download
* threads 并发下载线程数,如:8
* headers 请求头(按需修改,比如需要正确的 Referer/Origin/Cookie),如:
* Map<String, String> headers = new LinkedHashMap<>(DEFAULT_HEADERS);
* headers.put("Referer", "https://vip.dytt-hot.com/");
* headers.put("Origin", "https://vip.dytt-hot.com");
**/
public static void download(String m3u8Url, String destDir, int threads, Map<String, String> headers) throws Exception {
downloadAndMergeToMp4(m3u8Url, destDir, threads, headers, false, null);
}
/**
* 根据mm3us链接下载ts视频,然后合并为一个MP4格式视频 (电脑要先安装有ffmpeg)
*
* m3u8Url m3u8链接,如:https://vip.dytt-hot.com/20250602/92117_4692c37d/3000k/hls/mixed.m3u8
* destDir 下载输出目录,如:C:\\Program Files\\ffmpeg\\ffmpegMedia\\download
* threads 并发下载线程数,如:8
* headers 请求头(按需修改,比如需要正确的 Referer/Origin/Cookie),如:
* Map<String, String> headers = new LinkedHashMap<>(DEFAULT_HEADERS);
* headers.put("Referer", "https://vip.dytt-hot.com/");
* headers.put("Origin", "https://vip.dytt-hot.com");
* ffmpegExePath 安装的ffmpeg的ffmpeg.exe路径,如:C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe
**/
public static void downloadAndMergeToMp4(String m3u8Url, String destDir, int threads, Map<String, String> headers, String ffmpegExePath) throws Exception {
downloadAndMergeToMp4(m3u8Url, destDir, threads, headers, true, ffmpegExePath);
}
private static void downloadAndMergeToMp4(String m3u8Url, String destDir, int threads, Map<String, String> headers, boolean mergeToMp4, String ffmpegExePath) throws Exception {
Path destDirPath = Paths.get(destDir); // 输出目录
Files.createDirectories(destDirPath);
Map<String, String> headersMap = new HashMap<>(DEFAULT_HEADERS);
headersMap.putAll(headers);
System.out.println("==> 拉取清单: " + m3u8Url);
String content = httpGetString(m3u8Url, headersMap);
if (content.contains("#EXT-X-STREAM-INF")) {
System.out.println("检测到主清单,选择带宽最高的子清单...");
String best = chooseBestVariant(content, m3u8Url);
if (best == null) {
throw new IllegalStateException("未能在主清单中解析到子清单");
}
System.out.println("使用子清单: " + best);
content = httpGetString(best, headersMap);
runDownload(best, content, destDirPath, threads, headersMap, mergeToMp4, ffmpegExePath);
} else {
runDownload(m3u8Url, content, destDirPath, threads, headersMap, mergeToMp4, ffmpegExePath);
}
System.out.println("==> 完成");
}
private static void runDownload(String mediaM3u8Url, String playlistContent, Path destDir, int threads, Map<String, String> headers, boolean mergeToMp4, String ffmpegExePath) throws Exception {
URI base = URI.create(mediaM3u8Url);
int mediaSequence = parseMediaSequence(playlistContent);
List<String> segments = parseTsSegments(playlistContent);
if (segments.isEmpty()) {
throw new IllegalStateException("清单中未解析到任何 .ts 段");
}
System.out.println("解析到分片数量: " + segments.size() + ",起始序列号: " + mediaSequence);
List<SegmentTask> tasks = new ArrayList<>(segments.size());
for (String seg : segments) {
URI segUri = seg.startsWith("http") ? URI.create(seg) : base.resolve(seg);
String fileName = extractNameFromUri(segUri);
Path out = destDir.resolve(fileName);
tasks.add(new SegmentTask(segUri, out));
}
ExecutorService pool = Executors.newFixedThreadPool(Math.max(1, threads));
List<Future<Void>> futures = new ArrayList<>(tasks.size());
long t0 = System.currentTimeMillis();
for (SegmentTask t : tasks) {
futures.add(pool.submit(() -> {
downloadSegmentWithRetry(t, headers);
return null;
}));
}
int ok = 0, fail = 0;
for (Future<Void> f : futures) {
try {
f.get();
ok++;
} catch (ExecutionException ee) {
fail++;
System.err.println("分片失败: " + ee.getCause().getMessage());
}
}
pool.shutdown();
long t1 = System.currentTimeMillis();
System.out.printf("下载完成:成功 %d,失败 %d,用时 %.2fs%n", ok, fail, (t1 - t0) / 1000.0);
Path list = destDir.resolve("filelist.txt");
try (BufferedWriter bw = Files.newBufferedWriter(list, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
for (SegmentTask t : tasks) {
bw.write("file '" + t.out.getFileName().toString().replace("'", "\\'") + "'");
bw.newLine();
}
}
System.out.println("已生成清单文件: " + list.toAbsolutePath());
if (mergeToMp4) {
System.out.println("正在调用ffmpeg合并命令将视频合并为一个MP4格式:");
mergeToMp4(ffmpegExePath, list, destDir.resolve("out.mp4"));
} else {
System.out.println("可在命令行手动执行如下ffmpeg合并命令将视频合并为一个MP4格式:");
System.out.println(" ffmpeg.exe -f concat -safe 0 -i \"" + list.toAbsolutePath() + "\" -c copy \"" + destDir.resolve("out.mp4").toAbsolutePath() + "\"");
}
}
private static void mergeToMp4(String ffmpegExePath, Path fileList, Path outMp4) throws IOException, InterruptedException {
List<String> cmd = new ArrayList<>();
cmd.add(ffmpegExePath);
// 建议加 -y 覆盖同名输出
cmd.add("-y");
cmd.add("-f"); cmd.add("concat");
cmd.add("-safe"); cmd.add("0");
cmd.add("-i"); cmd.add(fileList.toAbsolutePath().toString());
cmd.add("-c"); cmd.add("copy");
// 可选:显示进度
cmd.add("-stats");
cmd.add("-loglevel"); cmd.add("info");
cmd.add(outMp4.toAbsolutePath().toString());
ProcessBuilder pb = new ProcessBuilder(cmd);
// 合并标准错误到标准输出,便于统一读取
pb.redirectErrorStream(true);
System.out.println(String.join(" ", cmd));
System.out.println();
Process p = pb.start();
// Windows 控制台常用 GBK;其他平台用默认即可
Charset cs = isWindows() ? Charset.forName("GBK") : Charset.defaultCharset();
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream(), cs))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
int code = p.waitFor();
if (code == 0) {
System.out.println("ffmpeg 合并转换为MP4成功 -> " + outMp4.toAbsolutePath());
} else {
throw new IOException("ffmpeg 合并转换为MP4失败,退出码=" + code);
}
}
private static boolean isWindows() {
String os = System.getProperty("os.name", "").toLowerCase();
return os.contains("win");
}
private static List<String> parseTsSegments(String playlist) {
List<String> result = new ArrayList<>();
String[] lines = playlist.split("\\r?\\n");
for (String raw : lines) {
String line = raw.trim();
if (line.isEmpty() || line.startsWith("#")) continue;
if (line.endsWith(".ts") || line.contains(".ts?")) {
result.add(line);
}
}
return result;
}
private static int parseMediaSequence(String playlist) {
Matcher m = Pattern.compile("#EXT-X-MEDIA-SEQUENCE:(\\d+)").matcher(playlist);
if (m.find()) {
try {
return Integer.parseInt(m.group(1));
} catch (NumberFormatException ignore) {
}
}
return 0;
}
private static String chooseBestVariant(String masterContent, String masterUrl) {
Pattern p = Pattern.compile("#EXT-X-STREAM-INF:.*?BANDWIDTH=(\\d+).*?(?:\\r?\\n)([^#\\r\\n]+)", Pattern.DOTALL);
Matcher m = p.matcher(masterContent);
long bestBw = -1;
String bestUri = null;
while (m.find()) {
long bw = Long.parseLong(m.group(1));
String uri = m.group(2).trim();
if (bw > bestBw) {
bestBw = bw;
bestUri = uri;
}
}
if (bestUri == null) return null;
URI base = URI.create(masterUrl);
return bestUri.startsWith("http") ? bestUri : base.resolve(bestUri).toString();
}
private static void downloadSegmentWithRetry(SegmentTask task, Map<String, String> headers) throws Exception {
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
byte[] bytes = httpGetBytes(task.uri.toString(), headers);
Files.write(task.out, bytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
System.out.println("OK " + task.out.getFileName() + " <- " + task.uri);
return;
} catch (Exception ex) {
System.err.println("FAIL (" + attempt + "/" + MAX_RETRY + ") " + task.uri + " : " + ex.getMessage());
if (attempt == MAX_RETRY) throw ex;
Thread.sleep(500L * attempt);
}
}
}
private static byte[] httpGetBytes(String url, Map<String, String> headers) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setInstanceFollowRedirects(true);
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestMethod("GET");
for (Map.Entry<String, String> e : headers.entrySet()) {
conn.setRequestProperty(e.getKey(), e.getValue());
}
int code = conn.getResponseCode();
InputStream in;
if (code >= 200 && code < 300) {
in = conn.getInputStream();
} else {
in = conn.getErrorStream();
if (in == null) throw new IOException("HTTP " + code + " (no body)");
String err = new String(readAllBytes(in), StandardCharsets.UTF_8);
throw new IOException("HTTP " + code + " : " + err);
}
byte[] data = readAllBytes(in);
conn.disconnect();
return data;
}
private static String httpGetString(String url, Map<String, String> headers) throws IOException {
byte[] data = httpGetBytes(url, headers);
return new String(data, StandardCharsets.UTF_8);
}
private static byte[] readAllBytes(InputStream in) throws IOException {
try (InputStream input = in; ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buf = new byte[64 * 1024];
int n;
while ((n = input.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
return bos.toByteArray();
}
}
private static String extractNameFromUri(URI u) {
String path = u.getPath();
String name = path.substring(path.lastIndexOf('/') + 1);
return name;
}
private static class SegmentTask {
final URI uri;
final Path out;
SegmentTask(URI uri, Path out) {
this.uri = uri;
this.out = out;
}
}
}
二、测试
下面以 https://www.ntdm8.com/play/6840-2-4.html
这个网页示例
(1)下载所有的 ts 视频并调用 ffmpeg 合并视频为一个 MP4 格式:
(合并视频用到 ffmepg,需要先安装它,可参考 ffmpeg的下载及安装。下面代码会先下载所有的 ts 视频,最终会在下载目录下合并生成一个 out.mp4 格式的视频)
java
public static void main(String[] args) throws Exception {
String m3u8Url ="https://vip.dytt-cinema.com/20250616/24837_e75b50aa/3000k/hls/mixed.m3u8";
String destDir ="C:\\Program Files\\ffmpeg\\ffmpegMedia\\download";
int threads = 10;
Map<String, String> headers = new HashMap<>();
headers.put("Referer", "https://vip.dytt-hot.com/");
headers.put("Origin", "https://vip.dytt-hot.com");
String ffmpegExePath ="C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe";
M3u8TsDownLoadUtils.downloadAndMergeToMp4(m3u8Url,destDir,threads,headers,ffmpegExePath);
}
(2)下载所有的 ts 视频:
java
public static void main(String[] args) throws Exception {
String m3u8Url ="https://vip.dytt-cinema.com/20250616/24837_e75b50aa/3000k/hls/mixed.m3u8";
String destDir ="C:\\Program Files\\ffmpeg\\ffmpegMedia\\download";
int threads = 10;
Map<String, String> headers = new HashMap<>();
headers.put("Referer", "https://vip.dytt-hot.com/");
headers.put("Origin", "https://vip.dytt-hot.com");
M3u8TsDownLoadUtils.download(m3u8Url,destDir,threads,headers);
}