#!/usr/bin/env python3
'''
根据 MPD 清单文件下载完整视频文件
'''

VER = r'''
dvdo version: 2026.2.2.0
'''

COPR = r'''
版权所有 2025 锐码[rymaa.cn] - rymaa_cn@163.com 本软件采用 锐码署名传播许可证(RSPL) 授权。详情请见 LICENSE 或 LICENSE-EN 文件。
'''

INFO = r'''
根据 MPD 清单文件下载完整视频文件，MPD 文件可通过浏览器扩展 CoCoCut 捕获，也可以根据单个 URL 推断编号规则并探测到所有的视频分片进行下载，最后合并为一个完整的视频文件。

功能亮点

功能	说明
MPD 解析	支持  $ RepresentationID $  和  $ Number%05d $  模板
自动探测	从单个 URL 推断编号规则并探测到 404
流类型识别	用 ffprobe 区分音视频，避免合并错误
安全下载	跳过 404，不中断整个流程
自然排序	正确处理 00001, 00002, ..., 00100
FFmpeg 合并	无损 copy，速度快

依赖要求
确保系统已安装：

pip install requests
# 并安装 ffmpeg（含 ffprobe）
sudo apt install ffmpeg    # Ubuntu/Debian
# 或 brew install ffmpeg   # macOS

使用方法
rypy ryto dvdo -m "/url/mpd_urls.txt"
rypy ryto dvdo -m "https://example.com/dash_1.mpd, https://example.com/dash_1.mpd"
rypy ryto dvdo -m "https://example.com/manifest.mpd"
rypy ryto dvdo -m "https://example.com/manifest.mpd" -k  # 检测视频尺寸，不下载
rypy ryto dvdo -m "https://example.com/manifest.mpd" -s 1920  # 下载 1920 尺寸的视频
rypy ryto dvdo -u "https://example.com/dash_av1/chunk-stream1-00001.m4s"
'''

HELP = r'''
+-------------------------------------------+
|           dvdo: download video            |
+-------------------------------------------+
|            HomePage: rymaa.cn             |
+-------------------------------------------+

Usage:
    rypi ryto dvdo [option]

Options:
    -H, --help   Show help / 显示帮助
    -I, --info   Show info / 显示信息
    -C, --copr   Show copyright / 显示版权
    -V, --version   Show version / 显示版本
    
    -m/--mpd   mpd link / MPD 清单文件链接
    -c/--chunk   chunk link / 分片链接（用于探测）
    -o/--out   output file / 输出文件，默认为 out.mp4（当有多个 URL 时，会忽略此参数，自动使用递增数字命名）
    -k/--check   check size / 检查视频有哪些尺寸
    -s/--size   video size / 指定视频尺寸，默认为 1280
'''

##############################

import argparse
import os
import re
import sys
import requests
from pathlib import Path
from urllib.parse import urljoin, urlparse
import xml.etree.ElementTree as ET
import subprocess
import shutil

##############################

VIDEO_CODEC = None

def fetch_mpd(mpd_url):
    print(f"\n📥 下载 MPD: {mpd_url}")
    resp = requests.get(mpd_url)
    resp.raise_for_status()
    return resp.text

def parse_representations(mpd_content, base_url):
    """解析 MPD，返回音视频 Representations 列表"""
    root = ET.fromstring(mpd_content)
    ns = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'}

    video_reps = []
    audio_reps = []

    for period in root.findall('.//mpd:Period', ns):
        for adaptation in period.findall('.//mpd:AdaptationSet', ns):
            content_type = adaptation.attrib.get('contentType', '')
            mime_type = adaptation.attrib.get('mimeType', '')
            
            is_video = 'video' in content_type or 'video/' in mime_type
            is_audio = 'audio' in content_type or 'audio/' in mime_type

            for rep in adaptation.findall('.//mpd:Representation', ns):
                rep_id = rep.attrib['id']
                width = rep.attrib.get('width')
                height = rep.attrib.get('height')
                bandwidth = rep.attrib.get('bandwidth', '0')
                codecs = rep.attrib.get('codecs', '')

                seg_template = rep.find('.//mpd:SegmentTemplate', ns)
                if seg_template is None:
                    continue

                init_tpl = seg_template.attrib.get('initialization', '')
                media_tpl = seg_template.attrib.get('media', '')
                start_num = int(seg_template.attrib.get('startNumber', '1'))
                duration = int(seg_template.attrib.get('duration', '0'))
                timescale = int(seg_template.attrib.get('timescale', '1'))

                rep_info = {
                    'id': rep_id,
                    'width': int(width) if width else None,
                    'height': int(height) if height else None,
                    'bandwidth': int(bandwidth),
                    'codecs': codecs,
                    'init_template': init_tpl,
                    'media_template': media_tpl,
                    'start_number': start_num,
                    'duration': duration,
                    'timescale': timescale,
                }

                if is_video:
                    video_reps.append(rep_info)
                elif is_audio:
                    audio_reps.append(rep_info)

    return video_reps, audio_reps, base_url

