批量下载m3u8的ts文件

| 分类 技术随笔 

目标是下载欧洲委员会的翻译论坛的视频:https://webcast.ec.europa.eu/translating-europe-forum-2017-07-nov-2017

可以知道播放的视频文件是以m3u8索引的。网上有m3u8 downloader,但是使用ffmpeg命令下载,下载速度较慢。于是我考虑直接批量下载ts流文件。测试可以用idm的批量下载功能进行下载。

另外我想用我的linux vps进行下载,于是网上搜索了一下,发现一个用shell脚本调用wget进行批量下载的文章。但复制下来运行失败,我不懂shell,于是转用python。

python使用os.system不是很好控制wget,于是转用python wget模块。提示SSL Certificates错误,需要pip install certif。

具体代码其实很简单:

#!/usr/bin/env python
import wget
count=0
while count<5: #count的值表示总共要下多少ts文件,要查阅m3u8文件
    try:
        result=wget.download("https://stream.scic.ec.europa.eu/vod/_definst_/smil:11066_12470_5a01687c2dbff.smil/media_w442363958_b466000_"+str(count)+".ts?tracks=or&clientip=218.247.220.230&smvplayersession=80c4681f-acac-4906-5611-52d0af6818f0")
        count=count+1
    except:
        continue
        
    

更新:下载最好还是要有一个队列。我现在改用B4J来进行批量下载,使用最新增加的wait for语句,并且记录下下载失败的文件。

下载下来的TS文件需要合并,网上有ts merger这个软件。其实ts文件直接用windows copy命令就可以合并了。不过事先要处理一下文件名,让它按播放顺序排列。主要是要把0.ts这样的补全为0000.ts这样的名字。我这次下载的ts文件总共有2946个。

重命名:

#!/usr/bin/env python
import os
filelist=os.listdir("./")
for file in filelist:
    orgName=file
    file=file.split("_")
    if len(file[3])<7:
        for i in range(7-len(file[3])):
            file[3]="0"+file[3]
        print(file)
        filename="_".join(file)
        print(filename)
        os.rename(orgName,filename)
    else:
        pass

或者使用linux的rename命令,具体见此:http://blog.xulihang.me/notes-of-linux-commands/

合并:

copy /b *.ts E:\new.ts

在linux下可以用cat命令:

cat *.ts>out.ts

2026年更新

因为直接在浏览器里下载比较方便,不会被限制。我选择在浏览器控制台用JavaScript下载m3u8的ts文件。然后合并,再用ffmpeg转为mp4。

下载文件的JavaScript:

/**
 * 在浏览器控制台运行,下载 m3u8 中的所有 ts 文件
 * 使用方法:
 * 1. 打开包含目标视频的页面(确保同源,或已处理CORS)
 * 2. 将本代码复制到控制台,按需修改 m3u8Url 变量
 * 3. 回车执行
 */
