一.前言
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视频,根据需要自己完善功能即可。
本教程基于本身需求经验编写,只供参考学习,不足之处还请指正,欢迎伙伴们来一起探讨交流!