def select_video_rep(video_reps, target_size):
    """
    根据 target_size 选择视频 Representation
    target_size 可以是:
      - "1280x720"
      - "1280"
      - None → 默认选 1280x720
    """
    if not video_reps:
        print("❌ MPD 中未找到任何视频流")
        sys.exit(1)

    # 解析目标尺寸
    target_w, target_h = None, None
    if target_size:
        if 'x' in target_size:
            parts = target_size.split('x')
            target_w = int(parts[0])
            target_h = int(parts[1]) if len(parts) > 1 else None
        else:
            target_w = int(target_size)
    else:
        # 默认：1280x720
        target_w, target_h = 1280, 720

    # 精确匹配
    for rep in video_reps:
        if rep['width'] == target_w and (target_h is None or rep['height'] == target_h):
            return rep

    # 宽度匹配（忽略高度）
    if target_h is None:
        for rep in video_reps:
            if rep['width'] == target_w:
                return rep

    # 未找到
    print("❌ 可用视频尺寸:")
    for rep in sorted(video_reps, key=lambda x: x['width'] or 0):
        res = f"{rep['width']}x{rep['height']}" if rep['width'] and rep['height'] else "unknown"
        print(f"   ID={rep['id']}, {res}, {rep['bandwidth']//1000}kbps")
    print(f"\n 请求的尺寸 '{target_size}' 不存在！")
    sys.exit(1)

def probe_url_pattern(base_url, download_dir):
    """从分片 URL 推断模板并探测最大编号"""
    parsed = urlparse(base_url)
    path = parsed.path
    dirname = os.path.dirname(path)
    basename = os.path.basename(path)

    # 尝试提取 stream ID 和编号
    match = re.search(r'chunk-stream(\d+)-(\d+)\.m4s$', basename)
    if not match:
        raise ValueError("无法从 URL 推断分片模式")

    stream_id = match.group(1)
    current_num = int(match.group(2))
    num_width = len(match.group(2))

    base_media_url = f"{dirname}/chunk-stream{stream_id}-{{:0{num_width}d}}.m4s"
    init_url = f"{dirname}/init-stream{stream_id}.m4s"

    # 下载 init
    full_init_url = urljoin(base_url, init_url)
    init_dest = download_dir / f"init-stream{stream_id}.m4s"
    download_file(full_init_url, init_dest)

    global VIDEO_CODEC
    if VIDEO_CODEC is None and init_dest:
        try:
            result = subprocess.run([
                'ffprobe', '-v', 'quiet',
                '-show_entries', 'stream=codec_name',
                '-of', 'csv=p=0', str(init_dest)
            ], capture_output=True, text=True, check=True)
            codec_names = [line.strip() for line in result.stdout.splitlines() if line.strip()]
            if 'av1' in codec_names:
                VIDEO_CODEC = 'av01.'
        except:
            pass

    # 探测最大编号
    urls_to_download = [full_init_url]
    max_num = current_num
    while True:
        test_num = max_num + 1
        test_url = urljoin(base_url, base_media_url.format(test_num))
        try:
            resp = requests.head(test_url)
            if resp.status_code == 200:
                urls_to_download.append(test_url)
                max_num = test_num
            else:
                break
        except:
            break

    # 也添加之前的编号（从 1 开始）
    for i in range(1, max_num + 1):
        url = urljoin(base_url, base_media_url.format(i))
        urls_to_download.append(url)

    return list(set(urls_to_download))

