Java 通过 m3u8 链接下载所有 ts 视频切片并合并转换为 mp4 格式

目录

前言

很多网站为了防止视频被轻易的下载,从而将一个完整的视频切片分成了很多小段的 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);
    }

参考:Java使用ffmpeg进行视频格式转换、音视频合并、播放、截图