Python编程:封装M3U8格式视频下载类

一.前言

m3u8是苹果公司推出的视频播放标准,是m3u的一种,只是编码格式采用的是UTF-8。

m3u8准确来说是一种索引文件,使用m3u8文件实际上是通过它来解析对应的放在服务器上的视频网络地址,从而实现在线播放。使用m3u8格式文件主要因为可以实现多码率视频的适配,视频网站可以根据用户的网络带宽情况,自动为客户端匹配一个合适的码率文件进行播放,从而保证视频的流畅度。

二.正文

根据m3u8格式,可以得到下载思路:解析视频链接得到视频分块链接列表,下载全部分块后按序合并就可以了。

(1)下载m3u8链接的文件,通常会先得到一个引导文件,里面包含了不同清晰度的链接,如下图中只有一个清晰度的链接:

(2)通过该链接可以得到该视频分块的下载地址 ,如下图:

因此可以封装一个纯下载类,不用考虑m3u8格式中的各种标签,获取分块的链接列表即可。m3u8视频分块通常很多,下载时可以选择多线程加快下载速度,下面是封装的下载类:

import hashlib
import os
import threading
from time import sleep, time
import requests


class VideoDownload:
    def __init__(self, url: str, saveName: str) -> None:
        self.url = url
        if(saveName.find(".ts") == -1):
            saveName += ".ts"
        self.saveName = saveName
        self.partUrlList = []
        self.singleMode = True
        self.tmpDir = "tmp/"
        self.saveDir = "download/"

    def md5(self, data):
        """
        信息摘要
        """
        md5 = hashlib.md5()
        md5.update(data.encode())
        return md5.hexdigest()

    def setSingleMode(self, mode: bool):
        """
        设置下载模式
        """
        self.singleMode = mode

    def getTmpPartPath(self, partUrl):
        """
        获取指定视频分块的缓存路径
        """
        return self.tmpDir+self.md5(partUrl)+".ts"

    def getHostUrl(self, url: str):
        """
        获取主页链接路径
        """
        start = url.find("//")+2
        index = url.find("/", start)
        if(index == -1):
            return url
        else:
            return url[0:index]

    def getRelativeUrl(self, url: str):
        """
        获取相对链接路径
        """
        old = -1
        new = url.find("/")
        while(new != -1):
            old = new
            new = url.find("/", old+1)
        return url[0:old+1]

    def checkDirs(self):
        """
        检查目录
        """
        if(not os.path.exists(self.tmpDir)):
            os.makedirs(self.tmpDir)
        if(not os.path.exists(self.saveDir)):
            os.makedirs(self.saveDir)

    def parseProgramUrl(self, url):
        """
        解析视频链接,默认选择最后一个清晰度
        """
        try:
            res = requests.get(url)
            result = None
            if(res.status_code == 200):
                lines = res.text.split("\n")
                for line in lines:
                    if(line.find(".m3u8") == -1):
                        continue
                    if(line[0] == "/"):
                        result = self.getHostUrl(url)+line
                    else:
                        result = self.getRelativeUrl(url)+line
                return result
            else:
                return None
        except:
            return None

    def parsePartList(self, url):
        """
        解析视频分块链接
        """
        try:
            res = requests.get(url)
            result = []
            if(res.status_code == 200):
                lines = res.text.split("\n")
                for line in lines:
                    if(line.find(".ts") > -1):
                        if(line[0] == "/"):
                            result.append(self.getHostUrl(url)+line)
                        else:
                            result.append(self.getRelativeUrl(url)+line)
                return result
            else:
                return None
        except:
            return None

    def isVideoExist(self):
        """
        视频是否已存在
        """
        saveFilePath = self.saveDir+self.saveName
        if(os.path.isfile(saveFilePath)):
            return True
        else:
            return False

    def isVideoPartExist(self, partUrl):
        """
        视频分块是否存在
        """
        partPath = self.getTmpPartPath(partUrl)
        if(os.path.isfile(partPath)):
            return True
        else:
            return False

    def isDownAll(self):
        for part in self.partUrlList:
            partPath = self.getTmpPartPath(part)
            if(not os.path.isfile(partPath)):
                return False
        return True

    def connectVideosParts(self):
        tgFilePath = self.saveDir+self.saveName
        tgFile = open(tgFilePath, mode="bw+")
        current = 1
        total = len(self.partUrlList)
        for partUrl in self.partUrlList:
            partPath = self.getTmpPartPath(partUrl)
            if(os.path.isfile(partPath)):
                print(f"合并中:{current}/{total}")
                current += 1
                partFile = open(partPath, mode="br+")
                tgFile.write(partFile.read())
                partFile.close()
            else:
                print("文件下载不完全,合并停止")
                tgFile.close()
                os.remove(tgFilePath)
                return
        print("合并完成,准备删除缓存文件...")
        self.deleteParts()

    def deleteParts(self):
        for partUrl in self.partUrlList:
            partPath = self.getTmpPartPath(partUrl)
            if(os.path.isfile(partPath)):
                os.remove(partPath)
        print("删除缓存文件完成")

    def down(self, url):
        """
        下载指定分块
        """
        try:
            res = requests.get(url)
            self.saveVideoPart(url, res.content)
        except Exception as e:
            print("分块下载错误:"+str(e))

    def saveVideoPart(self, partUrl, data):
        """
        保存视频分块
        """
        fileName = self.getTmpPartPath(partUrl)
        file = open(file=fileName, mode="bw+")
        file.write(data)
        file.close()

    def downByMulti(self):
        """
        多线程下载模式
        """
        self.threads = []
        self.threadNum = 10
        current = 1
        total = len(self.partUrlList)
        for partUrl in self.partUrlList:
            if(self.isVideoPartExist(partUrl)):
                print(f"下载中:{current}/{total}")
                current += 1
                continue
            isAccept = False
            while(not isAccept):
                if(len(self.threads) < self.threadNum):
                    th = threading.Thread(
                        name=f't{int(time())}', target=self.down, args=(partUrl,))
                    self.threads.append(th)
                    th.start()
                    isAccept = True
                else:
                    for index in range(self.threadNum):
                        if(not self.threads[index].is_alive()):
                            th = threading.Thread(
                                name=f't{int(time())}', target=self.down, args=(partUrl,))
                            self.threads[index] = th
                            th.start()
                            isAccept = True
                            break
                if(isAccept):
                    print(f"下载中:{current}/{total}")
                    current += 1
                sleep(0.05)

    def downBySingle(self):
        """
        单线程下载模式
        """
        current = 1
        total = len(self.partUrlList)
        for partUrl in self.partUrlList:
            print(f"下载中:{current}/{total}")
            current += 1
            if(self.isVideoPartExist(partUrl)):
                continue
            else:
                self.down(partUrl)

    def start(self):
        # 检查目录
        self.checkDirs()
        # 视频是否已下载完成
        if(self.isVideoExist()):
            print("已下载完成")
            return
        # 解析视频分块列表
        programUrl = self.parseProgramUrl(self.url)
        if(not programUrl):
            print("解析视频链接失败,请检查链接是否可用")
            return
        self.partUrlList = self.parsePartList(programUrl)
        # 视频分块是否已下载完全
        if(self.isDownAll()):
            print("分块文件已全部下载,准备合并...")
            self.connectVideosParts()
            return
        print(f"视频分块数:{len(self.partUrlList)}")
        print("即将开始下载...")
        # 选择下载模式
        if(self.singleMode):
            self.downBySingle()
        else:
            self.downByMulti()
        # 多线程模式下等待线程结束再合并
        if(not self.singleMode):
            isEnd = False
            while(not isEnd):
                isEnd = True
                for th in self.threads:
                    if(th.is_alive()):
                        isEnd = False
                        break
                sleep(2)
        # 分块检查、合并
        print("下载结束,准备检查分块...")
        if(self.isDownAll()):
            print("分块文件已全部下载,准备合并...")
            self.connectVideosParts()
        else:
            print("分块未完全下载,请重启执行!")

使用方法也很简单,下载的视频会放在同目录的download文件夹中:

url = "https://wolongzywcdn3.com:65/20220312/hRUCaZ4y/index.m3u8"
saveName = "02集"
videoDownload = VideoDownload(url=url, saveName=saveName)
# 单线程下载(默认)
videoDownload.setSingleMode(False)
# 多线程下载
videoDownload.setSingleMode(True)
# 开始下载
videoDownload.start()

下载后得到.ts视频,可以通过各大视频软件或电脑上自带的播放器来播放,效果如下:

三.结语

对有加密的m3u8视频,根据需要自己完善功能即可。

本教程基于本身需求经验编写,只供参考学习,不足之处还请指正,欢迎伙伴们来一起探讨交流!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

开发大观园

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值