Python M3U8文件解析

# coding: UTF-8
# FileName: m3u8.py
# Time: 06/02/2024 12:29
# Author: Amundsen Severus Rubeus Bjaaland


# 导入标准库
import re
import os
import json
import copy
import shutil
from threading import Thread
from urllib.parse import urljoin
from typing import List, Union, Callable, Iterator
from concurrent.futures import ThreadPoolExecutor, wait


# 导入第三方库
import requests
from Crypto.Cipher import AES
from Crypto.Util.number import bytes_to_long, long_to_bytes


# 定义常量
URL_PATH_PATTERN = re.compile(r'^(/.*?)*?(/[^"]*/|/[^"]*)$')
PATH_DATA_DIR = os.path.join(os.getcwd(), "data")
PATH_CACHE_DIR = os.path.join(PATH_DATA_DIR, "cache")
PATH_TS_VIDEOS_DIR = os.path.join(PATH_DATA_DIR, "ts_videos")
class DownloadMode(object):
	ONE_THREAD = ("One_Thread", 1)
	MULTITHREAD = ("multithread", 2)

# 定义异常
class AnalyzeError(Exception): pass
class M3U8AnalyzeError(AnalyzeError): pass
class M3UAnalyzeError(AnalyzeError): pass
class NetworkError(Exception): pass


# 创建运行时必要的文件夹
try: os.mkdir("./data")
except OSError: pass
try: os.mkdir("./data/ts_videos")
except OSError: pass
try: os.mkdir("./data/cache")
except OSError: pass


