import tkinter as tk
from tkinter import ttk, filedialog, scrolledtext
import asyncio
import threading
import aiohttp
import os
import shutil
import tempfile
import requests
from subprocess import run, CalledProcessError, PIPE
from urllib.parse import urljoin
import time
import re
# ------------------- 配置(请根据实际情况修改) -------------------
FFMPEG_PATH = r"D:\下载\ffmpeg-2025-08-14-git-cdbb5f1b93-essentials_build\bin\ffmpeg.exe" # FFmpeg路径
MAX_RETRY = 3 # 下载重试次数
TIMEOUT = 30 # 网络超时时间(秒)
# 防盗链请求头(从浏览器抓包获取)
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
"Referer": "https://6x.iu6437d.cc:8888/", # 替换为视频所在页面地址
"Cookie": "" # 替换为浏览器中的Cookie(登录后获取)
}
# ------------------- 工具函数 -------------------
def run_async(coroutine):
"""在新线程中运行异步函数,解决事件循环冲突"""
def wrapper():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
return loop.run_until_complete(coroutine)
finally:
loop.close()
thread = threading.Thread(target=wrapper, daemon=True)
thread.start()
thread.join() # 等待异步任务完成
return thread.result if hasattr(thread, 'result') else None
# ------------------- GUI 主类 -------------------
class VideoDownloaderGUI:
def __init__(self, master):
self.master = master
master.title("M3U8视频下载工具(最终版)")
master.geometry("800x650")
master.minsize(800, 650)
master.configure(bg="#2C3E50")
# 设置网格权重,让界面可拉伸
master.grid_rowconfigure(8, weight=1)
master.grid_columnconfigure(1, weight=1)
# 变量初始化
self.url_var = tk.StringVar()
self.title_var = tk.StringVar(value="未解析")
self.duration_var = tk.StringVar(value="未知")
self.save_path_var = tk.StringVar(value=os.path.join(os.path.expanduser("~"), "Videos"))
self.headers = DEFAULT_HEADERS.copy()
self.temp_dir = self._create_safe_temp_dir() # 安全的临时目录
self.is_downloading = False
self.m3u8_content = None # 存储M3U8内容,避免重复下载
# 创建界面组件
self._create_widgets()
def _create_safe_temp_dir(self):
"""创建确保可读写的临时目录"""
try:
temp_dir = os.path.join(os.path.expanduser("~"), "m3u8_download_temp")
os.makedirs(temp_dir, exist_ok=True)
# 验证目录可写
test_file = os.path.join(temp_dir, "test_write.txt")
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file)
return temp_dir
except Exception as e:
temp_dir = tempfile.mkdtemp(prefix="m3u8_safe_")
self.log(f"⚠️ 用户目录临时文件夹创建失败,使用系统临时目录:{temp_dir},错误:{str(e)}")
return temp_dir
def _create_widgets(self):
# 1. M3U8链接输入区
frame_url = ttk.Frame(self.master)
frame_url.grid(row=0, column=0, columnspan=3, padx=10, pady=10, sticky=tk.W+tk.E)
frame_url.grid_columnconfigure(1, weight=1)
ttk.Label(frame_url, text="M3U8链接:", background="#2C3E50", foreground="#ECF0F1").pack(side=tk.LEFT, padx=5)
self.url_entry = ttk.Entry(frame_url, textvariable=self.url_var)
self.url_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
self.parse_btn = ttk.Button(frame_url, text="解析视频", command=self.parse_video)
self.parse_btn.pack(side=tk.LEFT, padx=5)
# 2. 视频信息区
frame_info = ttk.Frame(self.master)
frame_info.grid(row=1, column=0, columnspan=3, padx=10, pady=5, sticky=tk.W)
ttk.Label(frame_info, text="视频标题:", background="#2C3E50", foreground="#ECF0F1").grid(row=0, column=0, padx=10, pady=3, sticky=tk.W)
self.title_entry = ttk.Entry(frame_info, textvariable=self.title_var, width=50)
self.title_entry.grid(row=0, column=1, padx=5, pady=3, sticky=tk.W)
ttk.Label(frame_info, text="时长:", background="#2C3E50", foreground="#ECF0F1").grid(row=1, column=0, padx=10, pady=3, sticky=tk.W)
ttk.Label(frame_info, textvariable=self.duration_var, background="#2C3E50", foreground="#ECF0F1").grid(row=1, column=1, padx=5, pady=3, sticky=tk.W)
# 3. 保存路径区
frame_save = ttk.Frame(self.master)
frame_save.grid(row=2, column=0, columnspan=3, padx=10, pady=10, sticky=tk.W+tk.E)
frame_save.grid_columnconfigure(1, weight=1)
ttk.Label(frame_save, text="保存路径:", background="#2C3E50", foreground="#ECF0F1").pack(side=tk.LEFT, padx=5)
self.path_entry = ttk.Entry(frame_save, textvariable=self.save_path_var)
self.path_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
self.browse_btn = ttk.Button(frame_save, text="浏览...", command=self.browse_path)
self.browse_btn.pack(side=tk.LEFT, padx=5)
# 4. 请求头配置区
ttk.Label(self.master, text="请求头配置(防盗链):", background="#2C3E50", foreground="#ECF0F1").grid(row=3, column=0, padx=10, pady=5, sticky=tk.W)
self.headers_text = scrolledtext.ScrolledText(self.master, height=4, wrap=tk.WORD)
self.headers_text.grid(row=4, column=0, columnspan=3, padx=10, pady=5, sticky=tk.W+tk.E)
self._init_headers_text()
# 5. 下载控制区
self.start_btn = ttk.Button(self.master, text="开始下载", command=self.start_download, state=tk.DISABLED)
self.start_btn.grid(row=5, column=1, pady=10)
# 6. 进度条
ttk.Label(self.master, text="下载进度:", background="#2C3E50", foreground="#ECF0F1").grid(row=6, column=0, padx=10, pady=5, sticky=tk.W)
self.progress_bar = ttk.Progressbar(self.master, orient=tk.HORIZONTAL, length=0, mode='determinate')
self.progress_bar.grid(row=6, column=1, padx=5, pady=5, sticky=tk.W+tk.E)
# 7. 日志区
ttk.Label(self.master, text="操作日志:", background="#2C3E50", foreground="#ECF0F1").grid(row=7, column=0, padx=10, pady=5, sticky=tk.W)
self.log_text = scrolledtext.ScrolledText(self.master, wrap=tk.WORD, background="#34495E", foreground="#ECF0F1")
self.log_text.grid(row=8, column=0, columnspan=3, padx=10, pady=5, sticky=tk.W+tk.E+tk.N+tk.S)
def _init_headers_text(self):
"""初始化请求头文本框内容"""
headers_str = ""
for key, value in self.headers.items():
headers_str += f"{key}: {value}\n"
self.headers_text.insert(tk.END, headers_str.strip())
def browse_path(self):
"""选择保存目录"""
path = filedialog.askdirectory()
if path:
self.save_path_var.set(path)
self.log(f"已选择保存目录: {path}")
def parse_video(self):
"""解析视频信息"""
url = self.url_var.get().strip()
if not url or not url.endswith(".m3u8"):
self.log("❌ 请输入有效的M3U8链接(必须以.m3u8结尾)")
return
# 更新请求头
self._update_headers()
# 重置状态
self.title_var.set("解析中...")
self.duration_var.set("解析中...")
self.start_btn.config(state=tk.DISABLED)
self.m3u8_content = None
# 异步解析
threading.Thread(target=self._async_parse, args=(url,), daemon=True).start()
def _update_headers(self):
"""从文本框更新请求头"""
try:
headers_text = self.headers_text.get(1.0, tk.END).strip()
self.headers = {}
for line in headers_text.split('\n'):
if ':' in line:
key, value = line.split(':', 1)
self.headers[key.strip()] = value.strip()
self.log("✅ 请求头已更新")
except Exception as e:
self.log(f"⚠️ 请求头格式错误:{str(e)},将使用默认配置")
self.headers = DEFAULT_HEADERS.copy()
def _async_parse(self, url):
"""后台解析M3U8信息"""
try:
# 使用自定义异步运行函数,避免事件循环冲突
self.m3u8_content = run_async(self._fetch_m3u8(url))
if not self.m3u8_content:
raise Exception("未获取到M3U8内容")
# 解析标题和时长
title = self._parse_title(self.m3u8_content, url)
duration = self._parse_duration(self.m3u8_content)
# 更新界面
self.master.after(0, lambda: self.title_var.set(title))
self.master.after(0, lambda: self.duration_var.set(duration))
self.master.after(0, lambda: self.start_btn.config(state=tk.NORMAL))
self.log(f"✅ 解析成功:{title}(时长:{duration})")
except Exception as e:
self.log(f"❌ 解析失败:{str(e)}")
self.master.after(0, lambda: self.title_var.set("解析失败"))
self.master.after(0, lambda: self.duration_var.set("未知"))
self.master.after(0, lambda: self.start_btn.config(state=tk.DISABLED))
async def _fetch_m3u8(self, url):
"""异步获取M3U8内容"""
async with aiohttp.ClientSession() as session:
async with session.get(
url,
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=TIMEOUT)
) as resp:
if resp.status != 200:
raise Exception(f"服务器拒绝访问(状态码:{resp.status})")
return await resp.text()
def _parse_title(self, m3u8_content, url):
"""解析视频标题"""
default_title = os.path.basename(url).replace(".m3u8", "")
if not default_title:
default_title = f"视频_{int(time.time())}"
for line in m3u8_content.splitlines():
if line.startswith("#EXTINF") and ',' in line:
title = line.split(',', 1)[1].strip()
# 过滤非法字符
return re.sub(r'[\\/:\*\?"<>\|]', '_', title)
return default_title
def _parse_duration(self, m3u8_content):
"""解析视频时长"""
for line in m3u8_content.splitlines():
if line.startswith("#EXT-X-TOTAL-DURATION"):
try:
duration = float(line.split(':', 1)[1].strip())
return f"{duration:.1f}秒"
except:
return "计算失败"
return "未知"
def start_download(self):
"""开始下载"""
if self.is_downloading:
self.log("⚠️ 正在下载中,请不要重复点击")
return
url = self.url_var.get().strip()
save_dir = self.save_path_var.get()
title = self.title_var.get()
# 验证保存目录
if not os.path.exists(save_dir):
self.log("❌ 保存目录不存在,请重新选择")
return
# 生成安全的文件名(解决"Q:/视频\.mp4"这种错误路径)
safe_title = re.sub(r'[\\/:\*\?"<>\|]', '_', title)
if not safe_title: # 防止标题为空
safe_title = f"视频_{int(time.time())}"
save_path = os.path.join(save_dir, f"{safe_title}.mp4")
# 检查文件是否已存在
if os.path.exists(save_path):
self.log(f"⚠️ 文件已存在,将覆盖:{save_path}")
# 更新状态
self.is_downloading = True
self.start_btn.config(text="下载中...", state=tk.DISABLED)
self.progress_bar.config(value=10)
self.log(f"开始下载:{save_path}")
# 启动下载线程
threading.Thread(
target=self._async_download,
args=(url, save_path),
daemon=True
).start()
def _async_download(self, m3u8_url, save_path):
"""后台下载并合并视频"""
downloader = M3U8Downloader(
ffmpeg_path=FFMPEG_PATH,
temp_dir=self.temp_dir,
log_func=self.log,
headers=self.headers
)
try:
# 使用解析阶段已获取的M3U8内容,避免重复下载
if self.m3u8_content:
result = downloader.download_with_content(m3u8_url, save_path, self.m3u8_content)
else:
result = downloader.download(m3u8_url, save_path)
if result:
self.master.after(0, lambda: self.progress_bar.config(value=100))
self.log(f"✅ 下载完成!文件保存至:{save_path}")
else:
raise Exception("下载过程未正常完成")
except Exception as e:
self.log(f"❌ 下载失败:{str(e)}")
self.master.after(0, lambda: self.progress_bar.config(value=0))
finally:
# 重置状态
self.is_downloading = False
self.master.after(0, lambda: self.start_btn.config(text="开始下载", state=tk.NORMAL))
# 清理临时文件
self._clean_temp_files()
def _clean_temp_files(self):
"""清理临时文件"""
try:
if os.path.exists(self.temp_dir):
shutil.rmtree(self.temp_dir, ignore_errors=True)
# 重新创建临时目录,供下次使用
self.temp_dir = self._create_safe_temp_dir()
self.log("✅ 临时文件已清理")
except Exception as e:
self.log(f"⚠️ 临时文件清理失败:{str(e)}")
def log(self, message):
"""添加日志"""
self.log_text.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {message}\n")
self.log_text.see(tk.END)
# ------------------- 下载核心类 -------------------
class M3U8Downloader:
def __init__(self, ffmpeg_path, temp_dir, log_func, headers):
self.ffmpeg_path = ffmpeg_path
self.temp_dir = temp_dir
self.log = log_func
self.headers = headers
self.base_url = ""
async def _fetch_with_headers(self, session, url, is_binary=False, retry=0):
"""带请求头的网络请求"""
try:
async with session.get(
url,
headers=self.headers,
timeout=aiohttp.ClientTimeout(total=TIMEOUT)
) as resp:
if resp.status != 200:
raise Exception(f"状态码:{resp.status}")
return await resp.read() if is_binary else await resp.text()
except Exception as e:
if retry < MAX_RETRY:
self.log(f"⚠️ 下载失败({e}),第{retry+1}次重试...")
return await self._fetch_with_headers(session, url, is_binary, retry + 1)
raise
async def _download_key(self, key_url):
"""异步下载加密密钥(单独的异步函数,避免事件循环冲突)"""
async with aiohttp.ClientSession() as session:
return await self._fetch_with_headers(session, key_url, is_binary=True)
def _parse_encryption(self, m3u8_content):
"""解析加密信息"""
key_url = None
iv = None
for line in m3u8_content.splitlines():
if line.startswith("#EXT-X-KEY"):
parts = line.split(',')
for part in parts:
if "URI=" in part:
key_url = part.split('URI="')[1].split('"')[0]
if "IV=" in part:
iv = part.split('IV=')[1]
break
if not key_url:
return None, None
# 处理相对路径
if not key_url.startswith(("http://", "https://")):
key_url = urljoin(self.base_url, key_url)
# 下载密钥(使用自定义异步运行函数)
key_path = os.path.join(self.temp_dir, "encryption_key.bin")
try:
key_data = run_async(self._download_key(key_url))
if not key_data:
raise Exception("未获取到密钥数据")
# 确保临时目录存在
os.makedirs(self.temp_dir, exist_ok=True)
with open(key_path, "wb") as f:
f.write(key_data)
# 验证文件是否写入成功
if not os.path.exists(key_path) or os.path.getsize(key_path) == 0:
raise FileNotFoundError("密钥文件写入失败或为空")
self.log("✅ 已获取加密密钥")
return key_path, iv
except Exception as e:
self.log(f"❌ 加密密钥获取失败:{e}")
# 即使密钥获取失败也继续尝试,可能视频未加密
return None, None
def _merge_with_ffmpeg(self, m3u8_path, save_path, key_path, iv):
"""合并视频,并返回详细错误信息"""
# 确保FFmpeg路径正确
if not os.path.exists(self.ffmpeg_path):
raise FileNotFoundError(f"FFmpeg未找到:{self.ffmpeg_path}")
cmd = [
self.ffmpeg_path,
"-hide_banner",
"-loglevel", "error", # 只输出错误信息
"-allowed_extensions", "ALL",
"-i", m3u8_path,
"-c", "copy",
"-y",
save_path
]
# 添加解密参数
if key_path:
cmd.insert(4, "-decryption_key")
cmd.insert(5, f"file:{key_path}")
if iv:
cmd.insert(6, "-iv")
cmd.insert(7, iv)
try:
# 捕获FFmpeg的错误输出
result = run(cmd, check=True, stderr=PIPE, text=True)
return True
except CalledProcessError as e:
# 输出FFmpeg的详细错误信息
self.log(f"FFmpeg错误详情:{e.stderr}")
raise Exception(f"FFmpeg合并失败(代码:{e.returncode}):{e.stderr[:200]}")
def download_with_content(self, m3u8_url, save_path, m3u8_content):
"""使用已有的M3U8内容进行下载"""
try:
self.base_url = urljoin(m3u8_url, ".") # 设置基地址
# 保存M3U8到临时文件
os.makedirs(self.temp_dir, exist_ok=True)
temp_m3u8 = os.path.join(self.temp_dir, "index.m3u8")
with open(temp_m3u8, "w", encoding="utf-8") as f:
f.write(m3u8_content)
# 验证文件是否存在
if not os.path.exists(temp_m3u8):
raise FileNotFoundError(f"M3U8临时文件创建失败:{temp_m3u8}")
self.log(f"✅ M3U8索引已保存到临时文件")
# 解析加密信息
key_path, iv = self._parse_encryption(m3u8_content)
# 合并视频
self.log("开始合并视频...")
return self._merge_with_ffmpeg(temp_m3u8, save_path, key_path, iv)
except Exception as e:
raise Exception(f"核心错误:{str(e)}")
def download(self, m3u8_url, save_path):
"""从网络下载M3U8并处理(备用方法)"""
try:
# 下载M3U8索引
self.log("正在下载M3U8索引...")
m3u8_content = run_async(self._fetch_with_headers(
aiohttp.ClientSession(), m3u8_url
))
return self.download_with_content(m3u8_url, save_path, m3u8_content)
except Exception as e:
raise Exception(f"下载M3U8失败:{str(e)}")
# ------------------- 启动程序 -------------------
if __name__ == "__main__":
root = tk.Tk()
app = VideoDownloaderGUI(root)
root.mainloop()
帮我优化代码