├── requirements.txt ├── .gitignore ├── LICENSE ├── README.md ├── upload_apis.py ├── app.py └── core.py /requirements.txt: -------------------------------------------------------------------------------- 1 | loguru>=0.7.2 2 | pycryptodome>=3.19.1 3 | requests>=2.31.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | env/ 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # 项目特定 24 | input/ 25 | output/ 26 | Urloutput/ 27 | *.ts 28 | *.m3u8 29 | *.mp4 30 | *.png 31 | *.json 32 | temp 33 | .env 34 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 heilo.cn 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # M3U8 to Image hosting 2 | 3 | 修改自[M3U8-Uploader](https://github.com/239144498/M3U8-Uploader) 4 | 5 | 一个用于处理M3U8视频伪装成图片上传到图床的Python工具。支持本地视频切片和远程M3U8下载上传。 6 | 7 | 自备可以跨域的视频床就能使用网页播放。 8 | 9 | 内置的端口都不支持跨域!!! 10 | 11 | ## Demo 12 | 13 | 自行本地播放器播放,图床有防盗链,不能网页播放 14 | 本地切片:https://tupian.us.kg/m3u8/6.m3u8 15 | 16 | 远程切片:https://tupian.us.kg/m3u8/66.m3u8 17 | 远程原视频:https://v.cdnlz17.com/20231115/48113_b6b7b01f/index.m3u8 18 | 远程原视频就有一点点音频错位,这个是正常的 19 | 20 | ## 功能特点 21 | 22 | - 支持本地视频切片并上传 23 | - 支持远程M3U8下载并上传 24 | - 支持多个图床接口 25 | - 支持断点续传 26 | - 支持命令行和交互式操作 27 | 28 | ## 环境要求 29 | 30 | - Python 3.7+ 31 | - FFmpeg 32 | 33 | ## 依赖安装 34 | ```bash 35 | pip install -r requirements.txt 36 | ``` 37 | ## 使用方法 38 | 39 | ### 交互式模式(推荐) 40 | 41 | 直接运行程序: 42 | ```bash 43 | python app.py 44 | ``` 45 | 46 | 按照提示进行操作: 47 | 1. 选择是否测试接口 48 | 2. 选择操作模式(本地切片/远程上传) 49 | 3. 根据选择的模式进行相应操作 50 | 51 | ### 命令行模式 52 | 53 | 1. 本地视频切片: 54 | ```bash 55 | python app.py -L -new -u 1 56 | ``` 57 | 2. 远程M3U8下载: 58 | ```bash 59 | python app.py -R -url "your_m3u8_url" -new -u 1 60 | ``` 61 | 62 | 参数说明: 63 | - `-L/--local`: 本地上传模式 64 | - `-R/--remote`: 远程上传模式 65 | - `-new/--new_upload`: 新上传(清空已有文件) 66 | - `-N/--no_verify`: 不验证接口 67 | - `-u/--upload_api`: 指定上传接口(1-3) 68 | - `-url/--m3u8_url`: M3U8链接(远程模式必需) 69 | 70 | 71 | ## 注意事项 72 | 73 | 1. 使用前请确保已安装FFmpeg并添加到系统环境变量 74 | 2. 本地视频请放在input目录下 75 | 3. 上传接口可能会有限制,请注意文件大小 76 | 4. 建议先测试接口可用性再使用 77 | 78 | ## License 79 | 80 | MIT License @ heilo.cn 81 | 82 | ## 鸣谢 83 | 84 | [M3U8-Uploader](https://github.com/239144498/M3U8-Uploader) 85 | 86 | ## 免责声明 87 | 88 | 本工具仅供学习交流使用,请勿用于非法用途。使用本工具所产生的一切后果由使用者自行承担。 89 | -------------------------------------------------------------------------------- /upload_apis.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | import requests 4 | from loguru import logger 5 | from base64 import b64decode 6 | 7 | # PNG文件头的base64编码 8 | prefix = b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=".encode()) 9 | 10 | # 禁用SSL警告 11 | requests.packages.urllib3.disable_warnings() 12 | 13 | USER_AGENTS = [ 14 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", 15 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", 16 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", 17 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Edge/120.0.0.0", 18 | ] 19 | 20 | def upload1(filename, fdata): 21 | file = prefix + fdata # 添加PNG文件头 22 | url = "https://pic.2xb.cn/uppic.php?type=qq" 23 | headers = { 24 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", 25 | "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2", 26 | "Accept-Encoding": "gzip, deflate, br", 27 | "Connection": "keep-alive", 28 | } 29 | data = {"file": (f"{int(time.time())}.png", file, "image/png")} 30 | for i in range(3): 31 | try: 32 | with requests.post(url=url, headers=headers, files=data, verify=False) as resp: 33 | data = resp.json() 34 | return data["url"] 35 | except Exception as e: 36 | logger.warning(f"上传TS请求出错 {e}") 37 | time.sleep(2) 38 | 39 | raise Exception(f"{filename} TS 上传失败") 40 | 41 | def upload2(filename, fdata): 42 | url = "https://api.vviptuangou.com/api/upload" 43 | headers = { 44 | 'Accept': 'application/json, text/javascript, */*; q=0.01', 45 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7', 46 | 'Sign': 'e346dedcb06bace9cd7ccc6688dd7ca1', 47 | 'Token': 'b3bc3a220db6317d4a08284c6119d136', 48 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' 49 | } 50 | file = prefix + fdata # size < 50MB 51 | data = {"file": (f"{int(time.time())}.png", file, "image/png")} 52 | for i in range(3): 53 | try: 54 | with requests.post(url=url, headers=headers, files=data, verify=False) as resp: 55 | data = resp.json() 56 | return f"https://assets.vviptuangou.com/{data['imgurl']}" 57 | except Exception as e: 58 | logger.warning(f"上传TS请求出错 {e}") 59 | time.sleep(2) 60 | raise Exception(f"{filename} TS 上传失败") 61 | 62 | def upload3(filename, fdata): 63 | url = "https://api.da8m.cn/api/upload" 64 | headers = { 65 | 'Accept': 'application/json, text/javascript, */*; q=0.01', 66 | 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7', 67 | 'Sign': 'e346dedcb06bace9cd7ccc6688dd7ca1', 68 | 'Token': '4ca04a3ff8ca3b8f0f8cfa01899ddf8e', 69 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' 70 | } 71 | file = prefix + fdata # size < 50MB 72 | data = {"file": (f"{int(time.time())}.png", file, "image/png")} 73 | for i in range(3): 74 | try: 75 | with requests.post(url=url, headers=headers, files=data, verify=False) as resp: 76 | data = resp.json() 77 | return f"https://assets.da8m.cn/{data['imgurl']}" 78 | except Exception as e: 79 | logger.warning(f"上传TS请求出错 {e}") 80 | time.sleep(2) 81 | raise Exception(f"{filename} TS 上传失败") 82 | 83 | 84 | # 导出所有上传接口 85 | UPLOAD_APIS = [upload1, upload2, upload3] -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import argparse 4 | from loguru import logger 5 | from core import local_slice_and_upload, remote_upload, Down 6 | from upload_apis import UPLOAD_APIS 7 | 8 | # 添加测试文件检查 9 | def ensure_test_file(): 10 | test_file = "1.png" 11 | if not os.path.exists(test_file): 12 | # 如果测试文件不存在,创建一个小的测试文件 13 | with open(test_file, "wb") as f: 14 | f.write(b"test") 15 | 16 | def test_upload_interface(upload_func): 17 | ensure_test_file() # 确保测试文件存在 18 | try: 19 | with open("1.png", "rb") as file: 20 | fdata = file.read() 21 | url = upload_func("1.png", fdata) 22 | logger.info(f"{upload_func.__name__} 接口可用") 23 | return True 24 | except Exception as e: 25 | logger.warning(f"{upload_func.__name__} 接口不可用: {e}") 26 | return False 27 | 28 | def parse_args(): 29 | parser = argparse.ArgumentParser(description='M3U8视频处理工具') 30 | 31 | # 基础参数 32 | parser.add_argument('-L', '--local', action='store_true', help='本地上传模式') 33 | parser.add_argument('-R', '--remote', action='store_true', help='远程上传模式') 34 | parser.add_argument('-new', '--new_upload', action='store_true', help='新上传(清空已有文件)') 35 | parser.add_argument('-N', '--no_verify', action='store_true', help='不验证接口') 36 | parser.add_argument('-u', '--upload_api', type=int, choices=[1,2,3,4], help='指定上传接口(1-4)') 37 | parser.add_argument('-url', '--m3u8_url', type=str, help='M3U8链接(远程模式必需)') 38 | 39 | if len(sys.argv) > 1: 40 | args = parser.parse_args() 41 | if not (args.local or args.remote): 42 | parser.error('必须指定模式: -L(本地上传) 或 -R(远程上传)') 43 | if args.local and args.remote: 44 | parser.error('不能同时指定本地和远程模式') 45 | if args.remote and not args.m3u8_url: 46 | parser.error('远程模式必须提供M3U8链接(-url)') 47 | return args 48 | return None 49 | 50 | def main(): 51 | args = parse_args() 52 | 53 | if args: # 命令行模式 54 | if args.local: 55 | local_slice_and_upload(args) 56 | elif args.remote: 57 | remote_upload(args) 58 | else: # 交互式模式 59 | logger.info("开始运行") 60 | 61 | # 1. 首先询问是否测试接口 62 | test_interfaces = input("是否测试接口 (Y/N): ").strip().upper() 63 | if test_interfaces == 'Y': 64 | available_uploads = [] 65 | for upload_func in UPLOAD_APIS: 66 | if test_upload_interface(upload_func): 67 | available_uploads.append(upload_func) 68 | if not available_uploads: 69 | logger.error("所有接口都不可用") 70 | return 71 | 72 | # 2. 选择模式 73 | mode = input("请选择模式 (1: 本地切片, 2: 远程上传): ").strip() 74 | 75 | if mode == '1': # 本地切片模式 76 | # 3a. 选择新切还是续传 77 | is_new = input("是否新切片 (Y/N): ").strip().upper() 78 | args = argparse.Namespace() 79 | args.new_upload = (is_new == 'Y') 80 | 81 | # 4a. 选择上传接口 82 | logger.info("可用接口: " + ", ".join([f"{i+1}. {func.__name__}" for i, func in enumerate(UPLOAD_APIS)])) 83 | selected_index = int(input("请选择一个接口 (序号): ").strip()) - 1 84 | if selected_index < 0 or selected_index >= len(UPLOAD_APIS): 85 | logger.error("选择的接口序号无效") 86 | return 87 | args.upload_api = selected_index + 1 88 | 89 | # 5a. 执行本地切片 90 | local_slice_and_upload(args) 91 | 92 | elif mode == '2': # 远程上传模式 93 | # 3b. 输入m3u8链接 94 | m3u8_url = input("请输入m3u8链接: ").strip() 95 | if not m3u8_url: 96 | logger.error("m3u8链接不能为空") 97 | return 98 | 99 | # 4b. 选择新下载还是续传 100 | is_new = input("是否新下载 (Y/N): ").strip().upper() 101 | args = argparse.Namespace() 102 | args.new_upload = (is_new == 'Y') 103 | args.m3u8_url = m3u8_url 104 | args.remote = True # 修改这里,使用remote替代network 105 | 106 | # 5b. 选择上传接口 107 | logger.info("可用接口: " + ", ".join([f"{i+1}. {func.__name__}" for i, func in enumerate(UPLOAD_APIS)])) 108 | selected_index = int(input("请选择一个接口 (序号): ").strip()) - 1 109 | if selected_index < 0 or selected_index >= len(UPLOAD_APIS): 110 | logger.error("选择的接口序号无效") 111 | return 112 | args.upload_api = selected_index + 1 113 | 114 | # 6b. 执行远程上传 115 | remote_upload(args) 116 | 117 | else: 118 | logger.error("无效的选择") 119 | 120 | if __name__ == '__main__': 121 | main() 122 | 123 | -------------------------------------------------------------------------------- /core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import json 4 | import time 5 | import shutil 6 | import requests 7 | import threading 8 | import subprocess 9 | import math 10 | from loguru import logger 11 | from Crypto.Cipher import AES 12 | from Crypto.Util.Padding import unpad 13 | from urllib.parse import urljoin 14 | from concurrent.futures import ThreadPoolExecutor 15 | import random 16 | 17 | from upload_apis import UPLOAD_APIS, prefix 18 | 19 | requests.packages.urllib3.disable_warnings() 20 | 21 | def test_upload_interface(upload_func): 22 | try: 23 | with open("1.png", "rb") as file: 24 | fdata = file.read() 25 | url = upload_func("1.png", fdata) 26 | logger.info(f"{upload_func.__name__} 接口可用") 27 | return True 28 | except Exception as e: 29 | logger.warning(f"{upload_func.__name__} 接口不可用: {e}") 30 | return False 31 | 32 | def request_get(url, headers, session=requests): 33 | for i in range(3): 34 | try: 35 | with session.get(url=url, headers=headers) as resp: 36 | resp.raise_for_status() 37 | content = resp.content 38 | return content 39 | except Exception as e: 40 | logger.warning(f"Get出错 {url} 报错内容 {e}") 41 | time.sleep(2) 42 | 43 | raise Exception(f"{url} 请求失败") 44 | 45 | class Down: 46 | def __init__(self, filename=None, m3u8link=None): 47 | self.session = requests.session() 48 | self.vinfo = { 49 | "filename": filename, 50 | "m3u8link": m3u8link, 51 | "key": b"", 52 | "iv": b"", 53 | "ts": [], 54 | } 55 | self.upload_s3 = None 56 | self.failed_uploads = [] 57 | self.lock = threading.Lock() 58 | 59 | def load_m3u8(self, url=None): 60 | m3u8link = url or self.vinfo["m3u8link"] 61 | self.vinfo["m3u8link"] = m3u8link 62 | headers = { 63 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", 64 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", 65 | "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2", 66 | "Accept-Encoding": "gzip, deflate, br", 67 | } 68 | logger.info(f'M3U8 Downloading {m3u8link}') 69 | content = request_get(m3u8link, headers, self.session).decode() 70 | 71 | # 检查是否需要重定向 72 | if '#EXT-X-STREAM-INF' in content: 73 | logger.info('检测到多码率m3u8,正在解析真实地址...') 74 | # 解析所有可码率 75 | available_streams = [] 76 | lines = content.split('\n') 77 | for i, line in enumerate(lines): 78 | if line.startswith('#EXT-X-STREAM-INF:'): 79 | stream_info = line 80 | stream_url = lines[i + 1] if i + 1 < len(lines) else None 81 | if stream_url and not stream_url.startswith('#'): 82 | # 解析码率信息 83 | bandwidth = re.search(r'BANDWIDTH=(\d+)', stream_info) 84 | resolution = re.search(r'RESOLUTION=(\d+x\d+)', stream_info) 85 | bandwidth = int(bandwidth.group(1)) if bandwidth else 0 86 | resolution = resolution.group(1) if resolution else 'unknown' 87 | available_streams.append({ 88 | 'bandwidth': bandwidth, 89 | 'resolution': resolution, 90 | 'url': stream_url.strip() 91 | }) 92 | 93 | if not available_streams: 94 | raise Exception('未找到可用的媒体流') 95 | 96 | # 选择码率最高的流 97 | selected_stream = max(available_streams, key=lambda x: x['bandwidth']) 98 | logger.info(f'选择码率: {selected_stream["bandwidth"]}, 分辨率: {selected_stream["resolution"]}') 99 | 100 | # 构建新的m3u8地址 101 | if selected_stream['url'].startswith('http'): 102 | real_m3u8_url = selected_stream['url'] 103 | else: 104 | # 使用urljoin处理相对路径 105 | real_m3u8_url = urljoin(m3u8link, selected_stream['url']) 106 | 107 | logger.info(f'重定向到真实m3u8: {real_m3u8_url}') 108 | # 递归调用加载真实的m3u8 109 | return self.load_m3u8(real_m3u8_url) 110 | 111 | # 处理实际的m3u8内容 112 | if not self.vinfo["filename"]: 113 | self.vinfo["filename"] = os.path.basename(m3u8link).split("?")[0].split(".m3u8")[0] 114 | _content = content.split("\n").__iter__() 115 | while True: 116 | try: 117 | _ = _content.__next__() 118 | if "#EXTINF" in _: 119 | while True: 120 | _2 = _content.__next__() 121 | if not _2 or _2.startswith("#"): 122 | continue 123 | else: 124 | self.vinfo["ts"].append(urljoin(m3u8link, _2)) 125 | break 126 | except StopIteration: 127 | break 128 | del _content 129 | 130 | # 处理加密相关信息 131 | keyurl = (re.findall(r"URI=\"(.*)\"", content) or [''])[0] 132 | if keyurl: 133 | iv = bytes.fromhex((re.findall(r"IV=(.*)", content) or ['12'])[0][2:]) 134 | self.vinfo["iv"] = iv or b'\x00' * 16 135 | logger.info(f'IV {iv}') 136 | keyurl = keyurl if keyurl.startswith("http") else urljoin(m3u8link, keyurl) 137 | logger.info(f'KEY Downloading {keyurl}') 138 | self.vinfo["key"] = request_get(keyurl, dict(headers, **{"Host": keyurl.split("/")[2]}), self.session) 139 | 140 | # 保存文件 141 | if not os.path.exists(self.vinfo['filename']): 142 | os.makedirs(self.vinfo['filename'], exist_ok=True) 143 | logger.info("保存raw.m3u8到本地") 144 | with open(f'{self.vinfo["filename"]}/raw.m3u8', "w") as fp: 145 | fp.write(content) 146 | logger.info("保存meta.json到本地") 147 | with open(f'{self.vinfo["filename"]}/meta.json', "w") as fp: 148 | fp.write(json.dumps(dict(self.vinfo, **{ 149 | "key": self.vinfo["key"].hex(), 150 | "iv": self.vinfo["iv"].hex() 151 | }))) 152 | 153 | def load_ts(self, index, handle, local_files=None): 154 | max_retries = 3 155 | for retry in range(max_retries): 156 | try: 157 | if local_files: 158 | ts_file = local_files[int(index)] 159 | with open(ts_file, "rb") as file: 160 | content = file.read() 161 | else: 162 | ts_url = self.vinfo["ts"][int(index)] 163 | if not ts_url.startswith('http'): 164 | ts_url = urljoin(self.vinfo["m3u8link"], ts_url) 165 | headers = { 166 | "Host": ts_url.split("/")[2], 167 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", 168 | } 169 | logger.info(f'TS{index} Downloading') 170 | content = request_get(ts_url, headers, self.session) 171 | 172 | filesize = len(content) 173 | s3_ts_url = random.choice(self.upload_s3)(f"{index}.ts", content) 174 | with self.lock: 175 | handle.write(f"{index}@@{filesize}@@{s3_ts_url}\n") 176 | logger.info(f'TS{index} Saving to URL') 177 | return index 178 | except Exception as e: 179 | if retry < max_retries - 1: 180 | logger.warning(f"TS{index} 上传失败,将在 {(retry+1)*2} 秒后重试: {e}") 181 | time.sleep((retry+1)*2) 182 | else: 183 | raise 184 | 185 | def retry_failed_uploads(self, handle): 186 | if not self.failed_uploads: 187 | return 188 | 189 | logger.info(f"开始重试 {len(self.failed_uploads)} 个失败的上传") 190 | retry_list = self.failed_uploads.copy() 191 | self.failed_uploads.clear() 192 | 193 | for index, decrypted_ts, filesize in retry_list: 194 | max_retries = 3 195 | upload_func = self.upload_s3[0] 196 | 197 | for retry in range(max_retries): 198 | try: 199 | s3_ts_url = upload_func(f"{index}.ts", decrypted_ts) 200 | with self.lock: 201 | handle.write(f"{index}@@{filesize}@@{s3_ts_url}\n") 202 | handle.flush() 203 | logger.info(f'TS{index} 重试上传成功') 204 | break 205 | except Exception as e: 206 | retry_sleep = 5 * (retry + 1) 207 | if retry < max_retries - 1: 208 | logger.warning(f'TS{index} 重试上传失败,{retry_sleep}秒后重试 ({retry + 1}/{max_retries}): {str(e)}') 209 | time.sleep(retry_sleep) 210 | else: 211 | logger.error(f'TS{index} 重试上传失败: {str(e)}') 212 | with self.lock: 213 | self.failed_uploads.append((index, decrypted_ts, filesize)) 214 | 215 | def verify_m3u8_content(self, content): 216 | """验证m3u8文件内容是否符合格式要求""" 217 | lines = content.split('\n') 218 | if not lines[0].strip() == '#EXTM3U': 219 | logger.error("M3U8文件必须以#EXTM3U开头") 220 | return False 221 | if not lines[1].strip() == '#EXT-X-VERSION:4': 222 | logger.error("M3U8文件版本必须为4") 223 | return False 224 | return True 225 | 226 | def save_m3u8(self): 227 | try: 228 | # 读取原始m3u8文件获取头部信息 229 | with open(f'{self.vinfo["filename"]}/raw.m3u8', "r") as fp: 230 | m3u8_text = fp.read() 231 | 232 | # 提取所有头部标签(以#开头的行,直到第一个非头部内容) 233 | headers = [] 234 | for line in m3u8_text.split('\n'): 235 | if line.startswith('#') and not line.startswith('#EXTINF'): 236 | headers.append(line) 237 | elif not line.startswith('#'): 238 | break 239 | 240 | # 读取上传后的ts文件信息并排序 241 | try: 242 | with open(f'{self.vinfo["filename"]}/temp', "r") as fp: 243 | content = fp.read().strip("\n") 244 | if content: # 如果temp文件不为空 245 | data = [] 246 | for line in content.split("\n"): 247 | index, filesize, url = line.split("@@") 248 | data.append((int(index), filesize, url)) 249 | data.sort(key=lambda x: x[0]) 250 | else: 251 | data = [] # temp文件为空时使用空列表 252 | except FileNotFoundError: 253 | logger.warning("temp文件不存在,将只保存m3u8头部信息") 254 | data = [] 255 | 256 | # 提取EXTINF信息 257 | extinf = re.findall(r"#EXTINF:.*,", m3u8_text) 258 | 259 | # 构建新的m3u8内容,使用原始头部 260 | m3u8_list = headers.copy() 261 | 262 | # 按照排序后的顺序添加分片信息 263 | if data: # 如果有上传的文件信息 264 | for i, (_, _, url) in enumerate(data): 265 | if i < len(extinf): # 确保不会超出extinf的范围 266 | m3u8_list.append(extinf[i]) 267 | m3u8_list.append(url) 268 | m3u8_list.append("#EXT-X-ENDLIST") 269 | 270 | # 写入新的m3u8文件 271 | content = "\n".join(m3u8_list) 272 | with open(f'{self.vinfo["filename"]}/new_raw.m3u8', "w", encoding='utf-8') as fp: 273 | fp.write(content) 274 | 275 | except Exception as e: 276 | logger.error(f"保存m3u8文件时出错: {e}") 277 | raise 278 | 279 | def local_slice_and_upload(args=None): 280 | input_folder = "input" 281 | output_folder = "output" 282 | 283 | # 清空output文件夹 284 | if os.path.exists(output_folder): 285 | shutil.rmtree(output_folder) 286 | os.makedirs(output_folder) 287 | 288 | if not os.path.exists(input_folder): 289 | logger.error(f"输入文件夹 {input_folder} 不存在") 290 | return 291 | 292 | for filename in os.listdir(input_folder): 293 | if filename.endswith(".mp4"): 294 | input_file = os.path.join(input_folder, filename) 295 | output_file = os.path.join(output_folder, "playlist.m3u8") 296 | segment_file = os.path.join(output_folder, "%05d.ts") 297 | 298 | # 本地切片模式,使用完整的转码参数 299 | cmd = [ 300 | 'ffmpeg', 301 | '-re', 302 | '-i', input_file, 303 | '-codec:v', 'libx264', 304 | '-codec:a', 'aac', 305 | '-s', '1280x720', 306 | '-map', '0', 307 | '-f', 'hls', 308 | '-hls_time', '5', 309 | '-hls_list_size', '0', 310 | '-hls_segment_filename', segment_file, 311 | output_file 312 | ] 313 | 314 | try: 315 | subprocess.run(cmd, check=True) 316 | logger.info(f"成功切割视频: {filename}") 317 | except subprocess.CalledProcessError as e: 318 | logger.error(f"切割视频失败 {filename}: {e}") 319 | continue 320 | 321 | # 获取所有ts文件并排序 322 | local_files = [os.path.join(output_folder, f) for f in os.listdir(output_folder) if f.endswith(".ts")] 323 | local_files.sort() 324 | 325 | # 读取原始m3u8文件 326 | with open(os.path.join(output_folder, "playlist.m3u8"), 'r') as f: 327 | original_m3u8 = f.read() 328 | 329 | # 复制原始m3u8为raw.m3u8 330 | with open(f'{output_folder}/raw.m3u8', 'w', encoding='utf-8') as f: 331 | f.write(original_m3u8) 332 | 333 | down = Down(filename=output_folder) 334 | down.upload_s3 = UPLOAD_APIS 335 | 336 | if args and args.upload_api: 337 | down.upload_s3 = [UPLOAD_APIS[args.upload_api - 1]] 338 | elif args and args.no_verify: 339 | logger.info("可用接口: " + ", ".join([f"{i+1}. {func.__name__}" for i, func in enumerate(UPLOAD_APIS)])) 340 | selected_index = int(input("请选择一个接口 (序号): ").strip()) - 1 341 | if selected_index < 0 or selected_index >= len(UPLOAD_APIS): 342 | logger.error("选择的接口序号无效") 343 | return 344 | down.upload_s3 = [UPLOAD_APIS[selected_index]] 345 | else: 346 | available_uploads = [] 347 | for upload_func in UPLOAD_APIS: 348 | if test_upload_interface(upload_func): 349 | available_uploads.append(upload_func) 350 | 351 | if not available_uploads: 352 | logger.error("所有接口都不可用") 353 | return 354 | 355 | logger.info("可用接口: " + ", ".join([f"{i+1}. {func.__name__}" for i, func in enumerate(available_uploads)])) 356 | selected_index = int(input("请选择一个可用接口 (序号): ").strip()) - 1 357 | if selected_index < 0 or selected_index >= len(available_uploads): 358 | logger.error("选择的接口序号无效") 359 | return 360 | down.upload_s3 = [available_uploads[selected_index]] 361 | 362 | workers = 10 363 | with open(f'{output_folder}/temp', "a", encoding='utf-8') as handle: 364 | with ThreadPoolExecutor(max_workers=workers) as executor: 365 | futures = {executor.submit(down.load_ts, f"{index:04}", handle, local_files): index 366 | for index in range(len(local_files))} 367 | for future in futures: 368 | try: 369 | future.result() 370 | except Exception: 371 | continue 372 | 373 | if down.failed_uploads: 374 | logger.info("等待30秒后开始重试失败的上传...") 375 | time.sleep(30) 376 | down.retry_failed_uploads(handle) 377 | 378 | while down.failed_uploads: 379 | failed_indexes = [str(item[0]) for item in down.failed_uploads] 380 | logger.error(f"以下分片上传失败: {', '.join(failed_indexes)}") 381 | retry = input("是否要重试失败的上传? (Y/N): ").strip().upper() 382 | if retry == 'Y': 383 | logger.info("等待10秒后开始重试...") 384 | time.sleep(10) 385 | down.retry_failed_uploads(handle) 386 | else: 387 | break 388 | 389 | logger.info(f"{output_folder} 载完成") 390 | down.save_m3u8() 391 | print("任务完成") 392 | 393 | def remote_upload(args): 394 | # 修改输出文件夹名称为Urloutput 395 | base_dir = "Urloutput" 396 | if not os.path.exists(base_dir): 397 | os.makedirs(base_dir) 398 | 399 | if args.new_upload and os.path.exists(base_dir): 400 | shutil.rmtree(base_dir) 401 | os.makedirs(base_dir) 402 | 403 | if os.path.exists(os.path.join(base_dir, "temp")) and not args.new_upload: 404 | # 如果是续传,使用已有的文件 405 | down = Down(filename=base_dir) 406 | down.vinfo["m3u8link"] = args.m3u8_url 407 | with open(os.path.join(base_dir, "meta.json"), "r") as f: 408 | meta = json.load(f) 409 | down.vinfo.update(meta) 410 | down.vinfo["key"] = bytes.fromhex(meta["key"]) if meta["key"] else b"" 411 | down.vinfo["iv"] = bytes.fromhex(meta["iv"]) if meta["iv"] else b"" 412 | else: 413 | # 如果是新下载 414 | down = Down(filename=base_dir) 415 | 416 | down.upload_s3 = UPLOAD_APIS 417 | if args.upload_api: 418 | down.upload_s3 = [UPLOAD_APIS[args.upload_api - 1]] 419 | elif not args.no_verify: 420 | available_uploads = [] 421 | for upload_func in UPLOAD_APIS: 422 | if test_upload_interface(upload_func): 423 | available_uploads.append(upload_func) 424 | if not available_uploads: 425 | logger.error("所有接口都不可用") 426 | return 427 | logger.info("可用接口: " + ", ".join([f"{i+1}. {func.__name__}" for i, func in enumerate(available_uploads)])) 428 | selected_index = int(input("请选择一个可用接口 (序号): ").strip()) - 1 429 | if selected_index < 0 or selected_index >= len(available_uploads): 430 | logger.error("选择的接口序号无效") 431 | return 432 | down.upload_s3 = [available_uploads[selected_index]] 433 | else: 434 | logger.info("可用接口: " + ", ".join([f"{i+1}. {func.__name__}" for i, func in enumerate(UPLOAD_APIS)])) 435 | selected_index = int(input("请选择一个接口 (序号): ").strip()) - 1 436 | if selected_index < 0 or selected_index >= len(UPLOAD_APIS): 437 | logger.error("选择的接口序号无效") 438 | return 439 | down.upload_s3 = [UPLOAD_APIS[selected_index]] 440 | 441 | # 直接加载m3u8,不需要先用ffmpeg下载 442 | if not down.vinfo["ts"]: 443 | # 强制设置filename为base_dir 444 | down.vinfo["filename"] = base_dir 445 | down.load_m3u8(args.m3u8_url) 446 | # 确保load_m3u8后filename仍然是base_dir 447 | down.vinfo["filename"] = base_dir 448 | 449 | if os.path.exists(os.path.join(base_dir, "temp")): 450 | with open(os.path.join(base_dir, "temp"), "r") as fp: 451 | uploaded_files = fp.read().strip("\n").split("\n") 452 | uploaded_indices = [i.split("@@")[0] for i in uploaded_files] 453 | else: 454 | uploaded_indices = [] 455 | 456 | remaining_indices = [f"{i:04}" for i in range(len(down.vinfo["ts"])) 457 | if f"{i:04}" not in uploaded_indices] 458 | 459 | if not remaining_indices: 460 | logger.info("所有文件已上传完成") 461 | if not os.path.exists(os.path.join(base_dir, "temp")): 462 | with open(os.path.join(base_dir, "temp"), "w") as f: 463 | pass 464 | down.save_m3u8() 465 | print("任务完成") 466 | return 467 | 468 | logger.info(f"开始上传 {len(remaining_indices)} 个文件") 469 | workers = 10 470 | 471 | with open(os.path.join(base_dir, "temp"), "a", encoding='utf-8') as handle: 472 | with ThreadPoolExecutor(max_workers=workers) as executor: 473 | futures = {executor.submit(down.load_ts, index, handle): index 474 | for index in remaining_indices} 475 | for future in futures: 476 | try: 477 | future.result() 478 | except Exception: 479 | continue 480 | 481 | if down.failed_uploads: 482 | logger.info("等待30秒后开始重试失败的上传...") 483 | time.sleep(30) 484 | down.retry_failed_uploads(handle) 485 | 486 | while down.failed_uploads: 487 | failed_indexes = [str(item[0]) for item in down.failed_uploads] 488 | logger.error(f"以下分片上传失败: {', '.join(failed_indexes)}") 489 | retry = input("是否要重试失败的上传? (Y/N): ").strip().upper() 490 | if retry == 'Y': 491 | logger.info("等待10秒后开始重试...") 492 | time.sleep(10) 493 | down.retry_failed_uploads(handle) 494 | else: 495 | break 496 | 497 | logger.info(f"{base_dir} 下载完成") 498 | down.save_m3u8() 499 | print("任务完成") --------------------------------------------------------------------------------