游客
Python 最简易版 m3u8 跨平台下载实现

Python 最简易版 m3u8 跨平台下载实现

一言准备中...

带简易图形界面,之前有需求写的,几个月前就在github上开源,仓库名:“and-simple-downloader” 喜欢的希望点点小星星



功能特性

  • 图形用户界面: 操作直观,无需命令行知识。
  • 多线程下载: 同时下载多个视频分片,显著提升下载速度。
  • 支持主播放列表: 能自动检测包含多种清晰度的 M3U8 主列表,并选择最高码率的流进行下载。
  • AES-128 解密: 支持下载经过 AES-128 加密的视频流。
  • 跨平台: 基于 Python 和 tkinter,可在 Windows, macOS 和 Linux 上运行。

  1. import os
  2. import requests
  3. import m3u8
  4. from Crypto.Cipher import AES
  5. import tkinter as tk
  6. from tkinter import ttk, filedialog, messagebox
  7. import threading
  8. from concurrent.futures import ThreadPoolExecutor, as_completed
  9. # --- Core Downloader Logic ---
  10. HEADERS = {
  11.     'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
  12. }
  13. MAX_RETRIES = 3
  14. MAX_WORKERS = 20  # 并发下载线程数
  15. class Downloader:
  16.     def __init__(self, m3u8_url, output_filename, progress_callback, status_callback):
  17.         self.m3u8_url = m3u8_url
  18.         self.output_filename = output_filename
  19.         self.progress_callback = progress_callback
  20.         self.status_callback = status_callback
  21.         self.session = requests.Session()
  22.     def run(self):
  23.         try:
  24.             self.status_callback("正在解析 M3U8...")
  25.             playlist = self._get_playlist(self.m3u8_url)
  26.             if not playlist:
  27.                 raise Exception("无法获取或解析 M3U8 文件。")
  28.             if playlist.is_variant:
  29.                 self.status_callback("检测到主播放列表,选择最高码率...")
  30.                 stream_info = sorted(playlist.playlists, key=lambda p: p.stream_info.bandwidth, reverse=True)[0]
  31.                 playlist_url = stream_info.absolute_uri
  32.                 playlist = self._get_playlist(playlist_url)
  33.                 if not playlist:
  34.                     raise Exception("获取子播放列表失败。")
  35.             key = self._get_key(playlist)
  36.             
  37.             self._download_and_merge(playlist, key)
  38.             
  39.             self.status_callback(f"下载完成!文件保存在: {self.output_filename}")
  40.         except Exception as e:
  41.             self.status_callback(f"错误: {e}")
  42.             messagebox.showerror("错误", str(e))
  43.     def _get_playlist(self, url):
  44.         try:
  45.             response = self.session.get(url, headers=HEADERS, timeout=10)
  46.             response.raise_for_status()
  47.             return m3u8.loads(response.text, uri=url)
  48.         except requests.exceptions.RequestException as e:
  49.             print(f"获取 M3U8 文件失败: {e}")
  50.             return None
  51.     def _get_key(self, playlist):
  52.         if playlist.keys and playlist.keys[0]:
  53.             key_uri = playlist.keys[0].absolute_uri
  54.             self.status_callback(f"正在获取密钥: {key_uri}")
  55.             try:
  56.                 response = self.session.get(key_uri, headers=HEADERS, timeout=10)
  57.                 response.raise_for_status()
  58.                 self.status_callback("密钥获取成功。")
  59.                 return response.content
  60.             except requests.exceptions.RequestException as e:
  61.                 raise Exception(f"获取密钥失败: {e}")
  62.         return None
  63.     def _download_segment(self, segment_info):
  64.         index, segment = segment_info
  65.         ts_url = segment.absolute_uri
  66.         for _ in range(MAX_RETRIES):
  67.             try:
  68.                 response = self.session.get(ts_url, timeout=15)
  69.                 response.raise_for_status()
  70.                 return index, response.content
  71.             except requests.exceptions.RequestException:
  72.                 continue
  73.         print(f"分片 {index} 下载失败: {ts_url}")
  74.         return index, None
  75.     def _download_and_merge(self, playlist, key):
  76.         segments = list(enumerate(playlist.segments))
  77.         total_segments = len(segments)
  78.         self.status_callback(f"开始下载 {total_segments} 个分片...")
  79.         # 使用字典来保证分片顺序
  80.         ts_contents = {}
  81.          
  82.         with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
  83.             futures = {executor.submit(self._download_segment, s): s for s in segments}
  84.             
  85.             for i, future in enumerate(as_completed(futures)):
  86.                 index, content = future.result()
  87.                 if content:
  88.                     ts_contents[index] = content
  89.                  
  90.                 progress = (i + 1) / total_segments * 100
  91.                 self.progress_callback(progress)
  92.         if len(ts_contents) != total_segments:
  93.             print("警告:部分分片下载失败。")
  94.         self.status_callback("下载完成,开始合并文件...")
  95.         with open(self.output_filename, 'wb') as f:
  96.             for i in range(total_segments):
  97.                 content = ts_contents.get(i)
  98.                 if not content:
  99.                     continue
  100.                 if key:
  101.                     iv = (playlist.media_sequence + i).to_bytes(16, 'big')
  102.                     cipher = AES.new(key, AES.MODE_CBC, iv)
  103.                     try:
  104.                         decrypted_content = cipher.decrypt(content)
  105.                         # 简单的 unpad,可能不适用于所有情况
  106.                         unpad = lambda s: s[:-ord(s[len(s)-1:])]
  107.                         f.write(unpad(decrypted_content))
  108.                     except Exception:
  109.                          f.write(content) # 解密失败则直接写入
  110.                 else:
  111.                     f.write(content)
  112. # --- GUI Application ---
  113. class App(tk.Tk):
  114.     def __init__(self):
  115.         super().__init__()
  116.         self.title("M3U8 下载器")
  117.         self.geometry("500x200")
  118.         self.url_label = ttk.Label(self, text="M3U8 链接:")
  119.         self.url_label.pack(pady=5)
  120.         self.url_entry = ttk.Entry(self, width=60)
  121.         self.url_entry.pack(pady=5, padx=10)
  122.         self.download_button = ttk.Button(self, text="下载", command=self.start_download)
  123.         self.download_button.pack(pady=10)
  124.         self.progress = ttk.Progressbar(self, orient="horizontal", length=400, mode="determinate")
  125.         self.progress.pack(pady=10)
  126.         self.status_label = ttk.Label(self, text="等待下载...")
  127.         self.status_label.pack(pady=5)
  128.     def start_download(self):
  129.         m3u8_url = self.url_entry.get()
  130.         if not m3u8_url:
  131.             messagebox.showwarning("警告", "请输入 M3U8 链接!")
  132.             return
  133.         output_filename = filedialog.asksaveasfilename(
  134.             title="保存视频文件",
  135.             defaultextension=".mp4",
  136.             filetypes=[("MPEG-4", "*.mp4"), ("All Files", "*.*")]
  137.         )
  138.         if not output_filename:
  139.             return
  140.         self.download_button.config(state=tk.DISABLED)
  141.         self.progress["value"] = 0
  142.          
  143.         downloader = Downloader(
  144.             m3u8_url,
  145.             output_filename,
  146.             self.update_progress,
  147.             self.update_status
  148.         )
  149.          
  150.         # 在新线程中运行下载
  151.         threading.Thread(target=downloader.run, daemon=True).start()
  152.     def update_progress(self, value):
  153.         self.progress["value"] = value
  154.         self.update_idletasks()
  155.     def update_status(self, text):
  156.         self.status_label.config(text=text)
  157.         if "完成" in text or "错误" in text:
  158.             self.download_button.config(state=tk.NORMAL)
  159.         self.update_idletasks()
  160. if __name__ == "__main__":
  161.     app = App()
  162.     app.mainloop()
复制代码


    • 本文作者:Boy
    • 本文链接: https://blog.ksdiy.cn/?post=432
    • 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 4.0 许可协议。
    0
    0
    • 支付宝打赏
      支付宝扫一扫
    • 微信打赏
      微信扫一扫
    感谢支持
    文章很赞!支持一下吧
    关于作者
    Boy
    363
    0
    0
    0
    内卷太严重,已躺平...

    9月13日,星期日,在这里每天60秒读懂世界!

    上一篇

    9月14日,星期一,在这里每天60秒读懂世界!

    下一篇
    评论区
    内容为空

    这一切,似未曾拥有

    • 复制图片
    按住ctrl可打开默认菜单