提示:视频下载请遵守B站用户协议,仅下载获得授权的公开内容
本工具是一个基于Python开发的桌面应用程序,使用ttkbootstrap库构建现代UI界面。该工具允许用户通过GUI界面轻松下载B站视频,自动解析视频流和音频流,并使用FFmpeg进行合并处理。
由于B站的视频分为视频内容和音频内容,合并后才是用户所观看到的内容,所以需要用到FFmpeg。
具体的分析过程太过赘述,本文不过多描述,需要具体了解分析过程,同行皆有分析,请自行搜索查看。
核心功能
- 智能解析:提取B站视频标题和音视频流地址
- 双线程下载:并行下载视频流和音频流
- 进度监控:实时显示下载进度百分比
- 日志系统:完整记录操作和下载过程
- FFmpeg集成:自动合并音视频流为完整MP4文件
FFmpeg依赖配置详解
为什么需要FFmpeg?
B站视频采用音视频分离的传输方式:
- 视频流(不含音频)
- 音频流(单独文件)
- 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 |
抑制控制台输出 | 可选 |
项目使用指南
- 安装Python依赖:
pip install requests ttkbootstrap lxml
- 运行程序:
python Bilibili_GUI_Pro.py
- 界面操作:
- 粘贴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()
运行效果