(async function downloadM3U8TsFiles() {
    // ========== 配置区域 ==========
    const m3u8Url = 'https://cdn3.jiuse3.cloud/hls/334613/index.m3u8?t=1792152693&m=0pH5KBg4vo5C1moklPGAXA'; // 替换为你的 m3u8 地址
    const downloadDelay = 300;      // 每个文件下载间隔(毫秒),防止浏览器拦截
    const maxConcurrent = 1;        // 同时下载数,建议 1~3
    const renamePrefix = '';        // 重命名前缀,如 'video_',留空则保持原文件名
    // ==============================

    // 解析 m3u8 内容
    async function fetchM3U8(url) {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`获取 m3u8 失败: ${response.status}`);
        return await response.text();
    }

    // 从 m3u8 文本中提取 ts 文件名(或完整URL)
    function parseTsFiles(m3u8Text, baseUrl) {
        const lines = m3u8Text.split(/\r?\n/);
        const tsFiles = [];
        const base = new URL(baseUrl);

        for (let line of lines) {
            line = line.trim();
            if (!line || line.startsWith('#')) continue; // 跳过注释和空行
            // 如果是相对路径,拼接成绝对URL
            if (!/^https?:\/\//i.test(line)) {
                // 处理相对路径(如 index0.ts)
                const absoluteUrl = new URL(line, base).href;
                tsFiles.push(absoluteUrl);
            } else {
                tsFiles.push(line);
            }
        }
        return tsFiles;
    }

    // 从 URL 中提取文件名
    function getFileNameFromUrl(url) {
        try {
            const pathname = new URL(url).pathname;
            const parts = pathname.split('/');
            let fileName = parts.pop() || 'video.ts';
            // 如果文件名没有 .ts 后缀,补上
            if (!fileName.endsWith('.ts')) fileName += '.ts';
            return fileName;
        } catch {
            return 'video.ts';
        }
    }

    // 下载单个文件
    async function downloadFile(url, index) {
        try {
            const response = await fetch(url);
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            const blob = await response.blob();
            const blobUrl = URL.createObjectURL(blob);

            const a = document.createElement('a');
            a.href = blobUrl;
            let fileName = getFileNameFromUrl(url);
            if (renamePrefix) fileName = renamePrefix + fileName;
            a.download = fileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            // 释放 blob URL
            setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
            console.log(`[${index + 1}] 下载完成: ${fileName}`);
        } catch (err) {
            console.error(`[${index + 1}] 下载失败: ${url}`, err);
        }
    }

    // 主流程
    try {
        console.log('正在获取 m3u8 文件...');
        const m3u8Text = await fetchM3U8(m3u8Url);
        console.log('m3u8 内容获取成功,正在解析 ts 列表...');

        const tsUrls = parseTsFiles(m3u8Text, m3u8Url);
        if (tsUrls.length === 0) {
            console.warn('未找到任何 ts 文件,请检查 m3u8 内容。');
            return;
        }
        console.log(`共找到 ${tsUrls.length} 个 ts 文件,开始下载...`);

        // 控制并发下载
        const queue = [...tsUrls.entries()]; // [index, url]
        const workers = Array(Math.min(maxConcurrent, queue.length))
            .fill(null)
            .map(async () => {
                while (queue.length > 0) {
                    const [index, url] = queue.shift();
                    await downloadFile(url, index);
                    // 每个文件下载后延迟,避免浏览器拦截
                    await new Promise(resolve => setTimeout(resolve, downloadDelay));
                }
            });

        await Promise.all(workers);
        console.log('所有 ts 文件下载任务已完成。');
    } catch (err) {
        console.error('执行出错:', err);
    }
})();

合并:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
合并 MPEG-TS 分片文件为一个完整的 .ts 文件。

用法:
    python merge_ts.py                      # 合并当前目录 index*.ts -> merged.ts
    python merge_ts.py -o out.ts            # 指定输出文件名
    python merge_ts.py -d D:\\hls -o D:\\out.ts   # 指定输入目录
    python merge_ts.py a001.ts a002.ts ...  # 显式指定要合并的文件(按给定顺序)

特性:
    - 按文件名中的数字自然排序(index2 在 index10 之前)
    - 校验每个分片是否为 188 字节的整数倍(TS 包大小),不一致会警告
    - 流式拷贝,内存占用低,支持大文件
    - 校验文件头同步字节 0x47,可选剥离 ID3 头
