小电视视频内容获取GUI工具

发布于:2025-08-15 ⋅ 阅读:(19) ⋅ 点赞:(0)

提示:视频下载请遵守B站用户协议,仅下载获得授权的公开内容

本工具是一个基于Python开发的桌面应用程序,使用ttkbootstrap库构建现代UI界面。该工具允许用户通过GUI界面轻松下载B站视频,自动解析视频流和音频流,并使用FFmpeg进行合并处理。
由于B站的视频分为视频内容和音频内容,合并后才是用户所观看到的内容,所以需要用到FFmpeg。
具体的分析过程太过赘述,本文不过多描述,需要具体了解分析过程,同行皆有分析,请自行搜索查看。

核心功能

  • 智能解析:提取B站视频标题和音视频流地址
  • 双线程下载:并行下载视频流和音频流
  • 进度监控:实时显示下载进度百分比
  • 日志系统:完整记录操作和下载过程
  • FFmpeg集成:自动合并音视频流为完整MP4文件

FFmpeg依赖配置详解

为什么需要FFmpeg?

B站视频采用音视频分离的传输方式:

  1. 视频流(不含音频)
  2. 音频流(单独文件)
  3. FFmpeg负责将两者合并为完整MP4文件

安装配置步骤

Windows系统
# 1. 下载官方编译版
https://www.gyan.dev/ffmpeg/builds/
# 2. 解压到本地目录(建议C:\ffmpeg)
# 3. 添加环境变量
系统属性 → 高级 → 环境变量 → Path → 添加:
C:\ffmpeg\bin
# 4. 验证安装
cmd输入:ffmpeg -version
macOS系统
# 通过Homebrew安装
brew install ffmpeg
# 验证安装
ffmpeg -version
Linux系统
# Ubuntu/Debian
sudo apt update
sudo apt install ffmpeg
# CentOS/Fedora
sudo yum install ffmpeg

项目中的FFmpeg调用逻辑

# 关键代码片段
output_path = os.path.join(save_path, f"{filename}_完整版.mp4")
cmd = f'ffmpeg -i "{mp4_path}" -i "{mp3_path}" -c:v copy -c:a copy "{output_path}" -loglevel quiet'
result = os.system(cmd)

参数解析

参数 作用 必要性
-c:v copy 直接复制视频流(不重新编码) 必需
-c:a copy 直接复制音频流(不重新编码) 必需
-loglevel quiet 抑制控制台输出 可选

项目使用指南

  1. 安装Python依赖:
pip install requests ttkbootstrap lxml
  1. 运行程序:
python Bilibili_GUI_Pro.py
  1. 界面操作:
    • 粘贴B站视频URL
    • 选择保存路径
    • 点击"开始下载"

具体代码

"""
#!/usr/bin/env python3
# --*-- coding:UTF-8 --*--
@Author : LuoQiu
@Software : PyCharm
@File : Bilibili_GUI_Pro.py
@Time : 2025/08/13 12:05:07
"""
import os
import re
import json
import threading
import requests
import tkinter as tk
from tkinter import filedialog, messagebox
from lxml import etree
import ttkbootstrap as ttk
from ttkbootstrap.constants import *
from ttkbootstrap.scrolled import ScrolledText


