Java 通过 m3u8 链接下载所有 ts 视频切片并合并转换为 mp4 格式
目录
- 前言
- 一、工具类
- 二、测试
前言
很多网站为了防止视频被轻易的下载,从而将一个完整的视频切片分成了很多小段的 ts 格式视频,网站先一个链接请求来获取 m3u8 文件,该文件中含有完整视频所有的ts 切片信息,现在写了一个工具类可以方便的通过 m3u8 链接将所有 ts 切片视频下载并合并为一个 mp4 格式视频。
一、工具类
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 格式的视频)
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 视频:
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);}
参考:Java使用ffmpeg进行视频格式转换、音视频合并、播放、截图