class M3U8(object):
	# M3U8文件的元信息Tag的正则表达式,用于提取文件元信息
	__INFO_TAG_PATTERN = re.compile(r"^#EXT-X-(.*?):(.*)$")

	class TsTag(object):
		def __init__(self, index: int, name: str, source: str, length: float):
			"""M3U8文件中视频片段信息
			:param index: 视频文件的序号
			:param name: 视频文件的名称
			:param source: 视频文件的网址
			:param length: 视频文件的时长
			"""
			self.index = index
			self.name = name
			self.source = source
			self.length = length
		
		def __repr__(self) -> str:
			return f"<TsTag index={self.index} time={self.length} name={self.name[:10]}...>"
	
	def __init__(self, content: str, source: str, name:str, info: dict = {}):
		"""M3U8文件解析
		:param content: 原始文件内容
		:param source: 文件来源,获取视频片段地址时使用
		:param name: 视频名称,保存文件时使用
		:param info: 视频除文件内已有信息的额外信息
		"""
		# 处理并存储所有参数
		self.__content: str = content.rstrip("\n")
		self.__source: str = source
		self.__name: str = name.replace("\\", "").replace("/", "")
		self.__info: dict = copy.deepcopy(info)  # 防止类变量与外部变量共用同一地址
		# 其它要用到的参数
		self.__key: bytes = b""  # 视频片段密码,用于AES解密
		self.__ts_tag_list: List[self.TsTag] = []  # 视频片段信息地址
		# 解析文件
		self.__analyze()
	
	def __str__(self) -> str: return self.__content

	def __repr__(self) -> str: return f"<M3U8 name={self.__name[:10]}>"

	@property
	def source(self) -> str: return self.__source

	@property
	def name(self) -> str: return self.__name

	@property
	def info(self) -> dict: return copy.deepcopy(self.__info)

	@property
	def ts_tags(self) -> Iterator[TsTag]:
		for i in self.__ts_tag_list: yield copy.deepcopy(i)

	@staticmethod
	def _log(ts_info: TsTag, flag: bool, part_number: int) -> None:
		"""指示下载情况的回调函数,该函数应支持多线程
		:param ts_info: 视频片段的信息
		:param flag: 下载成功与否
		:param part_number: 视频片段的总数
		"""
		print(
			f"视频({ts_info.name})的第{ts_info.index + 1}个片段(共{part_number}个片段)"
			f"下载{'成功' if flag else '失败'}, 时长为{ts_info.length}秒.\n",
			end=""
		)

	def __analyze_ht(self) -> None:
		"""判断文件开头结尾是否符合标准"""
		if not self.__content.startswith("#EXTM3U"):
			raise M3U8AnalyzeError("文件开头不为\"#EXTM3U\"")
		if not self.__content.endswith("#EXT-X-ENDLIST"):
			raise M3U8AnalyzeError("文件结尾不为\"#EXT-X-ENDLIST\"")
	
	def __analyze_key(self, match_result: re.Match) -> None:
		"""解析文件加密相关信息
		:param match_result: 信息名为KEY的信息的匹配结果
		"""
		# 获取信息的字符串
		text = match_result.group(2)
		# 将信息字符串转换为Json格式
		text = text.replace(" ", "").replace('"', '').replace("=", '":"').replace(",", '","')
		text = "{\"" + text + "\"}"
		# 将信息字符串解析为Json(Dict)
		info = json.loads(text)
		# 获取加密中的偏移相关信息
		iv = info["IV"]
		# # 将iv按照UTF-8编码,然后转换为int
		iv = bytes_to_long(iv.encode())
		# # 将int转换为bytes,并截取前16位(去除0x以后共32位)后作为iv信息真实值
		info["IV"] = long_to_bytes(iv)[2:18]
		# 将所有key相关信息保存
		self.__info[match_result.group(1)] = info
		# 获取文件的key
		url = urljoin(self.__source, info["URI"])
		try: self.__key = requests.get(url).content
		except Exception: raise NetworkError("获取文件的key失败")			
	
	def __analyze(self) -> None:
		"""解析M3U8文件"""
		# 判断文件开头结尾是否符合标准
		self.__analyze_ht()
		# 成功判断以后将文件按行分割,并去除开头和结尾的一行
		data = self.__content.split("\n")[1:-1]
		# 设置计数器,用于判断文件信息占据了文件多少行
		counter = 0
		# 获取文件的信息
		for one_line in data:
			# 获取文件信息
			one_info = self.__INFO_TAG_PATTERN.match(one_line)
			# 匹配失败说明信息部分已获取完毕,可以跳出循环
			if not one_info: break
			# 判断信息是否为文件的加密相关信息,
			# 若是,则需特殊处理,否则直接添加信息到类变量存储
			if one_info.group(1) == "KEY": self.__analyze_key(one_info)
			else: self.__info[one_info.group(1)] = one_info.group(2)
			#计数器加一
			counter += 1
		# 截取剩余部分,即视频片段相关信息
		data = data[counter:]
		# 判断视频片段信息是否成对存在
		if len(data) % 2 != 0: raise M3U8AnalyzeError("视频片段信息不成对存在")
		# 按步长为2遍历数据list
		for index, one_part in enumerate([data[i:i + 2] for i in range(0, len(data), 2)]):
			# 生成视频片段的地址并保存
			video_source = urljoin(self.__source, one_part[1])
			self.__ts_tag_list.append(
				self.TsTag(
					index, self.__name, video_source, float(one_part[0].split(":")[1].rstrip(","))
				)
			)
		# 将所有的信息名称转换为小写
		self.__info = {key.lower(): item for key, item in self.__info.items()}
	
	def __get_fragment(
			self, ts_info: TsTag, callback: Callable[[TsTag, bool, int], None] = _log
		) -> None:
		"""获取视频片段
		:param ts_info: 视频片段的信息
		:param callback: 指示下载情况的回调函数, 需要视频片段信息, 指示下载成功与否的布尔值和0视频片段总数
		"""
		# 合成视频片段的保存地址
		cache_path = f"{ts_info.name}\\{str(ts_info.index).rjust(10, '0')}.ts"
		cache_path = os.path.join(PATH_CACHE_DIR, cache_path)
		#尝试获取视频片段
		try: content = requests.get(ts_info.source).content
		except Exception: callback(ts_info, False, len(self.__ts_tag_list))
		else:
			# 如果视频片段有加密则解密后再保存
			if self.__info.get("key"):
				content = AES.new(
					self.__key, AES.MODE_CBC, self.__info["key"]["IV"]
				).decrypt(content)
			with open(cache_path, "wb") as ts_file: ts_file.write(content)
			callback(ts_info, True, len(self.__ts_tag_list))
	
	def __collect(self) -> None:
		"""将获取的视频片段合成为一个视频"""
		# 获取缓存的视频片段
		file_list = os.listdir(os.path.join(PATH_CACHE_DIR, self.name))
		# 将文件按index排序
		file_list = [(i, int(i.split(".")[0])) for i in file_list]
		file_list.sort(key=lambda x: x[1])
		file_list = [i[0] for  i in file_list]
		# 创建文件保存路径
		final_file_path = os.path.join(PATH_TS_VIDEOS_DIR, f"{self.__name}.ts")
		# 将片段合成为一个ts文件
		with open(final_file_path, "wb") as final_file:
			for one_file in file_list:
				ts_file_path = os.path.join(PATH_CACHE_DIR, f"{self.__name}\\{one_file}")
				with open(ts_file_path, "rb") as ts_file:
					final_file.write(ts_file.read())
		# 移除所有缓存文件
		try: shutil.rmtree(os.path.join(PATH_CACHE_DIR, self.__name))
		except OSError: pass
	
	def download(
			self, download_mode: tuple = DownloadMode.MULTITHREAD, 
			callback: Callable[[str, bool, str, int, int, str], None] = _log
		) -> None:
		"""下载视频片段
		:param download_mode: 使用的下载方式
		:param callback: 指示下载情况的回调函数
		"""
		try: os.mkdir(os.path.join(PATH_CACHE_DIR, self.__name))
		except OSError: pass
		# 匹配下载时使用的模式,未匹配到则不下载
		match download_mode:
			case DownloadMode.ONE_THREAD:
				# 逐个片段进行下载
				for one_ts in self.__ts_tag_list: self.__get_fragment(one_ts, callback)
			case DownloadMode.MULTITHREAD:
				# 启动一个线程池
				with ThreadPoolExecutor(
					thread_name_prefix=f"《{self.name[:10]} . . .》片段下载线程"
				) as pool:
					# 提交下载至线程池
					download_thread_list = [
						pool.submit(self.__get_fragment, i, callback)
						for i in self.__ts_tag_list
					]
					# 等待下载完成
					wait(download_thread_list)
			case _: return
		# 将片段合成为一个文件
		self.__collect()