class BilibiliDownloader(ttk.Window):
    def __init__(self):
        super().__init__(title="B站视频下载器", themename="cosmo", resizable=(True, True))
        self.geometry("800x600")
        self.minsize(700, 500)
        self.iconbitmap(default="")

        # 设置默认保存路径
        self.save_path = os.path.join(os.path.expanduser("~"), "Videos", "Bilibili")
        if not os.path.exists(self.save_path):
            os.makedirs(self.save_path, exist_ok=True)

        # 创建变量
        self.url_var = tk.StringVar()
        self.path_var = tk.StringVar(value=self.save_path)
        self.is_downloading = False
        self.download_thread = None

        # 创建界面
        self.create_widgets()
        self.center_window()

    def center_window(self):
        """将窗口居中显示"""
        self.update_idletasks()
        width = self.winfo_width()
        height = self.winfo_height()
        x = (self.winfo_screenwidth() // 2) - (width // 2)
        y = (self.winfo_screenheight() // 2) - (height // 2)
        self.geometry(f"{width}x{height}+{x}+{y}")

    def create_widgets(self):
        """创建GUI组件"""
        # 创建主框架
        main_frame = ttk.Frame(self, padding=10)
        main_frame.pack(fill=BOTH, expand=YES)

        # 顶部标题
        title_label = ttk.Label(
            main_frame,
            text="B站视频下载器",
            font=("Helvetica", 18, "bold"),
            bootstyle="primary"
        )
        title_label.pack(pady=10)

        # 输入框架
        input_frame = ttk.LabelFrame(main_frame, text="下载设置", padding=10)
        input_frame.pack(fill=X, pady=10)

        # URL输入
        url_frame = ttk.Frame(input_frame)
        url_frame.pack(fill=X, pady=5)

        url_label = ttk.Label(url_frame, text="视频URL:", width=10)
        url_label.pack(side=LEFT, padx=5)

        url_entry = ttk.Entry(url_frame, textvariable=self.url_var, bootstyle="primary")
        url_entry.pack(side=LEFT, fill=X, expand=YES, padx=5)

        paste_button = ttk.Button(
            url_frame,
            text="粘贴",
            command=self.paste_url,
            bootstyle="info-outline",
            width=8
        )
        paste_button.pack(side=LEFT, padx=5)

        # 保存路径
        path_frame = ttk.Frame(input_frame)
        path_frame.pack(fill=X, pady=5)

        path_label = ttk.Label(path_frame, text="保存路径:", width=10)
        path_label.pack(side=LEFT, padx=5)

        path_entry = ttk.Entry(path_frame, textvariable=self.path_var, bootstyle="primary")
        path_entry.pack(side=LEFT, fill=X, expand=YES, padx=5)

        browse_button = ttk.Button(
            path_frame,
            text="浏览",
            command=self.browse_path,
            bootstyle="info-outline",
            width=8
        )
        browse_button.pack(side=LEFT, padx=5)

        # 按钮框架
        button_frame = ttk.Frame(main_frame)
        button_frame.pack(fill=X, pady=10)

        self.download_button = ttk.Button(
            button_frame,
            text="开始下载",
            command=self.start_download,
            bootstyle="success",
            width=15
        )
        self.download_button.pack(side=LEFT, padx=5)

        self.cancel_button = ttk.Button(
            button_frame,
            text="取消下载",
            command=self.cancel_download,
            bootstyle="danger",
            state=DISABLED,
            width=15
        )
        self.cancel_button.pack(side=LEFT, padx=5)

        clear_button = ttk.Button(
            button_frame,
            text="清空日志",
            command=self.clear_log,
            bootstyle="warning",
            width=15
        )
        clear_button.pack(side=LEFT, padx=5)

        about_button = ttk.Button(
            button_frame,
            text="关于",
            command=self.show_about,
            bootstyle="info",
            width=15
        )
        about_button.pack(side=RIGHT, padx=5)

        # 进度条框架
        progress_frame = ttk.Frame(main_frame)
        progress_frame.pack(fill=X, pady=5)

        self.progress_var = ttk.IntVar(value=0)
        self.progress_label = ttk.Label(progress_frame, text="下载进度: 0%")
        self.progress_label.pack(side=LEFT, padx=5)

        self.progress_bar = ttk.Progressbar(
            progress_frame,
            variable=self.progress_var,
            bootstyle="success-striped",
            length=100,
            mode="determinate",
            maximum=100
        )
        self.progress_bar.pack(side=LEFT, fill=X, expand=YES, padx=5)

        # 日志框架
        log_frame = ttk.LabelFrame(main_frame, text="下载日志", padding=10)
        log_frame.pack(fill=BOTH, expand=YES, pady=10)

        self.log_text = ScrolledText(log_frame, padding=5, height=6, autohide=True, bootstyle="primary")
        self.log_text.pack(fill=BOTH, expand=YES)

        # 状态栏
        status_frame = ttk.Frame(main_frame)
        status_frame.pack(fill=X, side=BOTTOM, pady=5)

        self.status_label = ttk.Label(status_frame, text="就绪", bootstyle="secondary")
        self.status_label.pack(side=LEFT)

        # 版权信息
        copyright_label = ttk.Label(status_frame, text="© 2025 B站视频下载器", bootstyle="secondary")
        copyright_label.pack(side=RIGHT)

    def paste_url(self):
        """粘贴剪贴板内容到URL输入框"""
        try:
            clipboard_text = self.clipboard_get()
            self.url_var.set(clipboard_text)
        except:
            self.log("剪贴板中没有可用内容")

    def browse_path(self):
        """选择保存路径"""
        path = filedialog.askdirectory(initialdir=self.path_var.get())
        if path:
            self.path_var.set(path)

    def log(self, message):
        """向日志窗口添加消息"""
        self.log_text.insert(tk.END, f"{message}\n")
        self.log_text.see(tk.END)
        self.update_idletasks()

    def clear_log(self):
        """清空日志窗口"""
        self.log_text.delete(1.0, tk.END)

    def show_about(self):
        """显示关于信息"""
        messagebox.showinfo(
            "关于B站视频下载器",
            "B站视频下载器 v1.0\n\n"
            "这是一个使用Python和ttkbootstrap开发的B站视频下载工具。\n"
            "可以下载B站视频并自动合并音视频。\n\n"
            "© 2025 B站视频下载器"
        )

    def update_status(self, message):
        """更新状态栏信息"""
        self.status_label.config(text=message)

    def update_progress(self, percent, message=None):
        """更新进度条"""
        self.progress_var.set(percent)
        if message:
            self.progress_label.config(text=f"下载进度: {message}")
        else:
            self.progress_label.config(text=f"下载进度: {percent}%")

    def toggle_buttons(self, is_downloading):
        """切换按钮状态"""
        if is_downloading:
            self.download_button.config(state=DISABLED)
            self.cancel_button.config(state=NORMAL)
        else:
            self.download_button.config(state=NORMAL)
            self.cancel_button.config(state=DISABLED)

    def start_download(self):
        """开始下载视频"""
        url = self.url_var.get().strip()
        if not url:
            messagebox.showerror("错误", "请输入有效的B站视频URL")
            return

        if not url.startswith("https://www.bilibili.com"):
            messagebox.showerror("错误", "请输入有效的B站视频URL")
            return

        save_path = self.path_var.get()
        if not os.path.exists(save_path):
            try:
                os.makedirs(save_path, exist_ok=True)
            except Exception as e:
                messagebox.showerror("错误", f"无法创建保存目录: {str(e)}")
                return

        self.is_downloading = True
        self.toggle_buttons(True)
        self.update_status("正在下载...")
        self.update_progress(0, "准备中...")

        # 创建并启动下载线程
        self.download_thread = threading.Thread(target=self.download_video, args=(url, save_path))
        self.download_thread.daemon = True
        self.download_thread.start()

    def cancel_download(self):
        """取消下载"""
        if self.is_downloading:
            self.is_downloading = False
            self.log("正在取消下载...")
            self.update_status("已取消")
            self.update_progress(0, "已取消")
            self.toggle_buttons(False)

    def download_video(self, url, save_path):
        """在线程中下载视频"""
        try:
            # 模拟浏览器发送请求
            headers = {
                'referer': 'https://www.bilibili.com',
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/'
                              '106.0.0.0 Safari/537.36 Edg/106.0.1370.47'
            }

            self.log(f"开始下载: {url}")
            self.log("正在获取视频信息...")
            self.after(0, lambda: self.update_progress(5, "获取视频信息..."))

            # 获取爬取视频地址
            r = requests.get(url, headers=headers)

            if not self.is_downloading:
                return

            # 解析视频地址,截取关键信息
            try:
                info = re.findall('window.__playinfo__=(.*?)</script>', r.text)[0]
                video_url = json.loads(info)["data"]["dash"]["video"][0]['baseUrl']
                audio_url = json.loads(info)["data"]["dash"]["audio"][0]['baseUrl']

                # 获取视频名称
                html = etree.HTML(r.text)
                filename = html.xpath('//h1/text()')[0]
                # 替换文件名中的非法字符
                filename = re.sub(r'[\\/:*?"<>|]', "_", filename)

                self.log(f"视频标题: {filename}")
                self.after(0, lambda: self.update_progress(10, "解析完成"))
            except Exception as e:
                self.log(f"解析视频信息失败: {str(e)}")
                self.update_status("下载失败")
                self.after(0, lambda: self.update_progress(0, "下载失败"))
                self.toggle_buttons(False)
                self.is_downloading = False
                return

            if not self.is_downloading:
                return

            # 下载视频部分
            self.log("正在下载视频部分...")
            self.after(0, lambda: self.update_progress(20, "下载视频..."))

            try:
                response = requests.get(video_url, headers=headers, stream=True)
                # 获取文件大小
                file_size = int(response.headers.get('Content-Length', 0))
                downloaded = 0
                chunks = []

                for chunk in response.iter_content(chunk_size=1024 * 1024):  # 每次读取1MB
                    if not self.is_downloading:
                        return
                    if chunk:
                        chunks.append(chunk)
                        downloaded += len(chunk)
                        # 计算视频下载进度(占总进度的30%)
                        progress = int(20 + (downloaded / file_size) * 30) if file_size > 0 else 30
                        self.after(0, lambda p=progress: self.update_progress(p,
                                                                              f"视频: {int((downloaded / file_size) * 100)}%" if file_size > 0 else "视频下载中..."))

                video_content = b''.join(chunks)
            except Exception as e:
                self.log(f"下载视频失败: {str(e)}")
                self.after(0, lambda: self.update_progress(0, "下载失败"))
                self.update_status("下载失败")
                self.is_downloading = False
                return

            if not self.is_downloading:
                return

            # 下载音频部分
            self.log("正在下载音频部分...")
            self.after(0, lambda: self.update_progress(50, "下载音频..."))

            try:
                response = requests.get(audio_url, headers=headers, stream=True)
                # 获取文件大小
                file_size = int(response.headers.get('Content-Length', 0))
                downloaded = 0
                chunks = []

                for chunk in response.iter_content(chunk_size=1024 * 1024):  # 每次读取1MB
                    if not self.is_downloading:
                        return
                    if chunk:
                        chunks.append(chunk)
                        downloaded += len(chunk)
                        # 计算音频下载进度(占总进度的20%)
                        progress = int(50 + (downloaded / file_size) * 20) if file_size > 0 else 60
                        self.after(0, lambda p=progress: self.update_progress(p,
                                                                              f"音频: {int((downloaded / file_size) * 100)}%" if file_size > 0 else "音频下载中..."))

                audio_content = b''.join(chunks)
            except Exception as e:
                self.log(f"下载音频失败: {str(e)}")
                self.after(0, lambda: self.update_progress(0, "下载失败"))
                self.update_status("下载失败")
                self.is_downloading = False
                return

            if not self.is_downloading:
                return

            # 保存视频和音频
            mp4_path = os.path.join(save_path, f"{filename}.mp4")
            mp3_path = os.path.join(save_path, f"{filename}.mp3")
            output_path = os.path.join(save_path, f"{filename}_完整版.mp4")

            self.log("正在保存视频部分...")
            self.after(0, lambda: self.update_progress(70, "保存视频..."))
            with open(mp4_path, 'wb') as f:
                f.write(video_content)

            if not self.is_downloading:
                if os.path.exists(mp4_path):
                    os.remove(mp4_path)
                return

            self.log("正在保存音频部分...")
            self.after(0, lambda: self.update_progress(75, "保存音频..."))
            with open(mp3_path, 'wb') as f:
                f.write(audio_content)

            if not self.is_downloading:
                if os.path.exists(mp4_path):
                    os.remove(mp4_path)
                if os.path.exists(mp3_path):
                    os.remove(mp3_path)
                return

            # 合并视频和音频
            self.log("正在合并音视频...")
            self.after(0, lambda: self.update_progress(80, "合并音视频..."))
            cmd = f'ffmpeg -i "{mp4_path}" -i "{mp3_path}" -c:v copy -c:a copy "{output_path}" -loglevel quiet'
            result = os.system(cmd)

            if result != 0:
                self.log("合并失败,请确保已安装ffmpeg并添加到环境变量")
                self.log("视频和音频文件已保存,您可以使用其他工具手动合并")
                self.update_status("合并失败")
                self.after(0, lambda: self.update_progress(0, "合并失败"))
            else:
                self.log("合并成功,正在清理临时文件...")
                self.after(0, lambda: self.update_progress(90, "清理临时文件..."))
                # 删除临时文件
                os.remove(mp4_path)
                os.remove(mp3_path)
                self.log(f"下载完成!文件保存在: {output_path}")
                self.after(0, lambda: self.update_progress(100, "下载完成"))

                # 在主线程中显示完成消息
                self.after(0, lambda: messagebox.showinfo("下载完成",
                                                          f"视频 '{filename}' 已成功下载!\n\n保存位置: {output_path}"))

            self.update_status("下载完成")

        except Exception as e:
            self.log(f"下载过程中出错: {str(e)}")
            self.update_status("下载出错")
        finally:
            self.is_downloading = False
            self.after(0, lambda: self.toggle_buttons(False))


if __name__ == "__main__":
    app = BilibiliDownloader()
    app.mainloop()

运行效果

效果展示


 

在这里插入图片描述

在这里插入图片描述