def generate_urls_from_rep(rep, audio_rep, base_url):
    """根据选定的视频和音频 Representation 生成完整 URL 列表"""
    urls = set()

    def build_url(template, rep_id, number=None):
        url = template.replace('$RepresentationID$', rep_id)
        if number is not None:
            if '$Number%' in url:
                # 处理 $Number%05d$ → 00001
                match = re.search(r'\$Number%(\d+)d\$', url)
                if match:
                    fmt = f"{{:0{match.group(1)}d}}"
                    url = re.sub(r'\$Number%\d+d\$', fmt.format(number), url)
                else:
                    url = url.replace('$Number$', str(number))
            else:
                url = url.replace('$Number$', str(number))
        return urljoin(base_url, url)

    # 初始化段
    urls.add(build_url(rep['init_template'], rep['id']))
    urls.add(build_url(audio_rep['init_template'], audio_rep['id']))

    # 计算 chunk 数量
    total_seconds = 112.3  # 可从 MPD 的 mediaPresentationDuration 解析，此处简化
    if rep['duration'] > 0 and rep['timescale'] > 0:
        chunk_duration_sec = rep['duration'] / rep['timescale']
        max_chunks = int(total_seconds / chunk_duration_sec) + 2
    else:
        max_chunks = 100  # fallback

    start_num = rep['start_number']
    for i in range(start_num, start_num + max_chunks):
        urls.add(build_url(rep['media_template'], rep['id'], i))
        urls.add(build_url(audio_rep['media_template'], audio_rep['id'], i))

    return sorted(urls)

def probe_stream_type(file_path):
    try:
        result = subprocess.run([
            'ffprobe', '-v', 'quiet',
            '-show_entries', 'stream=codec_type',
            '-of', 'csv=p=0', str(file_path)
        ], capture_output=True, text=True, check=True)
        types = [line.strip() for line in result.stdout.splitlines() if line.strip()]
        if 'video' in types:
            return 'video'
        elif 'audio' in types:
            return 'audio'
        return 'unknown'
    except Exception:
        return 'unknown'

def download_file(url, dest):
    if dest.exists():
        return
    print(f"⬇️  {url} → {dest.name}")
    resp = requests.get(url)
    if resp.status_code == 404:
        raise FileNotFoundError()
    resp.raise_for_status()
    with open(dest, 'wb') as f:
        f.write(resp.content)

def merge_and_finalize(download_dir, out='out.mp4'):
    # 分类文件
    init_files = {}
    chunk_files = {}
    for f in download_dir.glob("*.m4s"):
        name = f.name
        sid = None
        if name.startswith('init-stream'):
            sid = re.search(r'init-stream(\d+)', name)
        elif name.startswith('chunk-stream'):
            sid = re.search(r'chunk-stream(\d+)', name)
        if sid:
            sid = sid.group(1)
            if name.startswith('init-'):
                typ = probe_stream_type(f)
                init_files[sid] = (f, typ)
            else:
                chunk_files.setdefault(sid, []).append(f)

    # 拼接
    video_input = audio_input = None
    for sid, (init_path, typ) in init_files.items():
        chunks = chunk_files.get(sid, [])
        if not chunks:
            continue
        chunks.sort(key=lambda x: [int(c) if c.isdigit() else c for c in re.split(r'(\d+)', x.name)])
        out_path = download_dir / f"full_stream{sid}.mp4"
        with open(out_path, 'wb') as o:
            o.write(init_path.read_bytes())
            for c in chunks:
                o.write(c.read_bytes())
        if typ == 'video':
            video_input = out_path
        elif typ == 'audio':
            audio_input = out_path

    if not video_input:
        print("❌ 未找到视频流")
        sys.exit(1)

    # 合并
    global VIDEO_CODEC
    is_av1 = VIDEO_CODEC and VIDEO_CODEC.lower().startswith('av01.')
    if is_av1:
        print(f"\n检测到 AV1 编码，需要转码为 H.264")
        cmd = [
            'ffmpeg', '-y',
            '-i', str(video_input),      # full_stream1.mp4 (AV1)
            '-i', str(audio_input),      # full_stream4.mp4 (AAC)
            '-c:v', 'libx264',           # ← 转为 H.264
            '-crf', '25',                # 画质（18~28）
            '-preset', 'fast',           # 速度
            '-c:a', 'aac',               # 音频保持 AAC
            '-b:a', '128k',              # 码率
            '-ar', '44100',              # 采样率
            str(out)
        ]
    else:
        print(f"\n使用无损 copy 模式合并")
        cmd = [
            'ffmpeg', '-y',
            '-i', str(video_input),      # full_stream1.mp4 (AV1)
            '-i', str(audio_input),      # full_stream4.mp4 (AAC)
            '-c', 'copy',                # 使用无损 copy 模式合并
            str(out)
        ]

    print(f"\n正在合并视频...\n")
    subprocess.run(cmd, check=True)
    print(f"\n✅ 输出: {out}")

