


你是否也有过这样的经历?从网上辛辛苦苦搜集了几百条电视直播源地址,满怀期待地导入播放器,结果十之八九都是打不开的"死链"。一条条手动测试?那得花上几个小时,而且明天可能又失效了一批。
作为一名热爱折腾的 Python 开发者,我决定用代码解决这个痛点。今天分享的这个项目,是一款基于 PyQt5 开发的桌面 GUI 工具 —— 电视直播源验证系统。它能做到:打开一个包含几百条直播地址的 TXT 文件,一键点击「开始验证」,程序自动用 多线程并发 逐一检测每个地址是否可用,实时在界面上滚动输出日志,最终把所有验证通过的可用频道,按照时间戳自动导出为 TXT、JSON、M3U 和 Markdown 报告四种格式。整个过程全程可视化,进度条实时跳动,结果表格一目了然。无论你是 IPTV 爱好者、电视盒子玩家,还是想学习 PyQt5 多线程编程的开发者,这篇文章都值得你收藏。
界面采用清爽的 浅色主题,卡片式分区布局,操作流程一目了然:选文件 → 设参数 → 点按钮 → 看结果。代码结构清晰,核心逻辑不到 300 行,非常适合作为 PyQt5 + 多线程的学习案例。
接下来,我将从 架构设计、核心代码解析、界面实现 三个维度,带你深入理解这个工具的实现细节,最后附上完整源码,复制即可运行。
┌─────────────────────────────────────────────────┐│ MainWindow (主窗口) │├────────────┬────────────────────────────────────┤│ ① 文件选择 │ 选择 TXT/M3U/JSON → 解析为频道列表 │├────────────┼────────────────────────────────────┤│ ② 参数设置 │ 超时时间 / 并发线程数 / 开始&停止 │├────────────┼────────────────────────────────────┤│ ③ 执行区域 │ 日志面板(左) | 结果表格(右) │├────────────┼────────────────────────────────────┤│ ④ 状态栏 │ 实时状态 + 进度条 │└────────────┴────────────────────────────────────┘ │ ▲ ▼ │ pyqtSignal┌──────────────────┐ ┌──────────────────┐│ VerifyEngine │ │ Signals ││ (子线程执行) │ ───► │ log/progress/ ││ 并发验证频道 │ │ finished/status │└──────────────────┘ └──────────────────┘pyqtSignal 回传结果 | |
ThreadPoolExecutor | |
名称,URL)、M3U(#EXTINF)、JSON({分组:[{name,url}]}) | |
20250630_143025_channels.txt,不覆盖历史 | |
PyQt5 的 GUI 线程(主线程)不能被阻塞,否则界面会卡死。我们用 pyqtSignal 实现线程间安全通信:
classSignals(QObject): log = pyqtSignal(str, str) # (消息文本, 颜色) progress = pyqtSignal(int, int) # (当前完成数, 总数) finished = pyqtSignal(dict) # 验证结果字典 status = pyqtSignal(str) # 状态栏文本子线程中调用 self.signals.log.emit("✅ CCTV1 (128ms)", "#2e7d32"),主线程收到信号后安全地更新 UI。这是 PyQt 多线程编程的标准模式。
验证单个频道的核心逻辑:
defverify_one(self, ch):try: start = time.time() resp = requests.get(ch["url"], headers=self.headers, timeout=self.timeout, stream=True) elapsed = int((time.time() - start) * 1000)if resp.status_code == 200:# 只读前512字节判断是否为有效m3u8 content = resp.raw.read(512).decode("utf-8", errors="ignore") resp.close() valid = any(k in content for k in ["#EXTM3U", "#EXT-X", ".ts"])if valid:return {**ch, "status": "ok", "speed_ms": elapsed}return {**ch, "status": "invalid", "speed_ms": elapsed}except requests.exceptions.Timeout:return {**ch, "status": "timeout", "speed_ms": 0}except requests.exceptions.ConnectionError:return {**ch, "status": "unreachable", "speed_ms": 0}关键技巧:
stream=True 避免下载整个视频流,只读 512 字节判断内容有效性#EXTM3U、#EXT-X、.ts 等 HLS 标志并发调度:
with ThreadPoolExecutor(max_workers=self.workers) as pool: futures = {pool.submit(self.verify_one, ch): ch for ch in self.channels}for future in as_completed(futures): result = future.result()# 实时发送信号更新UI self.signals.progress.emit(completed, total)支持三种输入格式,自动识别:
# TXT格式: CCTV1,http://xxx/index.m3u8parts = re.split(r'[,,]', line, maxsplit=1)if len(parts) == 2and parts[1].strip().startswith("http"): channels.append({"name": parts[0].strip(), "url": parts[1].strip()})# M3U格式: #EXTINF:-1,频道名 \n URLif line.startswith("#EXTINF:"): name = re.search(r',(.+)$', line).group(1)# 下一个非#开头的行就是URL# JSON格式: {"分组": [{"name":"xx","url":"xx"}]}data = json.loads(content)for group, chs in data.items():for ch in chs: channels.append({"name": ch["name"], "url": ch["url"]})整个界面采用纯 QSS 实现,无需图片资源:
self.setStyleSheet(""" QMainWindow { background: #f5f6fa; } QGroupBox { background: #fff; color: #333; border: 1px solid #e0e0e0; border-radius: 8px; padding: 14px; font-weight: bold; } QGroupBox::title { color: #1976d2; } QPushButton { background: #fff; color: #333; border: 1px solid #ccc; border-radius: 5px; padding: 5px 14px; } QPushButton:hover { background: #4361ee; color: #fff; } QProgressBar::chunk { background: #4361ee; border-radius: 3px; } QTableWidget { background: #fff; alternate-background-color: #f9f9f9; gridline-color: #eee; border: 1px solid #e0e0e0; } QHeaderView::section { background: #f0f0f0; border-bottom: 2px solid #4361ee; font-weight: bold; }""")使用 QVBoxLayout 纵向排列 + QSplitter 横向分割日志和表格:
QVBoxLayout (垂直主布局)├── QGroupBox "① 选择源文件" → QHBoxLayout (文件路径 + 按钮)├── QGroupBox "② 参数与操作" → QHBoxLayout (SpinBox + 按钮)├── QProgressBar → 进度条├── QSplitter (水平分割)│ ├── 左: QTextEdit (日志)│ └── 右: QTableWidget (结果表格)└── QStatusBar → 底部状态以下为完整可运行源码,复制保存为
gui_v2.py,安装依赖后直接运行。依赖安装:
pip install PyQt5 requests
# -*- coding: utf-8 -*-"""电视直播源采集验证系统 v2功能:选择TXT源文件 → 手动触发验证 → 实时日志 → 生成结果文件布局:上(文件选择+参数) → 中(日志+结果表格) → 下(状态栏)依赖:pip install PyQt5 requests"""import sysimport osimport jsonimport reimport timeimport requestsfrom datetime import datetimefrom concurrent.futures import ThreadPoolExecutor, as_completedfrom threading import Threadfrom PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QTextEdit, QLabel, QProgressBar, QGroupBox, QSpinBox, QFileDialog, QTableWidget, QTableWidgetItem, QHeaderView, QSplitter, QFrame, QMessageBox, QLineEdit)from PyQt5.QtCore import Qt, pyqtSignal, QObjectfrom PyQt5.QtGui import QFont, QColor, QTextCursor# ==================== 路径 ====================BASE_DIR = os.path.dirname(os.path.abspath(__file__))OUTPUT_DIR = os.path.join(BASE_DIR, "output")# ==================== 信号 ====================classSignals(QObject): log = pyqtSignal(str, str) progress = pyqtSignal(int, int) finished = pyqtSignal(dict) status = pyqtSignal(str)# ==================== 验证引擎 ====================classVerifyEngine:def__init__(self, signals, channels, timeout=5, workers=15): self.signals = signals self.channels = channels self.timeout = timeout self.workers = workers self.headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} self._stop = Falsedefstop(self): self._stop = Truedeflog(self, msg, color="#212529"): self.signals.log.emit(msg, color)defverify_one(self, ch):if self._stop:return {**ch, "status": "stopped", "speed_ms": 0}try: start = time.time() resp = requests.get(ch["url"], headers=self.headers, timeout=self.timeout, stream=True) elapsed = int((time.time() - start) * 1000)if resp.status_code == 200: content = resp.raw.read(512).decode("utf-8", errors="ignore") resp.close() valid = any(k in content for k in ["#EXTM3U", "#EXT-X", ".ts", "#EXTINF"])if valid:return {**ch, "status": "ok", "speed_ms": elapsed}return {**ch, "status": "invalid", "speed_ms": elapsed}except requests.exceptions.Timeout:return {**ch, "status": "timeout", "speed_ms": 0}except requests.exceptions.ConnectionError:return {**ch, "status": "unreachable", "speed_ms": 0}except Exception:return {**ch, "status": "error", "speed_ms": 0}defrun(self): total = len(self.channels) self.log(f"🚀 开始验证 {total} 个频道 (线程:{self.workers} 超时:{self.timeout}s)", "#1976d2") self.signals.status.emit(f"验证中... 0/{total}") results = {"ok": [], "failed": []} completed = 0with ThreadPoolExecutor(max_workers=self.workers) as pool: futures = {pool.submit(self.verify_one, ch): ch for ch in self.channels}for future in as_completed(futures):if self._stop:break completed += 1 r = future.result()if r["status"] == "ok": results["ok"].append(r) self.log(f" ✅ {r['name']} ({r['speed_ms']}ms)", "#2e7d32")else: results["failed"].append(r) self.log(f" ❌ {r['name']} [{r['status']}]", "#c62828") self.signals.progress.emit(completed, total) self.signals.status.emit(f"验证中 {completed}/{total} ✅{len(results['ok'])} ❌{len(results['failed'])}" ) results["ok"].sort(key=lambda x: x.get("speed_ms", 9999)) self.log(f"\n📊 完成: ✅{len(results['ok'])} 可用 | ❌{len(results['failed'])} 不可用", "#1976d2")if results["ok"] andnot self._stop: self._output(results) self.signals.finished.emit(results)def_output(self, results): os.makedirs(OUTPUT_DIR, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") ok = results["ok"] self.log(f"\n📝 输出到: {OUTPUT_DIR}", "#1976d2")# TXT p = os.path.join(OUTPUT_DIR, f"{ts}_channels.txt")with open(p, "w", encoding="utf-8") as f:for ch in ok: f.write(f'{ch["name"]},{ch["url"]}\n') self.log(f" 📄 {os.path.basename(p)} ({len(ok)}条)", "#555")# JSON grouped = {}for ch in ok: g = "央视频道"if"cctv"in ch["name"].lower() else ("卫视频道"if"卫视"in ch["name"] else"其他频道") grouped.setdefault(g, []).append({"name": ch["name"], "url": ch["url"]}) p = os.path.join(OUTPUT_DIR, f"{ts}_channels.json")with open(p, "w", encoding="utf-8") as f: json.dump(grouped, f, ensure_ascii=False, indent=2) self.log(f" 📄 {os.path.basename(p)}", "#555")# M3U p = os.path.join(OUTPUT_DIR, f"{ts}_channels.m3u")with open(p, "w", encoding="utf-8") as f: f.write("#EXTM3U\n")for ch in ok: g = "央视"if"cctv"in ch["name"].lower() else ("卫视"if"卫视"in ch["name"] else"其他") f.write(f'#EXTINF:-1 group-title="{g}",{ch["name"]}\n{ch["url"]}\n') self.log(f" 📄 {os.path.basename(p)}", "#555")# 报告 p = os.path.join(OUTPUT_DIR, f"{ts}_report.md")with open(p, "w", encoding="utf-8") as f: now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") f.write(f"# 检测报告 {now}\n\n") f.write(f"✅ 可用: {len(ok)} | ❌ 不可用: {len(results['failed'])}\n\n") f.write("| 频道 | 响应ms | 地址 |\n|---|---|---|\n")for ch in ok: f.write(f"| {ch['name']} | {ch.get('speed_ms','-')} | {ch['url']} |\n") self.log(f" 📄 {os.path.basename(p)}", "#555") self.log(f"\n✅ 全部完成!", "#2e7d32")# ==================== 主窗口 ====================classMainWindow(QMainWindow):def__init__(self): super().__init__() self.setWindowTitle("📺 直播源验证工具") self.setMinimumSize(860, 580) self.resize(1000, 680) self.channels = [] self.engine = None self.worker = None self.signals = Signals() self.signals.log.connect(self._append_log) self.signals.progress.connect(self._update_progress) self.signals.finished.connect(self._on_finished) self.signals.status.connect(self._update_status) self._build_ui()def_build_ui(self): central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(16, 12, 16, 8) root.setSpacing(10)# ① 文件选择 file_box = QGroupBox("① 选择源文件") file_lay = QHBoxLayout(file_box) self.file_path = QLineEdit() self.file_path.setReadOnly(True) self.file_path.setPlaceholderText("点击右侧按钮选择 TXT/M3U/JSON 源文件...") file_lay.addWidget(self.file_path, 1) btn_browse = QPushButton("📂 选择文件") btn_browse.setFixedHeight(32) btn_browse.clicked.connect(self._select_file) file_lay.addWidget(btn_browse) self.lbl_count = QLabel("未加载") file_lay.addWidget(self.lbl_count) root.addWidget(file_box)# ② 参数+操作 ctrl_box = QGroupBox("② 参数设置 & 操作") ctrl_lay = QHBoxLayout(ctrl_box) ctrl_lay.addWidget(QLabel("超时(秒):")) self.spin_timeout = QSpinBox(); self.spin_timeout.setRange(2, 30); self.spin_timeout.setValue(5) ctrl_lay.addWidget(self.spin_timeout) ctrl_lay.addWidget(QLabel("并发线程:")) self.spin_workers = QSpinBox(); self.spin_workers.setRange(1, 50); self.spin_workers.setValue(15) ctrl_lay.addWidget(self.spin_workers) ctrl_lay.addSpacing(20) self.btn_start = QPushButton("🚀 开始验证") self.btn_start.setFixedHeight(34); self.btn_start.setEnabled(False) self.btn_start.clicked.connect(self._start); ctrl_lay.addWidget(self.btn_start) self.btn_stop = QPushButton("⏹ 停止") self.btn_stop.setFixedHeight(34); self.btn_stop.setEnabled(False) self.btn_stop.clicked.connect(self._stop); ctrl_lay.addWidget(self.btn_stop) btn_output = QPushButton("📂 打开输出目录") btn_output.setFixedHeight(34); btn_output.clicked.connect(self._open_output) ctrl_lay.addWidget(btn_output); ctrl_lay.addStretch() root.addWidget(ctrl_box)# 进度条 self.progress = QProgressBar(); self.progress.setFixedHeight(18) root.addWidget(self.progress)# 日志 + 结果表格 splitter = QSplitter(Qt.Horizontal)# 日志 log_w = QWidget(); log_l = QVBoxLayout(log_w); log_l.setContentsMargins(0,0,0,0) log_l.addWidget(QLabel("📋 执行日志")) self.log_view = QTextEdit(); self.log_view.setReadOnly(True) self.log_view.setStyleSheet("background:#fafafa;font-family:Consolas;font-size:12px;""border:1px solid #dee2e6;border-radius:4px;padding:6px;") log_l.addWidget(self.log_view); splitter.addWidget(log_w)# 表格 tbl_w = QWidget(); tbl_l = QVBoxLayout(tbl_w); tbl_l.setContentsMargins(0,0,0,0) tbl_l.addWidget(QLabel("📊 验证结果")) self.table = QTableWidget(); self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels(["状态","频道","ms","地址"]) self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) self.table.setAlternatingRowColors(True) tbl_l.addWidget(self.table) self.lbl_summary = QLabel(""); tbl_l.addWidget(self.lbl_summary) splitter.addWidget(tbl_w) splitter.setStretchFactor(0, 5); splitter.setStretchFactor(1, 4) root.addWidget(splitter, 1) self.statusBar().showMessage("就绪 | 请先选择源文件") self._apply_light_style()def_apply_light_style(self): self.setStyleSheet(""" QMainWindow { background: #f5f6fa; } QGroupBox { background:#fff; border:1px solid #e0e0e0; border-radius:8px; padding:14px; margin-top:10px; font-weight:bold; } QGroupBox::title { color:#1976d2; } QPushButton { background:#fff; border:1px solid #ccc; border-radius:5px; padding:5px 14px; } QPushButton:hover { background:#4361ee; color:#fff; } QPushButton:disabled { background:#f5f5f5; color:#aaa; } QProgressBar { background:#e8e8e8; border-radius:4px; } QProgressBar::chunk { background:#4361ee; border-radius:3px; } QTableWidget { background:#fff; alternate-background-color:#f9f9f9; border:1px solid #e0e0e0; } QHeaderView::section { background:#f0f0f0; border-bottom:2px solid #4361ee; font-weight:bold; } """)# ... (文件选择、解析、验证控制、信号回调方法同上,完整代码见前文) ...if __name__ == "__main__": app = QApplication(sys.argv) app.setFont(QFont("Microsoft YaHei", 9)) app.setStyle("Fusion") win = MainWindow() win.show() sys.exit(app.exec_())💡 提示:由于公众号排版限制,上方
_build_ui后的方法(_select_file、_parse_file、_start、_stop、_on_finished等)请参照前文代码解析部分的完整逻辑。实际运行请使用文末 GitHub 链接获取完整文件。
| PyQt5 | ||
| pyqtSignal | ||
| ThreadPoolExecutor | concurrent.futures | |
| requests + stream | stream=True | |
| 正则表达式 | #EXTINF 行和 TXT 的逗号分隔 |
pyqtSignal 本质就是观察者模式的实现os.path.abspath(__file__) — 获取脚本自身绝对路径{**dict1, "key": val} — 字典合并(Python 3.5+)as_completed(futures) — 哪个线程先完成就先处理哪个(非顺序)resp.raw.read(512) — 底层 socket 级别读取,比 resp.text 快datetime.now().strftime("%Y%m%d_%H%M%S") — 时间戳格式化安装依赖
pip install PyQt5 requests准备源文件(任选一种格式)
TXT 格式 (test.txt):
CCTV1,http://example.com/cctv1.m3u8湖南卫视,http://example.com/hunan.m3u8M3U 格式 (test.m3u):
#EXTM3U#EXTINF:-1,CCTV1http://example.com/cctv1.m3u8运行程序
python gui_v2.py操作流程
| 定时自动执行 | |
| GitHub Actions 自动化 | .github/workflows/update.yml,每天自动检测并提交结果 |
| 对接电视盒子 | |
| Web 版本 | |
| 增加源采集 | |
| 通知推送 | |
| 导入播放器 |
这个小工具虽然代码量不大,但涵盖了 PyQt5 GUI 开发中最核心的几个难题:多线程不卡UI、实时日志输出、进度反馈、文件IO。希望这篇文章能帮助你理解 Python 桌面应用开发的完整流程。
如果觉得有用,欢迎 点赞 + 收藏 + 转发,你的支持是我持续输出的动力!
📌 完整源码获取:关注公众号回复
直播源获取全部代码和测试数据。