当前位置: 首页 > news >正文

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进行视频格式转换、音视频合并、播放、截图

http://www.lryc.cn/news/627041.html

相关文章:

  • 齐次线性方程组最小二乘解
  • 接美国血统(中序、后序→前序)
  • 【网络运维】Linux:正则表达式
  • 虚幻引擎目录结构
  • AGV小车cad+三维图+设计说明书
  • C++ 力扣 438.找到字符串中所有字母异位词 题解 优选算法 滑动窗口 每日一题
  • Java 线程池ThreadPoolExecutor源码解读
  • 服务器内存条不识别及服务器内存位置图
  • linux的sysctl系统以及systemd系统。
  • 【网络运维】Linux 文本处理利器:sed 命令
  • MYSQL-增删查改CRUD
  • uni-app跨端开发最后一公里:详解应用上架各大应用商店全流程
  • 生产级的雪花算法
  • 自动驾驶导航信号使用方式调研
  • C语言实现全排列(非递归法)(以猪八戒买包子的故事为例解释)
  • SpringBoot 整合 Langchain4j RAG 技术深度使用解析
  • imx6ull-驱动开发篇30——Linux 非阻塞IO实验
  • redis---常用数据类型及内部编码
  • 设计具有功能安全和网络安全能力的新型半导体芯片
  • 攻克PostgreSQL专家认证
  • Unicode 字符串转 UTF-8 编码算法剖析
  • JVM面试精选 20 题(终)
  • SQL count(*)与 sum 区别
  • 第三阶段数据-4:SqlHelper类,数据库删除,DataTable创建
  • STM32F4 内存管理介绍及应用
  • 建模工具Sparx EA的多视图协作教程
  • PyTorch - Developer Notes
  • 吴恩达 Machine Learning(Class 3)
  • 国产化PDF处理控件Spire.PDF教程:如何使用 Python 添加水印到 PDF
  • Linux命令大全-ps命令