def down(urls, download_dir, out='out.mp4'):
    # 保存并下载
    with open(download_dir / "urls.txt", 'w') as f:
        for u in urls:
            f.write(u + '\n')

    for url in urls:
        filename = Path(urlparse(url).path).name
        dest = download_dir / filename
        try:
            download_file(url, dest)
        except FileNotFoundError:
            continue  # 跳过 404

    merge_and_finalize(download_dir, out)

def process_single_url(mpd_url, out_file, size=None, check_only=False):
    """处理单个 MPD URL"""
    if check_only:
        mpd_content = fetch_mpd(mpd_url)
        base_url = mpd_url.rsplit('/', 1)[0] + '/'
        video_reps, _, _ = parse_representations(mpd_content, base_url)
        print("📺 可用视频尺寸:")
        for rep in sorted(video_reps, key=lambda x: x['width'] or 0):
            res = f"{rep['width']}x{rep['height']}" if rep['width'] and rep['height'] else "unknown"
            print(f"  ID={rep['id']}, {res}, {rep['bandwidth']//1000}kbps, codecs={rep['codecs']}")
        return True

    # 创建下载目录（使用输出文件名）
    out_path = Path(out_file)
    download_dir = Path(out_path.stem)  # 使用文件名（不含扩展名）作为目录名
    
    download_dir.mkdir(exist_ok=True)
    
    # === MPD 主流程 ===
    mpd_content = fetch_mpd(mpd_url)
    base_url = mpd_url.rsplit('/', 1)[0] + '/'
    video_reps, audio_reps, _ = parse_representations(mpd_content, base_url)

    if not audio_reps:
        print("❌ 未找到音频流")
        return False

    selected_video = select_video_rep(video_reps, size)
    selected_audio = audio_reps[0]  # 通常只有一个音频轨
    global VIDEO_CODEC
    VIDEO_CODEC = selected_video['codecs']

    print(f"\n🎯 选择视频: {selected_video['width']}x{selected_video['height']} (ID={selected_video['id']})")
    print(f"🔊 选择音频: {selected_audio['id']}\n")

    urls = generate_urls_from_rep(selected_video, selected_audio, base_url)
    down(urls, download_dir, out_file)
    
    # 下载完成后删除下载目录
    print(f"❌ 清理下载目录: {download_dir}")
    shutil.rmtree(download_dir)
    
    return True

def process_single_chunk(chunk_url, out_file):
    """处理单个分片 URL"""
    # 创建下载目录（使用输出文件名）
    out_path = Path(out_file)
    download_dir = Path(out_path.stem)  # 使用文件名（不含扩展名）作为目录名
    
    download_dir.mkdir(exist_ok=True)
    
    print("\n分片探测模式不支持 --size 和 --check，将下载该分片链接对应的流")
    urls = probe_url_pattern(chunk_url, download_dir)
    down(urls, download_dir, out_file)
    
    # 下载完成后删除下载目录
    print(f"❌ 清理下载目录: {download_dir}")
    shutil.rmtree(download_dir)
    
    return True

