python大文件分段下载器
本次使用到的技术点:大文件分割、多线程下载同一个文件、队列管理待下载文件片段、os.path模块管理本地文件、requests请求下载视频
一、项目由来
网上很少关于python使用多线程分段下载超清视频、大文本等超大文件的资料,由于多线程适合io密集型和网络请求,所以使用多线程下载大文件能极大的提高下载效率。本次需求产生的原因是朋友在做视频爬取项目,已经提取到了视频下载地址的情况下产生的,由于需要下载大量的视频,使用单线程下载速度极慢,又没有使用scrapy框架,所以本人就想着开辟多线程下载视频,于是写好了一个多线程下载视频的文件,但是由于下载的都是超清视频,下载速度还是不够快,本人就想到了将文件分段下载再合成,使用多线程下载一个文件不就又比单线程下载一个文件快了吗,估计迅雷也是这样实现的吧。
二、下载思路
- 获取要下载的视频的大小
- 将视频大小分割成N段每次每段的大小为1M(1024*1024)
- 将每段加入队列,开启多线程请求获取每段文件保存到本地
- 所有的文件片段下载完成将文件片段合成
三、具体实现
本次需要构建两个类
- 文件管理类FileManger
文件管理类FileManger类,负责文件的管理(分割文件、保存文件、合成文件片段、创建文件目录、获取本地保存文件片段编号列表等)
文件管理类使用多线程保存文件、使用os.path模块管理文件
- 下载器类Downloader
下载器类主要用于发送网络请求,分段获取视频文件,通过文件管理类将文件片段保存到本地并合成。
四、技术点要点
- 文件分割
def split_file(self, start=0, end=None, section_size=1024 * 1024):
"""
分割文件
:param start:开始位置,默认从头开始
:param end: 结束位置,默认到文件末尾
:param section_size: 分割片段的大小,默认为1M
:return: 字典格式,段数count及片段列表sections
"""
if end:
# 用户定义了分割大小
end = end
elif os.path.exists(self.file_path):
# 用户未定义分割大小,系统存在文件
end = os.path.getsize(self.file_path)
else:
# 其他情况
return False
if end < start:
# 末尾大于开始
return False
# 获取分割段数
n = (end - start) // section_size + 1
# 分割文件,将分割结果加入下载队列中
sections = {str(i): (i * section_size, (i + 1) * section_size - 1 if i + 1 != n else end - start) for i in
range(n)}
return sections
- 文件合成
def merge_section(self, path=None, count=None):
"""
合并本地的文件片段
:param path: 本地片段所在目录
:return: boolean
"""
# 获取当前管理的文件的文件夹名
path = path if path else os.path.splitext(self.file_path)[0]
index = 0
# print(path)
if not os.path.isdir(path):
# 文件夹不存在
return False
# 创建一个文件字典
file = dict()
file_size = 0
file_name = ""
for root, dirs, names in os.walk(path):
for name in names:
# 获取后缀名
id, ext = os.path.splitext(name)
# 获取所有具有编号的mp4文件
if ext == '.mp4' and id.isdigit():
# print(name)
# mp4文件原始地址
section_path = os.path.join(root, name)
# print(type(section_path), section_path)
file[id] = section_path
file_size += os.path.getsize(section_path)
file_name = os.path.join(path, f"{os.path.split(path)[-1]}{ext}")
# 没有获取到文件
if len(file.keys()) <= 0:
return False
# 如果用户输入count使用用户的count数
count = count if count else len(file.keys())
if count > len(file.keys()):
# 若用户输入的count大于实际的文件数,使用实际的文件数
count = len(file.keys())
file_size = 0
for i in range(count):
# 重新获取文件的大小
file_size += os.path.getsize(file.get(str(i)))
if os.path.exists(file_name):
# 文件已经合成完毕
if os.path.getsize(file_name) >= file_size:
# print("文件已经合成完毕,不需要再次合成")
return False
while index < count:
# 打开本地文件获取文件片段,合并文件片段
path = file.get(str(index))
if path == None:
print(f"未找到文件编号:{index}")
break
# 获取数据将数据写入本地
f = open(path, mode="rb")
data = f.read()
file_path = os.path.join(os.path.splitext(self.file_path)[0], os.path.basename(self.file_path))
fp = open(file_path, mode="ab")
fp.write(data)
fp.close()
f.close()
index += 1
- 文件多线程保存
def save_section(self, section_dict):
"""
合并文件片段
:param section_dict:所有片段的字典,片段内容为{"id": "section",}
:return: None
"""
# 开启线程获取文件片段,将片段加入队列中
t = Thread(target=self.__add_section_to_queue, args=(section_dict,))
t.start()
q_ls = list()
# 开辟线程保存文件
for i in range(3):
t = Thread(target=self.__save_section)
t.start()
q_ls.append(t)
def __save_section(self):
"""将文件片段保存到本地"""
while True:
# 从队列中获取文件片段
section = self.q_sections.get()
f = open(section.get("path"), mode="wb")
# 将文件片段写入文件
f.write(section.get("section"))
# 关闭文件
f.close()
# print(f"文件{section.get('path')}保存成功!")
time.sleep(0.1)
def __add_section_to_queue(self, section_dict):
# 获取文件名,以文件名为路径创建一个文件夹
self.__mkdir(os.path.splitext(self.file_path)[0])
while True:
# 获取文件名,以文件名为路径创建一个文件夹
path, ext = os.path.splitext(self.file_path)
for key in list(section_dict.keys()):
path_copy = os.path.join(path, f"{key}{ext}")
# 验证成功,将数据加入队列
self.q_sections.put({"path": path_copy, "section": section_dict.pop(key)})
time.sleep(1)
- 获取待下载视频的大小
def __get_file_size(self):
"""获取下载文件大小"""
# 连接服务器次数统计
con_count = 0
while True:
# 如果连接次数大于50取消下载
if con_count > 50:
print("文件大小获取失败")
return False
try:
# 请求获取文件大小
file_size = int(requests.get(url=self.url, stream=True).headers['content-length'])
except Exception as e:
continue
con_count += 1
time.sleep(0.1)
# 判断文件是否小于1M,小于1M继续获取
if file_size < 1024 * 1024:
time.sleep(0.5)
continue
return file_size
- 开启多线程下载视频
def __start_download(self):
print(f"开始下载文件:{os.path.basename(self.file_manager.file_path)}")
# 开启线程,下载文件片段
for i in range(self.thread_num):
t = Thread(target=self.__download)
t.start()
def __add_section_to_queue(self):
print(f"正在获取文件{os.path.basename(self.file_manager.file_path)}大小!")
# 获取待下载文件大小
self.file_size = self.__get_file_size()
# print(file_size)
# 判断文件大小是否获取成功
if self.file_size:
# 将文件分割为文件片段分段下载
section_dict = self.file_manager.split_file(end=self.file_size)
# 获取所有的文件编号
section_id = self.file_manager.get_section_id()
# print(section_dict)
# print(len(section_dict))
self.section_count = len(section_dict.keys())
# 去除已经下载的文件
for id in list(section_dict.keys()):
if id not in section_id:
section = section_dict.pop(id)
# 将文件片段加入到待下载队列中
self.q_file_section.put(
{"id": id, "size": self.section_size,
"section": f"bytes={section[0]}-{section[1]}"})
# print(section_dict)
# print(len(section_dict))
# # 将合成文件片段
# self.file_manager.merge_section()
else:
return self.__add_section_to_queue()
def __download(self):
"""下载文件片段"""
while True:
# 连接服务器次数统计
con_count = 0
# 获取待下载的文件片段
section_dict = self.q_file_section.get()
# print(section_dict)
# 获取片段size大小
section_size = section_dict.get("size")
section = section_dict.get("section")
id = section_dict.get("id")
if not section:
return False
# 构建请求头
headers = {"Range": section}
# print(headers)
while True:
if con_count > 5 * 600:
break
try:
con_count += 1
file_section = requests.get(url=self.url, headers=headers, stream=True).content
except Exception as e:
continue
# 判断数据是否获取完毕了,队列空了就结束下载
if not self.q_file_section.qsize():
self.section_dict[id] = file_section
break
# 获取到的文件大小不等于需要下载的文件大小继续下载
if section_size != len(file_section):
continue
else:
# 下载成功,将文件片段加入列表中
self.section_dict[id] = file_section
break
- 整体流程
def run(self):
start = time.time()
"""开启线程下载"""
# self.file_manager.merge_section()
# 将待下载的文件片段加入到待下载队列中
self.__add_section_to_queue()
self.print_download_progress()
# 保存开启线程下载文件片段,保存到本地
self.__start_download()
# 保存文件片段到本地
self.file_manager.save_section(section_dict=self.section_dict)
while True:
# 判断文件是否全部下载完成
if len(self.file_manager.get_section_id()) == self.section_count:
print(f"文件片段总数为:{self.section_count}")
self.file_manager.merge_section()
# print("文件合成成功")
end = time.time()
print(f"下载文件总{os.path.basename(self.file_manager.file_path)}共耗时:{int((end-start)*100)/100}s")
break
time.sleep(1)
四、代码实现
from threading import Thread
from queue import Queue
import os
import time
import requests
class Downloader(Thread):
"""下载器"""
def __init__(self, url, file_path=r".\yinyuetai\video\SOL.mp4", thread_num=100, section_size=1024 * 1024):
super().__init__()
# 下载文件的地址
self.url = url
# 开启线程数量
self.thread_num = thread_num
self.section_size = section_size
# 分割文件的队列
self.q_file_section = Queue()
# 保存队列
self.q_save_section = Queue()
self.file_manager = FileManager(file_path)
self.section_dict = dict()
self.section_count = 0
# 待下载文件的总大小
self.file_size = 0
def run(self):
start = time.time()
"""开启线程下载"""
# self.file_manager.merge_section()
# 将待下载的文件片段加入到待下载队列中
self.__add_section_to_queue()
self.print_download_progress()
# 保存开启线程下载文件片段,保存到本地
self.__start_download()
# 保存文件片段到本地
self.file_manager.save_section(section_dict=self.section_dict)
while True:
# 判断文件是否全部下载完成
if len(self.file_manager.get_section_id()) == self.section_count:
print(f"文件片段总数为:{self.section_count}")
self.file_manager.merge_section()
# print("文件合成成功")
end = time.time()
print(f"下载文件总{os.path.basename(self.file_manager.file_path)}共耗时:{int((end-start)*100)/100}s")
break
time.sleep(1)
def __start_download(self):
print(f"开始下载文件:{os.path.basename(self.file_manager.file_path)}")
# 开启线程,下载文件片段
for i in range(self.thread_num):
t = Thread(target=self.__download)
t.start()
def __add_section_to_queue(self):
print(f"正在获取文件{os.path.basename(self.file_manager.file_path)}大小!")
# 获取待下载文件大小
self.file_size = self.__get_file_size()
# print(file_size)
# 判断文件大小是否获取成功
if self.file_size:
# 将文件分割为文件片段分段下载
section_dict = self.file_manager.split_file(end=self.file_size)
# 获取所有的文件编号
section_id = self.file_manager.get_section_id()
# print(section_dict)
# print(len(section_dict))
self.section_count = len(section_dict.keys())
# 去除已经下载的文件
for id in list(section_dict.keys()):
if id not in section_id:
section = section_dict.pop(id)
# 将文件片段加入到待下载队列中
self.q_file_section.put(
{"id": id, "size": self.section_size,
"section": f"bytes={section[0]}-{section[1]}"})
# print(section_dict)
# print(len(section_dict))
# # 将合成文件片段
# self.file_manager.merge_section()
else:
return self.__add_section_to_queue()
def __download(self):
"""下载文件片段"""
while True:
# 连接服务器次数统计
con_count = 0
# 获取待下载的文件片段
section_dict = self.q_file_section.get()
# print(section_dict)
# 获取片段size大小
section_size = section_dict.get("size")
section = section_dict.get("section")
id = section_dict.get("id")
if not section:
return False
# 构建请求头
headers = {"Range": section}
# print(headers)
while True:
if con_count > 5 * 600:
break
try:
con_count += 1
file_section = requests.get(url=self.url, headers=headers, stream=True).content
except Exception as e:
continue
# 判断数据是否获取完毕了,队列空了就结束下载
if not self.q_file_section.qsize():
self.section_dict[id] = file_section
break
# 获取到的文件大小不等于需要下载的文件大小继续下载
if section_size != len(file_section):
continue
else:
# 下载成功,将文件片段加入列表中
self.section_dict[id] = file_section
break
def __get_file_size(self):
"""获取下载文件大小"""
# 连接服务器次数统计
con_count = 0
while True:
# 如果连接次数大于50取消下载
if con_count > 50:
print("文件大小获取失败")
return False
try:
# 请求获取文件大小
file_size = int(requests.get(url=self.url, stream=True).headers['content-length'])
except Exception as e:
continue
con_count += 1
time.sleep(0.1)
# 判断文件是否小于1M,小于1M继续获取
if file_size < 1024 * 1024:
time.sleep(0.5)
continue
return file_size
def print_download_progress(self):
t = Thread(target=self.__print_download_progress)
t.start()
def __print_download_progress(self):
while True:
print(f"文件:{os.path.basename(self.file_manager.file_path)}下载进度为:{int((len(self.file_manager.get_section_id())/(self.file_size/self.section_size)) * 10000)/100}%")
time.sleep(3)
class FileManager(object):
"""文件管理器类"""
def __init__(self, file_path):
"""初始化文件管理器"""
self.file_path = file_path
self.q_sections = Queue()
def split_file(self, start=0, end=None, section_size=1024 * 1024):
"""
分割文件
:param start:开始位置,默认从头开始
:param end: 结束位置,默认到文件末尾
:param section_size: 分割片段的大小,默认为1M
:return: 字典格式,段数count及片段列表sections
"""
if end:
# 用户定义了分割大小
end = end
elif os.path.exists(self.file_path):
# 用户未定义分割大小,系统存在文件
end = os.path.getsize(self.file_path)
else:
# 其他情况
return False
if end < start:
# 末尾大于开始
return False
# 获取分割段数
n = (end - start) // section_size + 1
# 分割文件,将分割结果加入下载队列中
sections = {str(i): (i * section_size, (i + 1) * section_size - 1 if i + 1 != n else end - start) for i in
range(n)}
return sections
def merge_section(self, path=None, count=None):
"""
合并本地的文件片段
:param path: 本地片段所在目录
:return: boolean
"""
# 获取当前管理的文件的文件夹名
path = path if path else os.path.splitext(self.file_path)[0]
index = 0
# print(path)
if not os.path.isdir(path):
# 文件夹不存在
return False
# 创建一个文件字典
file = dict()
file_size = 0
file_name = ""
for root, dirs, names in os.walk(path):
for name in names:
# 获取后缀名
id, ext = os.path.splitext(name)
# 获取所有具有编号的mp4文件
if ext == '.mp4' and id.isdigit():
# print(name)
# mp4文件原始地址
section_path = os.path.join(root, name)
# print(type(section_path), section_path)
file[id] = section_path
file_size += os.path.getsize(section_path)
file_name = os.path.join(path, f"{os.path.split(path)[-1]}{ext}")
# 没有获取到文件
if len(file.keys()) <= 0:
return False
# 如果用户输入count使用用户的count数
count = count if count else len(file.keys())
if count > len(file.keys()):
# 若用户输入的count大于实际的文件数,使用实际的文件数
count = len(file.keys())
file_size = 0
for i in range(count):
# 重新获取文件的大小
file_size += os.path.getsize(file.get(str(i)))
if os.path.exists(file_name):
# 文件已经合成完毕
if os.path.getsize(file_name) >= file_size:
# print("文件已经合成完毕,不需要再次合成")
return False
while index < count:
# 打开本地文件获取文件片段,合并文件片段
path = file.get(str(index))
if path == None:
print(f"未找到文件编号:{index}")
break
# 获取数据将数据写入本地
f = open(path, mode="rb")
data = f.read()
file_path = os.path.join(os.path.splitext(self.file_path)[0], os.path.basename(self.file_path))
fp = open(file_path, mode="ab")
fp.write(data)
fp.close()
f.close()
index += 1
def save_section(self, section_dict):
"""
合并文件片段
:param section_dict:所有片段的字典,片段内容为{"id": "section",}
:return: None
"""
# 开启线程获取文件片段,将片段加入队列中
t = Thread(target=self.__add_section_to_queue, args=(section_dict,))
t.start()
q_ls = list()
# 开辟线程保存文件
for i in range(3):
t = Thread(target=self.__save_section)
t.start()
q_ls.append(t)
def get_section_id(self):
"""获取所有的文件片段编号"""
ls = list()
# 获取文件夹
path = os.path.splitext(self.file_path)[0]
# 判断文件夹是否存在
if os.path.isdir(path):
for root, dirs, names in os.walk(path):
for name in names:
# 获取后缀名
id, ext = os.path.splitext(name)
# 获取所有具有编号的mp4文件
if ext == '.mp4' and id.isdigit():
# mp4文件原始地址
ls.append(id)
return ls
def __save_section(self):
"""将文件片段保存到本地"""
while True:
# 从队列中获取文件片段
section = self.q_sections.get()
f = open(section.get("path"), mode="wb")
# 将文件片段写入文件
f.write(section.get("section"))
# 关闭文件
f.close()
# print(f"文件{section.get('path')}保存成功!")
time.sleep(0.1)
def __add_section_to_queue(self, section_dict):
# 获取文件名,以文件名为路径创建一个文件夹
self.__mkdir(os.path.splitext(self.file_path)[0])
while True:
# 获取文件名,以文件名为路径创建一个文件夹
path, ext = os.path.splitext(self.file_path)
for key in list(section_dict.keys()):
path_copy = os.path.join(path, f"{key}{ext}")
# 验证成功,将数据加入队列
self.q_sections.put({"path": path_copy, "section": section_dict.pop(key)})
time.sleep(1)
def __mkdir(self, path):
"""创建文件夹"""
if path:
# 判断是否存在文件夹如果不存在则创建为文件夹
if not os.path.exists(path):
# 创建文件夹
os.makedirs(path)
return True
else:
return False
if __name__ == '__main__':
url = "http://sh.yinyuetai.com/uploads/videos/common/8116016A339B08FF3E8825F9ED3A3F2F.mp4?sc=31abf6cff07ce7db"
downloader = Downloader(url=url, file_path=r".\yinyuetai\video\name.mp4")
# 启动下载器下载视频
downloader.start()
五、有待改良
- 下载视频线程数动态开辟未实现(根据文件大小开辟不同数目的线程去下载视频估计会更好,测试时下载100M以内的视频开10个以内线程下载速度很快,但是7、8百M的视频就显得很慢了,于是笔者试了一下开一两百个线程速度立马提升好几倍)
- 线程开辟没有加入线程池管理,有一部分线程没有停止造成资源的浪费,下载过多视频时估计会使程序卡死