python大文件分段下载器

python大文件分段下载器

本次使用到的技术点:大文件分割、多线程下载同一个文件、队列管理待下载文件片段、os.path模块管理本地文件、requests请求下载视频

一、项目由来

网上很少关于python使用多线程分段下载超清视频、大文本等超大文件的资料,由于多线程适合io密集型和网络请求,所以使用多线程下载大文件能极大的提高下载效率。本次需求产生的原因是朋友在做视频爬取项目,已经提取到了视频下载地址的情况下产生的,由于需要下载大量的视频,使用单线程下载速度极慢,又没有使用scrapy框架,所以本人就想着开辟多线程下载视频,于是写好了一个多线程下载视频的文件,但是由于下载的都是超清视频,下载速度还是不够快,本人就想到了将文件分段下载再合成,使用多线程下载一个文件不就又比单线程下载一个文件快了吗,估计迅雷也是这样实现的吧。

二、下载思路

  1. 获取要下载的视频的大小
  2. 将视频大小分割成N段每次每段的大小为1M(1024*1024)
  3. 将每段加入队列,开启多线程请求获取每段文件保存到本地
  4. 所有的文件片段下载完成将文件片段合成

三、具体实现

本次需要构建两个类

  1. 文件管理类FileManger

文件管理类FileManger类,负责文件的管理(分割文件、保存文件、合成文件片段、创建文件目录、获取本地保存文件片段编号列表等)
文件管理类使用多线程保存文件、使用os.path模块管理文件

  1. 下载器类Downloader

    下载器类主要用于发送网络请求,分段获取视频文件,通过文件管理类将文件片段保存到本地并合成。

四、技术点要点

  1. 文件分割
    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

  1. 文件合成
    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

  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)


  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

  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

  1. 整体流程
    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()


五、有待改良

  1. 下载视频线程数动态开辟未实现(根据文件大小开辟不同数目的线程去下载视频估计会更好,测试时下载100M以内的视频开10个以内线程下载速度很快,但是7、8百M的视频就显得很慢了,于是笔者试了一下开一两百个线程速度立马提升好几倍)
  2. 线程开辟没有加入线程池管理,有一部分线程没有停止造成资源的浪费,下载过多视频时估计会使程序卡死
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值