class M3U8Manager(object):
	class M3U8State(object):
		def __init__(self, m3u8: M3U8, download_mode: tuple = DownloadMode.MULTITHREAD):
			"""M3U8文件的相关状态
			:param m3u8: M3U8文件信息
			:param download_mode: 使用的下载方式
			"""
			self.m3u8: M3U8 = m3u8
			self.download_mode: tuple = download_mode
			self.downloaded: bool = False  # 指示程序该次运行时是否下载过
		
		def __repr__(self) -> str: 
			return f"<M3U8State downloaded={self.downloaded} name={self.m3u8.name[:10]}...>"

	def __init__(self) -> None:
		"""管理M3U8文件"""
		self.__m3u8_list: List[self.M3U8State] = []
		self.__thread_list: List[Thread] = []

	@property
	def m3u8_list(self) -> Iterator[M3U8State]:
		for i in self.__m3u8_list: yield copy.deepcopy(i)

	@staticmethod
	def get_m3u8(source: str, name: str) -> Union[M3U8, None]:
		"""从网络上获取m3u8文件内容
		:param source: m3u8文件所在URL
		:param name: 视频名称
		"""
		try: return M3U8(requests.get(source).text, source, name)
		except Exception: return
	
	def append(self, m3u8: M3U8, download_mode: tuple = DownloadMode.MULTITHREAD) -> None:
		"""将一个m3u8文件纳入管理
		:param m3u8: m3u8文件信息
		:param download_mode: 使用的下载方式
		"""
		self.__m3u8_list.append(self.M3U8State(m3u8, download_mode))

	def download(self, index: int) -> None:
		"""下载指定的m3u8文件
		:param index: 在该m3u8管理器中文件的下标
		"""
		# 防止越界访问
		if index >= len(self.m3u8_list): return
		# 获取指定的文件并按照指定的下载方式下载
		one_m3u8_info: self.M3U8State = self.__m3u8_list[index]
		one_m3u8_info.m3u8.download(one_m3u8_info.download_mode)

	def download_all(self, downloaded: bool = False) -> None:
		"""下载所有的m3u8文件
		:param downloaded: 是否下载已经下载过的文件
		"""
		for one_m3u8_info in self.__m3u8_list:
			# 若不下载已经下载过的文件且该文件已经被下载过则跳过该文件
			if (not downloaded) and one_m3u8_info.downloaded: continue
			# 初始化并启动下载线程
			download_thread = Thread(
				target=one_m3u8_info.m3u8.download, args=(one_m3u8_info.download_mode, ),
				name = f"《{one_m3u8_info.m3u8.name[:10]} . . .》下载线程"
			)
			download_thread.start()
			# 将下载线程加入下载线程列表
			self.__thread_list.append(download_thread)
			# 将该m3u8文件标记为已下载
			one_m3u8_info.downloaded = True
	
	def join(self):
		"""等待所有的下载线程结束执行"""
		for i in self.__thread_list: i.join()
		self.__thread_list.clear()

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值