├── .gitignore ├── LICENSE ├── README.md ├── lib ├── ffmpeg └── ffmpeg.exe └── m3u8_downloader.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # m3u8_downloader 2 | m3u8(HLS流)下载,实现了AES解密、合并、多线程、批量下载 3 | 4 | # 1、开车姿势 5 | ## 1.1、导入源码中依赖的库(Python3) 6 | beautifulsoup4、m3u8、pycryptodome、requests、threadpool 7 | ## 1.2、m3u8_input.txt文件格式 8 |   本下载器支持批量下载,m3u8连接需要放在一个txt文本文件(utf-8编码)中,格式如下: 9 | ```python 10 | 视频名称1|https://www.aaaa.com/bbbb/cccc/index.m3u8 11 | 视频名称2|https://www.xxxx.com/yyyy/zzzz/index.m3u8 12 | 视频名称3|https://www.uuuu.com/vvvv/wwww/index.m3u8 13 | ... 14 | ``` 15 | ## 1.3、根据实际情况修改下载配置 16 | ```python 17 | ###############################配置信息################################ 18 | # m3u8链接批量输入文件(必须是utf-8编码) 19 | m3u8InputFilePath = "D:/input/m3u8_input.txt" 20 | # 设置视频保存路径 21 | saveRootDirPath = "D:/output" 22 | # 下载出错的m3u8保存文件 23 | errorM3u8InfoDirPath = "D:/output/error.txt" 24 | # m3u8文件、key文件下载尝试次数,ts流默认无限次尝试下载,直到成功 25 | m3u8TryCountConf = 10 26 | # 线程数(同时下载的分片数) 27 | processCountConf = 50 28 | ###################################################################### 29 | ``` 30 | # 2、车速展示 31 | ![image](https://user-images.githubusercontent.com/44233477/95989627-1743d180-0e5d-11eb-981a-ab2917ee9263.png) 32 | ![image](https://user-images.githubusercontent.com/44233477/95989823-570ab900-0e5d-11eb-81bf-9c9c2d984496.png) 33 | ![image](https://user-images.githubusercontent.com/44233477/95989904-71449700-0e5d-11eb-946f-280839da3b47.png) 34 | # 3、开车规范 35 | ## 3.1、注意身体!注意身体!注意身体! 36 | ## 3.2、以上源码仅作为Python技术学习、交流之用,切勿用于其他任何可能造成违法场景,否则后果自负! 37 | -------------------------------------------------------------------------------- /lib/ffmpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hestyle/m3u8_downloader/0d0625b474eede8680634239474a3afdfe1f412c/lib/ffmpeg -------------------------------------------------------------------------------- /lib/ffmpeg.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hestyle/m3u8_downloader/0d0625b474eede8680634239474a3afdfe1f412c/lib/ffmpeg.exe -------------------------------------------------------------------------------- /m3u8_downloader.py: -------------------------------------------------------------------------------- 1 | # UTF-8 2 | # author hestyle 3 | # desc 必须在终端直接执行,不能在pycharm等IDE中直接执行,否则看不到动态进度条效果 4 | 5 | import os 6 | import sys 7 | import m3u8 8 | import time 9 | import requests 10 | import traceback 11 | import threadpool 12 | from urllib.parse import urlparse 13 | from Crypto.Cipher import AES 14 | 15 | headers = { 16 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", 17 | "Connection": "Keep-Alive", 18 | "Accept-Encoding": "gzip, deflate, br", 19 | "Accept-Language": "zh-CN,zh;q=0.9", 20 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.102 Safari/537.36" 21 | } 22 | 23 | ###############################配置信息################################ 24 | # m3u8链接批量输入文件(必须是utf-8编码) 25 | m3u8InputFilePath = "D:/input/m3u8_input.txt" 26 | # 设置视频保存路径 27 | saveRootDirPath = "D:/output" 28 | # 下载出错的m3u8保存文件 29 | errorM3u8InfoDirPath = "D:/output/error.txt" 30 | # m3u8文件、key文件下载尝试次数,ts流默认无限次尝试下载,直到成功 31 | m3u8TryCountConf = 10 32 | # 线程数(同时下载的分片数) 33 | processCountConf = 50 34 | ###################################################################### 35 | 36 | 37 | # 全局变量 38 | # 全局线程池 39 | taskThreadPool = None 40 | # 当前下载的m3u8 url 41 | m3u8Url = None 42 | # url前缀 43 | rootUrlPath = None 44 | # title 45 | title = None 46 | # ts count 47 | sumCount = 0 48 | # 已处理的ts 49 | doneCount = 0 50 | # cache path 51 | cachePath = saveRootDirPath + "/cache" 52 | # log path 53 | logPath = cachePath + "/log.log" 54 | # log file 55 | logFile = None 56 | # download bytes(0.5/1 s) 57 | downloadedBytes = 0 58 | # download speed 59 | downloadSpeed = 0 60 | 61 | # 1、下载m3u8文件 62 | def getM3u8Info(): 63 | global m3u8Url 64 | global logFile 65 | global rootUrlPath 66 | tryCount = m3u8TryCountConf 67 | while True: 68 | if tryCount < 0: 69 | print("\t{0}下载失败!".format(m3u8Url)) 70 | logFile.write("\t{0}下载失败!".format(m3u8Url)) 71 | return None 72 | tryCount = tryCount - 1 73 | try: 74 | response = requests.get(m3u8Url, headers=headers, timeout=20) 75 | if response.status_code == 301: 76 | nowM3u8Url = response.headers["location"] 77 | print("\t{0}重定向至{1}!".format(m3u8Url, nowM3u8Url)) 78 | logFile.write("\t{0}重定向至{1}!\n".format(m3u8Url, nowM3u8Url)) 79 | m3u8Url = nowM3u8Url 80 | rootUrlPath = m3u8Url[0:m3u8Url.rindex('/')] 81 | continue 82 | 83 | contentLength = response.headers.get('Content-Length') 84 | if contentLength: 85 | expected_length = int(contentLength) 86 | actual_length = len(response.content) 87 | if expected_length > actual_length: 88 | raise Exception("m3u8下载不完整") 89 | 90 | print("\t{0}下载成功!".format(m3u8Url)) 91 | logFile.write("\t{0}下载成功!".format(m3u8Url)) 92 | rootUrlPath = m3u8Url[0:m3u8Url.rindex('/')] 93 | break 94 | except: 95 | print("\t{0}下载失败!正在重试".format(m3u8Url)) 96 | logFile.write("\t{0}下载失败!正在重试".format(m3u8Url)) 97 | # 解析m3u8中的内容 98 | m3u8Info = m3u8.loads(response.text) 99 | # 有可能m3u8Url是一个多级码流 100 | if m3u8Info.is_variant: 101 | print("\t{0}为多级码流!".format(m3u8Url)) 102 | logFile.write("\t{0}为多级码流!".format(m3u8Url)) 103 | for rowData in response.text.split('\n'): 104 | # 寻找响应内容的中的m3u8 105 | if rowData.endswith(".m3u8"): 106 | scheme = urlparse(m3u8Url).scheme 107 | netloc = urlparse(m3u8Url).netloc 108 | m3u8Url = scheme + "://" + netloc + rowData 109 | rootUrlPath = m3u8Url[0:m3u8Url.rindex('/')] 110 | 111 | return getM3u8Info() 112 | # 遍历未找到就返回None 113 | print("\t{0}响应未寻找到m3u8!".format(response.text)) 114 | logFile.write("\t{0}响应未寻找到m3u8!".format(response.text)) 115 | return None 116 | else: 117 | return m3u8Info 118 | 119 | # 2、下载key文件 120 | def getKey(keyUrl): 121 | global logFile 122 | tryCount = m3u8TryCountConf 123 | while True: 124 | if tryCount < 0: 125 | print("\t{0}下载失败!".format(keyUrl)) 126 | logFile.write("\t{0}下载失败!".format(keyUrl)) 127 | return None 128 | tryCount = tryCount - 1 129 | try: 130 | response = requests.get(keyUrl, headers=headers, timeout=20, allow_redirects=True) 131 | if response.status_code == 301: 132 | nowKeyUrl = response.headers["location"] 133 | print("\t{0}重定向至{1}!".format(keyUrl, nowKeyUrl)) 134 | logFile.write("\t{0}重定向至{1}!\n".format(keyUrl, nowKeyUrl)) 135 | keyUrl = nowKeyUrl 136 | continue 137 | expected_length = int(response.headers.get('Content-Length')) 138 | actual_length = len(response.content) 139 | if expected_length > actual_length: 140 | raise Exception("key下载不完整") 141 | print("\t{0}下载成功!key = {1}".format(keyUrl, response.content.decode("utf-8"))) 142 | logFile.write("\t{0}下载成功! key = {1}".format(keyUrl, response.content.decode("utf-8"))) 143 | break 144 | except : 145 | print("\t{0}下载失败!".format(keyUrl)) 146 | logFile.write("\t{0}下载失败!".format(keyUrl)) 147 | return response.text 148 | 149 | # 3、多线程下载ts流 150 | def mutliDownloadTs(playlist): 151 | global logFile 152 | global sumCount 153 | global doneCount 154 | global taskThreadPool 155 | global downloadedBytes 156 | global downloadSpeed 157 | taskList = [] 158 | # 每个ts单独作为一个task 159 | for index in range(len(playlist)): 160 | dict = {"playlist": playlist, "index": index} 161 | taskList.append((None, dict)) 162 | # 重新设置ts数量,已下载的ts数量 163 | doneCount = 0 164 | sumCount = len(taskList) 165 | printProcessBar(sumCount, doneCount, 50) 166 | # 构造thread pool 167 | requests = threadpool.makeRequests(downloadTs, taskList) 168 | [taskThreadPool.putRequest(req) for req in requests] 169 | # 等待所有任务处理完成 170 | while doneCount < sumCount: 171 | # 统计1秒钟下载的byte 172 | beforeDownloadedBytes = downloadedBytes 173 | time.sleep(1) 174 | downloadSpeed = downloadedBytes - beforeDownloadedBytes 175 | # 计算网速后打印一次 176 | printProcessBar(sumCount, doneCount, 50, True) 177 | print("") 178 | return True 179 | 180 | # 4、下载单个ts playlists[index] 181 | def downloadTs(playlist, index): 182 | global logFile 183 | global sumCount 184 | global doneCount 185 | global cachePath 186 | global rootUrlPath 187 | global downloadedBytes 188 | succeed = False 189 | while not succeed: 190 | # 文件名格式为 "00000001.ts",index不足8位补充0 191 | outputPath = cachePath + "/" + "{0:0>8}.ts".format(index) 192 | outputFp = open(outputPath, "wb+") 193 | if playlist[index].startswith("http"): 194 | tsUrl = playlist[index] 195 | else: 196 | tsUrl = rootUrlPath + "/" + playlist[index] 197 | try: 198 | response = requests.get(tsUrl, timeout=5, headers=headers, stream=True) 199 | if response.status_code == 200: 200 | expected_length = int(response.headers.get('Content-Length')) 201 | actual_length = len(response.content) 202 | # 累计下载的bytes 203 | downloadedBytes += actual_length 204 | if expected_length > actual_length: 205 | raise Exception("分片下载不完整") 206 | outputFp.write(response.content) 207 | doneCount += 1 208 | printProcessBar(sumCount, doneCount, 50, isPrintDownloadSpeed=True) 209 | logFile.write("\t分片{0:0>8} url = {1} 下载成功!".format(index, tsUrl)) 210 | succeed = True 211 | except Exception as exception: 212 | logFile.write("\t分片{0:0>8} url = {1} 下载失败!正在重试...msg = {2}".format(index, tsUrl, exception)) 213 | outputFp.close() 214 | 215 | # 5、合并ts 216 | def mergeTs(tsFileDir, outputFilePath, cryptor, count): 217 | global logFile 218 | outputFp = open(outputFilePath, "wb+") 219 | for index in range(count): 220 | printProcessBar(count, index + 1, 50) 221 | logFile.write("\t{0}\n".format(index)) 222 | inputFilePath = tsFileDir + "/" + "{0:0>8}.ts".format(index) 223 | if not os.path.exists(outputFilePath): 224 | print("\n分片{0:0>8}.ts, 不存在,已跳过!".format(index)) 225 | logFile.write("分片{0:0>8}.ts, 不存在,已跳过!\n".format(index)) 226 | continue 227 | inputFp = open(inputFilePath, "rb") 228 | fileData = inputFp.read() 229 | try: 230 | if cryptor is None: 231 | outputFp.write(fileData) 232 | else: 233 | outputFp.write(cryptor.decrypt(fileData)) 234 | except Exception as exception: 235 | inputFp.close() 236 | outputFp.close() 237 | print(exception) 238 | return False 239 | inputFp.close() 240 | print("") 241 | outputFp.close() 242 | return True 243 | 244 | # 6、删除ts文件 245 | def removeTsDir(tsFileDir): 246 | # 先清空文件夹 247 | for root, dirs, files in os.walk(tsFileDir, topdown=False): 248 | for name in files: 249 | os.remove(os.path.join(root, name)) 250 | for name in dirs: 251 | os.rmdir(os.path.join(root, name)) 252 | os.rmdir(tsFileDir) 253 | return True 254 | 255 | # 7、convert to mp4(调用了FFmpeg,将合并好的视频内容放置到一个mp4容器中) 256 | def ffmpegConvertToMp4(inputFilePath, ouputFilePath): 257 | global logFile 258 | if not os.path.exists(inputFilePath): 259 | print(inputFilePath + " 路径不存在!") 260 | logFile.write(inputFilePath + " 路径不存在!\n") 261 | return False 262 | cmd = r'.\lib\ffmpeg -i "{0}" -vcodec copy -acodec copy "{1}"'.format(inputFilePath, ouputFilePath) 263 | if sys.platform == "darwin": 264 | cmd = r'./lib/ffmpeg -i "{0}" -vcodec copy -acodec copy "{1}"'.format(inputFilePath, ouputFilePath) 265 | if os.system(cmd) == 0: 266 | print(inputFilePath + "转换成功!") 267 | logFile.write(inputFilePath + "转换成功!\n") 268 | return True 269 | else: 270 | print(inputFilePath + "转换失败!") 271 | logFile.write(inputFilePath + "转换失败!\n") 272 | return False 273 | 274 | # 8、模拟输出进度条(默认不打印网速) 275 | def printProcessBar(sumCount, doneCount, width, isPrintDownloadSpeed=False): 276 | global downloadSpeed 277 | precent = doneCount / sumCount 278 | useCount = int(precent * width) 279 | spaceCount = int(width - useCount) 280 | precent = precent*100 281 | if isPrintDownloadSpeed: 282 | # downloadSpeed的单位是B/s, 超过1024*1024转换为MiB/s, 超过1024转换为KiB/s 283 | if downloadSpeed > 1048576: 284 | print('\r\t{0}/{1} {2}{3} {4:.2f}% {5:>7.2f}MiB/s'.format(sumCount, doneCount, useCount * '■', spaceCount * '□', precent, downloadSpeed / 1048576), 285 | file=sys.stdout, flush=True, end='') 286 | elif downloadSpeed > 1024: 287 | print('\r\t{0}/{1} {2}{3} {4:.2f}% {5:>7.2f}KiB/s'.format(sumCount, doneCount, useCount * '■', spaceCount * '□', precent, downloadSpeed / 1024), 288 | file=sys.stdout, flush=True, end='') 289 | else: 290 | print('\r\t{0}/{1} {2}{3} {4:.2f}% {5:>7.2f}B/s '.format(sumCount, doneCount, useCount * '■', spaceCount * '□', precent, downloadSpeed), 291 | file=sys.stdout, flush=True, end='') 292 | else: 293 | print('\r\t{0}/{1} {2}{3} {4:.2f}%'.format(sumCount, doneCount, useCount*'■', spaceCount*'□', precent), file=sys.stdout, flush=True, end='') 294 | 295 | # m3u8下载器 296 | def m3u8VideoDownloader(): 297 | global title 298 | global logFile 299 | global m3u8Url 300 | global cachePath 301 | global downloadedBytes 302 | global downloadSpeed 303 | # 1、下载m3u8 304 | print("\t1、开始下载m3u8...") 305 | logFile.write("\t1、开始下载m3u8...\n") 306 | m3u8Info = getM3u8Info() 307 | if m3u8Info is None: 308 | return False 309 | tsList = [] 310 | for playlist in m3u8Info.segments: 311 | tsList.append(playlist.uri) 312 | # 2、获取key 313 | keyText = "" 314 | cryptor = None 315 | # 判断是否加密 316 | if (len(m3u8Info.keys) != 0) and (m3u8Info.keys[0] is not None): 317 | # 默认选择第一个key,且AES-128算法 318 | key = m3u8Info.keys[0] 319 | if key.method != "AES-128": 320 | print("\t{0}不支持的解密方式!".format(key.method)) 321 | logFile.write("\t{0}不支持的解密方式!\n".format(key.method)) 322 | return False 323 | # 如果key的url是相对路径,加上m3u8Url的路径 324 | keyUrl = key.uri 325 | if not keyUrl.startswith("http"): 326 | keyUrl = m3u8Url.replace("index.m3u8", keyUrl) 327 | print("\t2、开始下载key...") 328 | logFile.write("\t2、开始下载key...\n") 329 | keyText = getKey(keyUrl) 330 | if keyText is None: 331 | return False 332 | # 判断是否有偏移量 333 | if key.iv is not None: 334 | cryptor = AES.new(bytes(keyText, encoding='utf8'), AES.MODE_CBC, bytes(key.iv, encoding='utf8')) 335 | else: 336 | cryptor = AES.new(bytes(keyText, encoding='utf8'), AES.MODE_CBC, bytes(keyText, encoding='utf8')) 337 | # 3、下载ts 338 | print("\t3、开始下载ts...") 339 | logFile.write("\t3、开始下载ts...\n") 340 | # 清空bytes计数器 341 | downloadSpeed = 0 342 | downloadedBytes = 0 343 | if mutliDownloadTs(tsList): 344 | logFile.write("\tts下载完成---------------------\n") 345 | # 4、合并ts 346 | print("\t4、开始合并ts...") 347 | logFile.write("\t4、开始合并ts...\n") 348 | if mergeTs(cachePath, cachePath + "/cache.flv", cryptor, len(tsList)): 349 | logFile.write("\tts合并完成---------------------\n") 350 | else: 351 | print(keyText) 352 | print("\tts合并失败!") 353 | logFile.write("\tts合并失败!\n") 354 | return False 355 | # 5、开始转换成mp4 356 | print("\t5、开始mp4转换...") 357 | logFile.write("\t5、开始mp4转换...\n") 358 | if not ffmpegConvertToMp4(cachePath + "/cache.flv", saveRootDirPath + "/" + title + ".mp4"): 359 | return False 360 | return True 361 | 362 | 363 | if __name__ == '__main__': 364 | # 判断m3u8文件是否存在 365 | if not (os.path.exists(m3u8InputFilePath)): 366 | print("{0}文件不存在!".format(m3u8InputFilePath)) 367 | exit(0) 368 | # 如果输出目录不存在就创建 369 | if not (os.path.exists(saveRootDirPath)): 370 | os.mkdir(saveRootDirPath) 371 | 372 | # 如果记录错误文件不存在就创建 373 | if not (os.path.exists(errorM3u8InfoDirPath)): 374 | open(errorM3u8InfoDirPath, 'w+') 375 | 376 | m3u8InputFp = open(m3u8InputFilePath, "r", encoding="utf-8") 377 | # 设置error的m3u8 url输出 378 | errorM3u8InfoFp = open(errorM3u8InfoDirPath, "a+", encoding="utf-8") 379 | # 设置log file 380 | if not os.path.exists(cachePath): 381 | os.makedirs(cachePath) 382 | logFile = open(logPath, "w+", encoding="utf-8") 383 | # 初始化线程池 384 | taskThreadPool = threadpool.ThreadPool(processCountConf) 385 | while True: 386 | rowData = m3u8InputFp.readline() 387 | rowData = rowData.strip('\n') 388 | if rowData == "": 389 | break 390 | m3u8Info = rowData.split('|') 391 | title = m3u8Info[0] 392 | m3u8Url = m3u8Info[1] 393 | 394 | # title中去除 \ / : * ? " < > |字符,Windows系统中文件命名不能包含这些字符 395 | title = title.replace('\\', ' ', sys.maxsize) 396 | title = title.replace('/', ' ', sys.maxsize) 397 | title = title.replace(':', ' ', sys.maxsize) 398 | title = title.replace('*', ' ', sys.maxsize) 399 | title = title.replace('?', ' ', sys.maxsize) 400 | title = title.replace('"', ' ', sys.maxsize) 401 | title = title.replace('<', ' ', sys.maxsize) 402 | title = title.replace('>', ' ', sys.maxsize) 403 | title = title.replace('|', ' ', sys.maxsize) 404 | 405 | try: 406 | print("{0} 开始下载:".format(m3u8Info[0])) 407 | logFile.write("{0} 开始下载:\n".format(m3u8Info[0])) 408 | if m3u8VideoDownloader(): 409 | # 成功下载完一个m3u8则清空logFile 410 | logFile.seek(0) 411 | logFile.truncate() 412 | print("{0} 下载成功!".format(m3u8Info[0])) 413 | else: 414 | errorM3u8InfoFp.write(title + "," + m3u8Url + '\n') 415 | errorM3u8InfoFp.flush() 416 | print("{0} 下载失败!".format(m3u8Info[0])) 417 | logFile.write("{0} 下载失败!\n".format(m3u8Info[0])) 418 | except Exception as exception: 419 | print(exception) 420 | traceback.print_exc() 421 | # 关闭文件 422 | logFile.close() 423 | m3u8InputFp.close() 424 | errorM3u8InfoFp.close() 425 | print("----------------下载结束------------------") --------------------------------------------------------------------------------