"""
import argparse
import os
import re
import sys

TS_PACKET = 188
ID3_MAGIC = b"ID3"


def natural_key(name: str):
    """把文件名按其中出现的数字分段,实现自然排序。"""
    base = os.path.basename(name)
    parts = re.split(r"(\d+)", base)
    return [int(p) if p.isdigit() else p.lower() for p in parts]


def find_parts(directory: str):
    files = [
        os.path.join(directory, f)
        for f in os.listdir(directory)
        if f.lower().endswith(".ts") and os.path.isfile(os.path.join(directory, f))
    ]
    # 排除输出文件本身
    files = [f for f in files if not os.path.basename(f).startswith("merged")]
    return sorted(files, key=natural_key)


def copy_ts(src: str, dst, strip_id3: bool, stats: dict):
    with open(src, "rb") as f:
        head = f.read(3)
        if head == ID3_MAGIC and strip_id3:
            # 跳过 ID3v2 头: 10 字节头 + syncsafe 长度
            f.seek(0)
            hdr = f.read(10)
            if len(hdr) < 10:
                return
            size = (
                (hdr[6] & 0x7F) << 21
                | (hdr[7] & 0x7F) << 14
                | (hdr[8] & 0x7F) << 7
                | (hdr[9] & 0x7F)
            )
            f.seek(10 + size)
            stats["id3_stripped"] += 1
        else:
            f.seek(0)

        first = f.read(1)
        if first and first[0] != 0x47:
            stats["bad_sync"].append(os.path.basename(src))
        f.seek(0 if not (head == ID3_MAGIC and strip_id3) else 10 + size)

        # 流式拷贝
        while True:
            chunk = f.read(1024 * 1024)
            if not chunk:
                break
            dst.write(chunk)


def main():
    ap = argparse.ArgumentParser(description="合并 MPEG-TS 分片文件")
    ap.add_argument("inputs", nargs="*", help="显式指定分片文件(按顺序)。留空则自动查找目录中的 *.ts")
    ap.add_argument("-d", "--dir", default=".", help="输入目录,默认当前目录")
    ap.add_argument("-o", "--output", default="merged.ts", help="输出文件名,默认 merged.ts")
    ap.add_argument("-s", "--sorted", action="store_true",
                    help="使用文件名自然排序(默认即开启,此参数保留兼容)")
    ap.add_argument("--keep-id3", action="store_true",
                    help="保留分片开头的 ID3 标签(默认会剥离,避免污染 TS 流)")
    args = ap.parse_args()

    if args.inputs:
        parts = args.inputs
        for p in parts:
            if not os.path.isfile(p):
                sys.exit(f"错误: 找不到文件 {p}")
    else:
        parts = find_parts(args.dir)
        if not parts:
            sys.exit(f"错误: 在 {os.path.abspath(args.dir)} 中未找到 .ts 文件")

    out_path = args.output
    if os.path.isdir(out_path):
        out_path = os.path.join(out_path, "merged.ts")
    if os.path.abspath(out_path) in [os.path.abspath(p) for p in parts]:
        sys.exit("错误: 输出文件与输入文件相同,请用 -o 指定其它文件名")

    strip_id3 = not args.keep_id3
    stats = {"id3_stripped": 0, "bad_sync": []}

    print(f"共 {len(parts)} 个分片 -> {out_path}")
    print("-" * 60)

    total_bytes = 0
    misaligned = []
    with open(out_path, "wb") as out:
        for i, p in enumerate(parts, 1):
            size = os.path.getsize(p)
            if size % TS_PACKET != 0:
                misaligned.append((os.path.basename(p), size))
            copy_ts(p, out, strip_id3, stats)
            total_bytes += size
            print(f"[{i:>3}/{len(parts)}] {os.path.basename(p):<20} {size:>12,} 字节")

    print("-" * 60)
    print(f"完成: {total_bytes:,} 字节 ({total_bytes / 1024 / 1024:.1f} MiB) -> {out_path}")

    if stats["id3_stripped"]:
        print(f"已剥离 {stats['id3_stripped']} 个分片开头的 ID3 标签")
    if stats["bad_sync"]:
        print(f"警告: 以下分片未以 0x47 同步字节开头(可能已损坏): {', '.join(stats['bad_sync'])}")
    if misaligned:
        print(f"警告: 以下分片长度不是 {TS_PACKET} 的整数倍,可能导致播放器解码错位:")
        for name, size in misaligned:
            print(f"        {name} ({size} 字节, 余 {size % TS_PACKET})")


if __name__ == "__main__":
    main()

转换:

FF="C:/Users/xulih/Documents/GitHub/Silhouette/Objects/ffmpeg/ffmpeg.exe"
"$FF" -i merged.ts -c copy -bsf:a aac_adtstoasc -movflags +faststart merged.mp4

上一篇     下一篇