# -*- coding: utf-8 -*- """ YouTube 台湾新闻直播爬虫(最终修复版) - 扩展频道与动态配置 - 支持从 JSON 或 TXT(M3U风格)加载频道列表 - 支持 proxy 数组自动探测 - 保留分组信息 (group-title) - 强制使用已配置代理请求 M3U8/TS - 修复 TXT 分组识别 """ import re import sys import json import html import time import base64 import os import requests from urllib.parse import quote, unquote, urljoin from base.spider import Spider from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry sys.path.append('..') # DEBUG_LOG = '/sdcard/Download/yt_tw_news_debug.log' def debug_log(message, data=None): try: line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}" if data is not None: if isinstance(data, (dict, list)): line += ' ' + json.dumps(data, ensure_ascii=False, default=str) else: line += ' ' + str(data) with open(DEBUG_LOG, 'a', encoding='utf-8') as f: f.write(line + '\n') except Exception: pass # ==================== YouTubeLiveLite(原版不变) ==================== class YouTubeLiveLite: def __init__(self, session, headers=None, config=None): self.session = session self.headers = headers or {} self.config = config or {} self.cache = {} self.cache_ttl = int(self.config.get('live_cache_ttl') or 45) @staticmethod def extract_video_id(text): text = str(text or '').strip() for pattern in [ r'(?:v=|/v/|/embed/|/shorts/|youtu\.be/)([0-9A-Za-z_-]{11})', r'^([0-9A-Za-z_-]{11})$', ]: match = re.search(pattern, text) if match: return match.group(1) raise Exception('无法识别 YouTube 视频 ID') def extract_live(self, url_or_id): video_id = self.extract_video_id(url_or_id) now = time.time() cached = self.cache.get(video_id) if cached and cached.get('expires', 0) > now: debug_log('live cache hit', {'video_id': video_id, 'ttl': int(cached.get('expires', 0) - now)}) return cached.get('data') watch_url = f'https://www.youtube.com/watch?v={video_id}' debug_log('live extract start', {'input': url_or_id, 'video_id': video_id}) response = self._get(watch_url) page = response.text player_response = self._extract_initial_player_response(page) or {} ytcfg = self._extract_ytcfg(page) or {} api_key = ytcfg.get('INNERTUBE_API_KEY') or self._search(r'"INNERTUBE_API_KEY":"([^"]+)"', page) visitor_data = self._extract_visitor_data(ytcfg, player_response) status_obj = player_response.get('playabilityStatus') or {} streaming = player_response.get('streamingData') or {} details = player_response.get('videoDetails') or {} debug_log('live page parsed', { 'status': status_obj.get('status'), 'reason': status_obj.get('reason'), 'is_live': details.get('isLiveContent'), 'has_hls': bool(streaming.get('hlsManifestUrl')), 'has_api_key': bool(api_key), 'has_visitor': bool(visitor_data), }) page_hls_url = streaming.get('hlsManifestUrl') or '' hls_source = 'page' if page_hls_url else '' api_data = None if api_key: api_data = self._call_player_api(video_id, api_key, ytcfg, watch_url, visitor_data) if api_data: api_streaming = api_data.get('streamingData') or {} api_details = api_data.get('videoDetails') or {} api_hls_url = api_streaming.get('hlsManifestUrl') or '' if api_hls_url: streaming = api_streaming hls_source = api_data.get('_client_name') or 'api' elif not page_hls_url and api_streaming: streaming = api_streaming hls_source = api_data.get('_client_name') or 'api_no_hls' if api_details: details = api_details status_obj = api_data.get('playabilityStatus') or status_obj if not (streaming.get('hlsManifestUrl') or '') and page_hls_url: streaming = dict(streaming or {}) streaming['hlsManifestUrl'] = page_hls_url hls_source = 'page_fallback' hls_url = streaming.get('hlsManifestUrl') or '' is_live = bool(details.get('isLiveContent') or hls_url) status = status_obj.get('status') or '' reason = status_obj.get('reason') or '' title = details.get('title') or video_id data = { 'id': video_id, 'title': title, 'is_live': is_live, 'status': status, 'reason': reason, 'hls_url': hls_url, 'duration': int(details.get('lengthSeconds') or 0), } debug_log('live extract result', { 'video_id': video_id, 'status': status, 'is_live': is_live, 'has_hls': bool(hls_url), 'hls_source': hls_source, 'duration': data.get('duration'), }) self.cache[video_id] = {'data': data, 'expires': time.time() + self.cache_ttl} return data def _get(self, url, **kwargs): headers = self.headers.copy() headers.update(kwargs.pop('headers', {}) or {}) response = self.session.get(url, headers=headers, timeout=kwargs.pop('timeout', 30), **kwargs) response.raise_for_status() return response def _post_json(self, url, payload, headers=None): final_headers = self.headers.copy() final_headers.update({'Content-Type': 'application/json', 'Origin': 'https://www.youtube.com'}) if headers: final_headers.update({k: v for k, v in headers.items() if v}) response = self.session.post(url, json=payload, headers=final_headers, timeout=30) response.raise_for_status() return response.json() def _call_player_api(self, video_id, api_key, ytcfg, referer, visitor_data=None): context = ytcfg.get('INNERTUBE_CONTEXT') or { 'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'en', 'gl': 'US'} } clients = [ {'client': {'clientName': 'ANDROID', 'clientVersion': '21.02.35', 'androidSdkVersion': 30, 'userAgent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'osName': 'Android', 'osVersion': '11', 'hl': 'en', 'gl': 'US'}}, {'client': {'clientName': 'IOS', 'clientVersion': '21.02.3', 'deviceMake': 'Apple', 'deviceModel': 'iPhone16,2', 'userAgent': 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)', 'osName': 'iPhone', 'osVersion': '18.3.2.22D82', 'hl': 'en', 'gl': 'US'}}, {'client': {'clientName': 'MWEB', 'clientVersion': '2.20260115.01.00', 'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', 'hl': 'en', 'gl': 'US'}}, context, ] for ctx in clients: client = ctx.get('client') or {} client_name = client.get('clientName') or 'WEB' try: url = f'https://www.youtube.com/youtubei/v1/player?key={quote(api_key)}&prettyPrint=false' headers = { 'Referer': referer, 'X-YouTube-Client-Name': str(self._client_name_id(client_name)), 'X-YouTube-Client-Version': client.get('clientVersion') or '', } if visitor_data: headers['X-Goog-Visitor-Id'] = visitor_data if client.get('userAgent'): headers['User-Agent'] = client.get('userAgent') payload = { 'context': ctx, 'videoId': video_id, 'contentCheckOk': True, 'racyCheckOk': True, } data = self._post_json(url, payload, headers=headers) streaming = data.get('streamingData') or {} status = (data.get('playabilityStatus') or {}).get('status') debug_log('live api client', { 'client': client_name, 'status': status, 'has_hls': bool(streaming.get('hlsManifestUrl')), 'has_streaming': bool(streaming), }) if streaming.get('hlsManifestUrl'): data['_client_name'] = client_name return data except Exception as e: debug_log('live api client error', {'client': client_name, 'error': repr(e)}) return None def _extract_visitor_data(self, ytcfg, player_response): return ( self.config.get('visitor_data') or ytcfg.get('VISITOR_DATA') or (((ytcfg.get('INNERTUBE_CONTEXT') or {}).get('client') or {}).get('visitorData')) or ((player_response.get('responseContext') or {}).get('visitorData')) ) def _extract_ytcfg(self, text): match = re.search(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', text or '', re.S) if not match: return None try: return json.loads(match.group(1)) except Exception: return None def _extract_initial_player_response(self, text): return self._extract_json_after(text, 'ytInitialPlayerResponse') def _extract_json_after(self, text, marker): pos = (text or '').find(marker) if pos < 0: return None start = text.find('{', pos) if start < 0: return None depth = 0 in_str = None escape = False for index in range(start, len(text)): char = text[index] if escape: escape = False continue if char == '\\': escape = True continue if in_str: if char == in_str: in_str = None continue if char in ('"', "'"): in_str = char continue if char == '{': depth += 1 elif char == '}': depth -= 1 if depth == 0: try: return json.loads(text[start:index + 1]) except Exception: return None return None @staticmethod def _search(pattern, text, default=None): match = re.search(pattern, text or '', re.S) return match.group(1) if match else default def _client_name_id(self, client_name): return { 'WEB': 1, 'MWEB': 2, 'ANDROID': 3, 'IOS': 5, 'TVHTML5': 7, 'ANDROID_VR': 28, 'WEB_EMBEDDED_PLAYER': 56, 'WEB_REMIX': 67, }.get(client_name, 1) # ==================== 频道定义(默认) ==================== DEFAULT_CHANNELS = [ {"id": "CTI", "name": "中天新聞", "url": "https://m.youtube.com/@中天電視CtiTv/streams/1"}, {"id": "TVBS", "name": "TVBS新聞", "url": "https://m.youtube.com/@TVBSNEWS01/streams/1"}, {"id": "EBC", "name": "東森新聞", "url": "https://m.youtube.com/@newsebc/streams/1"}, {"id": "FTV", "name": "民視新聞", "url": "https://m.youtube.com/@FTV_News/streams/1"}, ] # ==================== Spider 类 ==================== class Spider(Spider): def getName(self): return "YouTube台湾新闻直播" def init(self, extend): try: self.extendDict = json.loads(extend) if extend else {} except: self.extendDict = {} # 创建 Session 并配置重试策略 self.session = requests.Session() retry = Retry(total=2, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry, pool_connections=5, pool_maxsize=10) self.session.mount('http://', adapter) self.session.mount('https://', adapter) # 请求头 self.session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept-Language': 'zh-TW,zh;q=0.9,en;q=0.8', 'Referer': 'https://www.youtube.com/' }) # ----- 代理配置:支持数组自动探测 ----- self.proxy_str = None self._proxy_configured = False # 标记是否使用了有效代理 proxy_val = self.extendDict.get('proxy') if proxy_val is not None: if isinstance(proxy_val, list): found = False for p in proxy_val: p_norm = self._normalize_proxy_url(p) if p_norm: test_proxies = {'http': p_norm, 'https': p_norm} try: r = requests.get('https://www.youtube.com', proxies=test_proxies, timeout=2) if r.status_code < 400: self.session.proxies = test_proxies self.proxy_str = p_norm.replace('http://', '').replace('https://', '') self._proxy_configured = True debug_log('使用用户列表代理', {'proxy': p_norm}) found = True break except Exception: continue if not found: self.session.proxies = {} self.proxy_str = '' self._proxy_configured = False debug_log('用户列表代理均不可用,回退系统代理') elif isinstance(proxy_val, str): p_norm = self._normalize_proxy_url(proxy_val) if p_norm: self.session.proxies = {'http': p_norm, 'https': p_norm} self.proxy_str = p_norm.replace('http://', '').replace('https://', '') self._proxy_configured = True debug_log('使用 ext 传入代理(字符串)', {'proxy': p_norm}) else: self._auto_detect_proxy() elif isinstance(proxy_val, dict): proxies = {} for k, v in proxy_val.items(): if k in ('http', 'https') and v: v = self._normalize_proxy_url(v) if v: proxies[k] = v if proxies: self.session.proxies = proxies self.proxy_str = (proxies.get('https') or proxies.get('http') or '').replace('http://', '').replace('https://', '') self._proxy_configured = True debug_log('使用 ext 传入的字典代理', proxies) else: self._auto_detect_proxy() else: self._auto_detect_proxy() else: self._auto_detect_proxy() # 初始化 YouTube 提取器 self.yt = YouTubeLiveLite(self.session, self.session.headers, {}) # 缓存 self.video_cache = {} self.fail_cache = {} self.m3u8_cache = {} self.video_ttl = 300 self.m3u8_ttl = 15 self.fail_ttl = 120 # ----- 频道列表加载:支持 JSON 或 TXT(本地/远程) ----- self.channels = DEFAULT_CHANNELS.copy() custom = self.extendDict.get('channels') if custom: loaded = False if isinstance(custom, list): if all(isinstance(c, dict) and 'id' in c and 'name' in c and 'url' in c for c in custom): self.channels = custom loaded = True debug_log("使用自定义频道列表(直接传入JSON列表)", {"count": len(self.channels)}) else: debug_log("自定义频道列表格式无效,使用默认列表") elif isinstance(custom, str): # ---------- 远程 URL ---------- if custom.startswith('http://') or custom.startswith('https://'): try: resp = self.session.get(custom, timeout=10) if resp.status_code == 200: # 强制使用 UTF-8 解码 try: txt_data = resp.content.decode('utf-8') except UnicodeDecodeError: txt_data = resp.content.decode('utf-8', errors='ignore') # 先尝试 JSON try: data = json.loads(txt_data) if isinstance(data, list) and all(isinstance(c, dict) and 'id' in c and 'name' in c and 'url' in c for c in data): self.channels = data loaded = True debug_log("从远程 URL 加载频道列表(JSON)", {"url": custom, "count": len(self.channels)}) else: debug_log("远程 JSON 数据格式无效,尝试 TXT 解析", {"url": custom}) except Exception as e: debug_log("远程 JSON 解析失败,尝试 TXT 解析", {"url": custom, "error": repr(e)}) # 如果 JSON 未成功加载,尝试 TXT if not loaded: try: channels, group = self._parse_txt_channels(txt_data) if channels: self.channels = channels loaded = True self._current_group = group debug_log("从远程 URL 加载频道列表(TXT)", {"url": custom, "count": len(self.channels), "group": group}) else: debug_log("远程 TXT 解析失败,无有效频道", {"url": custom}) except Exception as e2: debug_log("远程 TXT 解析异常", {"url": custom, "error": repr(e2)}) else: debug_log("远程请求失败", {"url": custom, "status": resp.status_code}) except Exception as e: debug_log("远程加载失败", {"url": custom, "error": repr(e)}) # ---------- 本地文件 ---------- else: script_dir = os.path.dirname(os.path.abspath(__file__)) if custom.startswith('./'): file_path = os.path.join(script_dir, custom[2:]) else: file_path = custom if file_path.lower().endswith('.txt'): try: with open(file_path, 'r', encoding='utf-8') as f: txt_content = f.read() channels, group = self._parse_txt_channels(txt_content) if channels: self.channels = channels loaded = True self._current_group = group debug_log("从本地 TXT 文件加载频道列表", {"path": file_path, "count": len(self.channels), "group": group}) else: debug_log("TXT 文件内容无效", {"path": file_path}) except Exception as e: debug_log("读取本地 TXT 失败", {"path": file_path, "error": repr(e)}) else: # JSON 文件 try: with open(file_path, 'r', encoding='utf-8') as f: data = json.load(f) if isinstance(data, list) and all(isinstance(c, dict) and 'id' in c and 'name' in c and 'url' in c for c in data): self.channels = data loaded = True debug_log("从本地 JSON 文件加载频道列表", {"path": file_path, "count": len(self.channels)}) else: debug_log("JSON 文件内容格式无效", {"path": file_path}) except Exception as e: debug_log("读取本地 JSON 失败", {"path": file_path, "error": repr(e)}) else: debug_log("channels 字段类型不支持,使用默认列表") if not loaded: debug_log("使用默认频道列表", {"count": len(self.channels)}) else: debug_log("未配置 channels,使用默认列表", {"count": len(self.channels)}) debug_log("Spider init", {"channels": len(self.channels)}) def _parse_txt_channels(self, content): """解析 TXT 格式(M3U风格),正确识别 #genre# 分组""" lines = content.splitlines() channels = [] current_group = "油管直播" # 默认组名 seen = set() for line in lines: line = line.strip() if not line: continue # 检测分组行:格式为 "分组名,#genre#" if '#genre#' in line: parts = line.split(',') if len(parts) >= 1: group_name = parts[0].strip() # 如果开头有 #,去掉 if group_name.startswith('#'): group_name = group_name[1:].strip() if group_name: current_group = group_name continue # 跳过其他 # 开头的注释行(但不是 #genre#) if line.startswith('#'): continue # 解析节目行:名称,URL if ',' in line: parts = line.split(',', 1) name = parts[0].strip() url = parts[1].strip() if not url or not name: continue try: video_id = YouTubeLiveLite.extract_video_id(url) except: # 无法提取ID,生成唯一ID video_id = f"unknown_{abs(hash(url))}" # 保证id唯一 base_id = video_id if base_id in seen: idx = 1 while f"{base_id}_{idx}" in seen: idx += 1 video_id = f"{base_id}_{idx}" seen.add(video_id) channels.append({ 'id': video_id, 'name': name, 'url': url, 'group': current_group }) return channels, current_group def _normalize_proxy_url(self, url): if not url: return '' url = url.strip() if not re.match(r'^[a-zA-Z]+://', url): url = 'http://' + url return url def _auto_detect_proxy(self): """内置代理列表探测""" proxy_list = [ "http://127.0.0.1:2080", "http://127.0.0.1:7890", "http://127.0.0.1:10809", "http://127.0.0.1:20172", "http://127.0.0.1:10172", "http://127.0.0.1:7891", "http://127.0.0.1:10808", "http://127.0.0.1:1087", "http://127.0.0.1:3128", "http://127.0.0.1:1080", "http://127.0.0.1:8080", "http://127.0.0.1:9090" ] for p in proxy_list: try: test_proxies = {'http': p, 'https': p} r = requests.get('https://www.youtube.com', proxies=test_proxies, timeout=2) if r.status_code < 400: self.session.proxies = test_proxies self.proxy_str = p.replace('http://', '').replace('https://', '') self._proxy_configured = True debug_log('内置代理探测成功', {'proxy': p}) return except Exception: continue self.session.proxies = {} self.proxy_str = '' self._proxy_configured = False debug_log('所有内置代理不可用,回退到系统/全局代理') def destroy(self): if self.session: self.session.close() debug_log("Spider destroyed") def liveContent(self, url): lines = ['#EXTM3U'] for ch in self.channels: proxy_url = f"http://127.0.0.1:9978/proxy?do=py&type=youtube_live&channel={ch['id']}" # 安全处理显示名称中的特殊字符 name = ch["name"].replace('"', '\\"').replace(',', '\\,') group_attr = f' group-title="{ch.get("group", "油管直播")}"' if ch.get("group") else '' lines.append(f'#EXTINF:-1 tvg-id="{ch["id"]}" tvg-name="{name}"{group_attr},{name}') lines.append(proxy_url) return '\n'.join(lines) def localProxy(self, params): t = params.get('type') if t == 'youtube_live': return self._handle_live(params) elif t == 'ts': return self._handle_ts(params) return [404, "text/plain", "未知请求"] def _handle_live(self, params): channel_id = params.get('channel') if not channel_id: return self._error_response("缺少频道ID") now = time.time() if channel_id in self.fail_cache and self.fail_cache[channel_id] > now: debug_log("Skip failed channel (cache)", {"channel": channel_id}) return self._error_response("该频道暂时无法获取直播流") cached_m3u8 = self.m3u8_cache.get(channel_id) if cached_m3u8 and cached_m3u8.get('expires', 0) > now: debug_log("M3U8 cache hit", {"channel": channel_id}) return [200, "application/vnd.apple.mpegurl", cached_m3u8['content']] video_info = self.video_cache.get(channel_id) if not video_info or video_info.get('expires', 0) <= now: debug_log("Refreshing video info", {"channel": channel_id}) info = self._get_live_video_info(channel_id) if not info: self.fail_cache[channel_id] = now + self.fail_ttl return self._error_response("无法获取直播视频") self.video_cache[channel_id] = { 'video_id': info['video_id'], 'hls_url': info['hls_url'], 'expires': now + self.video_ttl } self.fail_cache.pop(channel_id, None) video_info = self.video_cache[channel_id] m3u8_content = self._fetch_and_rewrite_m3u8(video_info['hls_url'], channel_id) if not m3u8_content: self.fail_cache[channel_id] = now + self.fail_ttl return self._error_response("获取 M3U8 失败") self.m3u8_cache[channel_id] = { 'content': m3u8_content, 'expires': now + self.m3u8_ttl } return [200, "application/vnd.apple.mpegurl", m3u8_content] def _handle_ts(self, params): b64_url = params.get('url', '') if not b64_url: return self._error_response("缺少 TS URL") try: ts_url = self._b64_decode(b64_url) except: return self._error_response("TS URL 解码失败") try: # 强制使用已配置的代理(如果有) resp = self.session.get(ts_url, headers={ 'User-Agent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'Accept': '*/*', 'Referer': 'https://www.youtube.com/', }, timeout=30) if resp.status_code != 200: return self._error_response(f"TS 请求失败 {resp.status_code}") return [200, "video/MP2T", resp.content, { 'Content-Type': 'video/MP2T', 'Content-Length': str(len(resp.content)), 'Cache-Control': 'no-cache' }] except Exception as e: debug_log("TS proxy error", {"url": ts_url, "error": repr(e)}) return self._error_response(f"TS 代理异常: {str(e)}") def _get_live_video_info(self, channel_id): ch = next((c for c in self.channels if c['id'] == channel_id), None) if not ch: return None url = ch['url'] if 'watch?v=' in url or 'youtu.be/' in url: try: video_id = YouTubeLiveLite.extract_video_id(url) except Exception as e: debug_log("提取视频ID失败", {"url": url, "error": repr(e)}) return None try: data = self.yt.extract_live(video_id) if data.get('is_live') and data.get('hls_url'): debug_log("直接视频链接 - 获取直播成功", {"video_id": video_id}) return {'video_id': video_id, 'hls_url': data['hls_url']} else: debug_log("直接视频链接 - 非直播或无HLS", {"video_id": video_id, "is_live": data.get('is_live')}) return None except Exception as e: debug_log("直接视频链接 - extract_live失败", {"video_id": video_id, "error": repr(e)}) return None try: resp = self.session.get(url, timeout=30) resp.raise_for_status() html_text = resp.text except Exception as e: debug_log("获取频道页失败", {"url": url, "error": repr(e)}) return None video_ids = re.findall(r'watch\?v=([a-zA-Z0-9_-]{11})', html_text) if not video_ids: debug_log("频道页未找到视频ID", {"url": url}) return None seen = set() for vid in video_ids: if vid in seen: continue seen.add(vid) try: data = self.yt.extract_live(vid) if data.get('is_live') and data.get('hls_url'): debug_log("频道页找到直播视频", {"video_id": vid, "hls_url": data['hls_url'][:80]}) return {'video_id': vid, 'hls_url': data['hls_url']} except Exception as e: debug_log("频道页 - extract_live失败", {"video_id": vid, "error": repr(e)}) continue debug_log("频道页未找到直播视频", {"url": url}) return None def _fetch_and_rewrite_m3u8(self, hls_url, channel_id): try: # 强制使用已配置的代理(session 已包含代理设置) resp = self.session.get(hls_url, headers={ 'User-Agent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'Referer': 'https://www.youtube.com/', }, timeout=30) resp.raise_for_status() text = resp.text except Exception as e: debug_log("Failed to fetch master m3u8", {"url": hls_url, "error": repr(e)}) return None if '#EXT-X-STREAM-INF' in text: variant_url = self._pick_best_variant(hls_url, text) if not variant_url: debug_log("No variant found", {"master": hls_url}) return None try: resp = self.session.get(variant_url, headers={ 'User-Agent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'Referer': 'https://www.youtube.com/', }, timeout=30) resp.raise_for_status() text = resp.text except Exception as e: debug_log("Failed to fetch variant m3u8", {"url": variant_url, "error": repr(e)}) return None rewritten = [] for line in text.splitlines(): if line.startswith('#'): rewritten.append(line) else: ts_url = urljoin(hls_url, line.strip()) encoded = self._b64_encode(ts_url) proxy_ts = f"http://127.0.0.1:9978/proxy?do=py&type=ts&url={encoded}&channel={channel_id}" rewritten.append(proxy_ts) return '\n'.join(rewritten) + '\n' def _pick_best_variant(self, base_url, text): lines = text.splitlines() best_score = -1 best_url = '' for i, line in enumerate(lines): if not line.startswith('#EXT-X-STREAM-INF'): continue bandwidth = re.search(r'BANDWIDTH=(\d+)', line) resolution = re.search(r'RESOLUTION=(\d+)x(\d+)', line) score = 0 if bandwidth: score += int(bandwidth.group(1)) if resolution: score += int(resolution.group(1)) * int(resolution.group(2)) for j in range(i + 1, len(lines)): if not lines[j].strip() or lines[j].startswith('#'): continue if score > best_score: best_score = score best_url = urljoin(base_url, lines[j].strip()) break return best_url def _b64_encode(self, s): return base64.urlsafe_b64encode(s.encode()).decode().rstrip('=') def _b64_decode(self, s): padding = 4 - (len(s) % 4) if padding != 4: s += '=' * padding return base64.urlsafe_b64decode(s).decode() def _error_response(self, msg): error_m3u8 = f"""#EXTM3U #EXT-X-VERSION:3 #EXT-X-MEDIA-SEQUENCE:0 #EXT-X-TARGETDURATION:10 #EXTINF:10.0, error.ts #EXT-X-ENDLIST # {msg} """ return [500, "application/vnd.apple.mpegurl", error_m3u8]