phlsM3U8-downner 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- phlsm3u8_downner-0.1.0/LICENSE +21 -0
- phlsm3u8_downner-0.1.0/PKG-INFO +90 -0
- phlsm3u8_downner-0.1.0/README.md +66 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/Download_Manage.py +267 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/_Converter.py +59 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/__init__.py +187 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/config.py +38 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/exceptions.py +67 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/main.py +283 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/models.py +96 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/parser.py +88 -0
- phlsm3u8_downner-0.1.0/phlsM3U8_downner/resolver.py +11 -0
- phlsm3u8_downner-0.1.0/pyproject.toml +41 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Error
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: phlsM3U8_downner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async HLS/m3u8 downloader with AES-128 decryption and ffmpeg merging
|
|
5
|
+
Project-URL: Homepage, https://github.com/ap945/phlsM3U8_downner
|
|
6
|
+
Author: Error
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: aes-128,downloader,ffmpeg,hls,m3u8,segment
|
|
10
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Multimedia :: Video
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: aiofiles
|
|
18
|
+
Requires-Dist: aiohttp
|
|
19
|
+
Requires-Dist: ffmpeg-python
|
|
20
|
+
Requires-Dist: pycryptodome
|
|
21
|
+
Requires-Dist: rich
|
|
22
|
+
Requires-Dist: tenacity
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# phlsM3U8_downner
|
|
26
|
+
|
|
27
|
+
Async HLS/m3u8 downloader with AES-128 decryption and ffmpeg merging.
|
|
28
|
+
支持主播放列表自动选择清晰度、切片并发下载、AES-128 解密、ffmpeg 无损合并.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
pip install phlsM3U8_downner
|
|
33
|
+
|
|
34
|
+
Requires **ffmpeg** on system PATH:
|
|
35
|
+
Windows `winget install ffmpeg` · macOS `brew install ffmpeg` · Linux `sudo apt install ffmpeg`
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
from phlsm3u8down import DownAndMerge
|
|
40
|
+
|
|
41
|
+
results = DownAndMerge(UrlAndName={"https://example.com/v.m3u8" : "1.mp4"})
|
|
42
|
+
r = results["1.mp4"]
|
|
43
|
+
print(r.segments_succeeded, "/", r.segments_total)
|
|
44
|
+
print(r.output_path, r.file_size_bytes)
|
|
45
|
+
|
|
46
|
+
## More Usage
|
|
47
|
+
- Config:
|
|
48
|
+
in config_set 它含有很多配置项,见下:
|
|
49
|
+
———————————————— ————————————————————————————
|
|
50
|
+
concurrency: int = 20 # 同时下载几个切片
|
|
51
|
+
limit : int = 20 #最大请求数量
|
|
52
|
+
timeout: float = 30.0 # 单个切片的超时秒数
|
|
53
|
+
retries: int = 3 # 单个切片失败重试次数
|
|
54
|
+
retry_backoff: float = 1.5 # 重试间隔倍数:1s → 1.5s → 2.25s(指数退避)
|
|
55
|
+
stop_after_delay : float | int =15 #设置整个重试过程的最大耗时
|
|
56
|
+
verify_ssl: bool = False # 是否校验 SSL(下载站证书经常有问题,默认关)
|
|
57
|
+
keep_segments: bool = False # 合并成功后是否保留 ts 文件(False=清理)
|
|
58
|
+
breakpoint_request : bool =False #加载上次下载未完成的文件重下(True为重下)
|
|
59
|
+
quiet : bool=False #安静模式
|
|
60
|
+
method : str = 'get' #请求方式
|
|
61
|
+
key_method : str = 'get' #请求方式
|
|
62
|
+
data_path : str|Path = str(Path.cwd() /'hls_downloads') #user_data的位置(存放ts/m4s/mp4媒体文件和data.json)
|
|
63
|
+
Wait_Merge : bool =True #等下载全部成功再合并(false是不等)
|
|
64
|
+
Rich : bool =True #显示进度条(默认是)
|
|
65
|
+
—————————————————— ————————————————————————
|
|
66
|
+
-config_set注意事项:
|
|
67
|
+
-如果你想使用breakpoint_request,这需要data,json中失败的片段(failed_segments/key_errors)不为[],否则什么都不会发生
|
|
68
|
+
-等你下载了一个文件后会自动刷新,需要注意的是,**phlsM3U8_downner会覆盖原有内容**
|
|
69
|
+
-quiet 这个设置如果为True,那么将会少很多打印,甚至连进度条都不会输出
|
|
70
|
+
-key_method method,method是为普通切片(segment)文件请求的模式,而key_method是专门为key请求的模式,在某一些场景中他很有用,你可以使他们全部为**'get'**
|
|
71
|
+
-Wait_Merge 如果你设置了True,那么他不会去管data.json中失败的片段反而会去将成功的片段合并,这可能会使合并后的视频缺斤少两
|
|
72
|
+
|
|
73
|
+
- resolver:
|
|
74
|
+
-defined_decode中 **defined_method**是自定义加密模式但是可能phlsM3U8_downner不支持(目前只支持AES-128加密,但是这够了)
|
|
75
|
+
|
|
76
|
+
-你可以使用**defined_func**参数来存放你的函数**{key_url:函数名称}**,phlsM3U8_downner会用你的函数来解密
|
|
77
|
+
|
|
78
|
+
-如果获取的key有加密,你可以使用encrypto_key**{key_url:函数名称}**来解密你的key
|
|
79
|
+
|
|
80
|
+
-如果phlsM3U8_downner获取不到key,例如DRM中,你可以使用**defined_key**来放你获取到的key(bytes),如果你放了**defined_key**,那请你务必需将**defined_iv**也补充上去**{key_url:函数名称}**,虽然phlsM3U8_downner内置了采用序列号的方式来替换iv但是这是一个保险
|
|
81
|
+
|
|
82
|
+
(resolver.defined_method Method resolver.defined_func func resolver.encrypto_key encrypto_key resolver.defined_iv iv resolver.defined_key key)
|
|
83
|
+
## Notes
|
|
84
|
+
- 不要同时对多个下载使用同一个输出文件名(后者会覆盖前者)
|
|
85
|
+
- 多个主播放列表同时下载时需手动选清晰度,建议逐个下载
|
|
86
|
+
- 配置项见 `config_set.config`(IDE 悬浮即可查看全部字段)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
## 0.1.0 first release
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# phlsM3U8_downner
|
|
2
|
+
|
|
3
|
+
Async HLS/m3u8 downloader with AES-128 decryption and ffmpeg merging.
|
|
4
|
+
支持主播放列表自动选择清晰度、切片并发下载、AES-128 解密、ffmpeg 无损合并.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
pip install phlsM3U8_downner
|
|
9
|
+
|
|
10
|
+
Requires **ffmpeg** on system PATH:
|
|
11
|
+
Windows `winget install ffmpeg` · macOS `brew install ffmpeg` · Linux `sudo apt install ffmpeg`
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
from phlsm3u8down import DownAndMerge
|
|
16
|
+
|
|
17
|
+
results = DownAndMerge(UrlAndName={"https://example.com/v.m3u8" : "1.mp4"})
|
|
18
|
+
r = results["1.mp4"]
|
|
19
|
+
print(r.segments_succeeded, "/", r.segments_total)
|
|
20
|
+
print(r.output_path, r.file_size_bytes)
|
|
21
|
+
|
|
22
|
+
## More Usage
|
|
23
|
+
- Config:
|
|
24
|
+
in config_set 它含有很多配置项,见下:
|
|
25
|
+
———————————————— ————————————————————————————
|
|
26
|
+
concurrency: int = 20 # 同时下载几个切片
|
|
27
|
+
limit : int = 20 #最大请求数量
|
|
28
|
+
timeout: float = 30.0 # 单个切片的超时秒数
|
|
29
|
+
retries: int = 3 # 单个切片失败重试次数
|
|
30
|
+
retry_backoff: float = 1.5 # 重试间隔倍数:1s → 1.5s → 2.25s(指数退避)
|
|
31
|
+
stop_after_delay : float | int =15 #设置整个重试过程的最大耗时
|
|
32
|
+
verify_ssl: bool = False # 是否校验 SSL(下载站证书经常有问题,默认关)
|
|
33
|
+
keep_segments: bool = False # 合并成功后是否保留 ts 文件(False=清理)
|
|
34
|
+
breakpoint_request : bool =False #加载上次下载未完成的文件重下(True为重下)
|
|
35
|
+
quiet : bool=False #安静模式
|
|
36
|
+
method : str = 'get' #请求方式
|
|
37
|
+
key_method : str = 'get' #请求方式
|
|
38
|
+
data_path : str|Path = str(Path.cwd() /'hls_downloads') #user_data的位置(存放ts/m4s/mp4媒体文件和data.json)
|
|
39
|
+
Wait_Merge : bool =True #等下载全部成功再合并(false是不等)
|
|
40
|
+
Rich : bool =True #显示进度条(默认是)
|
|
41
|
+
—————————————————— ————————————————————————
|
|
42
|
+
-config_set注意事项:
|
|
43
|
+
-如果你想使用breakpoint_request,这需要data,json中失败的片段(failed_segments/key_errors)不为[],否则什么都不会发生
|
|
44
|
+
-等你下载了一个文件后会自动刷新,需要注意的是,**phlsM3U8_downner会覆盖原有内容**
|
|
45
|
+
-quiet 这个设置如果为True,那么将会少很多打印,甚至连进度条都不会输出
|
|
46
|
+
-key_method method,method是为普通切片(segment)文件请求的模式,而key_method是专门为key请求的模式,在某一些场景中他很有用,你可以使他们全部为**'get'**
|
|
47
|
+
-Wait_Merge 如果你设置了True,那么他不会去管data.json中失败的片段反而会去将成功的片段合并,这可能会使合并后的视频缺斤少两
|
|
48
|
+
|
|
49
|
+
- resolver:
|
|
50
|
+
-defined_decode中 **defined_method**是自定义加密模式但是可能phlsM3U8_downner不支持(目前只支持AES-128加密,但是这够了)
|
|
51
|
+
|
|
52
|
+
-你可以使用**defined_func**参数来存放你的函数**{key_url:函数名称}**,phlsM3U8_downner会用你的函数来解密
|
|
53
|
+
|
|
54
|
+
-如果获取的key有加密,你可以使用encrypto_key**{key_url:函数名称}**来解密你的key
|
|
55
|
+
|
|
56
|
+
-如果phlsM3U8_downner获取不到key,例如DRM中,你可以使用**defined_key**来放你获取到的key(bytes),如果你放了**defined_key**,那请你务必需将**defined_iv**也补充上去**{key_url:函数名称}**,虽然phlsM3U8_downner内置了采用序列号的方式来替换iv但是这是一个保险
|
|
57
|
+
|
|
58
|
+
(resolver.defined_method Method resolver.defined_func func resolver.encrypto_key encrypto_key resolver.defined_iv iv resolver.defined_key key)
|
|
59
|
+
## Notes
|
|
60
|
+
- 不要同时对多个下载使用同一个输出文件名(后者会覆盖前者)
|
|
61
|
+
- 多个主播放列表同时下载时需手动选清晰度,建议逐个下载
|
|
62
|
+
- 配置项见 `config_set.config`(IDE 悬浮即可查看全部字段)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
## 0.1.0 first release
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional,Any
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
from Cryptodome.Cipher import AES
|
|
5
|
+
from rich.progress import Progress
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from Cryptodome.Util.Padding import unpad
|
|
8
|
+
import asyncio, aiohttp, aiofiles , time, logging
|
|
9
|
+
from tenacity import retry, stop_after_attempt, stop_after_delay,wait_exponential, retry_if_exception_type, AsyncRetrying
|
|
10
|
+
from . import config as config, resolver, exceptions,models
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
"""这个是用来控制经过"main.py"处理后的url进行下载, 合并管理的(主要是控制多线程数量和请求数量, 控制一下子下载多少个m3u8文件)"""
|
|
15
|
+
@dataclass
|
|
16
|
+
class downloader:
|
|
17
|
+
_request : aiohttp.client.ClientSession
|
|
18
|
+
outname : str
|
|
19
|
+
segment_lists : list =field(default_factory=list)
|
|
20
|
+
config : Optional[config.config] =None
|
|
21
|
+
defined_key : Optional[resolver.defined_decode] = None
|
|
22
|
+
headers : dict =None
|
|
23
|
+
json : Any =None
|
|
24
|
+
data : Any =None
|
|
25
|
+
task : int =None
|
|
26
|
+
progress : Progress =field(default_factory=Progress)
|
|
27
|
+
def __post_init__(self):
|
|
28
|
+
if self.defined_key is None:
|
|
29
|
+
self.defined_key=resolver.defined_decode()
|
|
30
|
+
if self.config is None:
|
|
31
|
+
self.config=config.config()
|
|
32
|
+
self.logger=logging.getLogger(__name__)
|
|
33
|
+
self._segment_list=[]
|
|
34
|
+
self.start_time=time.time()
|
|
35
|
+
self.lock=asyncio.Lock()
|
|
36
|
+
self.semaphore = asyncio.Semaphore(self.config.concurrency)
|
|
37
|
+
self.ts_list=[]
|
|
38
|
+
self.path = Path(self.config.data_path)
|
|
39
|
+
self.key_session=getattr(self._request,self.config.key_method.lower())
|
|
40
|
+
self.Session=getattr(self._request,self.config.method.lower())
|
|
41
|
+
self.key_dic={}
|
|
42
|
+
self.map_list=[]
|
|
43
|
+
self.FailedSegments=[]
|
|
44
|
+
self.FailedKey=[]#key下载失败
|
|
45
|
+
self.Bad_key=[]#解密失败
|
|
46
|
+
self.attempts=self.config.retries
|
|
47
|
+
self.retryer = AsyncRetrying(
|
|
48
|
+
stop=(stop_after_attempt(self.attempts) | stop_after_delay(self.config.stop_after_delay)),
|
|
49
|
+
wait=wait_exponential(multiplier=1, exp_base=self.config.retry_backoff, min=1, max=10),
|
|
50
|
+
retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)),
|
|
51
|
+
reraise=True, # 抛原始异常
|
|
52
|
+
before_sleep=self._on_retry, # 重试前的钩子
|
|
53
|
+
)
|
|
54
|
+
def _on_retry(self, retry_state):
|
|
55
|
+
if self.config.quiet:
|
|
56
|
+
return
|
|
57
|
+
exc = retry_state.outcome.exception() if retry_state.outcome else None
|
|
58
|
+
url = retry_state.args[0] if retry_state.args else '?'
|
|
59
|
+
self.progress.console.print(
|
|
60
|
+
f"[yellow]⚠ {str(url)[:80]} 第 {retry_state.attempt_number} 次失败,准备重试: {exc!r}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _down(self, url : str, headers : dict =None)->tuple: #下载内容
|
|
65
|
+
if self.config.method.lower()=='get':
|
|
66
|
+
async with self.Session(url, headers=headers) as response:
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
data = await response.read()
|
|
69
|
+
return data, True
|
|
70
|
+
elif self.config.method.lower()=='post':
|
|
71
|
+
async with self.Session(url, headers=headers, data=self.data, json=self.json) as response:
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
data = await response.read()
|
|
74
|
+
return data,True
|
|
75
|
+
else: raise exceptions.GetMethodError(method=self.config.method.lower(),reason='Wrong Method for getting data')
|
|
76
|
+
|
|
77
|
+
async def key_down(self, url : str, headers : dict =None): #下载内容
|
|
78
|
+
if self.config.key_method.lower()=='get':
|
|
79
|
+
async with self.key_session(url, headers=headers) as response:
|
|
80
|
+
response.raise_for_status()
|
|
81
|
+
data = await response.read()
|
|
82
|
+
return data
|
|
83
|
+
elif self.config.key_method.lower()=='post':
|
|
84
|
+
async with self.key_session(url, headers=headers, data=self.data, json=self.json) as response:
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
data = await response.read()
|
|
87
|
+
return data
|
|
88
|
+
else: raise exceptions.GetMethodError(method=self.config.method.lower(),reason='Wrong Method for getting data')
|
|
89
|
+
|
|
90
|
+
async def _down_with_retry(self, url : str, headers : dict =None)->tuple:
|
|
91
|
+
data, b=await self.retryer(self._down,url,headers)
|
|
92
|
+
return data ,b
|
|
93
|
+
|
|
94
|
+
async def _key_down_with_retry(self, url : str, segment : Optional[models.Segment], headers : dict =None):
|
|
95
|
+
try:
|
|
96
|
+
return await self.retryer(self.key_down,url,headers)
|
|
97
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
98
|
+
FailedKey=models.FailedKey(
|
|
99
|
+
segment=segment, reason='Down Failed',
|
|
100
|
+
attempts=self.attempts, exception=e)
|
|
101
|
+
|
|
102
|
+
self.FailedKey.append(FailedKey)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
async def Writefile(self, data : Any ,file_path : str | Path):
|
|
106
|
+
async with aiofiles.open(file_path ,'w+b') as f:
|
|
107
|
+
await f.write(data)
|
|
108
|
+
|
|
109
|
+
async def async_Writefile(self, file_path : str | Path, response : aiohttp.client_reqrep.ClientResponse):
|
|
110
|
+
async with aiofiles.open(file_path ,'w+b') as f:
|
|
111
|
+
async for chunk in response.content.iter_chunked(1024):
|
|
112
|
+
await f.write(chunk)
|
|
113
|
+
|
|
114
|
+
async def give_DECODE(self, data : bytes, segment : Optional[models.Segment])->bytes:
|
|
115
|
+
ok_data =None
|
|
116
|
+
if segment.key and segment.key.key_uri:
|
|
117
|
+
key_uri=segment.key.key_uri
|
|
118
|
+
else:
|
|
119
|
+
raise exceptions.KEYisNoneError(reason='No Have Key URI') #解密
|
|
120
|
+
if self.defined_key.defined_func:
|
|
121
|
+
if self.defined_key.defined_func.get(key_uri,None):
|
|
122
|
+
ok= self.defined_key.defined_func[key_uri](data, self.defined_key.defined_key.get(key_uri), self.defined_key.defined_iv.get(key_uri))
|
|
123
|
+
if asyncio.iscoroutine(ok):
|
|
124
|
+
ok = await ok
|
|
125
|
+
ok_data = ok
|
|
126
|
+
elif self.defined_key.defined_key.get(key_uri,None): #提前设置好的key
|
|
127
|
+
ok_data = await self.DECODE(data=data, key=self.defined_key.defined_key.get(key_uri), iv=self.defined_key.defined_iv.get(key_uri,None), method=self.defined_key.defined_method, seq=segment.num,key_uri=key_uri)
|
|
128
|
+
else:
|
|
129
|
+
ok_data = await self.DECODE(data, self.key_dic.get(key_uri), segment.key.iv, segment.key.method, segment.num,key_uri=key_uri)
|
|
130
|
+
return ok_data
|
|
131
|
+
|
|
132
|
+
async def DECODE(self, data, key, iv, method, seq : int,key_uri:str)->bytes:
|
|
133
|
+
if key is None:
|
|
134
|
+
raise exceptions.KEYisNoneError( reason='Key Not Cached')
|
|
135
|
+
if iv is None and seq is None:
|
|
136
|
+
raise exceptions.FailedDecodeSegmentError('see FailedSegment', reason='IV Missing')
|
|
137
|
+
if method :
|
|
138
|
+
if method==models.EncryptMethod.AES_128:
|
|
139
|
+
if iv is None and seq is not None:
|
|
140
|
+
iv=seq.to_bytes(16, byteorder='big')
|
|
141
|
+
if len(key)==16 and len(iv)==16:
|
|
142
|
+
cipher = AES.new(key, AES.MODE_CBC,iv)
|
|
143
|
+
try:
|
|
144
|
+
return unpad(cipher.decrypt(data), 16)
|
|
145
|
+
except ValueError as e:raise exceptions.FailedDecodeSegmentError(key_uri, reason=f'bad padding: {e}') from e
|
|
146
|
+
else:raise ValueError('Key Or iv len no 16 bytes')
|
|
147
|
+
else:
|
|
148
|
+
raise exceptions.FailedDecodeSegmentError('see FailedSegment',reason='Decoding Failed')
|
|
149
|
+
else:
|
|
150
|
+
raise exceptions.FailedNoMethodError()
|
|
151
|
+
|
|
152
|
+
async def Add_KEY(self, segment : Optional[models.Segment])->bool:
|
|
153
|
+
async with self.lock:
|
|
154
|
+
if segment.key and segment.key.key_uri:
|
|
155
|
+
key_uri=segment.key.key_uri
|
|
156
|
+
if key_uri not in self.key_dic and not self.defined_key.defined_key.get(key_uri,None):
|
|
157
|
+
key_data=await self._key_down_with_retry(url=key_uri.strip('\"\''), segment=segment, headers=self.headers)
|
|
158
|
+
if key_data is not None and len(key_data) == 16:
|
|
159
|
+
self.key_dic[key_uri]=key_data
|
|
160
|
+
if self.defined_key.encrypto_key.get(key_uri,None):#如有解密方式就解密key(要先有key的内容)
|
|
161
|
+
ok=self.defined_key.encrypto_key[key_uri](self.key_dic[key_uri])
|
|
162
|
+
if asyncio.iscoroutine(ok):
|
|
163
|
+
ok = await ok
|
|
164
|
+
self.key_dic[key_uri] = ok
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
async def Add_MAP(self, filepath, segment : models.Segment)->bool:
|
|
168
|
+
map_data=None
|
|
169
|
+
map_uri=segment.map_uri
|
|
170
|
+
if map_uri and map_uri not in self.map_list:
|
|
171
|
+
file_name = map_uri.rsplit('/',1)[1].rsplit('?',1)[0]
|
|
172
|
+
segment_tmp=[models.MapSegment(after_uri=segment,num=segment.num),file_name]
|
|
173
|
+
if self.config.Wait_Merge:
|
|
174
|
+
self._segment_list.append(segment_tmp)
|
|
175
|
+
|
|
176
|
+
map_data,b= await self._down_with_retry(url=map_uri.lstrip('\"\''),headers=self.headers)
|
|
177
|
+
|
|
178
|
+
#解密
|
|
179
|
+
if b:
|
|
180
|
+
if segment.key and segment.key.key_uri:
|
|
181
|
+
map_data=await self.give_DECODE(data=map_data, segment=segment)
|
|
182
|
+
await self.Writefile(map_data, str(filepath / file_name))
|
|
183
|
+
if segment_tmp not in self._segment_list:
|
|
184
|
+
self._segment_list.append(segment_tmp)
|
|
185
|
+
self.map_list.append(map_uri)
|
|
186
|
+
#self._down_with_retry已经抛过并添加金FailedSegment里了不用判断b
|
|
187
|
+
else:
|
|
188
|
+
return False
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
async def DownAndDecode(self, segment :Optional[models.Segment], file_path : Optional[Path],progress : Progress=None)->bool: #只负责将切片(密钥由Add_KEY负责)下载和解密和写入,其他都不负责
|
|
192
|
+
_uri =segment.uri #正常切片url
|
|
193
|
+
file_name = _uri.rsplit('/',1)[1].rsplit('?',1)[0]
|
|
194
|
+
segment_tmp=[segment, file_name]
|
|
195
|
+
if self.config.Wait_Merge:
|
|
196
|
+
self._segment_list.append(segment_tmp)
|
|
197
|
+
|
|
198
|
+
ok_data, b= await self._down_with_retry(_uri, headers=self.headers) #下载
|
|
199
|
+
|
|
200
|
+
if b: #是否正常获取
|
|
201
|
+
if segment.key and segment.key.key_uri:
|
|
202
|
+
ok_data=await self.give_DECODE(data=ok_data,segment=segment)
|
|
203
|
+
_file_path = str(file_path / file_name ) #写入
|
|
204
|
+
await self.Writefile(ok_data, _file_path)
|
|
205
|
+
progress.update(self.task, advance=1)
|
|
206
|
+
if segment_tmp not in self._segment_list:
|
|
207
|
+
self._segment_list.append(segment_tmp)
|
|
208
|
+
else:
|
|
209
|
+
raise exceptions.Down_BadError(url=segment.uri)
|
|
210
|
+
return True
|
|
211
|
+
|
|
212
|
+
async def downner(self)->dict:
|
|
213
|
+
progress=self.progress
|
|
214
|
+
_file_path=self.path / 'ts_file' / self.outname
|
|
215
|
+
_file_path.mkdir(parents=True,exist_ok=True)
|
|
216
|
+
async def task(segment):
|
|
217
|
+
async with self.semaphore:
|
|
218
|
+
try:
|
|
219
|
+
if segment.key:
|
|
220
|
+
await self.Add_KEY(segment) #记录key
|
|
221
|
+
await self.Add_MAP(segment=segment, filepath=_file_path)
|
|
222
|
+
await self.DownAndDecode(segment, _file_path,progress=progress)
|
|
223
|
+
except exceptions.FailedNoMethodError as e:
|
|
224
|
+
self.Bad_key.append(models.KeyErrorInfo(segment=segment,reason=exceptions.FailedNoMethodError(),exception=str(e)))
|
|
225
|
+
|
|
226
|
+
except exceptions.FailedMethodError as e:
|
|
227
|
+
self.Bad_key.append(models.KeyErrorInfo(segment=segment,reason='The encryption type is not supported right now',exception=str(e)))
|
|
228
|
+
|
|
229
|
+
except exceptions.FailedDecodeSegmentError as e:
|
|
230
|
+
self.Bad_key.append(models.KeyErrorInfo(segment=segment,reason='Decoding Bad',exception=str(e)))
|
|
231
|
+
|
|
232
|
+
except exceptions.KEYisNoneError as e:
|
|
233
|
+
self.FailedSegments.append(models.FailedSegment(segment=segment,reason='Key is None',attempts=self.attempts,exception=str(e)))
|
|
234
|
+
|
|
235
|
+
except exceptions.Down_BadError as e:
|
|
236
|
+
self.Bad_key.append(models.FailedSegment(segment=segment,reason='Bad URI',attempts=self.attempts,exception=str(e)))
|
|
237
|
+
|
|
238
|
+
except exceptions.FailedSegmentError as e:
|
|
239
|
+
self.FailedSegments.append(models.FailedSegment(segment=segment,reason='Down or Decode FailedSegmentError',attempts=self.attempts,exception=str(e)))
|
|
240
|
+
|
|
241
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
242
|
+
self.FailedSegments.append(models.FailedSegment(segment=segment, reason='Down Failed',attempts=self.attempts, exception=str(e)))
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
self.logger.exception(e)
|
|
246
|
+
self.FailedSegments.append(models.FailedSegment(segment=segment, reason='Down Failed',attempts=self.attempts, exception=str(e)))
|
|
247
|
+
|
|
248
|
+
#记录key和下载解密
|
|
249
|
+
|
|
250
|
+
await asyncio.gather(*[task(segment) for segment in self.segment_lists])
|
|
251
|
+
self.ts_list= [fn for seg, fn in sorted(self._segment_list,key=lambda x: (x[0].num, 0 if isinstance(x[0], models.MapSegment) else 1))]
|
|
252
|
+
LIST_PATH = str(_file_path / f'{self.outname}_list.txt')
|
|
253
|
+
async with aiofiles.open(LIST_PATH, 'w', encoding='utf-8') as f:
|
|
254
|
+
for i in self.ts_list:
|
|
255
|
+
a=f'file {i}\n'
|
|
256
|
+
await f.write(a)
|
|
257
|
+
return {'failed_key':self.FailedKey,
|
|
258
|
+
'failed_segments':self.FailedSegments,
|
|
259
|
+
'key_Error':self.Bad_key,
|
|
260
|
+
'segments_total':len(self.segment_lists),
|
|
261
|
+
'OutputPath':_file_path,
|
|
262
|
+
'list_path':LIST_PATH,
|
|
263
|
+
'key_dic':self.key_dic,
|
|
264
|
+
'map_list':self.map_list,
|
|
265
|
+
'semaphore':self.config.concurrency,
|
|
266
|
+
'time':time.time()-self.start_time
|
|
267
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import ffmpeg,asyncio,logging
|
|
4
|
+
from . import exceptions
|
|
5
|
+
|
|
6
|
+
"""这个是用来合并文件的"""
|
|
7
|
+
@dataclass
|
|
8
|
+
class converter:
|
|
9
|
+
def __post_init__(self):
|
|
10
|
+
self.logger = logging.getLogger(__name__)
|
|
11
|
+
LIST_TXT_PATH : str
|
|
12
|
+
OUT_NAME : str
|
|
13
|
+
quiet : bool =False
|
|
14
|
+
keep_segments : bool =False #是否清理分片默认否
|
|
15
|
+
async def converter(self):
|
|
16
|
+
self.logger.debug('Get ready Converter -> %s',self.OUT_NAME)
|
|
17
|
+
self.list_path = Path(self.LIST_TXT_PATH)
|
|
18
|
+
self.out_path=self.list_path.parent / self.OUT_NAME
|
|
19
|
+
self._list_path=self.list_path.read_text()[5:]
|
|
20
|
+
try:
|
|
21
|
+
stream = (
|
|
22
|
+
ffmpeg.input(str(self.list_path), format='concat', safe=0)
|
|
23
|
+
.output(str(self.out_path), c='copy').global_args('-nostdin', '-nostats', '-hide_banner', '-loglevel', 'error')
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
self.logger.info('Start Converter -> %s',self.OUT_NAME)
|
|
27
|
+
self.logger.debug('ffpeg Order: %s',' '.join(stream.compile()))
|
|
28
|
+
process = stream.run_async(
|
|
29
|
+
pipe_stdin=True, pipe_stdout=True, pipe_stderr=True,
|
|
30
|
+
overwrite_output=True, # 无条件 -y
|
|
31
|
+
)
|
|
32
|
+
_, stderr = await asyncio.to_thread(process.communicate)
|
|
33
|
+
|
|
34
|
+
if process.returncode != 0:
|
|
35
|
+
raise exceptions.FailedConverterError(
|
|
36
|
+
out_path=str(self.out_path),
|
|
37
|
+
reason=(stderr or b'').decode('utf-8', 'ignore') or 'no stderr captured',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
delete_list = [
|
|
41
|
+
line.strip().split(' ', 1)[1].strip().strip('\'"')
|
|
42
|
+
for line in self.list_path.read_text(encoding='utf-8').splitlines()
|
|
43
|
+
if line.strip()
|
|
44
|
+
]
|
|
45
|
+
self.logger.info('Converter Success %s Start delete [ts/m4s/mp4...]file',self.OUT_NAME)
|
|
46
|
+
if not self.keep_segments:
|
|
47
|
+
parent = self.list_path.parent
|
|
48
|
+
for f in delete_list:
|
|
49
|
+
(parent / f).unlink(missing_ok=True)
|
|
50
|
+
self.list_path.unlink()
|
|
51
|
+
self.logger.info('Converter Success %s',self.OUT_NAME)
|
|
52
|
+
|
|
53
|
+
return (self.out_path.stat().st_size)
|
|
54
|
+
except ffmpeg.Error as e:
|
|
55
|
+
self.logger.error('ffmpeg: %s', e)
|
|
56
|
+
raise exceptions.FailedConverterError(str(self.out_path),(e.stderr.decode('utf-8', 'ignore') if getattr(e, 'stderr', None) else str(e)))
|
|
57
|
+
|
|
58
|
+
# finally:
|
|
59
|
+
|