def main(args=None):
    '''
    入口主函数。
    返回: void
    参数列表：
        args (str): 参数列表，通过命令行传入或调用者传入。
    '''

    # 全局选项
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument('-H', '--help', action='store_true')
    parser.add_argument('-I', '--info', action='store_true')
    parser.add_argument('-C', '--copr', action='store_true')
    parser.add_argument('-V', '--version', action='store_true')

    parser.add_argument('-m', '--mpd', help='MPD 清单文件 URL 或包含多个 URL 的文件（每行一个）')
    parser.add_argument('-c', '--chunk', help='任意一个分片 URL（用于探测）')
    parser.add_argument('-o', '--out', default='out.mp4', help='输出文件，默认是 out.mp4（当有多个 URL 时，会忽略此参数，自动使用递增数字命名）')
    parser.add_argument('-k', '--check', action='store_true', help='仅列出可用视频尺寸（需配合 --mpd）')
    parser.add_argument('-s', '--size', type=str, help='指定下载尺寸，如 "1280x720" 或 "1280"')

    args = parser.parse_args(args)
    
    if args.version:
        print(VER)
    elif args.copr:
        print(COPR)
    elif args.info:
        print(INFO)
    elif args.help:
        print(HELP)

    elif args.mpd:
        # 检查是否传入的是文件
        mpd_input = args.mpd
        
        # 检查是否是文件路径
        if os.path.isfile(mpd_input):
            print(f"\n📄 读取 URL 列表文件: {mpd_input}")
            with open(mpd_input, 'r', encoding='utf-8') as f:
                urls = [line.strip() for line in f if line.strip()]
        elif ',' in mpd_input:
            # 处理逗号分隔的 URL 列表
            urls = [url.strip() for url in mpd_input.split(',') if url.strip()]
        else:
            # 单个 URL
            urls = [mpd_input]
        
        print(f"📋 共找到 {len(urls)} 个 URL")
        
        # 获取基础文件名（不含扩展名）
        base_out = Path(args.out).stem  # 例如 "out.mp4" -> "out"
        ext = Path(args.out).suffix  # 例如 ".mp4"
        
        # 处理多个 URL
        if len(urls) > 1:
            print("📥 检测到多个 URL，将使用递增数字命名输出文件")
            success_count = 0
            
            for i, url in enumerate(urls):
                print(f"\n{'='*60}")
                print(f"处理第 {i+1}/{len(urls)} 个 URL: {url}")
                print(f"{'='*60}")
                
                # 生成递增文件名：使用 args.out 的基础名 + 数字
                out_file = f"{base_out}-{i+1}{ext}"  # 例如 "out-1.mp4"
                
                try:
                    if process_single_url(url, out_file, args.size, args.check):
                        success_count += 1
                except Exception as e:
                    print(f"❌ 处理 URL {url} 时出错: {e}")
            
            print(f"\n{'='*60}")
            print(f"✅ 完成！成功处理 {success_count}/{len(urls)} 个 URL")
            print(f"{'='*60}")
            
        else:
            # 单个 URL，使用指定的输出文件名
            if args.check:
                process_single_url(urls[0], args.out, args.size, True)
            else:
                process_single_url(urls[0], args.out, args.size, False)

    elif args.chunk:
        # 检查是否传入的是文件或逗号分隔的列表
        chunk_input = args.chunk
        
        # 检查是否是文件路径
        if os.path.isfile(chunk_input):
            print(f"\n📄 读取 URL 列表文件: {chunk_input}")
            with open(chunk_input, 'r', encoding='utf-8') as f:
                urls = [line.strip() for line in f if line.strip()]
        elif ',' in chunk_input:
            # 处理逗号分隔的 URL 列表
            urls = [url.strip() for url in chunk_input.split(',') if url.strip()]
        else:
            # 单个 URL
            urls = [chunk_input]
        
        print(f"📋 共找到 {len(urls)} 个 URL")
        
        # 获取基础文件名（不含扩展名）
        base_out = Path(args.out).stem  # 例如 "out.mp4" -> "out"
        ext = Path(args.out).suffix  # 例如 ".mp4"
        
        # 处理多个 URL
        if len(urls) > 1:
            print("📥 检测到多个 URL，将使用递增数字命名输出文件")
            success_count = 0
            
            for i, url in enumerate(urls):
                print(f"\n{'='*60}")
                print(f"处理第 {i+1}/{len(urls)} 个 URL: {url}")
                print(f"{'='*60}")
                
                # 生成递增文件名：使用 args.out 的基础名 + 数字
                out_file = f"{base_out}-{i+1}{ext}"  # 例如 "out-1.mp4"
                
                try:
                    if process_single_chunk(url, out_file):
                        success_count += 1
                except Exception as e:
                    print(f"❌ 处理 URL {url} 时出错: {e}")
            
            print(f"\n{'='*60}")
            print(f"✅ 完成！成功处理 {success_count}/{len(urls)} 个 URL")
            print(f"{'='*60}")
            
        else:
            # 单个 URL，使用指定的输出文件名
            process_single_chunk(urls[0], args.out)

    else:
        print(HELP)

if __name__ == '__main__':
    main()