',
+ html_str, re.DOTALL)[0]
+ except Exception:
+ raise ConnectionError("Please check if your network can access the TikTok website normally")
+ json_data = json.loads(json_str)
+ return json_data
+
+
+@trace_error_decorator
+async def get_kuaishou_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ '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',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ try:
+ html_str = await async_req(url=url, proxy_addr=proxy_addr, headers=headers)
+ except Exception as e:
+ print(f"Failed to fetch data from {url}.{e}")
+ return {"type": 1, "is_live": False}
+
+ try:
+ json_str = re.search('', html_str)[0]
+ json_data = json.loads(json_str)
+ rid = json_data['pageProps']['room']['roomInfo']['roomInfo']['rid']
+
+ headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0'
+ url2 = f'https://www.douyu.com/betard/{rid}'
+ json_str = await async_req(url=url2, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ result = {
+ "anchor_name": json_data['room']['nickname'],
+ "is_live": False
+ }
+ if json_data['room']['videoLoop'] == 0 and json_data['room']['show_status'] == 1:
+ result["title"] = json_data['room']['room_name'].replace(' ', '')
+ result["is_live"] = True
+ result["room_id"] = json_data['room']['room_id']
+ return result
+
+
+@trace_error_decorator
+async def get_douyu_stream_data(rid: str, rate: str = '-1', proxy_addr: OptionalStr = None,
+ cookies: OptionalStr = None) -> dict:
+ did = '10000000000000000000000000003306'
+ params_list = await get_token_js(rid, did, proxy_addr=proxy_addr)
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'Referer': 'https://m.douyu.com/3125893?rid=3125893&dyshid=0-96003918aa5365bc6dcb4933000316p1&dyshci=181',
+ 'Cookie': 'dy_did=413b835d2ae00270f0c69f6400031601; acf_did=413b835d2ae00270f0c69f6400031601; Hm_lvt_e99aee90ec1b2106afe7ec3b199020a7=1692068308,1694003758; m_did=96003918aa5365bc6dcb4933000316p1; dy_teen_mode=%7B%22uid%22%3A%22472647365%22%2C%22status%22%3A0%2C%22birthday%22%3A%22%22%2C%22password%22%3A%22%22%7D; PHPSESSID=td59qi2fu2gepngb8mlehbeme3; acf_auth=94fc9s%2FeNj%2BKlpU%2Br8tZC3Jo9sZ0wz9ClcHQ1akL2Nhb6ZyCmfjVWSlR3LFFPuePWHRAMo0dt9vPSCoezkFPOeNy4mYcdVOM1a8CbW0ZAee4ipyNB%2Bflr58; dy_auth=bec5yzM8bUFYe%2FnVAjmUAljyrsX%2FcwRW%2FyMHaoArYb5qi8FS9tWR%2B96iCzSnmAryLOjB3Qbeu%2BBD42clnI7CR9vNAo9mva5HyyL41HGsbksx1tEYFOEwxSI; wan_auth37wan=5fd69ed5b27fGM%2FGoswWwDo%2BL%2FRMtnEa4Ix9a%2FsH26qF0sR4iddKMqfnPIhgfHZUqkAk%2FA1d8TX%2B6F7SNp7l6buIxAVf3t9YxmSso8bvHY0%2Fa6RUiv8; acf_uid=472647365; acf_username=472647365; acf_nickname=%E7%94%A8%E6%88%B776576662; acf_own_room=0; acf_groupid=1; acf_phonestatus=1; acf_avatar=https%3A%2F%2Fapic.douyucdn.cn%2Fupload%2Favatar%2Fdefault%2F24_; acf_ct=0; acf_ltkid=25305099; acf_biz=1; acf_stk=90754f8ed18f0c24; Hm_lpvt_e99aee90ec1b2106afe7ec3b199020a7=1694003778'
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ data = {
+ 'v': params_list[0],
+ 'did': params_list[1],
+ 'tt': params_list[2],
+ 'sign': params_list[3], # 10分钟有效期
+ 'ver': '22011191',
+ 'rid': rid,
+ 'rate': rate, # 0蓝光、3超清、2高清、-1默认
+ }
+
+ # app_api = 'https://m.douyu.com/hgapi/livenc/room/getStreamUrl'
+ app_api = f'https://www.douyu.com/lapi/live/getH5Play/{rid}'
+ json_str = await async_req(url=app_api, proxy_addr=proxy_addr, headers=headers, data=data)
+ json_data = json.loads(json_str)
+ return json_data
+
+
+@trace_error_decorator
+async def get_yy_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0',
+ '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',
+ 'Referer': 'https://www.yy.com/',
+ 'Cookie': 'hd_newui=0.2103068903976506; hdjs_session_id=0.4929014850884579; hdjs_session_time=1694004002636; hiido_ui=0.923076230899782'
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ html_str = await async_req(url=url, proxy_addr=proxy_addr, headers=headers)
+ anchor_name = re.search('nick: "(.*?)",\n\\s+logo', html_str).group(1)
+ cid = re.search('sid : "(.*?)",\n\\s+ssid', html_str, re.DOTALL).group(1)
+
+ data = '{"head":{"seq":1701869217590,"appidstr":"0","bidstr":"121","cidstr":"' + cid + '","sidstr":"' + cid + '","uid64":0,"client_type":108,"client_ver":"5.17.0","stream_sys_ver":1,"app":"yylive_web","playersdk_ver":"5.17.0","thundersdk_ver":"0","streamsdk_ver":"5.17.0"},"client_attribute":{"client":"web","model":"web0","cpu":"","graphics_card":"","os":"chrome","osversion":"0","vsdk_version":"","app_identify":"","app_version":"","business":"","width":"1920","height":"1080","scale":"","client_type":8,"h265":0},"avp_parameter":{"version":1,"client_type":8,"service_type":0,"imsi":0,"send_time":1701869217,"line_seq":-1,"gear":4,"ssl":1,"stream_format":0}}'
+ data_bytes = data.encode('utf-8')
+ params = {
+ "uid": "0",
+ "cid": cid,
+ "sid": cid,
+ "appid": "0",
+ "sequence": "1701869217590",
+ "encode": "json"
+ }
+ url2 = f'https://stream-manager.yy.com/v3/channel/streams?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(url=url2, data=data_bytes, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ json_data['anchor_name'] = anchor_name
+
+ params = {
+ 'uid': '',
+ 'sid': cid,
+ 'ssid': cid,
+ '_': int(time.time() * 1000),
+ }
+ detail_api = f'https://www.yy.com/live/detail?{urllib.parse.urlencode(params)}'
+ json_str2 = await async_req(detail_api, proxy_addr=proxy_addr, headers=headers)
+ json_data2 = json.loads(json_str2)
+ json_data['title'] = json_data2['data']['roomName']
+ return json_data
+
+
+@trace_error_decorator
+async def get_bilibili_room_info_h5(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> str:
+ headers = {
+ 'user-agent': 'Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile Safari/537.36',
+ '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',
+ 'cookie': '',
+ 'origin': 'https://live.bilibili.com',
+ 'referer': 'https://live.bilibili.com/26066074',
+ }
+ if cookies:
+ headers['cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ api = f'https://api.live.bilibili.com/xlive/web-room/v1/index/getH5InfoByRoom?room_id={room_id}'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ room_info = json.loads(json_str)
+ title = room_info['data']['room_info'].get('title') if room_info.get('data') else ''
+ return title
+
+
+@trace_error_decorator
+async def get_bilibili_room_info(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
+ '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',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ try:
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ json_str = await async_req(f'https://api.live.bilibili.com/room/v1/Room/room_init?id={room_id}',
+ proxy_addr=proxy_addr, headers=headers)
+ room_info = json.loads(json_str)
+ uid = room_info['data']['uid']
+ live_status = True if room_info['data']['live_status'] == 1 else False
+
+ api = f'https://api.live.bilibili.com/live_user/v1/Master/info?uid={uid}'
+ json_str2 = await async_req(url=api, proxy_addr=proxy_addr, headers=headers)
+ anchor_info = json.loads(json_str2)
+ anchor_name = anchor_info['data']['info']['uname']
+
+ title = await get_bilibili_room_info_h5(url, proxy_addr, cookies)
+ return {"anchor_name": anchor_name, "live_status": live_status, "room_url": url, "title": title}
+ except Exception as e:
+ print(e)
+ return {"anchor_name": '', "live_status": False, "room_url": url}
+
+
+@trace_error_decorator
+async def get_bilibili_stream_data(url: str, qn: str = '10000', platform: str = 'web', proxy_addr: OptionalStr = None,
+ cookies: OptionalStr = None) -> OptionalStr:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0',
+ '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',
+ 'origin': 'https://live.bilibili.com',
+ 'referer': 'https://live.bilibili.com/26066074',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ params = {
+ 'cid': room_id,
+ 'qn': qn,
+ 'platform': platform,
+ }
+ play_api = f'https://api.live.bilibili.com/room/v1/Room/playUrl?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ if json_data and json_data['code'] == 0:
+ for i in json_data['data']['durl']:
+ if 'd1--cn-gotcha' in i['url']:
+ return i['url']
+ return json_data['data']['durl'][-1]['url']
+ else:
+ params = {
+ "room_id": room_id,
+ "protocol": "0,1",
+ "format": "0,1,2",
+ "codec": "0,1,2",
+ "qn": qn,
+ "platform": "web",
+ "ptype": "8",
+ "dolby": "5",
+ "panorama": "1",
+ "hdr_type": "0,1"
+ }
+
+ # 此接口因网页上有限制, 需要配置登录后的cookie才能获取最高画质
+ api = f'https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ if json_data['data']['live_status'] == 0:
+ print("The anchor did not start broadcasting.")
+ return
+ playurl_info = json_data['data']['playurl_info']
+ format_list = playurl_info['playurl']['stream'][0]['format']
+ stream_data_list = format_list[0]['codec']
+ sorted_stream_list = sorted(stream_data_list, key=itemgetter("current_qn"), reverse=True)
+ # qn: 30000=杜比 20000=4K 10000=原画 400=蓝光 250=超清 150=高清 80=流畅
+ video_quality_options = {'10000': 0, '400': 1, '250': 2, '150': 3, '80': 4}
+ qn_count = len(sorted_stream_list)
+ select_stream_index = min(video_quality_options[qn], qn_count - 1)
+ stream_data: dict = sorted_stream_list[select_stream_index]
+ base_url = stream_data['base_url']
+ host = stream_data['url_info'][0]['host']
+ extra = stream_data['url_info'][0]['extra']
+ m3u8_url = host + base_url + extra
+ return m3u8_url
+
+@trace_error_decorator
+async def get_xhs_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'xy-common-params': 'platform=iOS&sid=session.1722166379345546829388',
+ 'referer': 'https://app.xhs.cn/',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if "xhslink.com" in url:
+ url = await async_req(url, proxy_addr=proxy_addr, headers=headers, redirect_url=True)
+
+ host_id = get_params(url, "host_id")
+ user_id = re.search("/user/profile/(.*?)(?=/|\\?|$)", url)
+ user_id = user_id.group(1) if user_id else host_id
+ result = {"anchor_name": '', "is_live": False}
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=headers)
+ match_data = re.search("", html_str)
+
+ if match_data:
+ json_str = match_data.group(1).replace("undefined", "null")
+ json_data = json.loads(json_str)
+
+ if json_data.get("liveStream"):
+ stream_data = json_data["liveStream"]
+ if stream_data.get("liveStatus") == "success":
+ room_info = stream_data["roomData"]["roomInfo"]
+ title = room_info.get("roomTitle")
+ if title and "回放" not in title:
+ live_link = room_info["deeplink"]
+ anchor_name = get_params(live_link, "host_nickname")
+ flv_url = get_params(live_link, "flvUrl")
+ room_id = flv_url.split('live/')[1].split('.')[0]
+ flv_url = f"http://live-source-play.xhscdn.com/live/{room_id}.flv"
+ m3u8_url = flv_url.replace('.flv', '.m3u8')
+ result |= {
+ "anchor_name": anchor_name,
+ "is_live": True,
+ "title": title,
+ "flv_url": flv_url,
+ "m3u8_url": m3u8_url,
+ 'record_url': flv_url
+ }
+ return result
+
+ profile_url = f"https://www.xiaohongshu.com/user/profile/{user_id}"
+ html_str = await async_req(profile_url, proxy_addr=proxy_addr, headers=headers)
+ anchor_name = re.search("
@(.*?) 的个人主页 ", html_str)
+ if anchor_name:
+ result["anchor_name"] = anchor_name.group(1)
+
+ return result
+
+
+@trace_error_decorator
+async def get_bigo_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
+ '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',
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ 'Referer': 'https://www.bigo.tv/',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if 'bigo.tv' not in url:
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=headers)
+ web_url = re.search(
+ '
',
+ html_str).group(1)
+ room_id = web_url.split('&h=')[-1]
+ else:
+ if '&h=' in url:
+ room_id = url.split('&h=')[-1]
+ else:
+ room_id = url.split("?")[0].rsplit("/", maxsplit=1)[-1]
+
+ data = {'siteId': room_id} # roomId
+ url2 = 'https://ta.bigo.tv/official_website/studio/getInternalStudioInfo'
+ json_str = await async_req(url=url2, proxy_addr=proxy_addr, headers=headers, data=data)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['nick_name']
+ live_status = json_data['data']['alive']
+ result = {"anchor_name": anchor_name, "is_live": False}
+
+ if live_status == 1:
+ live_title = json_data['data']['roomTopic']
+ m3u8_url = json_data['data']['hls_src']
+ result['m3u8_url'] = m3u8_url
+ result['record_url'] = m3u8_url
+ result |= {"title": live_title, "is_live": True, "m3u8_url": m3u8_url, 'record_url': m3u8_url}
+ elif result['anchor_name'] == '':
+ html_str = await async_req(url=f'https://www.bigo.tv/{url.split("/")[3]}/{room_id}',
+ proxy_addr=proxy_addr, headers=headers)
+ match_anchor_name = re.search('
欢迎来到(.*?)的直播间 ', html_str, re.DOTALL)
+ if match_anchor_name:
+ anchor_name = match_anchor_name.group(1)
+ else:
+ match_anchor_name = re.search('
', html_str, re.DOTALL)
+ anchor_name = match_anchor_name.group(1)
+ result['anchor_name'] = anchor_name
+
+ return result
+
+
+@trace_error_decorator
+async def get_blued_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
+ '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',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ html_str = await async_req(url=url, proxy_addr=proxy_addr, headers=headers)
+ json_str = re.search('decodeURIComponent\\(\"(.*?)\"\\)\\),window\\.Promise', html_str, re.DOTALL).group(1)
+ json_str = urllib.parse.unquote(json_str)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['userInfo']['name']
+ live_status = json_data['userInfo']['onLive']
+ result = {"anchor_name": anchor_name, "is_live": False}
+
+ if live_status:
+ m3u8_url = json_data['liveInfo']['liveUrl']
+ result |= {"is_live": True, "m3u8_url": m3u8_url, 'record_url': m3u8_url}
+ return result
+
+
+@trace_error_decorator
+async def login_sooplive(username: str, password: str, proxy_addr: OptionalStr = None) -> OptionalStr:
+ if len(username) < 6 or len(password) < 10:
+ raise RuntimeError("sooplive login failed! Please enter the correct account and password for the sooplive "
+ "platform in the config.ini file.")
+
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
+ 'Origin': 'https://play.sooplive.co.kr',
+ 'Referer': 'https://play.sooplive.co.kr/superbsw123/277837074',
+ }
+
+ data = {
+ 'szWork': 'login',
+ 'szType': 'json',
+ 'szUid': username,
+ 'szPassword': password,
+ 'isSaveId': 'true',
+ 'isSavePw': 'true',
+ 'isSaveJoin': 'true',
+ 'isLoginRetain': 'Y',
+ }
+
+ url = 'https://login.sooplive.co.kr/app/LoginAction.php'
+
+ try:
+ cookie_dict = await async_req(url, proxy_addr=proxy_addr, headers=headers,
+ data=data, return_cookies=True, timeout=20)
+ cookie_str = '; '.join([f"{k}={v}" for k, v in cookie_dict.items()])
+ return cookie_str
+ except Exception as e:
+ print(f"An error occurred during login: {e}")
+ raise Exception(
+ "sooplive login failed, please check if the account password in the configuration file is correct."
+ )
+
+
+@trace_error_decorator
+async def get_sooplive_cdn_url(broad_no: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
+ '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',
+ 'Origin': 'https://play.sooplive.co.kr',
+ 'Referer': 'https://play.sooplive.co.kr/oul282/249469582',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ params = {
+ 'return_type': 'gcp_cdn',
+ 'use_cors': 'false',
+ 'cors_origin_url': 'play.sooplive.co.kr',
+ 'broad_key': f'{broad_no}-common-master-hls',
+ 'time': '8361.086329376785',
+ }
+
+ url2 = 'http://livestream-manager.sooplive.co.kr/broad_stream_assign.html?' + urllib.parse.urlencode(params)
+ json_str = await async_req(url=url2, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+
+ return json_data
+
+
+@trace_error_decorator
+async def get_sooplive_tk(url: str, rtype: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> str | tuple:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
+ 'Origin': 'https://play.sooplive.co.kr',
+ 'Referer': 'https://play.sooplive.co.kr/secretx/250989857',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ split_url = url.split('/')
+ bj_id = split_url[3] if len(split_url) < 6 else split_url[5]
+ room_password = get_params(url, "pwd")
+ if not room_password:
+ room_password = ''
+ data = {
+ 'bid': bj_id,
+ 'bno': '',
+ 'type': rtype,
+ 'pwd': room_password,
+ 'player_type': 'html5',
+ 'stream_type': 'common',
+ 'quality': 'master',
+ 'mode': 'landing',
+ 'from_api': '0',
+ 'is_revive': 'false',
+ }
+
+ url2 = f'https://live.sooplive.co.kr/afreeca/player_live_api.php?bjid={bj_id}'
+ json_str = await async_req(url=url2, proxy_addr=proxy_addr, headers=headers, data=data, abroad=True)
+ json_data = json.loads(json_str)
+
+ if rtype == 'aid':
+ token = json_data["CHANNEL"]["AID"]
+ return token
+ else:
+ bj_name = json_data['CHANNEL']['BJNICK']
+ bj_id = json_data['CHANNEL']['BJID']
+ return f"{bj_name}-{bj_id}", json_data['CHANNEL']['BNO']
+
+
+def get_soop_headers(cookies):
+ headers = {
+ 'client-id': str(uuid.uuid4()),
+ 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, '
+ 'like Gecko) Version/18.5 Mobile/15E148 Safari/604.1 Edg/141.0.0.0',
+ }
+ if cookies:
+ headers['cookie'] = cookies
+ return headers
+
+
+async def _get_soop_channel_info_global(bj_id, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> str:
+ headers = get_soop_headers(cookies)
+ api = 'https://api.sooplive.com/v2/channel/info/' + str(bj_id)
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ nickname = json_data['data']['streamerChannelInfo']['nickname']
+ channelId = json_data['data']['streamerChannelInfo']['channelId']
+ anchor_name = f"{nickname}-{channelId}"
+ return anchor_name
+
+
+async def _get_soop_stream_info_global(bj_id, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> tuple:
+ headers = get_soop_headers(cookies)
+ api = 'https://api.sooplive.com/v2/stream/info/' + str(bj_id)
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ status = json_data['data']['isStream']
+ title = json_data['data']['title']
+ return status, title
+
+
+async def _fetch_web_stream_data_global(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ split_url = url.split('/')
+ bj_id = split_url[3] if len(split_url) < 6 else split_url[5]
+ anchor_name = await _get_soop_channel_info_global(bj_id)
+ result = {"anchor_name": anchor_name or '', "is_live": False, "live_url": url}
+ status, title = await _get_soop_stream_info_global(bj_id)
+ if not status:
+ return result
+ else:
+ async def _get_url_list(m3u8: str) -> list[str]:
+ headers = {
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0',
+ }
+ if cookies:
+ headers['cookie'] = cookies
+ resp = await async_req(url=m3u8, proxy_addr=proxy_addr, headers=headers)
+ play_url_list = []
+ url_prefix = '/'.join(m3u8.split('/')[0:3])
+ for i in resp.split('\n'):
+ if not i.startswith('#') and i.strip():
+ play_url_list.append(url_prefix + i.strip())
+ bandwidth_pattern = re.compile(r'BANDWIDTH=(\d+)')
+ bandwidth_list = bandwidth_pattern.findall(resp)
+ url_to_bandwidth = {purl: int(bandwidth) for bandwidth, purl in zip(bandwidth_list, play_url_list)}
+ play_url_list = sorted(play_url_list, key=lambda purl: url_to_bandwidth[purl], reverse=True)
+ return play_url_list
+
+ m3u8_url = 'https://global-media.sooplive.com/live/' + str(bj_id) + '/master.m3u8'
+ result |= {
+ 'is_live': True,
+ 'title': title,
+ 'm3u8_url': m3u8_url,
+ 'play_url_list': await _get_url_list(m3u8_url)
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_sooplive_stream_data(
+ url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None,
+ username: OptionalStr = None, password: OptionalStr = None
+) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
+ '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',
+ 'Referer': 'https://m.sooplive.co.kr/',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if "sooplive.com" in url:
+ return await _fetch_web_stream_data_global(url, proxy_addr, cookies)
+
+ split_url = url.split('/')
+ bj_id = split_url[3] if len(split_url) < 6 else split_url[5]
+
+ data = {
+ 'bj_id': bj_id,
+ 'broad_no': '',
+ 'agent': 'web',
+ 'confirm_adult': 'true',
+ 'player_type': 'webm',
+ 'mode': 'live',
+ }
+
+ url2 = 'http://api.m.sooplive.co.kr/broad/a/watch'
+
+ json_str = await async_req(url=url2, proxy_addr=proxy_addr, headers=headers, data=data, abroad=True)
+ json_data = json.loads(json_str)
+
+ if 'user_nick' in json_data['data']:
+ anchor_name = json_data['data']['user_nick']
+ if "bj_id" in json_data['data']:
+ anchor_name = f"{anchor_name}-{json_data['data']['bj_id']}"
+ else:
+ anchor_name = ''
+
+ result = {"anchor_name": anchor_name or '' ,"is_live": False}
+
+ async def get_url_list(m3u8: str) -> List[str]:
+ resp = await async_req(url=m3u8, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ play_url_list = []
+ url_prefix = m3u8.rsplit('/', maxsplit=1)[0] + '/'
+ for i in resp.split('\n'):
+ if i.startswith('auth_playlist'):
+ play_url_list.append(url_prefix + i.strip())
+ bandwidth_pattern = re.compile(r'BANDWIDTH=(\d+)')
+ bandwidth_list = bandwidth_pattern.findall(resp)
+ url_to_bandwidth = {purl: int(bandwidth) for bandwidth, purl in zip(bandwidth_list, play_url_list)}
+ play_url_list = sorted(play_url_list, key=lambda purl: url_to_bandwidth[purl], reverse=True)
+ return play_url_list
+
+ if not anchor_name:
+ async def handle_login() -> OptionalStr:
+ cookie = await login_sooplive(username, password, proxy_addr=proxy_addr)
+ if 'AuthTicket=' in cookie:
+ print("sooplive platform login successful! Starting to fetch live streaming data...")
+ return cookie
+
+ async def fetch_data(cookie, _result) -> dict:
+ aid_token = await get_sooplive_tk(url, rtype='aid', proxy_addr=proxy_addr, cookies=cookie)
+ _anchor_name, _broad_no = await get_sooplive_tk(url, rtype='info', proxy_addr=proxy_addr, cookies=cookie)
+ _view_url_data = await get_sooplive_cdn_url(_broad_no, proxy_addr=proxy_addr)
+ _view_url = _view_url_data['view_url']
+ _m3u8_url = _view_url + '?aid=' + aid_token
+ _result |= {
+ "anchor_name": _anchor_name,
+ "is_live": True,
+ "m3u8_url": _m3u8_url,
+ 'play_url_list': await get_url_list(_m3u8_url),
+ 'new_cookies': cookie
+ }
+ return _result
+
+ if json_data['data']['code'] == -3001:
+ print("sooplive live stream failed to retrieve, the live stream just ended.")
+ return result
+
+ elif json_data['data']['code'] == -3002:
+ print("sooplive live stream retrieval failed, the live needs 19+, you are not logged in.")
+ print("Attempting to log in to the sooplive live streaming platform with your account and password, "
+ "please ensure it is configured.")
+ new_cookie = await handle_login()
+ if new_cookie and len(new_cookie) > 0:
+ return await fetch_data(new_cookie, result)
+ raise RuntimeError("sooplive login failed, please check if the account and password are correct")
+
+ elif json_data['data']['code'] == -3004:
+ if cookies and len(cookies) > 0:
+ return await fetch_data(cookies, result)
+ else:
+ raise RuntimeError("sooplive login failed, please check if the account and password are correct")
+ elif json_data['data']['code'] == -6001:
+ print("error message:Please check if the input sooplive live room address "
+ "is correct.")
+ return result
+ if json_data['result'] == 1 and anchor_name:
+ broad_no = json_data['data']['broad_no']
+ hls_authentication_key = json_data['data']['hls_authentication_key']
+ view_url_data = await get_sooplive_cdn_url(broad_no, proxy_addr=proxy_addr)
+ view_url = view_url_data['view_url']
+ m3u8_url = view_url + '?aid=' + hls_authentication_key
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'play_url_list': await get_url_list(m3u8_url)}
+ result['new_cookies'] = None
+ return result
+
+
+@trace_error_decorator
+async def get_netease_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://cc.163.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ url = url + '/' if url[-1] != '/' else url
+
+ html_str = await async_req(url=url, proxy_addr=proxy_addr, headers=headers)
+ json_str = re.search('',
+ html_str, re.DOTALL).group(1)
+ json_data = json.loads(json_str)
+ room_data = json_data['props']['pageProps']['roomInfoInitData']
+ live_data = room_data['live']
+ result = {"is_live": False}
+ live_status = live_data.get('status') == 1
+ result["anchor_name"] = live_data.get('nickname', room_data.get('nickname'))
+ if live_status:
+ result |= {
+ 'is_live': True,
+ 'title': live_data['title'],
+ 'stream_list': live_data.get('quickplay'),
+ 'm3u8_url': live_data.get('sharefile')
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_qiandurebo_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,'
+ 'application/signed-exchange;v=b3;q=0.7',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://qiandurebo.com/web/index.php',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ html_str = await async_req(url=url, proxy_addr=proxy_addr, headers=headers)
+ data = re.search('var user = (.*?)\r\n\\s+user\\.play_url', html_str, re.DOTALL).group(1)
+ anchor_name = re.findall('"zb_nickname": "(.*?)",\r\n', data)
+
+ result = {"anchor_name": "", "is_live": False}
+ if len(anchor_name) > 0:
+ result['anchor_name'] = anchor_name[0]
+ play_url = re.findall('"play_url": "(.*?)",\r\n', data)
+
+ if len(play_url) > 0 and 'common-text-center" style="display:block' not in html_str:
+ result |= {
+ 'anchor_name': anchor_name[0],
+ 'is_live': True,
+ 'flv_url': play_url[0],
+ 'record_url': play_url[0]
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_pandatv_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'origin': 'https://www.pandalive.co.kr',
+ 'referer': 'https://www.pandalive.co.kr/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ user_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ url2 = 'https://api.pandalive.co.kr/v1/live/play'
+ data = {
+ 'userId': user_id,
+ 'info': 'media fanGrade',
+ }
+ room_password = get_params(url, "pwd")
+ if not room_password:
+ room_password = ''
+ data2 = {
+ 'action': 'watch',
+ 'userId': user_id,
+ 'password': room_password,
+ 'shareLinkType': '',
+ }
+
+ result = {"anchor_name": "", "is_live": False}
+ json_str = await async_req('https://api.pandalive.co.kr/v1/member/bj',
+ proxy_addr=proxy_addr, headers=headers, data=data, abroad=True)
+ json_data = json.loads(json_str)
+ if "bjInfo" not in json_data:
+ raise RuntimeError(json_data.get("message", 'Unknown error'))
+ anchor_id = json_data['bjInfo']['id']
+ anchor_name = f"{json_data['bjInfo']['nick']}-{anchor_id}"
+ result['anchor_name'] = anchor_name
+ live_status = 'media' in json_data
+
+ if live_status:
+ json_str = await async_req(url2, proxy_addr=proxy_addr, headers=headers, data=data2, abroad=True)
+ json_data = json.loads(json_str)
+ if 'errorData' in json_data:
+ if json_data['errorData']['code'] == 'needAdult':
+ raise RuntimeError(f"{url} The live room requires login and is only accessible to adults. Please "
+ f"correctly fill in the login cookie in the configuration file.")
+ else:
+ raise RuntimeError(json_data['errorData']['code'], json_data['message'])
+ play_url = json_data['PlayList']['hls'][0]['url']
+ play_url_list = await get_play_url_list(m3u8=play_url, proxy=proxy_addr, header=headers, abroad=True)
+ result |= {'is_live': True, 'm3u8_url': play_url, 'play_url_list': play_url_list}
+ return result
+
+
+@trace_error_decorator
+async def get_maoerfm_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://fm.missevan.com/live/868895007',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ url2 = f'https://fm.missevan.com/api/v2/live/{room_id}'
+
+ json_str = await async_req(url=url2, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ anchor_name = json_data['info']['creator']['username']
+ live_status = False
+ if 'room' in json_data['info']:
+ live_status = json_data['info']['room']['status']['broadcasting']
+
+ result = {"anchor_name": anchor_name, "is_live": live_status}
+ if live_status:
+ stream_list = json_data['info']['room']['channel']
+ m3u8_url = stream_list['hls_pull_url']
+ flv_url = stream_list['flv_pull_url']
+ title = json_data['info']['room']['name']
+ result |= {'is_live': True, 'title': title, 'm3u8_url': m3u8_url, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_winktv_bj_info(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> tuple:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'referer': 'https://www.winktv.co.kr/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ user_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ data = {
+ 'userId': user_id,
+ 'info': 'media',
+ }
+
+ info_api = 'https://api.winktv.co.kr/v1/member/bj'
+ json_str = await async_req(url=info_api, proxy_addr=proxy_addr, headers=headers, data=data, abroad=True)
+ json_data = json.loads(json_str)
+ live_status = 'media' in json_data
+ anchor_id = json_data['bjInfo']['id']
+ anchor_name = f"{json_data['bjInfo']['nick']}-{anchor_id}"
+ return anchor_name, live_status
+
+
+@trace_error_decorator
+async def get_winktv_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'referer': 'https://www.winktv.co.kr',
+ 'origin': 'https://www.winktv.co.kr',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ user_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ room_password = get_params(url, "pwd")
+ if not room_password:
+ room_password = ''
+ data = {
+ 'action': 'watch',
+ 'userId': user_id,
+ 'password': room_password,
+ 'shareLinkType': '',
+ }
+
+ anchor_name, live_status = await get_winktv_bj_info(url=url, proxy_addr=proxy_addr, cookies=cookies)
+ result = {"anchor_name": anchor_name, "is_live": live_status}
+ if live_status:
+ play_api = 'https://api.winktv.co.kr/v1/live/play'
+ json_str = await async_req(url=play_api, proxy_addr=proxy_addr, headers=headers, data=data, abroad=True)
+ if '403: Forbidden' in json_str:
+ raise ConnectionError(f"Your network has been banned from accessing WinkTV ({json_str})")
+ json_data = json.loads(json_str)
+ if 'errorData' in json_data:
+ if json_data['errorData']['code'] == 'needAdult':
+ raise RuntimeError(f"{url} The live stream is only accessible to logged-in adults. Please ensure that "
+ f"the cookie is correctly filled in the configuration file after logging in.")
+ else:
+ raise RuntimeError(json_data['errorData']['code'], json_data['message'])
+ m3u8_url = json_data['PlayList']['hls'][0]['url']
+ play_url_list = await get_play_url_list(m3u8=m3u8_url, proxy=proxy_addr, header=headers, abroad=True)
+ result['m3u8_url'] = m3u8_url
+ result['play_url_list'] = play_url_list
+ return result
+
+
+@trace_error_decorator
+async def login_flextv(username: str, password: str, proxy_addr: OptionalStr = None) -> OptionalStr:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'content-type': 'application/json;charset=UTF-8',
+ 'referer': 'https://www.ttinglive.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+
+ data = {
+ 'loginId': username,
+ 'password': password,
+ 'loginKeep': True,
+ 'saveId': True,
+ 'device': 'PCWEB',
+ }
+
+ url = 'https://www.ttinglive.com/v2/api/auth/signin'
+
+ try:
+ print("Logging into FlexTV platform...")
+ cookie_dict = await async_req(url, proxy_addr=proxy_addr, headers=headers, json_data=data,
+ return_cookies=True, timeout=20)
+
+ if cookie_dict and 'flx_oauth_access' in cookie_dict:
+ cookie_str = '; '.join([f"{k}={v}" for k, v in cookie_dict.items()])
+ return cookie_str
+ else:
+ print("Please check if the FlexTV account and password in the configuration file are correct.")
+ return None
+
+ except Exception as e:
+ print(f"FlexTV login request exception: {e}")
+ raise Exception(
+ "FlexTV login failed, please check if the account and password in the configuration file are correct."
+ )
+
+
+async def get_flextv_stream_url(
+ url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None
+) -> str:
+ async def fetch_data(cookie) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://www.ttinglive.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ user_id = url.split('/live')[0].rsplit('/', maxsplit=1)[-1]
+ if cookie:
+ headers['Cookie'] = cookie
+ play_api = f'https://www.ttinglive.com/api/channels/{user_id}/stream?option=all'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ if 'HTTP Error 400: Bad Request' in json_str:
+ raise ConnectionError(
+ "Failed to retrieve FlexTV live streaming data, please switch to a different proxy and try again."
+ )
+ return json.loads(json_str)
+
+ json_data = await fetch_data(cookies)
+ if 'sources' in json_data and len(json_data['sources']) > 0:
+ play_url = json_data['sources'][0]['url']
+ return play_url
+
+
+@trace_error_decorator
+async def get_flextv_stream_data(
+ url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None,
+ username: OptionalStr = None, password: OptionalStr = None
+) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://www.ttinglive.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ user_id = url.split('/live')[0].rsplit('/', maxsplit=1)[-1]
+ result = {"anchor_name": '', "is_live": False}
+ new_cookies = None
+ try:
+ url2 = f'https://www.ttinglive.com/channels/{user_id}/live'
+ html_str = await async_req(url2, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_str = re.search('', html_str).group(1)
+ json_data = json.loads(json_str)
+ channel_data = json_data['props']['pageProps']['channel']
+ login_need = 'message' in channel_data and '로그인후 이용이 가능합니다.' in channel_data.get('message')
+ if login_need:
+ print("FlexTV live stream retrieval failed [not logged in]: 19+ live streams are only available for "
+ "logged-in adults.")
+ print("Attempting to log in to the FlexTV live streaming platform, please ensure your account and "
+ "password are correctly filled in the configuration file.")
+ if len(username) < 6 or len(password) < 8:
+ raise RuntimeError("FlexTV登录失败!请在config.ini配置文件中填写正确的FlexTV平台的账号和密码")
+ new_cookies = await login_flextv(username, password, proxy_addr=proxy_addr)
+ if new_cookies:
+ print("Logged into FlexTV platform successfully! Starting to fetch live streaming data...")
+ else:
+ raise RuntimeError("FlexTV login failed")
+ cookies = new_cookies if new_cookies else cookies
+ headers['Cookie'] = cookies
+ html_str = await async_req(url2, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_str = re.search('', html_str).group(1)
+ json_data = json.loads(json_str)
+ channel_data = json_data['props']['pageProps']['channel']
+
+ live_status = 'message' not in channel_data
+ if live_status:
+ anchor_id = channel_data['owner']['loginId']
+ anchor_name = f"{channel_data['owner']['nickname']}-{anchor_id}"
+ result["anchor_name"] = anchor_name
+ play_url = await get_flextv_stream_url(url=url, proxy_addr=proxy_addr, cookies=cookies)
+ if play_url:
+ result['is_live'] = True
+ if '.m3u8' in play_url:
+ play_url_list = await get_play_url_list(m3u8=play_url, proxy=proxy_addr, header=headers, abroad=True)
+ if play_url_list:
+ result['m3u8_url'] = play_url
+ result['play_url_list'] = play_url_list
+ else:
+ result['flv_url'] = play_url
+ result['record_url'] = play_url
+ else:
+ url2 = f'https://www.ttinglive.com/channels/{user_id}'
+ html_str = await async_req(url2, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ anchor_name = re.search('
tuple:
+ # 本算法参考项目:https://github.com/785415581/MusicBox/blob/b8f716d43d/doc/analysis/analyze_captured_data.md
+
+ modulus = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee' \
+ '341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe487' \
+ '5d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
+ nonce = b'0CoJUm6Qyw8W8jud'
+ public_key = '010001'
+ from Crypto.Cipher import AES
+ from Crypto.Util.Padding import pad
+ import base64
+ import binascii
+ import secrets
+
+ def create_secret_key(size: int) -> bytes:
+ charset = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|;:,.<>?'
+ return ''.join(secrets.choice(charset) for _ in range(size)).encode('utf-8')
+
+ def aes_encrypt(_text: str | bytes, _sec_key: str | bytes) -> bytes:
+ if isinstance(_text, str):
+ _text = _text.encode('utf-8')
+ if isinstance(_sec_key, str):
+ _sec_key = _sec_key.encode('utf-8')
+ _sec_key = _sec_key[:16] # 16 (AES-128), 24 (AES-192), or 32 (AES-256) bytes
+ iv = bytes('0102030405060708', 'utf-8')
+ encryptor = AES.new(_sec_key, AES.MODE_CBC, iv)
+ padded_text = pad(_text, AES.block_size)
+ ciphertext = encryptor.encrypt(padded_text)
+ encoded_ciphertext = base64.b64encode(ciphertext)
+ return encoded_ciphertext
+
+ def rsa_encrypt(_text: str | bytes, pub_key: str, mod: str) -> str:
+ if isinstance(_text, str):
+ _text = _text.encode('utf-8')
+ text_reversed = _text[::-1]
+ text_int = int(binascii.hexlify(text_reversed), 16)
+ encrypted_int = pow(text_int, int(pub_key, 16), int(mod, 16))
+ return format(encrypted_int, 'x').zfill(256)
+
+ sec_key = create_secret_key(16)
+ enc_text = aes_encrypt(aes_encrypt(json.dumps(text), nonce), sec_key)
+ enc_sec_key = rsa_encrypt(sec_key, public_key, modulus)
+ return enc_text.decode(), enc_sec_key
+
+
+async def get_looklive_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ """
+ 通过PC网页端的接口获取完整直播源,只有params和encSecKey这两个加密请求参数。
+ params: 由两次AES加密完成
+ ncSecKey: 由一次自写的加密函数完成,值可固定
+ """
+
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0',
+ 'Accept': 'application/json, text/javascript',
+ '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',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Referer': 'https://look.163.com/',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = re.search('live\\?id=(.*?)&', url).group(1)
+ params, secretkey = get_looklive_secret_data({"liveRoomNo": room_id})
+ request_data = {'params': params, 'encSecKey': secretkey}
+ api = 'https://api.look.163.com/weapi/livestream/room/get/v3'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers, data=request_data)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['anchor']['nickName']
+ live_status = json_data['data']['liveStatus']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ result["is_live"] = True
+ if json_data['data']['roomInfo']['liveType'] == 1:
+ print("Look live currently only supports audio live streaming, not video live streaming!")
+ else:
+ play_url_list = json_data['data']['roomInfo']['liveUrl']
+ live_title = json_data['data']['roomInfo']['title']
+ result |= {
+ "title": live_title,
+ "flv_url": play_url_list['httpPullUrl'],
+ "m3u8_url": play_url_list['hlsPullUrl'],
+ "record_url": play_url_list['hlsPullUrl'],
+ }
+ return result
+
+
+@trace_error_decorator
+async def login_popkontv(
+ username: str, password: str, proxy_addr: OptionalStr = None, code: OptionalStr = 'P-00001'
+) -> tuple:
+ headers = {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Authorization': 'Basic FpAhe6mh8Qtz116OENBmRddbYVirNKasktdXQiuHfm88zRaFydTsFy63tzkdZY0u',
+ 'Content-Type': 'application/json',
+ 'Origin': 'https://www.popkontv.com',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+
+ data = {
+ 'partnerCode': code,
+ 'signId': username,
+ 'signPwd': password,
+ }
+
+ url = 'https://www.popkontv.com/api/proxy/member/v1/login'
+
+ try:
+ proxy_addr = utils.handle_proxy_addr(proxy_addr)
+ async with httpx.AsyncClient(proxy=proxy_addr, timeout=20, verify=False) as client:
+ response = await client.post(url, json=data, headers=headers)
+ response.raise_for_status()
+
+ json_data = response.json()
+ login_status_code = json_data.get("statusCd")
+
+ if login_status_code == 'E4010':
+ raise Exception("popkontv login failed, please reconfigure the correct login account or password!")
+ elif login_status_code == 'S2000':
+ token = json_data['data'].get("token")
+ partner_code = json_data['data'].get("partnerCode")
+ return token, partner_code
+ else:
+ raise Exception(f"popkontv login failed, {json_data.get('statusMsg', 'unknown error')}")
+ except httpx.HTTPStatusError as e:
+ print(f"HTTP status error occurred during login: {e.response.status_code}")
+ raise
+ except Exception as e:
+ print(f"An exception occurred during popkontv login: {e}")
+ raise
+
+
+@trace_error_decorator
+async def get_popkontv_stream_data(
+ url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None,
+ username: OptionalStr = None, code: OptionalStr = 'P-00001'
+) -> tuple:
+ headers = {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Content-Type': 'application/json',
+ 'Origin': 'https://www.popkontv.com',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ if 'mcid' in url:
+ anchor_id = re.search('mcid=(.*?)&', url).group(1)
+ else:
+ anchor_id = re.search('castId=(.*?)(?=&|$)', url).group(1)
+
+ data = {
+ 'partnerCode': code,
+ 'searchKeyword': anchor_id,
+ 'signId': username,
+ }
+
+ api = 'https://www.popkontv.com/api/proxy/broadcast/v1/search/all'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers, json_data=data, abroad=True)
+ json_data = json.loads(json_str)
+
+ partner_code = ''
+ anchor_name = 'Unknown'
+ for item in json_data['data']['broadCastList']:
+ if item['mcSignId'] == anchor_id:
+ mc_name = item['nickName']
+ anchor_name = f"{mc_name}-{anchor_id}"
+ partner_code = item['mcPartnerCode']
+ break
+
+ if not partner_code:
+ if 'mcPartnerCode' in url:
+ regex_result = re.search('mcPartnerCode=(P-\\d+)', url)
+ else:
+ regex_result = re.search('partnerCode=(P-\\d+)', url)
+ partner_code = regex_result.group(1) if regex_result else code
+ notices_url = f'https://www.popkontv.com/channel/notices?mcid={anchor_id}&mcPartnerCode={partner_code}'
+ notices_response = await async_req(notices_url, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ mc_name_match = re.search(r'"mcNickName":"([^"]+)"', notices_response)
+ mc_name = mc_name_match.group(1) if mc_name_match else 'Unknown'
+ anchor_name = f"{anchor_id}-{mc_name}"
+
+ live_url = f"https://www.popkontv.com/live/view?castId={anchor_id}&partnerCode={partner_code}"
+ html_str2 = await async_req(live_url, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_str2 = re.search('', html_str2).group(1)
+ json_data2 = json.loads(json_str2)
+ if 'mcData' in json_data2['props']['pageProps']:
+ room_data = json_data2['props']['pageProps']['mcData']['data']
+ is_private = room_data['mc_isPrivate']
+ cast_start_date_code = room_data['mc_castStartDate']
+ mc_sign_id = room_data['mc_signId']
+ cast_type = room_data['castType']
+ return anchor_name, [cast_start_date_code, partner_code, mc_sign_id, cast_type, is_private]
+ else:
+ return anchor_name, None
+
+
+@trace_error_decorator
+async def get_popkontv_stream_url(
+ url: str,
+ proxy_addr: OptionalStr = None,
+ access_token: OptionalStr = None,
+ username: OptionalStr = None,
+ password: OptionalStr = None,
+ partner_code: OptionalStr = 'P-00001'
+) -> dict:
+ headers = {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'ClientKey': 'Client FpAhe6mh8Qtz116OENBmRddbYVirNKasktdXQiuHfm88zRaFydTsFy63tzkdZY0u',
+ 'Content-Type': 'application/json',
+ 'Origin': 'https://www.popkontv.com',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+
+ if access_token:
+ headers['Authorization'] = f'Bearer {access_token}'
+
+ anchor_name, room_info = await get_popkontv_stream_data(
+ url, proxy_addr=proxy_addr, code=partner_code, username=username)
+ result = {"anchor_name": anchor_name, "is_live": False}
+ new_token = None
+ if room_info:
+ cast_start_date_code, cast_partner_code, mc_sign_id, cast_type, is_private = room_info
+ result["is_live"] = True
+ room_password = get_params(url, "pwd")
+ if int(is_private) != 0 and not room_password:
+ raise RuntimeError(f"Failed to retrieve live room data because {anchor_name}'s room is a private room. "
+ f"Please configure the room password and try again.")
+
+ async def fetch_data(header: dict = None, code: str = None) -> str:
+ data = {
+ 'androidStore': 0,
+ 'castCode': f'{mc_sign_id}-{cast_start_date_code}',
+ 'castPartnerCode': cast_partner_code,
+ 'castSignId': mc_sign_id,
+ 'castType': cast_type,
+ 'commandType': 0,
+ 'exePath': 5,
+ 'isSecret': is_private,
+ 'partnerCode': code,
+ 'password': room_password,
+ 'signId': username,
+ 'version': '4.6.2',
+ }
+ play_api = 'https://www.popkontv.com/api/proxy/broadcast/v1/castwatchonoffguest'
+ return await async_req(play_api, proxy_addr=proxy_addr, json_data=data, headers=header, abroad=True)
+
+ json_str = await fetch_data(headers, partner_code)
+
+ if 'HTTP Error 400' in json_str or 'statusCd":"E5000' in json_str:
+ print("Failed to retrieve popkontv live stream [token does not exist or has expired]: Please log in to "
+ "watch.")
+ print("Attempting to log in to the popkontv live streaming platform, please ensure your account "
+ "and password are correctly filled in the configuration file.")
+ if len(username) < 4 or len(password) < 10:
+ raise RuntimeError("popkontv login failed! Please enter the correct account and password for the "
+ "popkontv platform in the config.ini file.")
+ print("Logging into popkontv platform...")
+ new_access_token, new_partner_code = await login_popkontv(
+ username=username, password=password, proxy_addr=proxy_addr, code=partner_code
+ )
+ if new_access_token and len(new_access_token) == 640:
+ print("Logged into popkontv platform successfully! Starting to fetch live streaming data...")
+ headers['Authorization'] = f'Bearer {new_access_token}'
+ new_token = f'Bearer {new_access_token}'
+ json_str = await fetch_data(headers, new_partner_code)
+ else:
+ raise RuntimeError("popkontv login failed, please check if the account and password are correct")
+ json_data = json.loads(json_str)
+ status_msg = json_data["statusMsg"]
+ if json_data['statusCd'] == "L000A":
+ print("Failed to retrieve live stream source,", status_msg)
+ raise RuntimeError("You are an unverified member. After logging into the popkontv official website, "
+ "please verify your mobile phone at the bottom of the 'My Page' > 'Edit My "
+ "Information' to use the service.")
+ elif json_data['statusCd'] == "L0001":
+ cast_start_date_code = int(cast_start_date_code) - 1
+ json_str = await fetch_data(headers, partner_code)
+ json_data = json.loads(json_str)
+ m3u8_url = json_data['data']['castHlsUrl']
+ result |= {"m3u8_url": m3u8_url, "record_url": m3u8_url}
+ elif json_data['statusCd'] == "L0000":
+ m3u8_url = json_data['data']['castHlsUrl']
+ result |= {"m3u8_url": m3u8_url, "record_url": m3u8_url}
+ else:
+ raise RuntimeError("Failed to retrieve live stream source,", status_msg)
+ result['new_token'] = new_token
+ return result
+
+
+@trace_error_decorator
+async def login_twitcasting(
+ account_type: str, username: str, password: str, proxy_addr: OptionalStr = None,
+ cookies: OptionalStr = None
+) -> OptionalStr:
+ headers = {
+ '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.7',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Referer': 'https://twitcasting.tv/indexcaslogin.php?redir=%2Findexloginwindow.php%3Fnext%3D%252F&keep=1',
+ 'Cookie': 'hl=zh; did=04fb08f1b15d248644f1dfa82816d323; _ga=GA1.1.1021187740.1709706998; keep=1; mfadid=yrQiEB26ruRg7mlMavABMBZWdOddzojW; _ga_X8R46Y30YM=GS1.1.1709706998.1.1.1709712274.0.0.0',
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if account_type == "twitter":
+ login_url = 'https://twitcasting.tv/indexpasswordlogin.php'
+ login_api = 'https://twitcasting.tv/indexpasswordlogin.php?redir=/indexloginwindow.php?next=%2F&keep=1'
+ else:
+ login_url = 'https://twitcasting.tv/indexcaslogin.php?redir=%2F&keep=1'
+ login_api = 'https://twitcasting.tv/indexcaslogin.php?redir=/indexloginwindow.php?next=%2F&keep=1'
+
+ html_str = await async_req(login_url, proxy_addr=proxy_addr, headers=headers)
+ cs_session_id = re.search('
', html_str).group(1)
+
+ data = {
+ 'username': username,
+ 'password': password,
+ 'action': 'login',
+ 'cs_session_id': cs_session_id,
+ }
+ try:
+ cookie_dict = await async_req(login_api, proxy_addr=proxy_addr, headers=headers,
+ data=data, return_cookies=True, timeout=20)
+ if 'tc_ss' in cookie_dict:
+ cookie = utils.dict_to_cookie_str(cookie_dict)
+ return cookie
+ except Exception as e:
+ print("TwitCasting login error,", e)
+
+
+@trace_error_decorator
+async def get_twitcasting_stream_url(
+ url: str,
+ proxy_addr: OptionalStr = None,
+ cookies: OptionalStr = None,
+ account_type: OptionalStr = None,
+ username: OptionalStr = None,
+ password: OptionalStr = None,
+) -> dict:
+ headers = {
+ '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.7',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Referer': 'https://twitcasting.tv/?ch0',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ }
+
+ anchor_id = url.split('/')[3]
+ if cookies:
+ headers['Cookie'] = cookies
+
+ async def get_data(header) -> tuple:
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=header)
+ anchor = re.search("
(.*?) \\(@(.*?)\\) 的直播 - Twit", html_str)
+ title = re.search(' \n\\s+ dict:
+ headers = {
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Connection': 'keep-alive',
+ 'Referer': 'https://live.baidu.com/',
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ uid = random.choice([
+ 'h5-683e85bdf741bf2492586f7ca39bf465',
+ 'h5-c7c6dc14064a136be4215b452fab9eea',
+ 'h5-4581281f80bb8968bd9a9dfba6050d3a'
+ ])
+ room_id = re.search('room_id=(.*?)&', url).group(1)
+ params = {
+ 'cmd': '371',
+ 'action': 'star',
+ 'service': 'bdbox',
+ 'osname': 'baiduboxapp',
+ 'data': '{"data":{"room_id":"' + room_id + '","device_id":"h5-683e85bdf741bf2492586f7ca39bf465",'
+ '"source_type":0,"osname":"baiduboxapp"},"replay_slice":0,'
+ '"nid":"","schemeParams":{"src_pre":"pc","src_suf":"other",'
+ '"bd_vid":"","share_uid":"","share_cuk":"","share_ecid":"",'
+ '"zb_tag":"","shareTaskInfo":"{\\"room_id\\":\\"9175031377\\"}",'
+ '"share_from":"","ext_params":"","nid":""}}',
+ 'ua': '360_740_ANDROID_0',
+ 'bd_vid': '',
+ 'uid': uid,
+ '_': str(int(time.time() * 1000)),
+ }
+ app_api = f'https://mbd.baidu.com/searchbox?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(url=app_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ key = list(json_data['data'].keys())[0]
+ data = json_data['data'][key]
+ anchor_name = data['host']['name']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if data['status'] == "0":
+ result["is_live"] = True
+ live_title = data['video']['title']
+ play_url_list = data['video']['url_clarity_list']
+ url_list = []
+ prefix = 'https://hls.liveshow.bdstatic.com/live/'
+ if play_url_list:
+ for i in play_url_list:
+ url_list.append(
+ prefix + i['urls']['flv'].rsplit('.', maxsplit=1)[0].rsplit('/', maxsplit=1)[1] + '.m3u8')
+ else:
+ play_url_list = data['video']['url_list']
+ for i in play_url_list:
+ url_list.append(prefix + i['urls'][0]['hls'].rsplit('?', maxsplit=1)[0].rsplit('/', maxsplit=1)[1])
+
+ if url_list:
+ result |= {"is_live": True, "title": live_title, 'play_url_list': url_list}
+ return result
+
+
+@trace_error_decorator
+async def get_weibo_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Cookie': 'XSRF-TOKEN=qAP-pIY5V4tO6blNOhA4IIOD; SUB=_2AkMRNMCwf8NxqwFRmfwWymPrbI9-zgzEieKnaDFrJRMxHRl-yT9kqmkhtRB6OrTuX5z9N_7qk9C3xxEmNR-8WLcyo2PM; SUBP=0033WrSXqPxfM72-Ws9jqgMF55529P9D9WWemwcqkukCduUO11o9sBqA; WBPSESS=Wk6CxkYDejV3DDBcnx2LOXN9V1LjdSTNQPMbBDWe4lO2HbPmXG_coMffJ30T-Avn_ccQWtEYFcq9fab1p5RR6PEI6w661JcW7-56BszujMlaiAhLX-9vT4Zjboy1yf2l',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ 'Referer': 'https://weibo.com/u/5885340893'
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = ''
+ if 'show/' in url:
+ room_id = url.split('?')[0].split('show/')[1]
+ else:
+ uid = url.split('?')[0].rsplit('/u/', maxsplit=1)[1]
+ web_api = f'https://weibo.com/ajax/statuses/mymblog?uid={uid}&page=1&feature=0'
+ json_str = await async_req(web_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ for i in json_data['data']['list']:
+ if 'page_info' in i and i['page_info']['object_type'] == 'live':
+ room_id = i['page_info']['object_id']
+ break
+
+ result = {"anchor_name": '', "is_live": False}
+ if room_id:
+ app_api = f'https://weibo.com/l/pc/anchor/live?live_id={room_id}'
+ # app_api = f'https://weibo.com/l/!/2/wblive/room/show_pc_live.json?live_id={room_id}'
+ json_str = await async_req(url=app_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['user_info']['name']
+ result["anchor_name"] = anchor_name
+ live_status = json_data['data']['item']['status']
+ if live_status == 1:
+ result["is_live"] = True
+ live_title = json_data['data']['item']['desc']
+ play_url_list = json_data['data']['item']['stream_info']['pull']
+ m3u8_url = play_url_list['live_origin_hls_url']
+ flv_url = play_url_list['live_origin_flv_url']
+ result['title'] = live_title
+ result['play_url_list'] = [
+ {"m3u8_url": m3u8_url, "flv_url": flv_url},
+ {"m3u8_url": m3u8_url.split('_')[0] + '.m3u8', "flv_url": flv_url.split('_')[0] + '.flv'}
+ ]
+ return result
+
+
+@trace_error_decorator
+async def get_kugou_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ 'Accept': 'application/json',
+ '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',
+ 'Referer': 'https://fanxing2.kugou.com/',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if 'roomId' in url:
+ room_id = re.search('roomId=(\\d+)', url).group(1)
+ else:
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+
+ app_api = f'https://service2.fanxing.kugou.com/roomcen/room/web/cdn/getEnterRoomInfo?roomId={room_id}'
+ json_str = await async_req(url=app_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['normalRoomInfo']['nickName']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if not anchor_name:
+ raise RuntimeError(
+ "Music channel live rooms are not supported for recording, please switch to a different live room."
+ )
+ live_status = json_data['data']['liveType']
+ if live_status != -1:
+ params = {
+ 'std_rid': room_id,
+ 'std_plat': '7',
+ 'std_kid': '0',
+ 'streamType': '1-2-4-5-8',
+ 'ua': 'fx-flash',
+ 'targetLiveTypes': '1-5-6',
+ 'version': '1000',
+ 'supportEncryptMode': '1',
+ 'appid': '1010',
+ '_': str(int(time.time() * 1000)),
+ }
+ api = f'https://fx1.service.kugou.com/video/pc/live/pull/mutiline/streamaddr?{urllib.parse.urlencode(params)}'
+ json_str2 = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data2 = json.loads(json_str2)
+ stream_data = json_data2['data']['lines']
+ if stream_data:
+ flv_url = stream_data[-1]['streamProfiles'][0]['httpsFlv'][0]
+ result |= {"is_live": True, "flv_url": flv_url, "record_url": flv_url}
+ return result
+
+
+async def get_twitchtv_room_info(url: str, token: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> tuple:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0',
+ 'Accept-Language': 'zh-CN',
+ 'Referer': 'https://www.twitch.tv/',
+ 'Client-Id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
+ 'Client-Integrity': token,
+ 'Content-Type': 'text/plain;charset=UTF-8',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ uid = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+
+ data = [
+ {
+ "operationName": "ChannelShell",
+ "variables": {
+ "login": uid
+ },
+ "extensions": {
+ "persistedQuery": {
+ "version": 1,
+ "sha256Hash": "580ab410bcd0c1ad194224957ae2241e5d252b2c5173d8e0cce9d32d5bb14efe"
+ }
+ }
+ },
+ ]
+
+ json_str = await async_req('https://gql.twitch.tv/gql', proxy_addr=proxy_addr, headers=headers,
+ json_data=data, abroad=True)
+ json_data = json.loads(json_str)
+ user_data = json_data[0]['data']['userOrError']
+ login_name = user_data["login"]
+ nickname = f"{user_data['displayName']}-{login_name}"
+ status = True if user_data['stream'] else False
+ return nickname, status
+
+
+@trace_error_decorator
+async def get_twitchtv_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ 'Accept-Language': 'en-US',
+ 'Referer': 'https://www.twitch.tv/',
+ 'Client-ID': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
+ 'device-id': generate_random_string(16).lower(),
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+ uid = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+
+ data = {
+ "operationName": "PlaybackAccessToken_Template",
+ "query": "query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, "
+ "$isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, "
+ "params: {platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}) @include(if: "
+ "$isLive) { value signature authorization { isForbidden forbiddenReasonCode } __typename "
+ "} videoPlaybackAccessToken(id: $vodID, params: {platform: \"web\", playerBackend: \"mediaplayer\", "
+ "playerType: $playerType}) @include(if: $isVod) { value signature __typename }}",
+ "variables": {
+ "isLive": True,
+ "login": uid,
+ "isVod": False,
+ "vodID": "",
+ "playerType": "site"
+ }
+ }
+
+ json_str = await async_req('https://gql.twitch.tv/gql', proxy_addr=proxy_addr, headers=headers,
+ json_data=data, abroad=True)
+ json_data = json.loads(json_str)
+ token = json_data['data']['streamPlaybackAccessToken']['value']
+ sign = json_data['data']['streamPlaybackAccessToken']['signature']
+
+ anchor_name, live_status = await get_twitchtv_room_info(url=url, token=token, proxy_addr=proxy_addr, cookies=cookies)
+ result = {"anchor_name": anchor_name, "is_live": live_status}
+ if live_status:
+ play_session_id = random.choice(["bdd22331a986c7f1073628f2fc5b19da", "064bc3ff1722b6f53b0b5b8c01e46ca5"])
+ params = {
+ "acmb": "e30=",
+ "allow_source": "true",
+ "browser_family": "firefox",
+ "browser_version": "124.0",
+ "cdm": "wv",
+ "fast_bread": "true",
+ "os_name": "Windows",
+ "os_version": "NT%2010.0",
+ "p": "3553732",
+ "platform": "web",
+ "play_session_id": play_session_id,
+ "player_backend": "mediaplayer",
+ "player_version": "1.28.0-rc.1",
+ "playlist_include_framerate": "true",
+ "reassignments_supported": "true",
+ "sig": sign,
+ "token": token,
+ "transcode_mode": "cbr_v1"
+ }
+ access_key = urllib.parse.urlencode(params)
+ m3u8_url = f'https://usher.ttvnw.net/api/channel/hls/{uid}.m3u8?{access_key}'
+ play_url_list = await get_play_url_list(m3u8=m3u8_url, proxy=proxy_addr, header=headers, abroad=True)
+ result |= {'m3u8_url': m3u8_url, 'play_url_list': play_url_list}
+ return result
+
+
+@trace_error_decorator
+async def get_liveme_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'origin': 'https://www.liveme.com',
+ 'referer': 'https://www.liveme.com',
+ 'user-agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if 'index.html' not in url:
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ match_url = re.search(' ', html_str)
+ if match_url:
+ url = match_url.group(1)
+
+ room_id = url.split("/index.html")[0].rsplit('/', maxsplit=1)[-1]
+ sign_data = execjs.compile(open(f'{JS_SCRIPT_PATH}/liveme.js').read()).call('sign', room_id,
+ f'{JS_SCRIPT_PATH}/crypto-js.min.js')
+ lm_s_sign = sign_data.pop("lm_s_sign")
+ tongdun_black_box = sign_data.pop("tongdun_black_box")
+ platform = sign_data.pop("os")
+ headers['lm-s-sign'] = lm_s_sign
+
+ params = {
+ 'alias': 'liveme',
+ 'tongdun_black_box': tongdun_black_box,
+ 'os': platform,
+ }
+
+ api = f'https://live.liveme.com/live/queryinfosimple?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(api, data=sign_data, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ stream_data = json_data['data']['video_info']
+ anchor_name = stream_data['uname']
+ live_status = stream_data['status']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == "0":
+ m3u8_url = stream_data['hlsvideosource']
+ flv_url = stream_data['videosource']
+ result |= {
+ 'is_live': True,
+ 'm3u8_url': m3u8_url,
+ 'flv_url': flv_url,
+ 'record_url': m3u8_url or flv_url
+ }
+ return result
+
+
+async def get_huajiao_sn(url: str, cookies: OptionalStr = None, proxy_addr: OptionalStr = None) -> tuple | None:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://www.huajiao.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ live_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ api = f'https://www.huajiao.com/l/{live_id}'
+ try:
+ html_str = await async_req(url=api, proxy_addr=proxy_addr, headers=headers)
+ json_str = re.search('var feed = (.*?});', html_str).group(1)
+ json_data = json.loads(json_str)
+ sn = json_data['feed']['sn']
+ uid = json_data['author']['uid']
+ nickname = json_data['author']['nickname']
+ live_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ return nickname, sn, uid, live_id
+ except Exception:
+ utils.replace_url(f'{script_path}/config/URL_config.ini', old=url, new='#' + url)
+ raise RuntimeError("Failed to retrieve live room data, the Huajiao live room address is not fixed, please use "
+ "the anchor's homepage address for recording.")
+
+
+async def get_huajiao_user_info(url: str, cookies: OptionalStr = None, proxy_addr: OptionalStr = None) -> OptionalDict:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://www.huajiao.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if 'user' in url:
+ uid = url.split('?')[0].split('user/')[1]
+ params = {
+ 'uid': uid,
+ 'fmt': 'json',
+ '_': str(int(time.time() * 1000)),
+ }
+
+ api = f'https://webh.huajiao.com/User/getUserFeeds?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(url=api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ html_str = await async_req(url=f'https://www.huajiao.com/user/{uid}', proxy_addr=proxy_addr, headers=headers)
+ anchor_name = re.search('(.*?)的主页.* ', html_str).group(1)
+ if json_data['data'] and 'sn' in json_data['data']['feeds'][0]['feed']:
+ feed = json_data['data']['feeds'][0]['feed']
+ return {
+ "anchor_name": anchor_name,
+ "title": feed['title'],
+ "is_live": True,
+ "sn": feed['sn'],
+ "liveid": feed['relateid'],
+ "uid": uid
+ }
+ else:
+ return {"anchor_name": anchor_name, "is_live": False}
+
+
+async def get_huajiao_stream_url_app(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> OptionalDict:
+ headers = {
+ 'User-Agent': 'living/9.4.0 (com.huajiao.seeding; build:2410231746; iOS 17.0.0) Alamofire/9.4.0',
+ 'accept-language': 'zh-Hans-US;q=1.0',
+ 'sdk_version': '1',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ room_id = url.rsplit('/', maxsplit=1)[1]
+ api = f'https://live.huajiao.com/feed/getFeedInfo?relateid={room_id}'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ if json_data['errmsg'] or not json_data['data'].get('creatime'):
+ print("Failed to retrieve live room data, the Huajiao live room address is not fixed, please manually change "
+ "the address for recording.")
+ return
+ data = json_data['data']
+ return {
+ "anchor_name": data['author']['nickname'],
+ "title": data['feed']['title'],
+ "is_live": True,
+ "sn": data['feed']['sn'],
+ "liveid": data['feed']['relateid'],
+ "uid": data['author']['uid']
+ }
+
+
+@trace_error_decorator
+async def get_huajiao_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://www.huajiao.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ result = {"anchor_name": "", "is_live": False}
+
+ if 'user/' in url:
+ if not cookies:
+ return result
+ room_data = await get_huajiao_user_info(url, cookies, proxy_addr)
+ else:
+ url = await async_req(url, proxy_addr=proxy_addr, headers=headers, redirect_url=True)
+ if url.rstrip('/') == 'https://www.huajiao.com':
+ print(
+ "Failed to retrieve live room data, the Huajiao live room address is not fixed, please manually change "
+ "the address for recording.")
+ return result
+ room_data = await get_huajiao_stream_url_app(url, proxy_addr)
+
+ if room_data:
+ result["anchor_name"] = room_data.pop("anchor_name")
+ live_status = room_data.pop("is_live")
+
+ if live_status:
+ result["title"] = room_data.pop("title")
+ params = {
+ "time": int(time.time() * 1000),
+ "version": "1.0.0",
+ **room_data,
+ "encode": "h265"
+ }
+
+ api = f'https://live.huajiao.com/live/substream?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(url=api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ result |= {
+ 'is_live': True,
+ 'flv_url': json_data['data']['h264_url'],
+ 'record_url': json_data['data']['h264_url'],
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_liuxing_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'Accept': 'application/json, text/plain, */*',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'Referer': 'https://wap.7u66.com/198189?promoters=0',
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ params = {
+ "promoters": "0",
+ "roomidx": room_id,
+ "currentUrl": f"https://www.7u66.com/{room_id}?promoters=0"
+ }
+ api = f'https://wap.7u66.com/api/ui/room/v1.0.0/live.ashx?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(url=api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ room_info = json_data['data']['roomInfo']
+ anchor_name = room_info['nickname']
+ live_status = room_info["live_stat"]
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ idx = room_info['idx']
+ live_id = room_info['liveId1']
+ flv_url = f'https://txpull1.5see.com/live/{idx}/{live_id}.flv'
+ result |= {'is_live': True, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_showroom_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if '/room/profile' in url:
+ room_id = url.split('room_id=')[-1]
+ else:
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ room_id = re.search('href="/room/profile\\?room_id=(.*?)"', html_str).group(1)
+ info_api = f'https://www.showroom-live.com/api/live/live_info?room_id={room_id}'
+ json_str = await async_req(info_api, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['room_name']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ live_status = json_data['live_status']
+ if live_status == 2:
+ result["is_live"] = True
+ web_api = f'https://www.showroom-live.com/api/live/streaming_url?room_id={room_id}&abr_available=1'
+ json_str = await async_req(web_api, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ if json_str:
+ json_data = json.loads(json_str)
+ streaming_url_list = json_data['streaming_url_list']
+
+ for i in streaming_url_list:
+ if i['type'] == 'hls_all':
+ m3u8_url = i['url']
+ result['m3u8_url'] = m3u8_url
+ if m3u8_url:
+ m3u8_url_list = await get_play_url_list(m3u8_url, proxy=proxy_addr, header=headers, abroad=True)
+ if m3u8_url_list:
+ result['play_url_list'] = [f"{m3u8_url.rsplit('/', maxsplit=1)[0]}/{i}" for i in
+ m3u8_url_list]
+ else:
+ result['play_url_list'] = [m3u8_url]
+ result['play_url_list'] = [i.replace('https://', 'http://') for i in result['play_url_list']]
+ break
+ return result
+
+
+@trace_error_decorator
+async def get_acfun_sign_params(proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> tuple:
+ did = f'web_{utils.generate_random_string(16)}'
+ headers = {
+ 'referer': 'https://live.acfun.cn/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ 'cookie': f'_did={did};',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+ data = {
+ 'sid': 'acfun.api.visitor',
+ }
+ api = 'https://id.app.acfun.cn/rest/app/visitor/login'
+ json_str = await async_req(api, data=data, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ user_id = json_data["userId"]
+ visitor_st = json_data["acfun.api.visitor_st"]
+ return user_id, did, visitor_st
+
+
+@trace_error_decorator
+async def get_acfun_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'referer': 'https://live.acfun.cn/live/17912421',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ author_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ user_info_api = f'https://live.acfun.cn/rest/pc-direct/user/userInfo?userId={author_id}'
+ json_str = await async_req(user_info_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['profile']['name']
+ status = 'liveId' in json_data['profile']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if status:
+ result["is_live"] = True
+ user_id, did, visitor_st = await get_acfun_sign_params(proxy_addr=proxy_addr, cookies=cookies)
+ params = {
+ 'subBiz': 'mainApp',
+ 'kpn': 'ACFUN_APP',
+ 'kpf': 'PC_WEB',
+ 'userId': user_id,
+ 'did': did,
+ 'acfun.api.visitor_st': visitor_st,
+ }
+
+ data = {
+ 'authorId': author_id,
+ 'pullStreamType': 'FLV',
+ }
+ play_api = f'https://api.kuaishouzt.com/rest/zt/live/web/startPlay?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(play_api, data=data, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ live_title = json_data['data']['caption']
+ videoPlayRes = json_data['data']['videoPlayRes']
+ play_url_list = json.loads(videoPlayRes)['liveAdaptiveManifest'][0]['adaptationSet']['representation']
+ play_url_list = sorted(play_url_list, key=itemgetter('bitrate'), reverse=True)
+ result |= {'play_url_list': play_url_list, 'title': live_title}
+ return result
+
+
+@trace_error_decorator
+async def get_changliao_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'Accept': 'application/json, text/plain, */*',
+ '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',
+ 'Referer': 'https://wap.tlclw.com/phone/15777?promoters=0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ params = {
+ 'roomidx': room_id,
+ 'currentUrl': f'https://wap.tlclw.com/{room_id}',
+ }
+ play_api = f'https://wap.tlclw.com/api/ui/room/v1.0.0/live.ashx?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['roomInfo']['nickname']
+ live_status = json_data['data']['roomInfo']['live_stat']
+
+ async def get_live_domain(page_url):
+ html_str = await async_req(page_url, proxy_addr=proxy_addr, headers=headers)
+ config_json_str = re.findall("var config = (.*?)config.webskins",
+ html_str, re.DOTALL)[0].rsplit(";", maxsplit=1)[0].strip()
+ config_json_data = json.loads(config_json_str)
+ stream_flv_domain = config_json_data['domainpullstream_flv']
+ stream_hls_domain = config_json_data['domainpullstream_hls']
+ return stream_flv_domain, stream_hls_domain
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ flv_domain, hls_domain = await get_live_domain(url)
+ live_id = json_data['data']['roomInfo']['liveID']
+ flv_url = f'{flv_domain}/{live_id}.flv'
+ m3u8_url = f'{hls_domain}/{live_id}.m3u8'
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_yingke_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'Referer': 'https://www.inke.cn/',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ parsed_url = urllib.parse.urlparse(url)
+ query_params = urllib.parse.parse_qs(parsed_url.query)
+ uid = query_params['uid'][0]
+ live_id = query_params['id'][0]
+ params = {
+ 'uid': uid,
+ 'id': live_id,
+ '_t': str(int(time.time())),
+ }
+
+ api = f'https://webapi.busi.inke.cn/web/live_share_pc?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['media_info']['nick']
+ live_status = json_data['data']['status']
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ m3u8_url = json_data['data']['live_addr'][0]['hls_stream_addr']
+ flv_url = json_data['data']['live_addr'][0]['stream_addr']
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'flv_url': flv_url, 'record_url': m3u8_url}
+ return result
+
+
+@trace_error_decorator
+async def get_yinbo_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'Accept': 'application/json, text/plain, */*',
+ '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',
+ 'Referer': 'https://live.ybw1666.com/800005143?promoters=0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ params = {
+ 'roomidx': room_id,
+ 'currentUrl': f'https://wap.ybw1666.com/{room_id}',
+ }
+ play_api = f'https://wap.ybw1666.com/api/ui/room/v1.0.0/live.ashx?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ room_data = json_data['data']['roomInfo']
+ anchor_name = room_data['nickname']
+ live_status = room_data['live_stat']
+
+ async def get_live_domain(page_url):
+ html_str = await async_req(page_url, proxy_addr=proxy_addr, headers=headers)
+ config_json_str = re.findall("var config = (.*?)config.webskins",
+ html_str, re.DOTALL)[0].rsplit(";", maxsplit=1)[0].strip()
+ config_json_data = json.loads(config_json_str)
+ stream_flv_domain = config_json_data['domainpullstream_flv']
+ stream_hls_domain = config_json_data['domainpullstream_hls']
+ return stream_flv_domain, stream_hls_domain
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ flv_domain, hls_domain = await get_live_domain(url)
+ live_id = room_data['liveID']
+ flv_url = f'{flv_domain}/{live_id}.flv'
+ m3u8_url = f'{hls_domain}/{live_id}.m3u8'
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_zhihu_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if 'people/' in url:
+ user_id = url.split('people/')[1]
+ api = f'https://api.zhihu.com/people/{user_id}/profile?profile_new_version='
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ live_page_url = json_data['drama']['living_theater']['theater_url']
+ else:
+ live_page_url = url
+
+ web_id = live_page_url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ html_str = await async_req(live_page_url, proxy_addr=proxy_addr, headers=headers)
+ json_str2 = re.findall('', html_str)[0]
+ json_data2 = json.loads(json_str2)
+ live_data = json_data2['initialState']['theater']['theaters'][web_id]
+ anchor_name = live_data['actor']['name']
+ live_status = live_data['drama']['status']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ live_title = live_data['theme']
+ play_url = live_data['drama']['playInfo']
+ result |= {
+ 'is_live': True,
+ 'title': live_title,
+ 'm3u8_url': play_url['hlsUrl'],
+ 'flv_url': play_url['playUrl'],
+ 'record_url': play_url['hlsUrl']
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_chzzk_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'origin': 'https://chzzk.naver.com',
+ 'referer': 'https://chzzk.naver.com/live/458f6ec20b034f49e0fc6d03921646d2',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ play_api = f'https://api.chzzk.naver.com/service/v3/channels/{room_id}/live-detail'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ live_data = json_data['content']
+ anchor_name = live_data['channel']['channelName']
+ live_status = live_data['status']
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 'OPEN':
+ play_data = json.loads(live_data['livePlaybackJson'])
+ m3u8_url = play_data['media'][0]['path']
+ m3u8_url_list = await get_play_url_list(m3u8_url, proxy=proxy_addr, header=headers, abroad=True)
+ prefix = m3u8_url.split('?')[0].rsplit('/', maxsplit=1)[0]
+ m3u8_url_list = [prefix + '/' + i for i in m3u8_url_list]
+ result |= {"is_live": True, "m3u8_url": m3u8_url, "play_url_list": m3u8_url_list}
+ return result
+
+
+@trace_error_decorator
+async def get_haixiu_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'origin': 'https://www.haixiutv.com',
+ 'referer': 'https://www.haixiutv.com/',
+ 'user-agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split("?")[0].rsplit('/', maxsplit=1)[-1]
+ if 'haixiutv' in url:
+ access_token = "pLXSC%252FXJ0asc1I21tVL5FYZhNJn2Zg6d7m94umCnpgL%252BuVm31GQvyw%253D%253D"
+ else:
+ access_token = "s7FUbTJ%252BjILrR7kicJUg8qr025ZVjd07DAnUQd8c7g%252Fo4OH9pdSX6w%253D%253D"
+
+ params = {
+ "accessToken": access_token,
+ "tku": "3000006",
+ "c": "10138100100000",
+ "_st1": int(time.time() * 1000)
+ }
+ ajax_data = execjs.compile(open(f'{JS_SCRIPT_PATH}/haixiu.js').read()).call('sign', params,
+ f'{JS_SCRIPT_PATH}/crypto-js.min.js')
+
+ params["accessToken"] = urllib.parse.unquote(urllib.parse.unquote(access_token))
+ params['_ajaxData1'] = ajax_data
+ params['_'] = int(time.time() * 1000)
+
+ if 'haixiutv' in url:
+ api = f'https://service.haixiutv.com/v2/room/{room_id}/media/advanceInfoRoom?{urllib.parse.urlencode(params)}'
+ else:
+ headers['origin'] = 'https://www.lehaitv.com'
+ headers['referer'] = 'https://www.lehaitv.com'
+ api = f'https://service.lehaitv.com/v2/room/{room_id}/media/advanceInfoRoom?{urllib.parse.urlencode(params)}'
+
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+
+ stream_data = json_data['data']
+ anchor_name = stream_data['nickname']
+ live_status = stream_data['live_status']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ flv_url = stream_data['media_url_web']
+ result |= {'is_live': True, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_vvxqiu_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'Access-Control-Request-Method': 'GET',
+ 'Origin': 'https://h5webcdn-pro.vvxqiu.com',
+ 'Referer': 'https://h5webcdn-pro.vvxqiu.com/',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = get_params(url, "roomId")
+ api_1 = f'https://h5p.vvxqiu.com/activity-center/fanclub/activity/captain/banner?roomId={room_id}&product=vvstar'
+ json_str = await async_req(api_1, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['anchorName']
+ if not anchor_name:
+ params = {
+ 'sessionId': '',
+ 'userId': '',
+ 'product': 'vvstar',
+ 'tickToken': '',
+ 'roomId': room_id,
+ }
+ json_str = await async_req(
+ f'https://h5p.vvxqiu.com/activity-center/halloween2023/banner?{urllib.parse.urlencode(params)}',
+ proxy_addr=proxy_addr, headers=headers
+ )
+ json_data = json.loads(json_str)
+ anchor_name = json_data['data']['memberVO']['memberName']
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ m3u8_url = f'https://liveplay-pro.wasaixiu.com/live/1400442770_{room_id}_{room_id[2:]}_single.m3u8'
+ resp = await async_req(m3u8_url, proxy_addr=proxy_addr, headers=headers)
+ if 'Not Found' not in resp:
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'record_url': m3u8_url}
+ return result
+
+
+@trace_error_decorator
+async def get_17live_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'origin': 'https://17.live',
+ 'referer': 'https://17.live/',
+ 'user-agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ api_1 = f'https://wap-api.17app.co/api/v1/user/room/{room_id}'
+ json_str = await async_req(api_1, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data["displayName"]
+ result = {"anchor_name": anchor_name, "is_live": False}
+ json_data = {
+ 'liveStreamID': room_id,
+ }
+ api_1 = f'https://wap-api.17app.co/api/v1/lives/{room_id}/viewers/alive'
+ json_str = await async_req(api_1, json_data=json_data, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ live_status = json_data.get("status")
+ if live_status and live_status == 2:
+ flv_url = json_data['pullURLsInfo']['rtmpURLs'][0]['urlHighQuality']
+ result |= {'is_live': True, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_langlive_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'origin': 'https://www.lang.live',
+ 'referer': 'https://www.lang.live/',
+ 'user-agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ api_1 = f'https://api.lang.live/langweb/v1/room/liveinfo?room_id={room_id}'
+ json_str = await async_req(api_1, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ live_info = json_data['data']['live_info']
+ anchor_name = live_info['nickname']
+ live_status = live_info['live_status']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ flv_url = json_data['data']['live_info']['liveurl']
+ m3u8_url = json_data['data']['live_info']['liveurl_hls']
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'flv_url': flv_url, 'record_url': m3u8_url}
+ return result
+
+
+@trace_error_decorator
+async def get_pplive_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'Content-Type': 'application/json',
+ 'Origin': 'https://m.pp.weimipopo.com',
+ 'Referer': 'https://m.pp.weimipopo.com/',
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = get_params(url, 'anchorUid')
+ json_data = {
+ 'inviteUuid': '',
+ 'anchorUuid': room_id,
+ }
+
+ if 'catshow' in url:
+ api = 'https://api.catshow168.com/live/preview'
+ headers['Origin'] = 'https://h.catshow168.com'
+ headers['Referer'] = 'https://h.catshow168.com'
+ else:
+ api = 'https://api.pp.weimipopo.com/live/preview'
+ json_str = await async_req(api, json_data=json_data, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ live_info = json_data['data']
+ anchor_name = live_info['name']
+ live_status = live_info['living']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status:
+ m3u8_url = live_info['pullUrl']
+ result |= {'is_live': True, 'm3u8_url': m3u8_url, 'record_url': m3u8_url}
+ return result
+
+
+@trace_error_decorator
+async def get_6room_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://ios.6.cn/?ver=8.0.3&build=4',
+ 'user-agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('/', maxsplit=1)[1]
+ html_str = await async_req(f'https://v.6.cn/{room_id}', proxy_addr=proxy_addr, headers=headers)
+ room_id = re.search('rid: \'(.*?)\',\n\\s+roomid', html_str).group(1)
+ data = {
+ 'av': '3.1',
+ 'encpass': '',
+ 'logiuid': '',
+ 'project': 'v6iphone',
+ 'rate': '1',
+ 'rid': '',
+ 'ruid': room_id,
+ }
+ api = 'https://v.6.cn/coop/mobile/index.php?padapi=coop-mobile-inroom.php'
+ json_str = await async_req(api, data=data, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ flv_title = json_data['content']['liveinfo']['flvtitle']
+ anchor_name = json_data['content']['roominfo']['alias']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if flv_title:
+ flv_url = f'https://wlive.6rooms.com/httpflv/{flv_title}.flv'
+ result |= {'is_live': True, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_shopee_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept': 'application/json, text/plain, */*',
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'referer': 'https://live.shopee.sg/share?from=live&session=802458&share_user_id=',
+ 'user-agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ result = {"anchor_name": "", "is_live": False}
+ is_living = False
+
+ if 'live.shopee' not in url and 'uid' not in url:
+ url = await async_req(url, proxy_addr=proxy_addr, headers=headers, redirect_url=True, abroad=True)
+
+ if 'live.shopee' in url:
+ host_suffix = url.split('/')[2].rsplit('.', maxsplit=1)[1]
+ is_living = get_params(url, 'uid') is None
+ else:
+ host_suffix = url.split('/')[2].split('.', maxsplit=1)[0]
+
+ uid = get_params(url, 'uid')
+ api_host = f'https://live.shopee.{host_suffix}'
+ session_id = get_params(url, 'session')
+ if uid:
+ json_str = await async_req(f'{api_host}/api/v1/shop_page/live/ongoing?uid={uid}',
+ proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ if json_data['data']['ongoing_live']:
+ session_id = json_data['data']['ongoing_live']['session_id']
+ is_living = True
+ else:
+ json_str = await async_req(f'{api_host}/api/v1/shop_page/live/replay_list?offset=0&limit=1&uid={uid}',
+ proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ if json_data['data']['replay']:
+ result['anchor_name'] = json_data['data']['replay'][0]['nick_name']
+ return result
+
+ json_str = await async_req(f'{api_host}/api/v1/session/{session_id}', proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_data = json.loads(json_str)
+ if not json_data.get('data'):
+ print("Fetch shopee live data failed, please update the address of the live broadcast room and try again.")
+ return result
+ uid = json_data['data']['session']['uid']
+ anchor_name = json_data['data']['session']['nickname']
+ live_status = json_data['data']['session']['status']
+ result["anchor_name"] = anchor_name
+ result['uid'] = f'uid={uid}&session={session_id}'
+ if live_status == 1 and is_living:
+ flv_url = json_data['data']['session']['play_url']
+ title = json_data['data']['session']['title']
+ result |= {'is_live': True, 'title': title, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_youtube_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=headers, abroad=True)
+ json_str = re.search('var ytInitialPlayerResponse = (.*?);var meta = document\\.createElement', html_str).group(1)
+ json_data = json.loads(json_str)
+ result = {"anchor_name": "", "is_live": False}
+ if 'videoDetails' not in json_data:
+ print("Error: Please log in to YouTube on your device's webpage and configure cookies in the config.ini")
+ return result
+ result['anchor_name'] = json_data['videoDetails']['author']
+ live_status = json_data['videoDetails'].get('isLive')
+ if live_status:
+ live_title = json_data['videoDetails']['title']
+ m3u8_url = json_data['streamingData']["hlsManifestUrl"]
+ play_url_list = await get_play_url_list(m3u8_url, proxy=proxy_addr, header=headers, abroad=True)
+ result |= {"is_live": True, "title": live_title, "m3u8_url": m3u8_url, "play_url_list": play_url_list}
+ return result
+
+
+@trace_error_decorator
+async def get_taobao_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'Referer': 'https://huodong.m.taobao.com/',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ 'Cookie': '',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ if '_m_h5_tk' not in headers['Cookie']:
+ print('Error: Cookies is empty! please input correct cookies')
+
+ live_id = get_params(url, 'id')
+ if not live_id:
+ html_str = await async_req(url, proxy_addr=proxy_addr, headers=headers)
+ redirect_url = re.findall("var url = '(.*?)';", html_str)[0]
+ live_id = get_params(redirect_url, 'id')
+
+ params = {
+ 'jsv': '2.7.0',
+ 'appKey': '12574478',
+ 't': '1733104933120',
+ 'sign': '',
+ 'AntiFlood': 'true',
+ 'AntiCreep': 'true',
+ 'api': 'mtop.mediaplatform.live.livedetail',
+ 'v': '4.0',
+ 'preventFallback': 'true',
+ 'type': 'jsonp',
+ 'dataType': 'jsonp',
+ 'callback': 'mtopjsonp1',
+ 'data': '{"liveId":"' + live_id + '","creatorId":null}',
+ }
+
+ for i in range(2):
+ app_key = '12574478'
+ _m_h5_tk = re.findall('_m_h5_tk=(.*?);', headers['Cookie'])[0]
+ t13 = int(time.time() * 1000)
+ pre_sign_str = f'{_m_h5_tk.split("_")[0]}&{t13}&{app_key}&' + params['data']
+ sign = execjs.compile(open(f'{JS_SCRIPT_PATH}/taobao-sign.js').read()).call('sign', pre_sign_str)
+ params |= {'sign': sign, 't': t13}
+ api = f'https://h5api.m.taobao.com/h5/mtop.mediaplatform.live.livedetail/4.0/?{urllib.parse.urlencode(params)}'
+ jsonp_str, new_cookie = await async_req(url=api, proxy_addr=proxy_addr, headers=headers, timeout=20,
+ return_cookies=True, include_cookies=True)
+ json_data = utils.jsonp_to_json(jsonp_str)
+
+ ret_msg = json_data['ret']
+ if ret_msg == ['SUCCESS::调用成功']:
+ anchor_name = json_data['data']['broadCaster']['accountName']
+ result = {"anchor_name": anchor_name, "is_live": False}
+ live_status = json_data['data']['streamStatus']
+ if live_status == '1':
+ live_title = json_data['data']['title']
+ play_url_list = json_data['data']['liveUrlList']
+
+ def get_sort_key(item):
+ definition_priority = {
+ "lld": 0, "ld": 1, "md": 2, "hd": 3, "ud": 4
+ }
+ def_value = item.get('definition') or item.get('newDefinition')
+ priority = definition_priority.get(def_value, -1)
+ return priority
+
+ play_url_list = sorted(play_url_list, key=get_sort_key, reverse=True)
+ result |= {"is_live": True, "title": live_title, "play_url_list": play_url_list, 'live_id': live_id}
+
+ return result
+ else:
+ print(f'Error: Taobao live data fetch failed, {ret_msg[0]}')
+
+ if '_m_h5_tk' in new_cookie and '_m_h5_tk_enc' in new_cookie:
+ headers['Cookie'] = re.sub('_m_h5_tk=(.*?);', new_cookie['_m_h5_tk'], headers['Cookie'])
+ headers['Cookie'] = re.sub('_m_h5_tk_enc=(.*?);', new_cookie['_m_h5_tk_enc'], headers['Cookie'])
+ else:
+ print('Error: Try to update cookie failed, please update the cookies in the configuration file')
+
+
+@trace_error_decorator
+async def get_jd_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'User-Agent': 'ios/7.830 (ios 17.0; ; iPhone 15 (A2846/A3089/A3090/A3092))',
+ 'origin': 'https://lives.jd.com',
+ 'referer': 'https://lives.jd.com/',
+ 'x-referer-page': 'https://lives.jd.com/',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ redirect_url = await async_req(url, proxy_addr=proxy_addr, headers=headers, redirect_url=True)
+ author_id = get_params(redirect_url, 'authorId')
+ result = {"anchor_name": '', "is_live": False}
+ if not author_id:
+ live_id = re.search('#/(.*?)\\?origin', redirect_url)
+ if not live_id:
+ return result
+ live_id = live_id.group(1)
+ result['anchor_name'] = f'jd_{live_id}'
+ else:
+ data = {
+ 'functionId': 'talent_head_findTalentMsg',
+ 'appid': 'dr_detail',
+ 'body': '{"authorId":"' + author_id + '","monitorSource":"1","userId":""}',
+ }
+ info_api = 'https://api.m.jd.com/talent_head_findTalentMsg'
+ json_str = await async_req(info_api, data=data, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ anchor_name = json_data['result']['talentName']
+ result['anchor_name'] = anchor_name
+ if 'livingRoomJump' not in json_data['result']:
+ return result
+ live_id = json_data['result']['livingRoomJump']['params']['id']
+ params = {
+ "body": '{"liveId": "' + live_id + '"}',
+ "functionId": "getImmediatePlayToM",
+ "appid": "h5-live"
+ }
+
+ api = f'https://api.m.jd.com/client.action?{urllib.parse.urlencode(params)}'
+ # backup_api: https://api.m.jd.com/api
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ live_status = json_data['data']['status']
+ if live_status == 1:
+ if author_id:
+ data = {
+ 'functionId': 'jdTalentContentList',
+ 'appid': 'dr_detail',
+ 'body': '{"authorId":"' + author_id + '","type":1,"userId":"","page":1,"offset":"-1",'
+ '"monitorSource":"1","pageSize":1}',
+ }
+ json_str2 = await async_req('https://api.m.jd.com/jdTalentContentList', data=data,
+ proxy_addr=proxy_addr, headers=headers)
+ json_data2 = json.loads(json_str2)
+ result['title'] = json_data2['result']['content'][0]['title']
+
+ flv_url = json_data['data']['videoUrl']
+ m3u8_url = json_data['data']['h5VideoUrl']
+ result |= {"is_live": True, "m3u8_url": m3u8_url, "flv_url": flv_url, "record_url": m3u8_url}
+ return result
+
+
+@trace_error_decorator
+async def get_faceit_stream_data(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'Referer': 'https://www.faceit.com/zh/players/qpjzz/stream',
+ 'faceit-referer': 'web-next',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+ nickname = re.findall('/players/(.*?)/stream', url)[0]
+ api = f'https://www.faceit.com/api/users/v1/nicknames/{nickname}'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ user_id = json_data['payload']['id']
+ api2 = f'https://www.faceit.com/api/stream/v1/streamings?userId={user_id}'
+ json_str2 = await async_req(api2, proxy_addr=proxy_addr, headers=headers)
+ json_data2 = json.loads(json_str2)
+ platform_info = json_data2['payload'][0]
+ anchor_name = platform_info.get('userNickname')
+ anchor_id = platform_info.get('platformId')
+ platform = platform_info.get('platform')
+ if platform == 'twitch':
+ result = await get_twitchtv_stream_data(f'https://www.twitch.tv/{anchor_id}')
+ result['anchor_name'] = anchor_name
+ else:
+ result = {'anchor_name': anchor_name, 'is_live': False}
+ return result
+
+
+@trace_error_decorator
+async def get_migu_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'origin': 'https://www.miguvideo.com',
+ 'referer': 'https://www.miguvideo.com/',
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0',
+ 'appCode': 'miguvideo_default_www',
+ 'appId': 'miguvideo',
+ 'channel': 'H5',
+ }
+
+ if cookies:
+ headers['Cookie'] = cookies
+
+ web_id = url.split('?')[0].rsplit('/')[-1]
+ api = f'https://vms-sc.miguvideo.com/vms-match/v6/staticcache/basic/basic-data/{web_id}/miguvideo'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ anchor_name = json_data['body']['title']
+ live_title = json_data['body'].get('title') + '-' + json_data['body'].get('detailPageTitle', '')
+ room_id = json_data['body'].get('pId')
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if not room_id:
+ return result
+
+ params = {
+ 'contId': room_id,
+ 'rateType': '3',
+ 'clientId': str(uuid.uuid4()),
+ 'timestamp': int(time.time() * 1000),
+ 'flvEnable': 'true',
+ 'xh265': 'false',
+ 'chip': 'mgwww',
+ 'channelId': '',
+ }
+
+ api = f'https://webapi.miguvideo.com/gateway/playurl/v3/play/playurl?{urllib.parse.urlencode(params)}'
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+ live_status = json_data['body']['content']['currentLive']
+ if live_status != '1':
+ return result
+ else:
+ result['title'] = live_title
+ source_url = json_data['body']['urlInfo']['url']
+
+ async def _get_dd_calcu(url):
+ try:
+ result = subprocess.run(
+ ["node", f"{JS_SCRIPT_PATH}/migu.js", url],
+ capture_output=True,
+ text=True,
+ check=True
+ )
+ return result.stdout.strip()
+ except execjs.ProgramError:
+ raise execjs.ProgramError('Failed to execute JS code. Please check if the Node.js environment')
+
+ ddCalcu = await _get_dd_calcu(source_url)
+ real_source_url = f'{source_url}&ddCalcu={ddCalcu}&sv=10010'
+ if '.m3u8' in real_source_url:
+ m3u8_url = await async_req(
+ real_source_url, proxy_addr=proxy_addr, headers=headers, redirect_url=True)
+ result['m3u8_url'] = m3u8_url
+ result['record_url'] = m3u8_url
+ else:
+ result['flv_url'] = real_source_url
+ result['record_url'] = real_source_url
+ result['is_live'] = True
+ return result
+
+
+@trace_error_decorator
+async def get_lianjie_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0',
+ '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',
+ }
+
+ if cookies:
+ headers['cookie'] = cookies
+
+ room_id = url.split('?')[0].rsplit('lailianjie.com/', maxsplit=1)[-1]
+ play_api = f'https://api.lailianjie.com/ApiServices/service/live/getRoomInfo?&_$t=&_sign=&roomNumber={room_id}'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ room_data = json_data['data']
+ anchor_name = room_data['nickname']
+ live_status = room_data['isonline']
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status == 1:
+ title = room_data['defaultRoomTitle']
+ webrtc_url = room_data['videoUrl']
+ https_url = "https://" + webrtc_url.split('webrtc://')[1]
+ flv_url = https_url.replace('?', '.flv?')
+ m3u8_url = https_url.replace('?', '.m3u8?')
+ result |= {'is_live': True, 'title': title, 'm3u8_url': m3u8_url, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_laixiu_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ def generate_uuid(ua_type: str):
+ if ua_type == "mobile":
+ return str(uuid.uuid4())
+ return str(uuid.uuid4()).replace('-', '')
+
+ def calculate_sign(ua_type: str = 'pc'):
+ a = int(time.time() * 1000)
+ s = generate_uuid(ua_type)
+ u = 'kk792f28d6ff1f34ec702c08626d454b39pro'
+
+ input_str = f"web{s}{a}{u}"
+ md5_hash = hashlib.md5(input_str.encode('utf-8')).hexdigest()
+
+ return {
+ 'timestamp': a,
+ 'imei': s,
+ 'requestId': md5_hash,
+ 'inputString': input_str
+ }
+
+ sign_data = calculate_sign(ua_type='pc')
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0',
+ 'mobileModel': 'web',
+ 'timestamp': str(sign_data['timestamp']),
+ 'loginType': '2',
+ 'versionCode': '10003',
+ 'imei': sign_data['imei'],
+ 'requestId': sign_data['requestId'],
+ 'channel': '9',
+ 'version': '1.0.0',
+ 'os': 'web',
+ 'platform': 'WEB',
+ 'Origin': 'https://www.imkktv.com',
+ 'Referer': 'https://www.imkktv.com/',
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+ }
+
+ if cookies:
+ headers['cookie'] = cookies
+
+ pattern = r"(?:roomId|anchorId)=(.*?)(?=&|$)"
+ match = re.search(pattern, url)
+ room_id = match.group(1) if match else ''
+ play_api = f'https://api.imkktv.com/liveroom/getShareLiveVideo?roomId={room_id}'
+ json_str = await async_req(play_api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ room_data = json_data['data']
+ anchor_name = room_data['nickname']
+ live_status = room_data['playStatus'] == 0
+
+ result = {"anchor_name": anchor_name, "is_live": False}
+ if live_status:
+ flv_url = room_data['playUrl']
+ result |= {'is_live': True, 'flv_url': flv_url, 'record_url': flv_url}
+ return result
+
+
+@trace_error_decorator
+async def get_picarto_stream_url(url: str, proxy_addr: OptionalStr = None, cookies: OptionalStr = None) -> dict:
+ headers = {
+ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) '
+ 'Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0',
+ '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',
+ }
+
+ if cookies:
+ headers['cookie'] = cookies
+
+ anchor_id = url.split('?')[0].rsplit('/', maxsplit=1)[-1]
+ api = f'https://ptvintern.picarto.tv/api/channel/detail/{anchor_id}'
+
+ json_str = await async_req(api, proxy_addr=proxy_addr, headers=headers)
+ json_data = json.loads(json_str)
+
+ anchor_name = json_data['channel']['name']
+ live_status = json_data['channel']['online']
+
+ result = {"anchor_name": anchor_name, "is_live": live_status}
+ if live_status:
+ title = json_data['channel']['title']
+ m3u8_url = f"https://1-edge1-us-newyork.picarto.tv/stream/hls/golive+{anchor_name}/index.m3u8"
+ result |= {'is_live': True, 'title': title, 'm3u8_url': m3u8_url, 'record_url': m3u8_url}
+ return result
\ No newline at end of file
diff --git a/src/stream.py b/src/stream.py
new file mode 100644
index 0000000..ad5e641
--- /dev/null
+++ b/src/stream.py
@@ -0,0 +1,446 @@
+# -*- encoding: utf-8 -*-
+
+"""
+Author: Hmily
+GitHub: https://github.com/ihmily
+Date: 2023-07-15 23:15:00
+Update: 2025-02-06 02:28:00
+Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
+Function: Get live stream data.
+"""
+import base64
+import hashlib
+import json
+import time
+import random
+import re
+from operator import itemgetter
+import urllib.parse
+import urllib.request
+from .utils import trace_error_decorator
+from .spider import (
+ get_douyu_stream_data, get_bilibili_stream_data
+)
+from .http_clients.async_http import get_response_status
+
+QUALITY_MAPPING = {"OD": 0, "BD": 0, "UHD": 1, "HD": 2, "SD": 3, "LD": 4}
+
+
+def get_quality_index(quality) -> tuple:
+ if not quality:
+ return list(QUALITY_MAPPING.items())[0]
+
+ quality_str = str(quality).upper()
+ if quality_str.isdigit():
+ quality_int = int(quality_str[0])
+ quality_str = list(QUALITY_MAPPING.keys())[quality_int]
+ return quality_str, QUALITY_MAPPING.get(quality_str, 0)
+
+
+@trace_error_decorator
+async def get_douyin_stream_url(json_data: dict, video_quality: str, proxy_addr: str) -> dict:
+ anchor_name = json_data.get('anchor_name')
+
+ result = {
+ "anchor_name": anchor_name,
+ "is_live": False,
+ }
+
+ status = json_data.get("status", 4)
+
+ if status == 2:
+ stream_url = json_data['stream_url']
+ flv_url_dict = stream_url['flv_pull_url']
+ flv_url_list: list = list(flv_url_dict.values())
+ m3u8_url_dict = stream_url['hls_pull_url_map']
+ m3u8_url_list: list = list(m3u8_url_dict.values())
+
+ while len(flv_url_list) < 5:
+ flv_url_list.append(flv_url_list[-1])
+ m3u8_url_list.append(m3u8_url_list[-1])
+
+ video_quality, quality_index = get_quality_index(video_quality)
+ m3u8_url = m3u8_url_list[quality_index]
+ flv_url = flv_url_list[quality_index]
+ ok = await get_response_status(url=m3u8_url, proxy_addr=proxy_addr)
+ if not ok:
+ index = quality_index + 1 if quality_index < 4 else quality_index - 1
+ m3u8_url = m3u8_url_list[index]
+ flv_url = flv_url_list[index]
+ result |= {
+ 'is_live': True,
+ 'title': json_data['title'],
+ 'quality': video_quality,
+ 'm3u8_url': m3u8_url,
+ 'flv_url': flv_url,
+ 'record_url': m3u8_url or flv_url,
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_tiktok_stream_url(json_data: dict, video_quality: str, proxy_addr: str) -> dict:
+ if not json_data:
+ return {"anchor_name": None, "is_live": False}
+
+ def get_video_quality_url(stream, q_key) -> list:
+ play_list = []
+ for key in stream:
+ url_info = stream[key]['main']
+ sdk_params = url_info['sdk_params']
+ sdk_params = json.loads(sdk_params)
+ vbitrate = int(sdk_params['vbitrate'])
+ v_codec = sdk_params.get('VCodec', '')
+
+ play_url = ''
+ if url_info.get(q_key):
+ if url_info[q_key].endswith(".flv") or url_info[q_key].endswith(".m3u8"):
+ play_url = url_info[q_key] + '?codec=' + v_codec
+ else:
+ play_url = url_info[q_key] + '&codec=' + v_codec
+
+ resolution = sdk_params['resolution']
+ if vbitrate != 0 and resolution:
+ width, height = map(int, resolution.split('x'))
+ play_list.append({'url': play_url, 'vbitrate': vbitrate, 'resolution': (width, height)})
+
+ play_list.sort(key=itemgetter('vbitrate'), reverse=True)
+ play_list.sort(key=lambda x: (-x['vbitrate'], -x['resolution'][0], -x['resolution'][1]))
+ return play_list
+
+ live_room = json_data['LiveRoom']['liveRoomUserInfo']
+ user = live_room['user']
+ anchor_name = f"{user['nickname']}-{user['uniqueId']}"
+ status = user.get("status", 4)
+
+ result = {
+ "anchor_name": anchor_name,
+ "is_live": False,
+ }
+
+ if status == 2:
+ stream_data = live_room['liveRoom']['streamData']['pull_data']['stream_data']
+ stream_data = json.loads(stream_data).get('data', {})
+ flv_url_list = get_video_quality_url(stream_data, 'flv')
+ m3u8_url_list = get_video_quality_url(stream_data, 'hls')
+
+ while len(flv_url_list) < 5:
+ flv_url_list.append(flv_url_list[-1])
+ while len(m3u8_url_list) < 5:
+ m3u8_url_list.append(m3u8_url_list[-1])
+ video_quality, quality_index = get_quality_index(video_quality)
+ flv_dict: dict = flv_url_list[quality_index]
+ m3u8_dict: dict = m3u8_url_list[quality_index]
+
+ check_url = m3u8_dict.get('url') or flv_dict.get('url')
+ ok = await get_response_status(url=check_url, proxy_addr=proxy_addr, http2=False)
+
+ if not ok:
+ index = quality_index + 1 if quality_index < 4 else quality_index - 1
+ flv_dict: dict = flv_url_list[index]
+ m3u8_dict: dict = m3u8_url_list[index]
+
+ flv_url = flv_dict['url']
+ m3u8_url = m3u8_dict['url']
+ result |= {
+ 'is_live': True,
+ 'title': live_room['liveRoom']['title'],
+ 'quality': video_quality,
+ 'm3u8_url': m3u8_url,
+ 'flv_url': flv_url,
+ 'record_url': m3u8_url or flv_url,
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_kuaishou_stream_url(json_data: dict, video_quality: str) -> dict:
+ if json_data['type'] == 1 and not json_data["is_live"]:
+ return json_data
+ live_status = json_data['is_live']
+
+ result = {
+ "type": 2,
+ "anchor_name": json_data['anchor_name'],
+ "is_live": live_status,
+ }
+
+ if live_status:
+ quality_mapping_bit = {'OD': 99999, 'BD': 4000, 'UHD': 2000, 'HD': 1000, 'SD': 800, 'LD': 600}
+ if video_quality in QUALITY_MAPPING:
+
+ quality, quality_index = get_quality_index(video_quality)
+ if 'm3u8_url_list' in json_data:
+ m3u8_url_list = json_data['m3u8_url_list'][::-1]
+ while len(m3u8_url_list) < 5:
+ m3u8_url_list.append(m3u8_url_list[-1])
+ m3u8_url = m3u8_url_list[quality_index]['url']
+ result['m3u8_url'] = m3u8_url
+
+ if 'flv_url_list' in json_data:
+ if 'bitrate' in json_data['flv_url_list'][0]:
+ flv_url_list = json_data['flv_url_list']
+ flv_url_list = sorted(flv_url_list, key=lambda x: x['bitrate'], reverse=True)
+ quality_str = str(video_quality).upper()
+ if quality_str.isdigit():
+ video_quality, quality_index_bitrate_value = list(quality_mapping_bit.items())[int(quality_str)]
+ else:
+ quality_index_bitrate_value = quality_mapping_bit.get(quality_str, 99999)
+ video_quality = quality_str
+ quality_index = next(
+ (i for i, x in enumerate(flv_url_list) if x['bitrate'] <= quality_index_bitrate_value), None)
+ if quality_index is None:
+ quality_index = len(flv_url_list) - 1
+ flv_url = flv_url_list[quality_index]['url']
+
+ result['flv_url'] = flv_url
+ result['record_url'] = flv_url
+ else:
+ flv_url_list = json_data['flv_url_list'][::-1]
+ while len(flv_url_list) < 5:
+ flv_url_list.append(flv_url_list[-1])
+ flv_url = flv_url_list[quality_index]['url']
+ result |= {'flv_url': flv_url, 'record_url': flv_url}
+ result['is_live'] = True
+ result['quality'] = video_quality
+ return result
+
+
+@trace_error_decorator
+async def get_huya_stream_url(json_data: dict, video_quality: str) -> dict:
+ game_live_info = json_data['data'][0]['gameLiveInfo']
+ live_title = game_live_info['introduction']
+ stream_info_list = json_data['data'][0]['gameStreamInfoList']
+ anchor_name = game_live_info.get('nick', '')
+
+ result = {
+ "anchor_name": anchor_name,
+ "is_live": False,
+ }
+
+ if stream_info_list:
+ select_cdn = stream_info_list[0]
+ flv_url = select_cdn.get('sFlvUrl')
+ stream_name = select_cdn.get('sStreamName')
+ flv_url_suffix = select_cdn.get('sFlvUrlSuffix')
+ hls_url = select_cdn.get('sHlsUrl')
+ hls_url_suffix = select_cdn.get('sHlsUrlSuffix')
+ flv_anti_code = select_cdn.get('sFlvAntiCode')
+
+ def get_anti_code(old_anti_code: str) -> str:
+
+ # js地址:https://hd.huya.com/cdn_libs/mobile/hysdk-m-202402211431.js
+
+ params_t = 100
+ sdk_version = 2403051612
+
+ # sdk_id是13位数毫秒级时间戳
+ t13 = int(time.time()) * 1000
+ sdk_sid = t13
+
+ # 计算uuid和uid参数值
+ init_uuid = (int(t13 % 10 ** 10 * 1000) + int(1000 * random.random())) % 4294967295 # 直接初始化
+ uid = random.randint(1400000000000, 1400009999999) # 经过测试uid也可以使用init_uuid代替
+ seq_id = uid + sdk_sid # 移动端请求的直播流地址中包含seqId参数
+
+ # 计算ws_time参数值(16进制) 可以是当前毫秒时间戳,当然也可以直接使用url_query['wsTime'][0]
+ # 原始最大误差不得慢240000毫秒
+ target_unix_time = (t13 + 110624) // 1000
+ ws_time = f"{target_unix_time:x}".lower()
+
+ # fm参数值是经过url编码然后base64编码得到的,解码结果类似 DWq8BcJ3h6DJt6TY_$0_$1_$2_$3
+ # 具体细节在上面js中查看,大概在32657行代码开始,有base64混淆代码请自行替换
+ url_query = urllib.parse.parse_qs(old_anti_code)
+ ws_secret_pf = base64.b64decode(urllib.parse.unquote(url_query['fm'][0]).encode()).decode().split("_")[0]
+ ws_secret_hash = hashlib.md5(f'{seq_id}|{url_query["ctype"][0]}|{params_t}'.encode()).hexdigest()
+ ws_secret = f'{ws_secret_pf}_{uid}_{stream_name}_{ws_secret_hash}_{ws_time}'
+ ws_secret_md5 = hashlib.md5(ws_secret.encode()).hexdigest()
+
+ anti_code = (
+ f'wsSecret={ws_secret_md5}&wsTime={ws_time}&seqid={seq_id}&ctype={url_query["ctype"][0]}&ver=1'
+ f'&fs={url_query["fs"][0]}&uuid={init_uuid}&u={uid}&t={params_t}&sv={sdk_version}'
+ f'&sdk_sid={sdk_sid}&codec=264'
+ )
+ return anti_code
+
+ new_anti_code = get_anti_code(flv_anti_code)
+ flv_url = f'{flv_url}/{stream_name}.{flv_url_suffix}?{new_anti_code}&ratio='
+ m3u8_url = f'{hls_url}/{stream_name}.{hls_url_suffix}?{new_anti_code}&ratio='
+
+ quality_list = flv_anti_code.split('&exsphd=')
+ if len(quality_list) > 1 and video_quality not in ["OD", "BD"]:
+ pattern = r"(?<=264_)\d+"
+ quality_list = list(re.findall(pattern, quality_list[1]))[::-1]
+ while len(quality_list) < 5:
+ quality_list.append(quality_list[-1])
+
+ video_quality_options = {
+ "UHD": quality_list[0],
+ "HD": quality_list[1],
+ "SD": quality_list[2],
+ "LD": quality_list[3]
+ }
+
+ if video_quality not in video_quality_options:
+ raise ValueError(
+ f"Invalid video quality. Available options are: {', '.join(video_quality_options.keys())}")
+
+ flv_url = flv_url + str(video_quality_options[video_quality])
+ m3u8_url = m3u8_url + str(video_quality_options[video_quality])
+
+ result |= {
+ 'is_live': True,
+ 'title': live_title,
+ 'quality': video_quality,
+ 'm3u8_url': m3u8_url,
+ 'flv_url': flv_url,
+ 'record_url': flv_url or m3u8_url
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_douyu_stream_url(json_data: dict, video_quality: str, cookies: str, proxy_addr: str) -> dict:
+ if not json_data["is_live"]:
+ return json_data
+
+ video_quality_options = {
+ "OD": '0',
+ "BD": '0',
+ "UHD": '3',
+ "HD": '2',
+ "SD": '1',
+ "LD": '1'
+ }
+
+ rid = str(json_data["room_id"])
+ json_data.pop("room_id")
+ rate = video_quality_options.get(video_quality, '0')
+ flv_data = await get_douyu_stream_data(rid, rate, cookies=cookies, proxy_addr=proxy_addr)
+ rtmp_url = flv_data['data'].get('rtmp_url')
+ rtmp_live = flv_data['data'].get('rtmp_live')
+ if rtmp_live:
+ flv_url = f'{rtmp_url}/{rtmp_live}'
+ json_data |= {'quality': video_quality, 'flv_url': flv_url, 'record_url': flv_url}
+ return json_data
+
+
+@trace_error_decorator
+async def get_yy_stream_url(json_data: dict) -> dict:
+ anchor_name = json_data.get('anchor_name', '')
+ result = {
+ "anchor_name": anchor_name,
+ "is_live": False,
+ }
+ if 'avp_info_res' in json_data:
+ stream_line_addr = json_data['avp_info_res']['stream_line_addr']
+ cdn_info = list(stream_line_addr.values())[0]
+ flv_url = cdn_info['cdn_info']['url']
+ result |= {
+ 'is_live': True,
+ 'title': json_data['title'],
+ 'quality': 'OD',
+ 'flv_url': flv_url,
+ 'record_url': flv_url
+ }
+ return result
+
+
+@trace_error_decorator
+async def get_bilibili_stream_url(json_data: dict, video_quality: str, proxy_addr: str, cookies: str) -> dict:
+ anchor_name = json_data["anchor_name"]
+ if not json_data["live_status"]:
+ return {
+ "anchor_name": anchor_name,
+ "is_live": False
+ }
+
+ room_url = json_data['room_url']
+
+ video_quality_options = {
+ "OD": '10000',
+ "BD": '400',
+ "UHD": '250',
+ "HD": '150',
+ "SD": '80',
+ "LD": '80'
+ }
+
+ select_quality = video_quality_options[video_quality]
+ play_url = await get_bilibili_stream_data(
+ room_url, qn=select_quality, platform='web', proxy_addr=proxy_addr, cookies=cookies)
+ return {
+ 'anchor_name': json_data['anchor_name'],
+ 'is_live': True,
+ 'title': json_data['title'],
+ 'quality': video_quality,
+ 'record_url': play_url
+ }
+
+
+@trace_error_decorator
+async def get_netease_stream_url(json_data: dict, video_quality: str) -> dict:
+ if not json_data['is_live']:
+ return json_data
+
+ m3u8_url = json_data['m3u8_url']
+ flv_url = None
+ if json_data.get('stream_list'):
+ stream_list = json_data['stream_list']['resolution']
+ order = ['blueray', 'ultra', 'high', 'standard']
+ sorted_keys = [key for key in order if key in stream_list]
+ while len(sorted_keys) < 5:
+ sorted_keys.append(sorted_keys[-1])
+ video_quality, quality_index = get_quality_index(video_quality)
+ selected_quality = sorted_keys[quality_index]
+ flv_url_list = stream_list[selected_quality]['cdn']
+ selected_cdn = list(flv_url_list.keys())[0]
+ flv_url = flv_url_list[selected_cdn]
+
+ return {
+ "is_live": True,
+ "anchor_name": json_data['anchor_name'],
+ "title": json_data['title'],
+ 'quality': video_quality,
+ "m3u8_url": m3u8_url,
+ "flv_url": flv_url,
+ "record_url": flv_url or m3u8_url
+ }
+
+
+async def get_stream_url(json_data: dict, video_quality: str, url_type: str = 'm3u8', spec: bool = False,
+ hls_extra_key: str | int = None, flv_extra_key: str | int = None) -> dict:
+ if not json_data['is_live']:
+ return json_data
+
+ play_url_list = json_data['play_url_list']
+ while len(play_url_list) < 5:
+ play_url_list.append(play_url_list[-1])
+
+ video_quality, selected_quality = get_quality_index(video_quality)
+ data = {
+ "anchor_name": json_data['anchor_name'],
+ "is_live": True
+ }
+
+ def get_url(key):
+ play_url = play_url_list[selected_quality]
+ return play_url[key] if key else play_url
+
+ if url_type == 'all':
+ m3u8_url = get_url(hls_extra_key)
+ flv_url = get_url(flv_extra_key)
+ data |= {
+ "m3u8_url": json_data['m3u8_url'] if spec else m3u8_url,
+ "flv_url": json_data['flv_url'] if spec else flv_url,
+ "record_url": m3u8_url
+ }
+ elif url_type == 'm3u8':
+ m3u8_url = get_url(hls_extra_key)
+ data |= {"m3u8_url": json_data['m3u8_url'] if spec else m3u8_url, "record_url": m3u8_url}
+ else:
+ flv_url = get_url(flv_extra_key)
+ data |= {"flv_url": flv_url, "record_url": flv_url}
+ data['title'] = json_data.get('title')
+ data['quality'] = video_quality
+ return data
\ No newline at end of file
diff --git a/src/utils.py b/src/utils.py
new file mode 100644
index 0000000..414ed0a
--- /dev/null
+++ b/src/utils.py
@@ -0,0 +1,206 @@
+# -*- coding: utf-8 -*-
+import json
+import os
+import random
+import shutil
+import string
+from pathlib import Path
+import functools
+import hashlib
+import re
+import traceback
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+from collections import OrderedDict
+import execjs
+from .logger import logger
+import configparser
+
+OptionalStr = str | None
+OptionalDict = dict | None
+
+
+class Color:
+ RED = "\033[31m"
+ GREEN = "\033[32m"
+ YELLOW = "\033[33m"
+ BLUE = "\033[34m"
+ MAGENTA = "\033[35m"
+ CYAN = "\033[36m"
+ WHITE = "\033[37m"
+ RESET = "\033[0m"
+
+ @staticmethod
+ def print_colored(text, color):
+ print(f"{color}{text}{Color.RESET}")
+
+
+def trace_error_decorator(func: callable) -> callable:
+ @functools.wraps(func)
+ def wrapper(*args: list, **kwargs: dict) -> Any:
+ try:
+ return func(*args, **kwargs)
+ except execjs.ProgramError:
+ logger.warning('Failed to execute JS code. Please check if the Node.js environment')
+ except Exception as e:
+ error_line = traceback.extract_tb(e.__traceback__)[-1].lineno
+ error_info = f"message: type: {type(e).__name__}, {str(e)} in function {func.__name__} at line: {error_line}"
+ logger.error(error_info)
+ return []
+
+ return wrapper
+
+
+def check_md5(file_path: str | Path) -> str:
+ with open(file_path, 'rb') as fp:
+ file_md5 = hashlib.md5(fp.read()).hexdigest()
+ return file_md5
+
+
+def dict_to_cookie_str(cookies_dict: dict) -> str:
+ cookie_str = '; '.join([f"{key}={value}" for key, value in cookies_dict.items()])
+ return cookie_str
+
+
+def read_config_value(file_path: str | Path, section: str, key: str) -> str | None:
+ config = configparser.ConfigParser()
+
+ try:
+ config.read(file_path, encoding='utf-8-sig')
+ except Exception as e:
+ print(f"Error occurred while reading the configuration file: {e}")
+ return None
+
+ if section in config:
+ if key in config[section]:
+ return config[section][key]
+ else:
+ print(f"Key [{key}] does not exist in section [{section}].")
+ else:
+ print(f"Section [{section}] does not exist in the file.")
+
+ return None
+
+
+def update_config(file_path: str | Path, section: str, key: str, new_value: str) -> None:
+ config = configparser.ConfigParser()
+
+ try:
+ config.read(file_path, encoding='utf-8-sig')
+ except Exception as e:
+ print(f"An error occurred while reading the configuration file: {e}")
+ return
+
+ if section not in config:
+ print(f"Section [{section}] does not exist in the file.")
+ return
+
+ # 转义%字符
+ escaped_value = new_value.replace('%', '%%')
+ config[section][key] = escaped_value
+
+ try:
+ with open(file_path, 'w', encoding='utf-8-sig') as configfile:
+ config.write(configfile)
+ print(f"The value of {key} under [{section}] in the configuration file has been updated.")
+ except Exception as e:
+ print(f"Error occurred while writing to the configuration file: {e}")
+
+
+def get_file_paths(directory: str) -> list:
+ file_paths = []
+ for root, dirs, files in os.walk(directory):
+ for file in files:
+ file_paths.append(os.path.join(root, file))
+ return file_paths
+
+
+def remove_emojis(text: str, replace_text: str = '') -> str:
+ emoji_pattern = re.compile(
+ "["
+ "\U0001F1E0-\U0001F1FF" # flags (iOS)
+ "\U0001F300-\U0001F5FF" # symbols & pictographs
+ "\U0001F600-\U0001F64F" # emoticons
+ "\U0001F680-\U0001F6FF" # transport & map symbols
+ "\U0001F700-\U0001F77F" # alchemical symbols
+ "\U0001F780-\U0001F7FF" # Geometric Shapes Extended
+ "\U0001F800-\U0001F8FF" # Supplemental Arrows-C
+ "\U0001F900-\U0001F9FF" # Supplemental Symbols and Pictographs
+ "\U0001FA00-\U0001FA6F" # Chess Symbols
+ "\U0001FA70-\U0001FAFF" # Symbols and Pictographs Extended-A
+ "\U00002702-\U000027B0" # Dingbats
+ "]+",
+ flags=re.UNICODE
+ )
+ return emoji_pattern.sub(replace_text, text)
+
+
+def remove_duplicate_lines(file_path: str | Path) -> None:
+ unique_lines = OrderedDict()
+ text_encoding = 'utf-8-sig'
+ with open(file_path, 'r', encoding=text_encoding) as input_file:
+ for line in input_file:
+ unique_lines[line.strip()] = None
+ with open(file_path, 'w', encoding=text_encoding) as output_file:
+ for line in unique_lines:
+ output_file.write(line + '\n')
+
+
+def check_disk_capacity(file_path: str | Path, show: bool = False) -> float:
+ absolute_path = os.path.abspath(file_path)
+ directory = os.path.dirname(absolute_path)
+ disk_usage = shutil.disk_usage(directory)
+ disk_root = Path(directory).anchor
+ free_space_gb = disk_usage.free / (1024 ** 3)
+ if show:
+ print(f"{disk_root} Total: {disk_usage.total / (1024 ** 3):.2f} GB "
+ f"Used: {disk_usage.used / (1024 ** 3):.2f} GB "
+ f"Free: {free_space_gb:.2f} GB\n")
+ return free_space_gb
+
+
+def handle_proxy_addr(proxy_addr):
+ if proxy_addr:
+ if not proxy_addr.startswith('http'):
+ proxy_addr = 'http://' + proxy_addr
+ else:
+ proxy_addr = None
+ return proxy_addr
+
+
+def generate_random_string(length: int) -> str:
+ characters = string.ascii_uppercase + string.digits
+ random_string = ''.join(random.choices(characters, k=length))
+ return random_string
+
+
+def jsonp_to_json(jsonp_str: str) -> OptionalDict:
+ pattern = r'(\w+)\((.*)\);?$'
+ match = re.search(pattern, jsonp_str)
+
+ if match:
+ _, json_str = match.groups()
+ json_obj = json.loads(json_str)
+ return json_obj
+ else:
+ raise Exception("No JSON data found in JSONP response.")
+
+
+def replace_url(file_path: str | Path, old: str, new: str) -> None:
+ with open(file_path, 'r', encoding='utf-8-sig') as f:
+ content = f.read()
+ if old in content:
+ with open(file_path, 'w', encoding='utf-8-sig') as f:
+ f.write(content.replace(old, new))
+
+
+def get_query_params(url: str, param_name: OptionalStr) -> dict | list[str]:
+ parsed_url = urlparse(url)
+ query_params = parse_qs(parsed_url.query)
+
+ if param_name is None:
+ return query_params
+ else:
+ values = query_params.get(param_name, [])
+ return values
+
\ No newline at end of file
diff --git a/third_party/srs/README.txt b/third_party/srs/README.txt
new file mode 100644
index 0000000..b101954
--- /dev/null
+++ b/third_party/srs/README.txt
@@ -0,0 +1,2 @@
+SRS(https://github.com/ossrs/srs)可在此目录以子模块或源码形式集成,用于 RTMP/WebRTC 等媒体服务。
+当前主项目仍以 FFmpeg 推流至 YouTube 为主;具体融合方案与端口规划待定。
diff --git a/tiktok/index.html b/tiktok/index.html
new file mode 100644
index 0000000..f155e51
--- /dev/null
+++ b/tiktok/index.html
@@ -0,0 +1,175 @@
+
+
+
+
+
+HDMI 录制控制台
+
+
+
+
+
+
进程: hdmi ⚪ 加载中...
+
+
视频播放器(M3U8 / FLV)
+
+
播放
+
+
+
PM2 进程控制
+
+ 启动
+ 停止
+ 重启
+ 手动刷新
+
+
+
实时日志(自动每秒刷新)
+
正在加载状态与日志...
+
+
+
+
+
diff --git a/tiktok/tiktok.py b/tiktok/tiktok.py
new file mode 100644
index 0000000..60c7d92
--- /dev/null
+++ b/tiktok/tiktok.py
@@ -0,0 +1,142 @@
+from fastapi import FastAPI
+from fastapi.responses import FileResponse, JSONResponse
+from pathlib import Path
+import asyncio
+import re
+from fastapi.middleware.cors import CORSMiddleware
+
+app = FastAPI(title="HDMI 录制控制台")
+
+# ---------------- 配置 ----------------
+BOT_NAME = "hdmi"
+BASE_DIR = Path(__file__).parent
+DEFAULT_LOG_DIR = BASE_DIR.parent / ".pm2" / "logs"
+MAX_LOG_LINES = 300
+
+# CORS
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["GET"],
+ allow_headers=["*"],
+)
+
+# ---------------- 工具 ----------------
+def strip_ansi_codes(text: str) -> str:
+ return re.sub(r'\x1b\[[0-9;]*m', '', text)
+
+async def run_pm2(cmd: str) -> str:
+ process = await asyncio.create_subprocess_shell(
+ f"pm2 {cmd}",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=15.0)
+ return (stdout or stderr).decode(errors="ignore").strip()
+ except asyncio.TimeoutError:
+ process.kill()
+ return "ERROR: PM2 命令超时"
+
+async def read_log_path(path: str, lines: int) -> str:
+ if not path:
+ return "无日志路径\n"
+ if "/dev/null" in path:
+ return "日志输出被禁用(PM2 配置为无日志)\n"
+ log_file = Path(path)
+ if not log_file.exists():
+ return f"日志文件不存在: {path}\n"
+ try:
+ with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
+ content_lines = f.readlines()
+ return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
+ except Exception as e:
+ return f"读取日志失败 ({path}): {str(e)}\n"
+
+async def get_hdmi_info() -> dict:
+ describe_raw = await run_pm2(f"describe {BOT_NAME}")
+ describe_clean = strip_ansi_codes(describe_raw)
+
+ if ("No such process" in describe_clean or
+ "Unknown process" in describe_clean or
+ "not found" in describe_clean.lower() or
+ not describe_clean.strip()):
+ return {
+ "process_status": "not_found",
+ "out_path": None,
+ "err_path": None,
+ }
+
+ status = "unknown"
+ out_path = None
+ err_path = None
+
+ for line in describe_raw.splitlines():
+ line_clean = strip_ansi_codes(line)
+ if line_clean.count('│') < 2:
+ continue
+ parts = [p.strip() for p in line_clean.split('│') if p.strip()]
+
+ if len(parts) >= 2:
+ key = parts[0].lower()
+ value = parts[1]
+
+ if key == "status":
+ status = value.lower()
+ elif key == "out_log_path":
+ out_path = value
+ elif key == "err_log_path":
+ err_path = value
+
+ # 备用默认路径(仅当 describe 未提供时)
+ if not out_path:
+ out_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-out.log")
+ if not err_path:
+ err_path = str(DEFAULT_LOG_DIR / f"{BOT_NAME}-error.log")
+
+ return {
+ "process_status": status,
+ "out_path": out_path,
+ "err_path": err_path,
+ }
+
+# ---------------- 路由 ----------------
+@app.get("/start")
+async def start():
+ output = await run_pm2(f"start {BOT_NAME}")
+ return JSONResponse({"output": output})
+
+@app.get("/stop")
+async def stop():
+ output = await run_pm2(f"stop {BOT_NAME}")
+ return JSONResponse({"output": output})
+
+@app.get("/restart")
+async def restart():
+ output = await run_pm2(f"restart {BOT_NAME}")
+ return JSONResponse({"output": output})
+
+@app.get("/status")
+async def status():
+ full_status_raw = await run_pm2("status")
+ full_status = strip_ansi_codes(full_status_raw) # 去除颜色代码,前端显示更干净
+
+ info = await get_hdmi_info()
+
+ recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
+ recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
+
+ if info["process_status"] == "not_found":
+ recent_log = "进程未注册到 PM2(可能被 pm2 delete 删除或从未保存)\n"
+ recent_error = ""
+
+ return JSONResponse({
+ "raw_status": full_status,
+ "process_status": info["process_status"],
+ "recent_log": recent_log,
+ "recent_error": recent_error,
+ })
+
+@app.get("/")
+async def index():
+ return FileResponse(BASE_DIR / "index.html")
diff --git a/tiktok/tiktok.sh b/tiktok/tiktok.sh
new file mode 100644
index 0000000..584810b
--- /dev/null
+++ b/tiktok/tiktok.sh
@@ -0,0 +1 @@
+uvicorn web:app --host 0.0.0.0 --port 8000 --reload
diff --git a/tiktok2.py b/tiktok2.py
new file mode 100644
index 0000000..21e38c9
--- /dev/null
+++ b/tiktok2.py
@@ -0,0 +1,2207 @@
+# -*- encoding: utf-8 -*-
+
+"""
+Author: Hmily
+GitHub: https://github.com/ihmily
+Date: 2023-07-17 23:52:05
+Update: 2025-10-23 19:48:05
+Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
+Function: Record live stream video.
+"""
+import asyncio
+import os
+import sys
+import builtins
+import subprocess
+import signal
+import threading
+import time
+import datetime
+import re
+import shutil
+import random
+import uuid
+from pathlib import Path
+import urllib.request
+from urllib.error import URLError, HTTPError
+from typing import Any
+import configparser
+import httpx
+from src import spider, stream
+from src.proxy import ProxyDetector
+from src.utils import logger
+from src import utils
+from backup.msg_push import (
+ dingtalk, xizhi, tg_bot, send_email, bark, ntfy, pushplus
+)
+from ffmpeg_install import (
+ check_ffmpeg, ffmpeg_path, current_env_path
+)
+
+
+
+
+
+version = "v4.0.7"
+platforms = ("\n国内站点:抖音|快手|虎牙|斗鱼|YY|B站|小红书|bigo|blued|网易CC|千度热播|猫耳FM|Look|TwitCasting|百度|微博|"
+ "酷狗|花椒|流星|Acfun|畅聊|映客|音播|知乎|嗨秀|VV星球|17Live|浪Live|漂漂|六间房|乐嗨|花猫|淘宝|京东|咪咕|连接|来秀"
+ "\n海外站点:TikTok|SOOP|PandaTV|WinkTV|FlexTV|PopkonTV|TwitchTV|LiveMe|ShowRoom|CHZZK|Shopee|"
+ "Youtube|Faceit|Picarto")
+
+recording = set()
+error_count = 0
+pre_max_request = 10
+max_request_lock = threading.Lock()
+error_window = []
+error_window_size = 10
+error_threshold = 5
+monitoring = 0
+running_list = []
+url_tuples_list = []
+url_comments = []
+text_no_repeat_url = []
+create_var = locals()
+first_start = True
+exit_recording = False
+need_update_line_list = []
+first_run = True
+not_record_list = []
+start_display_time = datetime.datetime.now()
+global_proxy = False
+recording_time_list = {}
+script_path = os.path.split(os.path.realpath(sys.argv[0]))[0]
+config_file = f'{script_path}/config/config.ini'
+url_config_file = f'{script_path}/config/URL_config.ini'
+backup_dir = f'{script_path}/backup_config'
+text_encoding = 'utf-8-sig'
+rstr = r"[\/\\\:\*\??\"\<\>\|.。,, ~!· ]"
+default_path = f'{script_path}/downloads'
+os.makedirs(default_path, exist_ok=True)
+file_update_lock = threading.Lock()
+os_type = os.name
+clear_command = "cls" if os_type == 'nt' else "clear"
+color_obj = utils.Color()
+os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
+
+
+def signal_handler(_signal, _frame):
+ sys.exit(0)
+
+
+signal.signal(signal.SIGTERM, signal_handler)
+
+
+
+import shutil
+
+
+# 全局变量(只初始化一次)
+_last_lines_count = 0
+
+def _clear_lines(n: int):
+ """向上删除 n 行(不闪烁)"""
+ if n <= 0:
+ return
+ print(f"\033[{n}A\033[K", end="", flush=True) # 上移 + 清行
+
+def display_info() -> None:
+ global start_display_time, _last_lines_count
+ time.sleep(3) # 启动延迟
+
+ while True:
+ try:
+ lines = []
+ now_str = datetime.datetime.now().strftime("%H:%M:%S")
+
+ # 第一行:核心状态
+ lines.append(
+ f"监测 {monitoring} | 并发 {max_request} | "
+ f"代理 {'开' if use_proxy else '关'} | "
+ f"分段 {'开' if split_video_by_time else '关'}{f':{split_time}s' if split_video_by_time else ''} | "
+ f"时间文件 {'开' if create_time_file else '关'} | "
+ f"质量 {video_record_quality} | 格式 {video_save_type} | "
+ f"错误 {error_count} | {now_str}"
+ )
+
+ # 第二行:录制中的直播
+ if recording:
+ no_repeat = list(set(recording))
+ recording_str = f"正在录制 {len(no_repeat)} 个: "
+ details = []
+ now_dt = datetime.datetime.now()
+ for name in no_repeat[:10]: # 最多显示10个,防止爆屏
+ if name not in recording_time_list:
+ continue
+ start_time, quality = recording_time_list[name]
+ duration = str(now_dt - start_time).split('.')[0]
+ details.append(f"{name.split()[-1]}[{quality} {duration}]")
+ recording_str += " | ".join(details)
+ if len(no_repeat) > 10:
+ recording_str += f" ... (+{len(no_repeat)-10})"
+ lines.append(recording_str)
+ else:
+ if monitoring == 0:
+ lines.append("没有正在监测的直播")
+ else:
+ lines.append(f"无录制任务 | 循环间隔 {delay_default}s")
+
+ # 动态清屏 + 重绘(不闪烁!)
+ terminal_width, _ = shutil.get_terminal_size(fallback=(120, 24))
+ display_lines = []
+ for line in lines:
+ if len(line) > terminal_width - 5:
+ line = line[:terminal_width-8] + "..."
+ display_lines.append(line)
+
+ # 删除旧内容 + 打印新内容
+ if _last_lines_count > len(display_lines):
+ _clear_lines(_last_lines_count - 1)
+ print("\r" + display_lines[0])
+ for line in display_lines[1:]:
+ print(line)
+ else:
+ print("\r" + display_lines[0], end="")
+ for line in display_lines[1:]:
+ print(f"\n{line}", end="")
+
+ _last_lines_count = len(display_lines)
+ print(flush=True) # 强制刷新
+
+ time.sleep(5)
+
+ except Exception as e:
+ # 避免崩溃后疯狂打印错误
+ try:
+ print(f"\n[display_info 错误] {e}")
+ except:
+ pass
+ time.sleep(10) # 出错了也别刷屏
+def display_info2() -> None:
+ global start_display_time
+ time.sleep(5)
+ while True:
+ try:
+ sys.stdout.flush()
+ time.sleep(5)
+ if Path(sys.executable).name != 'pythonw.exe':
+ os.system(clear_command)
+ print(f"\r共监测{monitoring}个直播中", end=" | ")
+ print(f"同一时间访问网络的线程数: {max_request}", end=" | ")
+ print(f"是否开启代理录制: {'是' if use_proxy else '否'}", end=" | ")
+ if split_video_by_time:
+ print(f"录制分段开启: {split_time}秒", end=" | ")
+ else:
+ print("录制分段开启: 否", end=" | ")
+ if create_time_file:
+ print("是否生成时间文件: 是", end=" | ")
+ print(f"录制视频质量为: {video_record_quality}", end=" | ")
+ print(f"录制视频格式为: {video_save_type}", end=" | ")
+ print(f"目前瞬时错误数为: {error_count}", end=" | ")
+ now = time.strftime("%H:%M:%S", time.localtime())
+ print(f"当前时间: {now}")
+
+ if len(recording) == 0:
+ time.sleep(5)
+ if monitoring == 0:
+ print("\r没有正在监测和录制的直播")
+ else:
+ print(f"\r没有正在录制的直播 循环监测间隔时间:{delay_default}秒")
+ else:
+ now_time = datetime.datetime.now()
+ print("x" * 60)
+ no_repeat_recording = list(set(recording))
+ print(f"正在录制{len(no_repeat_recording)}个直播: ")
+ for recording_live in no_repeat_recording:
+ rt, qa = recording_time_list[recording_live]
+ have_record_time = now_time - rt
+ print(f"{recording_live}[{qa}] 正在录制中 {str(have_record_time).split('.')[0]}")
+
+ # print('\n本软件已运行:'+str(now_time - start_display_time).split('.')[0])
+ print("x" * 60)
+ start_display_time = now_time
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+
+
+def update_file(file_path: str, old_str: str, new_str: str, start_str: str = None) -> str | None:
+ if old_str == new_str and start_str is None:
+ return old_str
+ with file_update_lock:
+ file_data = []
+ with open(file_path, "r", encoding=text_encoding) as f:
+ try:
+ for text_line in f:
+ if old_str in text_line:
+ text_line = text_line.replace(old_str, new_str)
+ if start_str:
+ text_line = f'{start_str}{text_line}'
+ if text_line not in file_data:
+ file_data.append(text_line)
+ except RuntimeError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ if ini_URL_content:
+ with open(file_path, "w", encoding=text_encoding) as f2:
+ f2.write(ini_URL_content)
+ return old_str
+ if file_data:
+ with open(file_path, "w", encoding=text_encoding) as f:
+ f.write(''.join(file_data))
+ return new_str
+
+
+def delete_line(file_path: str, del_line: str, delete_all: bool = False) -> None:
+ with file_update_lock:
+ with open(file_path, 'r+', encoding=text_encoding) as f:
+ lines = f.readlines()
+ f.seek(0)
+ f.truncate()
+ skip_line = False
+ for txt_line in lines:
+ if del_line in txt_line:
+ if delete_all or not skip_line:
+ skip_line = True
+ continue
+ else:
+ skip_line = False
+ f.write(txt_line)
+
+
+def get_startup_info(system_type: str):
+ if system_type == 'nt':
+ startup_info = subprocess.STARTUPINFO()
+ startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ else:
+ startup_info = None
+ return startup_info
+
+
+def segment_video(converts_file_path: str, segment_save_file_path: str, segment_format: str, segment_time: str,
+ is_original_delete: bool = True) -> None:
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ ffmpeg_command = [
+ "ffmpeg",
+ "-i", converts_file_path,
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-map", "0",
+ "-f", "segment",
+ "-segment_time", segment_time,
+ "-segment_format", segment_format,
+ "-reset_timestamps", "1",
+ "-movflags", "+frag_keyframe+empty_moov",
+ segment_save_file_path,
+ ]
+ _output = subprocess.check_output(
+ ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
+ )
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ except subprocess.CalledProcessError as e:
+ logger.error(f'Error occurred during conversion: {e}')
+ except Exception as e:
+ logger.error(f'An unknown error occurred: {e}')
+
+
+def converts_mp4(converts_file_path: str, is_original_delete: bool = True) -> None:
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ if converts_to_h264:
+ color_obj.print_colored("正在转码为MP4格式并重新编码为h264\n", color_obj.YELLOW)
+ ffmpeg_command = [
+ "ffmpeg", "-i", converts_file_path,
+ "-c:v", "libx264",
+ "-preset", "veryfast",
+ "-crf", "23",
+ "-vf", "format=yuv420p",
+ "-c:a", "copy",
+ "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4",
+ ]
+ else:
+ color_obj.print_colored("正在转码为MP4格式\n", color_obj.YELLOW)
+ ffmpeg_command = [
+ "ffmpeg", "-i", converts_file_path,
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4",
+ ]
+ _output = subprocess.check_output(
+ ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
+ )
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ except subprocess.CalledProcessError as e:
+ logger.error(f'Error occurred during conversion: {e}')
+ except Exception as e:
+ logger.error(f'An unknown error occurred: {e}')
+
+
+def converts_m4a(converts_file_path: str, is_original_delete: bool = True) -> None:
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ _output = subprocess.check_output([
+ "ffmpeg", "-i", converts_file_path,
+ "-n", "-vn",
+ "-c:a", "aac", "-bsf:a", "aac_adtstoasc", "-ab", "320k",
+ converts_file_path.rsplit('.', maxsplit=1)[0] + ".m4a",
+ ], stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type))
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ except subprocess.CalledProcessError as e:
+ logger.error(f'Error occurred during conversion: {e}')
+ except Exception as e:
+ logger.error(f'An unknown error occurred: {e}')
+
+
+def generate_subtitles(record_name: str, ass_filename: str, sub_format: str = 'srt') -> None:
+ index_time = 0
+ today = datetime.datetime.now()
+ re_datatime = today.strftime('%Y-%m-%d %H:%M:%S')
+
+ def transform_int_to_time(seconds: int) -> str:
+ m, s = divmod(seconds, 60)
+ h, m = divmod(m, 60)
+ return f"{h:02d}:{m:02d}:{s:02d}"
+
+ while True:
+ index_time += 1
+ txt = str(index_time) + "\n" + transform_int_to_time(index_time) + ',000 --> ' + transform_int_to_time(
+ index_time + 1) + ',000' + "\n" + str(re_datatime) + "\n\n"
+
+ with open(f"{ass_filename}.{sub_format.lower()}", 'a', encoding=text_encoding) as f:
+ f.write(txt)
+
+ if record_name not in recording:
+ return
+ time.sleep(1)
+ today = datetime.datetime.now()
+ re_datatime = today.strftime('%Y-%m-%d %H:%M:%S')
+
+
+def adjust_max_request() -> None:
+ global max_request, error_count, pre_max_request, error_window
+ preset = max_request
+
+ while True:
+ time.sleep(5)
+ with max_request_lock:
+ if error_window:
+ error_rate = sum(error_window) / len(error_window)
+ else:
+ error_rate = 0
+
+ if error_rate > error_threshold:
+ max_request = max(1, max_request - 1)
+ elif error_rate < error_threshold / 2 and max_request < preset:
+ max_request += 1
+ else:
+ pass
+
+ if pre_max_request != max_request:
+ pre_max_request = max_request
+ print(f"\r同一时间访问网络的线程数动态改为 {max_request}")
+
+ error_window.append(error_count)
+ if len(error_window) > error_window_size:
+ error_window.pop(0)
+ error_count = 0
+
+
+def push_message(record_name: str, live_url: str, content: str) -> None:
+ msg_title = push_message_title.strip() or "直播间状态更新通知"
+ push_functions = {
+ '微信': lambda: xizhi(xizhi_api_url, msg_title, content),
+ '钉钉': lambda: dingtalk(dingtalk_api_url, content, dingtalk_phone_num, dingtalk_is_atall),
+ '邮箱': lambda: send_email(
+ email_host, login_email, email_password, sender_email, sender_name,
+ to_email, msg_title, content, smtp_port, open_smtp_ssl
+ ),
+ 'TG': lambda: tg_bot(tg_chat_id, tg_token, content),
+ 'BARK': lambda: bark(
+ bark_msg_api, title=msg_title, content=content, level=bark_msg_level, sound=bark_msg_ring
+ ),
+ 'NTFY': lambda: ntfy(
+ ntfy_api, title=msg_title, content=content, tags=ntfy_tags, action_url=live_url, email=ntfy_email
+ ),
+ 'PUSHPLUS': lambda: pushplus(pushplus_token, msg_title, content),
+ }
+
+ for platform, func in push_functions.items():
+ if platform in live_status_push.upper():
+ try:
+ result = func()
+ print(f'提示信息:已经将[{record_name}]直播状态消息推送至你的{platform},'
+ f' 成功{len(result["success"])}, 失败{len(result["error"])}')
+ except Exception as e:
+ color_obj.print_colored(f"直播消息推送到{platform}失败: {e}", color_obj.RED)
+
+
+def run_script(command: str) -> None:
+ try:
+ process = subprocess.Popen(
+ command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=get_startup_info(os_type)
+ )
+ stdout, stderr = process.communicate()
+ stdout_decoded = stdout.decode('utf-8')
+ stderr_decoded = stderr.decode('utf-8')
+ if stdout_decoded.strip():
+ print(stdout_decoded)
+ if stderr_decoded.strip():
+ print(stderr_decoded)
+ except PermissionError as e:
+ logger.error(e)
+ logger.error('脚本无执行权限!, 若是Linux环境, 请先执行:chmod +x your_script.sh 授予脚本可执行权限')
+ except OSError as e:
+ logger.error(e)
+ logger.error('Please add `#!/bin/bash` at the beginning of your bash script file.')
+
+
+def clear_record_info(record_name: str, record_url: str) -> None:
+ global monitoring
+ recording.discard(record_name)
+ if record_url in url_comments and record_url in running_list:
+ running_list.remove(record_url)
+ monitoring -= 1
+ color_obj.print_colored(f"[{record_name}]已经从录制列表中移除\n", color_obj.YELLOW)
+
+
+def direct_download_stream(source_url: str, save_path: str, record_name: str, live_url: str, platform: str) -> bool:
+ try:
+ with open(save_path, 'wb') as f:
+ client = httpx.Client(timeout=None)
+
+ headers = {}
+ header_params = get_record_headers(platform, live_url)
+ if header_params:
+ key, value = header_params.split(":", 1)
+ headers[key] = value
+
+ with client.stream('GET', source_url, headers=headers, follow_redirects=True) as response:
+ if response.status_code != 200:
+ logger.error(f"请求直播流失败,状态码: {response.status_code}")
+ return False
+
+ downloaded = 0
+ chunk_size = 1024 * 16
+
+ for chunk in response.iter_bytes(chunk_size):
+ if live_url in url_comments or exit_recording:
+ color_obj.print_colored(f"[{record_name}]录制时已被注释或请求停止,下载中断", color_obj.YELLOW)
+ clear_record_info(record_name, live_url)
+ return False
+
+ if chunk:
+ f.write(chunk)
+ downloaded += len(chunk)
+ print()
+ return True
+ except Exception as e:
+ logger.error(f"FLV下载错误: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ return False
+
+
+def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, save_type: str,
+ script_command: str | None = None) -> bool:
+ save_file_path = ffmpeg_command[-1]
+ process = subprocess.Popen(
+ ffmpeg_command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
+ )
+
+ subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
+ subs_thread_name = f'subs_{Path(subs_file_path).name}'
+ if create_time_file and not split_video_by_time and '音频' not in save_type:
+ create_var[subs_thread_name] = threading.Thread(
+ target=generate_subtitles, args=(record_name, subs_file_path)
+ )
+ create_var[subs_thread_name].daemon = True
+ create_var[subs_thread_name].start()
+
+ while process.poll() is None:
+ if record_url in url_comments or exit_recording:
+ color_obj.print_colored(f"[{record_name}]录制时已被注释,本条线程将会退出", color_obj.YELLOW)
+ clear_record_info(record_name, record_url)
+ # process.terminate()
+ if os.name == 'nt':
+ if process.stdin:
+ process.stdin.write(b'q')
+ process.stdin.close()
+ else:
+ process.send_signal(signal.SIGINT)
+ process.wait()
+ return True
+ time.sleep(1)
+
+ return_code = process.returncode
+ stop_time = time.strftime('%Y-%m-%d %H:%M:%S')
+ if return_code == 0:
+ if converts_to_mp4 and save_type == 'TS':
+ if split_video_by_time:
+ file_paths = utils.get_file_paths(os.path.dirname(save_file_path))
+ prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0]
+ for path in file_paths:
+ if prefix in path:
+ threading.Thread(target=converts_mp4, args=(path, delete_origin_file)).start()
+ else:
+ threading.Thread(target=converts_mp4, args=(save_file_path, delete_origin_file)).start()
+ print(f"\n{record_name} {stop_time} 直播录制完成\n")
+
+ if script_command:
+ logger.debug("开始执行脚本命令!")
+ if "python" in script_command:
+ params = [
+ f'--record_name "{record_name}"',
+ f'--save_file_path "{save_file_path}"',
+ f'--save_type {save_type}',
+ f'--split_video_by_time {split_video_by_time}',
+ f'--converts_to_mp4 {converts_to_mp4}',
+ ]
+ else:
+ params = [
+ f'"{record_name.split(" ", maxsplit=1)[-1]}"',
+ f'"{save_file_path}"',
+ save_type,
+ f'split_video_by_time:{split_video_by_time}',
+ f'converts_to_mp4:{converts_to_mp4}'
+ ]
+ script_command = script_command.strip() + ' ' + ' '.join(params)
+ run_script(script_command)
+ logger.debug("脚本命令执行结束!")
+
+ else:
+ color_obj.print_colored(f"\n{record_name} {stop_time} 直播录制出错,返回码: {return_code}\n", color_obj.RED)
+
+ recording.discard(record_name)
+ return False
+
+
+def clean_name(input_text):
+ cleaned_name = re.sub(rstr, "_", input_text.strip()).strip('_')
+ cleaned_name = cleaned_name.replace("(", "(").replace(")", ")")
+ if clean_emoji:
+ cleaned_name = utils.remove_emojis(cleaned_name, '_').strip('_')
+ return cleaned_name or '空白昵称'
+
+
+def get_quality_code(qn):
+ QUALITY_MAPPING = {
+ "原画": "OD",
+ "蓝光": "BD",
+ "超清": "UHD",
+ "高清": "HD",
+ "标清": "SD",
+ "流畅": "LD"
+ }
+ return QUALITY_MAPPING.get(qn)
+
+
+def get_record_headers(platform, live_url):
+ live_domain = '/'.join(live_url.split('/')[0:3])
+ record_headers = {
+ 'PandaTV': 'origin:https://www.pandalive.co.kr',
+ 'WinkTV': 'origin:https://www.winktv.co.kr',
+ 'PopkonTV': 'origin:https://www.popkontv.com',
+ 'FlexTV': 'origin:https://www.flextv.co.kr',
+ '千度热播': 'referer:https://qiandurebo.com',
+ '17Live': 'referer:https://17.live/en/live/6302408',
+ '浪Live': 'referer:https://www.lang.live',
+ 'shopee': f'origin:{live_domain}',
+ 'Blued直播': 'referer:https://app.blued.cn'
+ }
+ return record_headers.get(platform)
+
+
+def is_flv_preferred_platform(link):
+ return any(i in link for i in ["douyin", "tiktok"])
+
+
+def select_source_url(link, stream_info):
+ if is_flv_preferred_platform(link):
+ codec = utils.get_query_params(stream_info.get('flv_url'), "codec")
+ if codec and codec[0] == 'h265':
+ logger.warning("FLV is not supported for h265 codec, use HLS source instead")
+ else:
+ return stream_info.get('flv_url')
+
+ return stream_info.get('record_url')
+
+
+def start_record(url_data: tuple, count_variable: int = -1) -> None:
+ global error_count
+
+ while True:
+ try:
+ record_finished = False
+ run_once = False
+ start_pushed = False
+ new_record_url = ''
+ count_time = time.time()
+ retry = 0
+ record_quality_zh, record_url, anchor_name = url_data
+ record_quality = get_quality_code(record_quality_zh)
+ proxy_address = proxy_addr
+ platform = '未知平台'
+ live_domain = '/'.join(record_url.split('/')[0:3])
+
+ if proxy_addr:
+ proxy_address = None
+ for platform in enable_proxy_platform_list:
+ if platform and platform.strip() in record_url:
+ proxy_address = proxy_addr
+ break
+
+ if not proxy_address:
+ if extra_enable_proxy_platform_list:
+ for pt in extra_enable_proxy_platform_list:
+ if pt and pt.strip() in record_url:
+ proxy_address = proxy_addr_bak or None
+
+ # print(f'\r代理地址:{proxy_address}')
+ # print(f'\r全局代理:{global_proxy}')
+ while True:
+ try:
+ port_info = []
+ if record_url.find("douyin.com/") > -1:
+ platform = '抖音直播'
+ with semaphore:
+ if 'v.douyin.com' not in record_url and '/user/' not in record_url:
+ json_data = asyncio.run(spider.get_douyin_web_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=dy_cookie))
+ else:
+ json_data = asyncio.run(spider.get_douyin_app_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=dy_cookie))
+ port_info = asyncio.run(
+ stream.get_douyin_stream_url(json_data, record_quality, proxy_address))
+
+ elif record_url.find("https://www.tiktok.com/") > -1:
+ platform = 'TikTok直播'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_tiktok_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=tiktok_cookie))
+ port_info = asyncio.run(
+ stream.get_tiktok_stream_url(json_data, record_quality, proxy_address))
+ else:
+ logger.error("错误信息: 网络异常,请检查网络是否能正常访问TikTok平台")
+
+ elif record_url.find("https://live.kuaishou.com/") > -1:
+ platform = '快手直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_kuaishou_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=ks_cookie))
+ port_info = asyncio.run(stream.get_kuaishou_stream_url(json_data, record_quality))
+
+ elif record_url.find("https://www.huya.com/") > -1:
+ platform = '虎牙直播'
+ with semaphore:
+ if record_quality not in ['OD', 'BD', 'UHD']:
+ json_data = asyncio.run(spider.get_huya_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=hy_cookie))
+ port_info = asyncio.run(stream.get_huya_stream_url(json_data, record_quality))
+ else:
+ port_info = asyncio.run(spider.get_huya_app_stream_url(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=hy_cookie
+ ))
+
+ elif record_url.find("https://www.douyu.com/") > -1:
+ platform = '斗鱼直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_douyu_info_data(
+ url=record_url, proxy_addr=proxy_address, cookies=douyu_cookie))
+ port_info = asyncio.run(stream.get_douyu_stream_url(
+ json_data, video_quality=record_quality, cookies=douyu_cookie, proxy_addr=proxy_address
+ ))
+
+ elif record_url.find("https://www.yy.com/") > -1:
+ platform = 'YY直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_yy_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=yy_cookie))
+ port_info = asyncio.run(stream.get_yy_stream_url(json_data))
+
+ elif record_url.find("https://live.bilibili.com/") > -1:
+ platform = 'B站直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_bilibili_room_info(
+ url=record_url, proxy_addr=proxy_address, cookies=bili_cookie))
+ port_info = asyncio.run(stream.get_bilibili_stream_url(
+ json_data, video_quality=record_quality, cookies=bili_cookie, proxy_addr=proxy_address))
+
+ elif record_url.find("http://xhslink.com/") > -1 or \
+ record_url.find("https://www.xiaohongshu.com/") > -1:
+ platform = '小红书直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_xhs_stream_url(
+ record_url, proxy_addr=proxy_address, cookies=xhs_cookie))
+ retry += 1
+
+ elif record_url.find("www.bigo.tv/") > -1 or record_url.find("slink.bigovideo.tv/") > -1:
+ platform = 'Bigo直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_bigo_stream_url(
+ record_url, proxy_addr=proxy_address, cookies=bigo_cookie))
+
+ elif record_url.find("https://app.blued.cn/") > -1:
+ platform = 'Blued直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_blued_stream_url(
+ record_url, proxy_addr=proxy_address, cookies=blued_cookie))
+
+ elif record_url.find("sooplive.co.kr/") > -1 or record_url.find("sooplive.com/") > -1:
+ platform = 'SOOP'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_sooplive_stream_data(
+ url=record_url, proxy_addr=proxy_address,
+ cookies=sooplive_cookie,
+ username=sooplive_username,
+ password=sooplive_password
+ ))
+ if json_data and json_data.get('new_cookies'):
+ utils.update_config(
+ config_file, 'Cookie', 'sooplive_cookie', json_data['new_cookies']
+ )
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问SOOP平台")
+
+ elif record_url.find("cc.163.com/") > -1:
+ platform = '网易CC直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_netease_stream_data(
+ url=record_url, cookies=netease_cookie))
+ port_info = asyncio.run(stream.get_netease_stream_url(json_data, record_quality))
+
+ elif record_url.find("qiandurebo.com/") > -1:
+ platform = '千度热播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_qiandurebo_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=qiandurebo_cookie))
+
+ elif record_url.find("www.pandalive.co.kr/") > -1:
+ platform = 'PandaTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_pandatv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=pandatv_cookie
+ ))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问PandaTV直播平台")
+
+ elif record_url.find("fm.missevan.com/") > -1:
+ platform = '猫耳FM直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_maoerfm_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=maoerfm_cookie))
+
+ elif record_url.find("www.winktv.co.kr/") > -1:
+ platform = 'WinkTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_winktv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=winktv_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问WinkTV直播平台")
+
+ elif record_url.find("www.flextv.co.kr/") > -1 or record_url.find("www.ttinglive.com/") > -1:
+ platform = 'FlexTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_flextv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=flextv_cookie,
+ username=flextv_username,
+ password=flextv_password
+ ))
+ if json_data and json_data.get('new_cookies'):
+ utils.update_config(
+ config_file, 'Cookie', 'flextv_cookie', json_data['new_cookies']
+ )
+ if 'play_url_list' in json_data:
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ port_info = json_data
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问FlexTV直播平台")
+
+ elif record_url.find("look.163.com/") > -1:
+ platform = 'Look直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_looklive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=look_cookie
+ ))
+
+ elif record_url.find("www.popkontv.com/") > -1:
+ platform = 'PopkonTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ port_info = asyncio.run(spider.get_popkontv_stream_url(
+ url=record_url,
+ proxy_addr=proxy_address,
+ access_token=popkontv_access_token,
+ username=popkontv_username,
+ password=popkontv_password,
+ partner_code=popkontv_partner_code
+ ))
+ if port_info and port_info.get('new_token'):
+ utils.update_config(
+ file_path=config_file, section='Authorization', key='popkontv_token',
+ new_value=port_info['new_token']
+ )
+
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问PopkonTV直播平台")
+
+ elif record_url.find("twitcasting.tv/") > -1:
+ platform = 'TwitCasting'
+ with semaphore:
+ json_data = asyncio.run(spider.get_twitcasting_stream_url(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=twitcasting_cookie,
+ account_type=twitcasting_account_type,
+ username=twitcasting_username,
+ password=twitcasting_password
+ ))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=False))
+
+ if port_info and port_info.get('new_cookies'):
+ utils.update_config(
+ file_path=config_file, section='Cookie', key='twitcasting_cookie',
+ new_value=port_info['new_cookies']
+ )
+
+ elif record_url.find("live.baidu.com/") > -1:
+ platform = '百度直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_baidu_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=baidu_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality))
+
+ elif record_url.find("weibo.com/") > -1:
+ platform = '微博直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_weibo_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=weibo_cookie))
+ port_info = asyncio.run(stream.get_stream_url(
+ json_data, record_quality, hls_extra_key='m3u8_url'))
+
+ elif record_url.find("kugou.com/") > -1:
+ platform = '酷狗直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_kugou_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=kugou_cookie))
+
+ elif record_url.find("www.twitch.tv/") > -1:
+ platform = 'TwitchTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_twitchtv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=twitch_cookie
+ ))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问TwitchTV直播平台")
+
+ elif record_url.find("www.liveme.com/") > -1:
+ if global_proxy or proxy_address:
+ platform = 'LiveMe'
+ with semaphore:
+ port_info = asyncio.run(spider.get_liveme_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=liveme_cookie))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问LiveMe直播平台")
+
+ elif record_url.find("www.huajiao.com/") > -1:
+ platform = '花椒直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_huajiao_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=huajiao_cookie))
+
+ elif record_url.find("7u66.com/") > -1:
+ platform = '流星直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_liuxing_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=liuxing_cookie))
+
+ elif record_url.find("showroom-live.com/") > -1:
+ platform = 'ShowRoom'
+ with semaphore:
+ json_data = asyncio.run(spider.get_showroom_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=showroom_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+
+ elif record_url.find("live.acfun.cn/") > -1 or record_url.find("m.acfun.cn/") > -1:
+ platform = 'Acfun'
+ with semaphore:
+ json_data = asyncio.run(spider.get_acfun_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=acfun_cookie))
+ port_info = asyncio.run(stream.get_stream_url(
+ json_data, record_quality, url_type='flv', flv_extra_key='url'))
+
+ elif record_url.find("live.tlclw.com/") > -1:
+ platform = '畅聊直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_changliao_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=changliao_cookie))
+
+ elif record_url.find("ybw1666.com/") > -1:
+ platform = '音播直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_yinbo_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=yinbo_cookie))
+
+ elif record_url.find("www.inke.cn/") > -1:
+ platform = '映客直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_yingke_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=yingke_cookie))
+
+ elif record_url.find("www.zhihu.com/") > -1:
+ platform = '知乎直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_zhihu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=zhihu_cookie))
+
+ elif record_url.find("chzzk.naver.com/") > -1:
+ platform = 'CHZZK'
+ with semaphore:
+ json_data = asyncio.run(spider.get_chzzk_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=chzzk_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+
+ elif record_url.find("www.haixiutv.com/") > -1:
+ platform = '嗨秀直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_haixiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=haixiu_cookie))
+
+ elif record_url.find("vvxqiu.com/") > -1:
+ platform = 'VV星球'
+ with semaphore:
+ port_info = asyncio.run(spider.get_vvxqiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=vvxqiu_cookie))
+
+ elif record_url.find("17.live/") > -1:
+ platform = '17Live'
+ with semaphore:
+ port_info = asyncio.run(spider.get_17live_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=yiqilive_cookie))
+
+ elif record_url.find("www.lang.live/") > -1:
+ platform = '浪Live'
+ with semaphore:
+ port_info = asyncio.run(spider.get_langlive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=langlive_cookie))
+
+ elif record_url.find("m.pp.weimipopo.com/") > -1:
+ platform = '漂漂直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_pplive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=pplive_cookie))
+
+ elif record_url.find(".6.cn/") > -1:
+ platform = '六间房直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_6room_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=six_room_cookie))
+
+ elif record_url.find("lehaitv.com/") > -1:
+ platform = '乐嗨直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_haixiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=lehaitv_cookie))
+
+ elif record_url.find("h.catshow168.com/") > -1:
+ platform = '花猫直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_pplive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=huamao_cookie))
+
+ elif record_url.find("live.shopee") > -1 or record_url.find("shp.ee/") > -1:
+ platform = 'shopee'
+ with semaphore:
+ port_info = asyncio.run(spider.get_shopee_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=shopee_cookie))
+ if port_info.get('uid'):
+ new_record_url = record_url.split('?')[0] + '?' + str(port_info['uid'])
+
+ elif record_url.find("www.youtube.com/") > -1 or record_url.find("youtu.be/") > -1:
+ platform = 'Youtube'
+ with semaphore:
+ json_data = asyncio.run(spider.get_youtube_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=youtube_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+
+ elif record_url.find("tb.cn") > -1:
+ platform = '淘宝直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_taobao_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=taobao_cookie))
+ port_info = asyncio.run(stream.get_stream_url(
+ json_data, record_quality,
+ url_type='all', hls_extra_key='hlsUrl', flv_extra_key='flvUrl'
+ ))
+
+ elif record_url.find("3.cn") > -1 or record_url.find("m.jd.com") > -1:
+ platform = '京东直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_jd_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=jd_cookie))
+
+ elif record_url.find("faceit.com/") > -1:
+ platform = 'faceit'
+ with semaphore:
+ if global_proxy or proxy_address:
+ with semaphore:
+ json_data = asyncio.run(spider.get_faceit_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=faceit_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问faceit直播平台")
+
+ elif record_url.find("www.miguvideo.com") > -1 or record_url.find("m.miguvideo.com") > -1:
+ platform = '咪咕直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_migu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=migu_cookie))
+
+ elif record_url.find("show.lailianjie.com") > -1:
+ platform = '连接直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_lianjie_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=lianjie_cookie))
+
+ elif record_url.find("www.imkktv.com") > -1:
+ platform = '来秀直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_laixiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=laixiu_cookie))
+
+ elif record_url.find("www.picarto.tv") > -1:
+ platform = 'Picarto'
+ with semaphore:
+ port_info = asyncio.run(spider.get_picarto_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=picarto_cookie))
+
+ elif record_url.find(".m3u8") > -1 or record_url.find(".flv") > -1:
+ platform = '自定义录制直播'
+ port_info = {
+ "anchor_name": platform + '_' + str(uuid.uuid4())[:8],
+ "is_live": True,
+ "record_url": record_url,
+ }
+ if '.flv' in record_url:
+ port_info['flv_url'] = record_url
+ else:
+ port_info['m3u8_url'] = record_url
+
+ else:
+ logger.error(f'{record_url} {platform}直播地址')
+ return
+
+ if anchor_name:
+ if '主播:' in anchor_name:
+ anchor_split: list = anchor_name.split('主播:')
+ if len(anchor_split) > 1 and anchor_split[1].strip():
+ anchor_name = anchor_split[1].strip()
+ else:
+ anchor_name = port_info.get("anchor_name", '')
+ else:
+ anchor_name = port_info.get("anchor_name", '')
+
+ if not port_info.get("anchor_name", ''):
+ print(f'序号{count_variable} 网址内容获取失败,进行重试中...获取失败的地址是:{url_data}')
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+ else:
+ anchor_name = clean_name(anchor_name)
+ record_name = f'序号{count_variable} {anchor_name}'
+
+ if record_url in url_comments:
+ print(f"[{anchor_name}]已被注释,本条线程将会退出")
+ clear_record_info(record_name, record_url)
+ return
+
+ if not url_data[-1] and run_once is False:
+ if new_record_url:
+ need_update_line_list.append(
+ f'{record_url}|{new_record_url},主播: {anchor_name.strip()}')
+ not_record_list.append(new_record_url)
+ else:
+ need_update_line_list.append(f'{record_url}|{record_url},主播: {anchor_name.strip()}')
+ run_once = True
+
+ push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S')
+ if port_info['is_live'] is False:
+ print(f"\r{record_name} 等待直播... ")
+ # 直接退出线程eric
+ # continue # 继续循环
+ return # 线程彻底退出
+
+ if start_pushed:
+ if over_show_push:
+ push_content = "直播间状态更新:[直播间名称] 直播已结束!时间:[时间]"
+ if over_push_message_text:
+ push_content = over_push_message_text
+
+ push_content = (push_content.replace('[直播间名称]', record_name).
+ replace('[时间]', push_at))
+ threading.Thread(
+ target=push_message,
+ args=(record_name, record_url, push_content.replace(r'\n', '\n')),
+ daemon=True
+ ).start()
+ start_pushed = False
+
+ else:
+ content = f"\r{record_name} 正在直播中..."
+ print(content)
+ # 检查是否已有直播在录制
+ if len(recording) > 0 :
+ # 如果已有直播在录制,跳过这次录制(主播在线)
+ # return
+ continue #如果在循环中
+
+ if live_status_push and not start_pushed:
+ if begin_show_push:
+ push_content = "直播间状态更新:[直播间名称] 正在直播中,时间:[时间]"
+ if begin_push_message_text:
+ push_content = begin_push_message_text
+
+ push_content = (push_content.replace('[直播间名称]', record_name).
+ replace('[时间]', push_at))
+ threading.Thread(
+ target=push_message,
+ args=(record_name, record_url, push_content.replace(r'\n', '\n')),
+ daemon=True
+ ).start()
+ start_pushed = True
+
+ if disable_record:
+ time.sleep(push_check_seconds)
+ continue
+
+ real_url = select_source_url(record_url, port_info)
+ full_path = f'{default_path}/{platform}'
+ if real_url:
+ now = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
+ live_title = port_info.get('title')
+ title_in_name = ''
+ if live_title:
+ live_title = clean_name(live_title)
+ title_in_name = live_title + '_' if filename_by_title else ''
+
+ try:
+ if len(video_save_path) > 0:
+ if not video_save_path.endswith(('/', '\\')):
+ full_path = f'{video_save_path}/{platform}'
+ else:
+ full_path = f'{video_save_path}{platform}'
+
+ full_path = full_path.replace("\\", '/')
+ if folder_by_author:
+ full_path = f'{full_path}/{anchor_name}'
+ if folder_by_time:
+ full_path = f'{full_path}/{now[:10]}'
+ if folder_by_title and port_info.get('title'):
+ if folder_by_time:
+ full_path = f'{full_path}/{live_title}_{anchor_name}'
+ else:
+ full_path = f'{full_path}/{now[:10]}_{live_title}'
+ if not os.path.exists(full_path):
+ os.makedirs(full_path)
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+
+ if platform != '自定义录制直播':
+ if enable_https_recording and real_url.startswith("http://"):
+ real_url = real_url.replace("http://", "https://")
+
+ http_record_list = ['shopee', "migu"]
+ if platform in http_record_list:
+ real_url = real_url.replace("https://", "http://")
+
+ user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 ("
+ "KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile "
+ "Safari/537.36")
+
+ rw_timeout = "15000000"
+ analyzeduration = "20000000"
+ probesize = "10000000"
+ bufsize = "8000k"
+ max_muxing_queue_size = "1024"
+ for pt_host in overseas_platform_host:
+ if pt_host in record_url:
+ rw_timeout = "50000000"
+ analyzeduration = "40000000"
+ probesize = "20000000"
+ bufsize = "15000k"
+ max_muxing_queue_size = "2048"
+ break
+
+ ffmpeg_command = [
+ 'ffmpeg', "-y",
+ "-v", "verbose",
+ "-rw_timeout", rw_timeout,
+ "-loglevel", "error",
+ "-hide_banner",
+ "-user_agent", user_agent,
+ "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy",
+ "-thread_queue_size", "1024",
+ "-analyzeduration", analyzeduration,
+ "-probesize", probesize,
+ "-fflags", "+discardcorrupt",
+ "-re", "-i", real_url,
+ "-bufsize", bufsize,
+ "-sn", "-dn",
+ "-reconnect_delay_max", "60",
+ "-reconnect_streamed", "-reconnect_at_eof",
+ "-max_muxing_queue_size", max_muxing_queue_size,
+ "-correct_ts_overflow", "1",
+ "-avoid_negative_ts", "1"
+ ]
+
+ headers = get_record_headers(platform, record_url)
+ if headers:
+ ffmpeg_command.insert(11, "-headers")
+ ffmpeg_command.insert(12, headers)
+
+ _ff_proxy = (proxy_address or "").strip() or (
+ os.environ.get("STREAM_HTTP_PROXY") or ""
+ ).strip() or None
+ if _ff_proxy:
+ ffmpeg_command.insert(1, "-http_proxy")
+ ffmpeg_command.insert(2, _ff_proxy)
+
+ recording.add(record_name)
+ start_record_time = datetime.datetime.now()
+ recording_time_list[record_name] = [start_record_time, record_quality_zh]
+ rec_info = f"\r{anchor_name} 准备开始录制视频: {full_path}"
+ if show_url:
+ re_plat = ('WinkTV', 'PandaTV', 'ShowRoom', 'CHZZK', 'Youtube')
+ if platform in re_plat:
+ logger.info(
+ f"{platform} | {anchor_name} | 直播源地址: {port_info.get('m3u8_url')}")
+ else:
+ logger.info(
+ f"{platform} | {anchor_name} | 直播源地址: {real_url}")
+
+ only_flv_record = False
+ only_flv_platform_list = ['shopee', '花椒直播']
+ if platform in only_flv_platform_list:
+ logger.debug(f"提示: {platform} 将强制使用FLV格式录制")
+ only_flv_record = True
+
+ only_audio_record = False
+ only_audio_platform_list = ['猫耳FM直播', 'Look直播']
+ if platform in only_audio_platform_list:
+ only_audio_record = True
+
+ record_save_type = video_save_type
+
+ if is_flv_preferred_platform(record_url) and port_info.get('flv_url'):
+ codec = utils.get_query_params(port_info['flv_url'], "codec")
+ if codec and codec[0] == 'h265':
+ logger.warning("FLV is not supported for h265 codec, use TS format instead")
+ record_save_type = "TS"
+
+ if only_audio_record or any(i in record_save_type for i in ['MP3', 'M4A']):
+ try:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ extension = "mp3" if "m4a" not in record_save_type.lower() else "m4a"
+ name_format = "_%03d" if split_video_by_time else ""
+ save_file_path = (f"{full_path}/{anchor_name}_{title_in_name}{now}"
+ f"{name_format}.{extension}")
+
+ if split_video_by_time:
+ print(f'\r{anchor_name} 准备开始录制音频: {save_file_path}')
+
+ if "MP3" in record_save_type:
+ command = [
+ "-map", "0:a",
+ "-c:a", "libmp3lame",
+ "-ab", "320k",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-reset_timestamps", "1",
+ save_file_path,
+ ]
+ else:
+ command = [
+ "-map", "0:a",
+ "-c:a", "aac",
+ "-bsf:a", "aac_adtstoasc",
+ "-ab", "320k",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", 'mpegts',
+ "-reset_timestamps", "1",
+ save_file_path,
+ ]
+
+ else:
+ if "MP3" in record_save_type:
+ command = [
+ "-map", "0:a",
+ "-c:a", "libmp3lame",
+ "-ab", "320k",
+ save_file_path,
+ ]
+
+ else:
+ command = [
+ "-map", "0:a",
+ "-c:a", "aac",
+ "-bsf:a", "aac_adtstoasc",
+ "-ab", "320k",
+ "-movflags", "+faststart",
+ save_file_path,
+ ]
+
+ ffmpeg_command.extend(command)
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+
+
+ # if only_flv_record:
+ if False:
+
+ logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}")
+ filename = anchor_name + f'_{title_in_name}' + now + '.flv'
+ save_file_path = f'{full_path}/{filename}'
+ print(f'{rec_info}/{filename}')
+
+ subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
+ subs_thread_name = f'subs_{Path(subs_file_path).name}'
+ if create_time_file:
+ create_var[subs_thread_name] = threading.Thread(
+ target=generate_subtitles, args=(record_name, subs_file_path)
+ )
+ create_var[subs_thread_name].daemon = True
+ create_var[subs_thread_name].start()
+
+ try:
+ flv_url = port_info.get('flv_url')
+ if flv_url:
+ recording.add(record_name)
+ start_record_time = datetime.datetime.now()
+ recording_time_list[record_name] = [start_record_time, record_quality_zh]
+
+ download_success = direct_download_stream(
+ flv_url, save_file_path, record_name, record_url, platform
+ )
+
+ if download_success:
+ record_finished = True
+ print(
+ f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制完成\n")
+
+ recording.discard(record_name)
+ else:
+ logger.debug("未找到FLV直播流,跳过录制")
+ except Exception as e:
+ clear_record_info(record_name, record_url)
+ color_obj.print_colored(
+ f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制出错,请检查网络\n",
+ color_obj.RED)
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ elif record_save_type == "FLV":
+ filename = anchor_name + f'_{title_in_name}' + now + ".flv"
+ print(f'{rec_info}/{filename}')
+ save_file_path = full_path + '/' + filename
+
+ try:
+ if split_video_by_time:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.flv"
+ command = [
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-bsf:a", "aac_adtstoasc",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", "flv",
+ "-reset_timestamps", "1",
+ save_file_path
+ ]
+
+ else:
+ command = [
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-bsf:a", "aac_adtstoasc",
+ "-f", "flv",
+ "{path}".format(path=save_file_path),
+ ]
+ ffmpeg_command.extend(command)
+
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ try:
+ if converts_to_mp4:
+ seg_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4"
+ if split_video_by_time:
+ segment_video(
+ save_file_path, seg_file_path,
+ segment_format='mp4', segment_time=split_time,
+ is_original_delete=delete_origin_file
+ )
+ else:
+ threading.Thread(
+ target=converts_mp4,
+ args=(save_file_path, delete_origin_file)
+ ).start()
+
+ else:
+ seg_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.flv"
+ if split_video_by_time:
+ segment_video(
+ save_file_path, seg_file_path,
+ segment_format='flv', segment_time=split_time,
+ is_original_delete=delete_origin_file
+ )
+ except Exception as e:
+ logger.error(f"转码失败: {e} ")
+
+ elif record_save_type == "MKV":
+ filename = anchor_name + f'_{title_in_name}' + now + ".mkv"
+ print(f'{rec_info}/{filename}')
+ save_file_path = full_path + '/' + filename
+
+ try:
+ if split_video_by_time:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mkv"
+ command = [
+ "-flags", "global_header",
+ "-c:v", "copy",
+ "-c:a", "aac",
+ "-map", "0",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", "matroska",
+ "-reset_timestamps", "1",
+ save_file_path,
+ ]
+
+ else:
+ command = [
+ "-flags", "global_header",
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "matroska",
+ "{path}".format(path=save_file_path),
+ ]
+ ffmpeg_command.extend(command)
+
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ elif record_save_type == "MP4":
+ filename = anchor_name + f'_{title_in_name}' + now + ".mp4"
+ print(f'{rec_info}/{filename}')
+ save_file_path = full_path + '/' + filename
+
+ try:
+ if split_video_by_time:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4"
+ command = [
+ "-c:v", "copy",
+ "-c:a", "aac",
+ "-map", "0",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", "mp4",
+ "-reset_timestamps", "1",
+ "-movflags", "+frag_keyframe+empty_moov",
+ save_file_path,
+ ]
+
+ else:
+ command = [
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "mp4",
+ save_file_path,
+ ]
+
+ ffmpeg_command.extend(command)
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ else:
+ import sys
+ sys.path.insert(0, './dist')
+ from douyin_srs_ffplay import start_douyin_srs_ffplay
+ # 调用
+ start_douyin_srs_ffplay(
+ title=title_in_name,
+ anchor_name=anchor_name,
+ real_url=real_url,
+ rec_info=rec_info,
+ record_name=record_name,
+ recording=recording,
+ recording_time_list=recording_time_list,
+ running_list=running_list,
+ monitoring=monitoring,
+ clear_record_info=clear_record_info,
+ color_obj=color_obj
+ )
+ count_time = time.time()
+
+
+
+
+
+
+
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ num = random.randint(-5, 5) + delay_default
+ if num < 0:
+ num = 0
+ x = num
+
+ if error_count > 20:
+ x = x + 60
+ # x += min(error_count * 2, 30)
+ color_obj.print_colored("\r瞬时错误太多,延迟最多60s", color_obj.YELLOW)
+
+ # 这里是.如果录制结束后,循环时间会暂时变成30s后检测一遍. 这样一定程度上防止主播卡顿造成少录
+ # 当30秒过后检测一遍后. 会回归正常设置的循环秒数
+ if record_finished:
+ count_time_end = time.time() - count_time
+ if count_time_end < 60:
+ x = 30
+ record_finished = False
+
+ else:
+ x = num
+
+ # 这里是正常循环
+ while x:
+ x = x - 1
+ if loop_time:
+ print(f'\r{anchor_name}循环等待{x}秒 ', end="")
+ time.sleep(1)
+ if loop_time:
+ print('\r检测直播间中...', end="")
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+ time.sleep(2)
+
+
+def backup_file(file_path: str, backup_dir_path: str, limit_counts: int = 6) -> None:
+
+ try:
+ if not os.path.exists(backup_dir_path):
+ os.makedirs(backup_dir_path)
+
+ timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
+ backup_file_name = os.path.basename(file_path) + '_' + timestamp
+ backup_file_path = os.path.join(backup_dir_path, backup_file_name).replace("\\", "/")
+ shutil.copy2(file_path, backup_file_path)
+
+ files = os.listdir(backup_dir_path)
+ _files = [f for f in files if f.startswith(os.path.basename(file_path))]
+ _files.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir_path, x)))
+
+ while len(_files) > limit_counts:
+ oldest_file = _files[0]
+ os.remove(os.path.join(backup_dir_path, oldest_file))
+ _files = _files[1:]
+
+ except Exception as e:
+ logger.error(f'\r备份配置文件 {file_path} 失败:{str(e)}')
+
+
+def backup_file_start() -> None:
+ config_md5 = ''
+ url_config_md5 = ''
+
+ while True:
+ try:
+ if os.path.exists(config_file):
+ new_config_md5 = utils.check_md5(config_file)
+ if new_config_md5 != config_md5:
+ backup_file(config_file, backup_dir)
+ config_md5 = new_config_md5
+
+ if os.path.exists(url_config_file):
+ new_url_config_md5 = utils.check_md5(url_config_file)
+ if new_url_config_md5 != url_config_md5:
+ backup_file(url_config_file, backup_dir)
+ url_config_md5 = new_url_config_md5
+ time.sleep(600)
+ except Exception as e:
+ logger.error(f"备份配置文件失败, 错误信息: {e}")
+
+
+def check_ffmpeg_existence() -> bool:
+ try:
+ result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True)
+ if result.returncode == 0:
+ lines = result.stdout.splitlines()
+ version_line = lines[0]
+ built_line = lines[1]
+ print(version_line)
+ print(built_line)
+ except subprocess.CalledProcessError as e:
+ logger.error(e)
+ except FileNotFoundError:
+ pass
+ finally:
+ if check_ffmpeg():
+ time.sleep(1)
+ return True
+ return False
+
+
+# --------------------------初始化程序-------------------------------------
+print("-----------------------------------------------------")
+print("| DouyinLiveRecorder |")
+print("-----------------------------------------------------")
+
+print(f"版本号: {version}")
+print("GitHub: https://github.com/ihmily/DouyinLiveRecorder")
+print(f'支持平台: {platforms}')
+print('.....................................................')
+if not check_ffmpeg_existence():
+ logger.error("缺少ffmpeg无法进行录制,程序退出")
+ sys.exit(1)
+os.makedirs(os.path.dirname(config_file), exist_ok=True)
+t3 = threading.Thread(target=backup_file_start, args=(), daemon=True)
+t3.start()
+utils.remove_duplicate_lines(url_config_file)
+
+
+def read_config_value(config_parser: configparser.RawConfigParser, section: str, option: str, default_value: Any) \
+ -> Any:
+ try:
+
+ config_parser.read(config_file, encoding=text_encoding)
+ if '录制设置' not in config_parser.sections():
+ config_parser.add_section('录制设置')
+ if '推送配置' not in config_parser.sections():
+ config_parser.add_section('推送配置')
+ if 'Cookie' not in config_parser.sections():
+ config_parser.add_section('Cookie')
+ if 'Authorization' not in config_parser.sections():
+ config_parser.add_section('Authorization')
+ if '账号密码' not in config_parser.sections():
+ config_parser.add_section('账号密码')
+ return config_parser.get(section, option)
+ except (configparser.NoSectionError, configparser.NoOptionError):
+ config_parser.set(section, option, str(default_value))
+ with open(config_file, 'w', encoding=text_encoding) as f:
+ config_parser.write(f)
+ return default_value
+
+
+options = {"是": True, "否": False}
+config = configparser.RawConfigParser()
+language = read_config_value(config, '录制设置', 'language(zh_cn/en)', "zh_cn")
+skip_proxy_check = options.get(read_config_value(config, '录制设置', '是否跳过代理检测(是/否)', "否"), False)
+if language and 'en' not in language.lower():
+ from i18n import translated_print
+
+ builtins.print = translated_print
+
+try:
+ if skip_proxy_check:
+ global_proxy = True
+ else:
+ print('系统代理检测中,请耐心等待...')
+ response_g = urllib.request.urlopen("https://www.google.com/", timeout=15)
+ global_proxy = True
+ print('\r全局/规则网络代理已开启√')
+ pd = ProxyDetector()
+ if pd.is_proxy_enabled():
+ proxy_info = pd.get_proxy_info()
+ print("System Proxy: http://{}:{}".format(proxy_info.ip, proxy_info.port))
+except HTTPError as err:
+ print(f"HTTP error occurred: {err.code} - {err.reason}")
+except URLError:
+ color_obj.print_colored("INFO:未检测到全局/规则网络代理,请检查代理配置(若无需录制海外直播请忽略此条提示)",
+ color_obj.YELLOW)
+except Exception as err:
+ print("An unexpected error occurred:", err)
+
+while True:
+
+ try:
+ if not os.path.isfile(config_file):
+ with open(config_file, 'w', encoding=text_encoding) as file:
+ pass
+
+ ini_URL_content = ''
+ if os.path.isfile(url_config_file):
+ with open(url_config_file, 'r', encoding=text_encoding) as file:
+ ini_URL_content = file.read().strip()
+
+ if not ini_URL_content.strip():
+ input_url = input('请输入要录制的主播直播间网址(尽量使用PC网页端的直播间地址):\n')
+ with open(url_config_file, 'w', encoding=text_encoding) as file:
+ file.write(input_url)
+ except OSError as err:
+ logger.error(f"发生 I/O 错误: {err}")
+
+ video_save_path = read_config_value(config, '录制设置', '直播保存路径(不填则默认)', "")
+ folder_by_author = options.get(read_config_value(config, '录制设置', '保存文件夹是否以作者区分', "是"), False)
+ folder_by_time = options.get(read_config_value(config, '录制设置', '保存文件夹是否以时间区分', "否"), False)
+ folder_by_title = options.get(read_config_value(config, '录制设置', '保存文件夹是否以标题区分', "否"), False)
+ filename_by_title = options.get(read_config_value(config, '录制设置', '保存文件名是否包含标题', "否"), False)
+ clean_emoji = options.get(read_config_value(config, '录制设置', '是否去除名称中的表情符号', "是"), True)
+ video_save_type = read_config_value(config, '录制设置', '视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频', "ts")
+ video_record_quality = read_config_value(config, '录制设置', '原画|超清|高清|标清|流畅', "原画")
+ use_proxy = options.get(read_config_value(config, '录制设置', '是否使用代理ip(是/否)', "是"), False)
+ proxy_addr_bak = read_config_value(config, '录制设置', '代理地址', "")
+ proxy_addr = None if not use_proxy else proxy_addr_bak
+ max_request = int(read_config_value(config, '录制设置', '同一时间访问网络的线程数', 3))
+ semaphore = threading.Semaphore(max_request)
+ delay_default = int(read_config_value(config, '录制设置', '循环时间(秒)', 120))
+ local_delay_default = int(read_config_value(config, '录制设置', '排队读取网址时间(秒)', 0))
+ loop_time = options.get(read_config_value(config, '录制设置', '是否显示循环秒数', "否"), False)
+ show_url = options.get(read_config_value(config, '录制设置', '是否显示直播源地址', "否"), False)
+ split_video_by_time = options.get(read_config_value(config, '录制设置', '分段录制是否开启', "否"), False)
+ enable_https_recording = options.get(read_config_value(config, '录制设置', '是否强制启用https录制', "否"), False)
+ disk_space_limit = float(read_config_value(config, '录制设置', '录制空间剩余阈值(gb)', 1.0))
+ split_time = str(read_config_value(config, '录制设置', '视频分段时间(秒)', 1800))
+ converts_to_mp4 = options.get(read_config_value(config, '录制设置', '录制完成后自动转为mp4格式', "否"), False)
+ converts_to_h264 = options.get(read_config_value(config, '录制设置', 'mp4格式重新编码为h264', "否"), False)
+ delete_origin_file = options.get(read_config_value(config, '录制设置', '追加格式后删除原文件', "否"), False)
+ create_time_file = options.get(read_config_value(config, '录制设置', '生成时间字幕文件', "否"), False)
+ is_run_script = options.get(read_config_value(config, '录制设置', '是否录制完成后执行自定义脚本', "否"), False)
+ custom_script = read_config_value(config, '录制设置', '自定义脚本执行命令', "") if is_run_script else None
+ enable_proxy_platform = read_config_value(
+ config, '录制设置', '使用代理录制的平台(逗号分隔)',
+ 'tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit'
+ )
+ enable_proxy_platform_list = enable_proxy_platform.replace(',', ',').split(',') if enable_proxy_platform else None
+ extra_enable_proxy = read_config_value(config, '录制设置', '额外使用代理录制的平台(逗号分隔)', '')
+ extra_enable_proxy_platform_list = extra_enable_proxy.replace(',', ',').split(',') if extra_enable_proxy else None
+ live_status_push = read_config_value(config, '推送配置', '直播状态推送渠道', "")
+ dingtalk_api_url = read_config_value(config, '推送配置', '钉钉推送接口链接', "")
+ xizhi_api_url = read_config_value(config, '推送配置', '微信推送接口链接', "")
+ bark_msg_api = read_config_value(config, '推送配置', 'bark推送接口链接', "")
+ bark_msg_level = read_config_value(config, '推送配置', 'bark推送中断级别', "active")
+ bark_msg_ring = read_config_value(config, '推送配置', 'bark推送铃声', "bell")
+ dingtalk_phone_num = read_config_value(config, '推送配置', '钉钉通知@对象(填手机号)', "")
+ dingtalk_is_atall = options.get(read_config_value(config, '推送配置', '钉钉通知@全体(是/否)', "否"), False)
+ tg_token = read_config_value(config, '推送配置', 'tgapi令牌', "")
+ tg_chat_id = read_config_value(config, '推送配置', 'tg聊天id(个人或者群组id)', "")
+ email_host = read_config_value(config, '推送配置', 'SMTP邮件服务器', "")
+ open_smtp_ssl = options.get(read_config_value(config, '推送配置', '是否使用SMTP服务SSL加密(是/否)', "是"), True)
+ smtp_port = read_config_value(config, '推送配置', 'SMTP邮件服务器端口', "")
+ login_email = read_config_value(config, '推送配置', '邮箱登录账号', "")
+ email_password = read_config_value(config, '推送配置', '发件人密码(授权码)', "")
+ sender_email = read_config_value(config, '推送配置', '发件人邮箱', "")
+ sender_name = read_config_value(config, '推送配置', '发件人显示昵称', "")
+ to_email = read_config_value(config, '推送配置', '收件人邮箱', "")
+ ntfy_api = read_config_value(config, '推送配置', 'ntfy推送地址', "")
+ ntfy_tags = read_config_value(config, '推送配置', 'ntfy推送标签', "tada")
+ ntfy_email = read_config_value(config, '推送配置', 'ntfy推送邮箱', "")
+ pushplus_token = read_config_value(config, '推送配置', 'pushplus推送token', "")
+ push_message_title = read_config_value(config, '推送配置', '自定义推送标题', "直播间状态更新通知")
+ begin_push_message_text = read_config_value(config, '推送配置', '自定义开播推送内容', "")
+ over_push_message_text = read_config_value(config, '推送配置', '自定义关播推送内容', "")
+ disable_record = options.get(read_config_value(config, '推送配置', '只推送通知不录制(是/否)', "否"), False)
+ push_check_seconds = int(read_config_value(config, '推送配置', '直播推送检测频率(秒)', 1800))
+ begin_show_push = options.get(read_config_value(config, '推送配置', '开播推送开启(是/否)', "是"), True)
+ over_show_push = options.get(read_config_value(config, '推送配置', '关播推送开启(是/否)', "否"), False)
+ sooplive_username = read_config_value(config, '账号密码', 'sooplive账号', '')
+ sooplive_password = read_config_value(config, '账号密码', 'sooplive密码', '')
+ flextv_username = read_config_value(config, '账号密码', 'flextv账号', '')
+ flextv_password = read_config_value(config, '账号密码', 'flextv密码', '')
+ popkontv_username = read_config_value(config, '账号密码', 'popkontv账号', '')
+ popkontv_partner_code = read_config_value(config, '账号密码', 'partner_code', 'P-00001')
+ popkontv_password = read_config_value(config, '账号密码', 'popkontv密码', '')
+ twitcasting_account_type = read_config_value(config, '账号密码', 'twitcasting账号类型', 'normal')
+ twitcasting_username = read_config_value(config, '账号密码', 'twitcasting账号', '')
+ twitcasting_password = read_config_value(config, '账号密码', 'twitcasting密码', '')
+ popkontv_access_token = read_config_value(config, 'Authorization', 'popkontv_token', '')
+ dy_cookie = read_config_value(config, 'Cookie', '抖音cookie', '')
+ ks_cookie = read_config_value(config, 'Cookie', '快手cookie', '')
+ tiktok_cookie = read_config_value(config, 'Cookie', 'tiktok_cookie', '')
+ hy_cookie = read_config_value(config, 'Cookie', '虎牙cookie', '')
+ douyu_cookie = read_config_value(config, 'Cookie', '斗鱼cookie', '')
+ yy_cookie = read_config_value(config, 'Cookie', 'yy_cookie', '')
+ bili_cookie = read_config_value(config, 'Cookie', 'B站cookie', '')
+ xhs_cookie = read_config_value(config, 'Cookie', '小红书cookie', '')
+ bigo_cookie = read_config_value(config, 'Cookie', 'bigo_cookie', '')
+ blued_cookie = read_config_value(config, 'Cookie', 'blued_cookie', '')
+ sooplive_cookie = read_config_value(config, 'Cookie', 'sooplive_cookie', '')
+ netease_cookie = read_config_value(config, 'Cookie', 'netease_cookie', '')
+ qiandurebo_cookie = read_config_value(config, 'Cookie', '千度热播_cookie', '')
+ pandatv_cookie = read_config_value(config, 'Cookie', 'pandatv_cookie', '')
+ maoerfm_cookie = read_config_value(config, 'Cookie', '猫耳fm_cookie', '')
+ winktv_cookie = read_config_value(config, 'Cookie', 'winktv_cookie', '')
+ flextv_cookie = read_config_value(config, 'Cookie', 'flextv_cookie', '')
+ look_cookie = read_config_value(config, 'Cookie', 'look_cookie', '')
+ twitcasting_cookie = read_config_value(config, 'Cookie', 'twitcasting_cookie', '')
+ baidu_cookie = read_config_value(config, 'Cookie', 'baidu_cookie', '')
+ weibo_cookie = read_config_value(config, 'Cookie', 'weibo_cookie', '')
+ kugou_cookie = read_config_value(config, 'Cookie', 'kugou_cookie', '')
+ twitch_cookie = read_config_value(config, 'Cookie', 'twitch_cookie', '')
+ liveme_cookie = read_config_value(config, 'Cookie', 'liveme_cookie', '')
+ huajiao_cookie = read_config_value(config, 'Cookie', 'huajiao_cookie', '')
+ liuxing_cookie = read_config_value(config, 'Cookie', 'liuxing_cookie', '')
+ showroom_cookie = read_config_value(config, 'Cookie', 'showroom_cookie', '')
+ acfun_cookie = read_config_value(config, 'Cookie', 'acfun_cookie', '')
+ changliao_cookie = read_config_value(config, 'Cookie', 'changliao_cookie', '')
+ yinbo_cookie = read_config_value(config, 'Cookie', 'yinbo_cookie', '')
+ yingke_cookie = read_config_value(config, 'Cookie', 'yingke_cookie', '')
+ zhihu_cookie = read_config_value(config, 'Cookie', 'zhihu_cookie', '')
+ chzzk_cookie = read_config_value(config, 'Cookie', 'chzzk_cookie', '')
+ haixiu_cookie = read_config_value(config, 'Cookie', 'haixiu_cookie', '')
+ vvxqiu_cookie = read_config_value(config, 'Cookie', 'vvxqiu_cookie', '')
+ yiqilive_cookie = read_config_value(config, 'Cookie', '17live_cookie', '')
+ langlive_cookie = read_config_value(config, 'Cookie', 'langlive_cookie', '')
+ pplive_cookie = read_config_value(config, 'Cookie', 'pplive_cookie', '')
+ six_room_cookie = read_config_value(config, 'Cookie', '6room_cookie', '')
+ lehaitv_cookie = read_config_value(config, 'Cookie', 'lehaitv_cookie', '')
+ huamao_cookie = read_config_value(config, 'Cookie', 'huamao_cookie', '')
+ shopee_cookie = read_config_value(config, 'Cookie', 'shopee_cookie', '')
+ youtube_cookie = read_config_value(config, 'Cookie', 'youtube_cookie', '')
+ taobao_cookie = read_config_value(config, 'Cookie', 'taobao_cookie', '')
+ jd_cookie = read_config_value(config, 'Cookie', 'jd_cookie', '')
+ faceit_cookie = read_config_value(config, 'Cookie', 'faceit_cookie', '')
+ migu_cookie = read_config_value(config, 'Cookie', 'migu_cookie', '')
+ lianjie_cookie = read_config_value(config, 'Cookie', 'lianjie_cookie', '')
+ laixiu_cookie = read_config_value(config, 'Cookie', 'laixiu_cookie', '')
+ picarto_cookie = read_config_value(config, 'Cookie', 'picarto_cookie', '')
+
+ video_save_type_list = ("FLV", "MKV", "TS", "MP4", "MP3音频", "M4A音频", "MP3", "M4A")
+ if video_save_type and video_save_type.upper() in video_save_type_list:
+ video_save_type = video_save_type.upper()
+ else:
+ video_save_type = "TS"
+
+ check_path = video_save_path or default_path
+ if utils.check_disk_capacity(check_path, show=first_run) < disk_space_limit:
+ exit_recording = True
+ if not recording:
+ logger.warning(f"Disk space remaining is below {disk_space_limit} GB. "
+ f"Exiting program due to the disk space limit being reached.")
+ sys.exit(-1)
+
+
+ def contains_url(string: str) -> bool:
+ pattern = r"(https?://)?(www\.)?[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:\d+)?(/.*)?"
+ return re.search(pattern, string) is not None
+
+
+ try:
+ url_comments, line_list, url_line_list = [[] for _ in range(3)]
+ with (open(url_config_file, "r", encoding=text_encoding, errors='ignore') as file):
+ for origin_line in file:
+ if origin_line in line_list:
+ delete_line(url_config_file, origin_line)
+ line_list.append(origin_line)
+ line = origin_line.strip()
+ if len(line) < 18:
+ continue
+
+ line_spilt = line.split('主播: ')
+ if len(line_spilt) > 2:
+ line = update_file(url_config_file, line, f'{line_spilt[0]}主播: {line_spilt[-1]}')
+
+ is_comment_line = line.startswith("#")
+ if is_comment_line:
+ line = line.lstrip('#')
+
+ if re.search('[,,]', line):
+ split_line = re.split('[,,]', line)
+ else:
+ split_line = [line, '']
+
+ if len(split_line) == 1:
+ url = split_line[0]
+ quality, name = [video_record_quality, '']
+ elif len(split_line) == 2:
+ if contains_url(split_line[0]):
+ quality = video_record_quality
+ url, name = split_line
+ else:
+ quality, url = split_line
+ name = ''
+ else:
+ quality, url, name = split_line
+
+ if quality not in ("原画", "蓝光", "超清", "高清", "标清", "流畅"):
+ quality = '原画'
+
+ if url not in url_line_list:
+ url_line_list.append(url)
+ else:
+ delete_line(url_config_file, origin_line)
+
+ url = 'https://' + url if '://' not in url else url
+ url_host = url.split('/')[2]
+
+ platform_host = [
+ 'live.douyin.com',
+ 'v.douyin.com',
+ 'www.douyin.com',
+ 'live.kuaishou.com',
+ 'www.huya.com',
+ 'www.douyu.com',
+ 'www.yy.com',
+ 'live.bilibili.com',
+ 'www.redelight.cn',
+ 'www.xiaohongshu.com',
+ 'xhslink.com',
+ 'www.bigo.tv',
+ 'slink.bigovideo.tv',
+ 'app.blued.cn',
+ 'cc.163.com',
+ 'qiandurebo.com',
+ 'fm.missevan.com',
+ 'look.163.com',
+ 'twitcasting.tv',
+ 'live.baidu.com',
+ 'weibo.com',
+ 'fanxing.kugou.com',
+ 'fanxing2.kugou.com',
+ 'mfanxing.kugou.com',
+ 'www.huajiao.com',
+ 'www.7u66.com',
+ 'wap.7u66.com',
+ 'live.acfun.cn',
+ 'm.acfun.cn',
+ 'live.tlclw.com',
+ 'wap.tlclw.com',
+ 'live.ybw1666.com',
+ 'wap.ybw1666.com',
+ 'www.inke.cn',
+ 'www.zhihu.com',
+ 'www.haixiutv.com',
+ "h5webcdnp.vvxqiu.com",
+ "17.live",
+ 'www.lang.live',
+ "m.pp.weimipopo.com",
+ "v.6.cn",
+ "m.6.cn",
+ 'www.lehaitv.com',
+ 'h.catshow168.com',
+ 'e.tb.cn',
+ 'huodong.m.taobao.com',
+ '3.cn',
+ 'eco.m.jd.com',
+ 'www.miguvideo.com',
+ 'm.miguvideo.com',
+ 'show.lailianjie.com',
+ 'www.imkktv.com',
+ 'www.picarto.tv'
+ ]
+ overseas_platform_host = [
+ 'www.tiktok.com',
+ 'play.sooplive.co.kr',
+ 'm.sooplive.co.kr',
+ 'www.sooplive.com',
+ 'm.sooplive.com',
+ 'www.pandalive.co.kr',
+ 'www.winktv.co.kr',
+ 'www.flextv.co.kr',
+ 'www.ttinglive.com',
+ 'www.popkontv.com',
+ 'www.twitch.tv',
+ 'www.liveme.com',
+ 'www.showroom-live.com',
+ 'chzzk.naver.com',
+ 'm.chzzk.naver.com',
+ 'live.shopee.',
+ '.shp.ee',
+ 'www.youtube.com',
+ 'youtu.be',
+ 'www.faceit.com'
+ ]
+
+ platform_host.extend(overseas_platform_host)
+ clean_url_host_list = (
+ "live.douyin.com",
+ "live.bilibili.com",
+ "www.huajiao.com",
+ "www.zhihu.com",
+ "www.huya.com",
+ "chzzk.naver.com",
+ "www.liveme.com",
+ "www.haixiutv.com",
+ "v.6.cn",
+ "m.6.cn",
+ 'www.lehaitv.com'
+ )
+
+ if 'live.shopee.' in url_host or '.shp.ee' in url_host:
+ url_host = 'live.shopee.' if 'live.shopee.' in url_host else '.shp.ee'
+
+ if url_host in platform_host or any(ext in url for ext in (".flv", ".m3u8")):
+ if url_host in clean_url_host_list:
+ url = update_file(url_config_file, old_str=url, new_str=url.split('?')[0])
+
+ if 'xiaohongshu' in url:
+ host_id = re.search('&host_id=(.*?)(?=&|$)', url)
+ if host_id:
+ new_url = url.split('?')[0] + f'?host_id={host_id.group(1)}'
+ url = update_file(url_config_file, old_str=url, new_str=new_url)
+
+ url_comments = [i for i in url_comments if url not in i]
+ if is_comment_line:
+ url_comments.append(url)
+ else:
+ new_line = (quality, url, name)
+ url_tuples_list.append(new_line)
+ else:
+ if not origin_line.startswith('#'):
+ color_obj.print_colored(f"\r{origin_line.strip()} 本行包含未知链接.此条跳过", color_obj.YELLOW)
+ update_file(url_config_file, old_str=origin_line, new_str=origin_line, start_str='#')
+
+ while len(need_update_line_list):
+ a = need_update_line_list.pop()
+ replace_words = a.split('|')
+ if replace_words[0] != replace_words[1]:
+ if replace_words[1].startswith("#"):
+ start_with = '#'
+ new_word = replace_words[1][1:]
+ else:
+ start_with = None
+ new_word = replace_words[1]
+ update_file(url_config_file, old_str=replace_words[0], new_str=new_word, start_str=start_with)
+
+ text_no_repeat_url = list(set(url_tuples_list))
+
+ if len(text_no_repeat_url) > 0:
+ for url_tuple in text_no_repeat_url:
+ monitoring = len(running_list)
+
+ if url_tuple[1] in not_record_list:
+ continue
+ if url_tuple[1] not in running_list:
+ print(f"\r{'新增' if not first_start else '传入'}地址: {url_tuple[1]}")
+ monitoring += 1
+ args = [url_tuple, monitoring]
+ create_var[f'thread_{monitoring}'] = threading.Thread(target=start_record, args=args)
+ create_var[f'thread_{monitoring}'].daemon = True
+ create_var[f'thread_{monitoring}'].start()
+ running_list.append(url_tuple[1])
+ time.sleep(local_delay_default)
+
+
+
+
+ url_tuples_list = []
+ first_start = False
+
+
+
+ except Exception as err:
+ logger.error(f"错误信息: {err} 发生错误的行数: {err.__traceback__.tb_lineno}")
+
+ if first_run:
+ t = threading.Thread(target=display_info, args=(), daemon=True)
+ t.start()
+ t2 = threading.Thread(target=adjust_max_request, args=(), daemon=True)
+ t2.start()
+ first_run = False
+
+ time.sleep(3)
\ No newline at end of file
diff --git a/tools/ffmpeg/懒人包说明.txt b/tools/ffmpeg/懒人包说明.txt
new file mode 100644
index 0000000..ef72b6d
--- /dev/null
+++ b/tools/ffmpeg/懒人包说明.txt
@@ -0,0 +1,3 @@
+将 Windows 版 ffmpeg 解压后,把 ffmpeg.exe 放在本文件夹(或 bin 子文件夹)中。
+正式安装包打包时,electron-builder 会把整个项目根目录(排除 electron 等)拷入安装目录,
+此处文件会随软件分发给用户,用户无需单独安装 ffmpeg。
diff --git a/web2.py b/web2.py
new file mode 100644
index 0000000..31adf88
--- /dev/null
+++ b/web2.py
@@ -0,0 +1,565 @@
+from fastapi import FastAPI, Query, Request
+from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
+from pathlib import Path
+import asyncio
+import html
+import os
+from contextlib import asynccontextmanager
+from typing import Optional
+
+from fastapi.middleware.cors import CORSMiddleware
+
+from web2_supervisor import (
+ BuiltinSupervisor,
+ force_kill_ffmpeg_cross_platform,
+ get_process_info_pm2,
+ run_pm2_shell,
+ strip_ansi_codes,
+ use_builtin,
+)
+from web2_network import run_network_dashboard
+from web2_proxy_config import read_proxy_config, write_proxy_config
+from web2_system_metrics import snapshot as system_metrics_snapshot
+from web2_youtube_oauth import (
+ fetch_stream_key_and_write_ini,
+ oauth_authorization_url,
+ oauth_exchange_code,
+ oauth_status as youtube_oauth_status_snapshot,
+)
+from web2_wechat_chatbot import register_wechat_chatbot_routes
+from web2_client_overview import build_client_overview
+from web2_relay_pro import (
+ build_relay_live_status,
+ ensure_default_relay_config,
+ first_youtube_pm2_name,
+ get_channel_detail,
+ list_channels_for_pro,
+ read_url_config_for_channel,
+ sync_channel_to_legacy_files,
+ update_channel_key,
+ write_url_config_for_channel,
+)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ ensure_default_relay_config(CONFIG_DIR)
+ yield
+
+
+app = FastAPI(title="多进程录制控制台", lifespan=lifespan)
+
+# ---------------- 配置(根目录结构) ----------------
+BASE_DIR = Path(__file__).parent
+CONFIG_DIR = BASE_DIR / "config"
+DEFAULT_LOG_DIR = BASE_DIR / ".pm2" / "logs"
+
+MAX_LOG_LINES = 300
+
+
+def _label_short(prefix: str) -> str:
+ return {"tiktok": "TikTok", "youtube": "YouTube", "obs": "OBS", "web": "Web 控制台"}.get(prefix, prefix)
+
+
+def discover_script_entries() -> tuple:
+ """扫描项目根目录自动识别入口:tiktok*.py、youtube*.py、obs*.sh / obs*.bat,以及当前本文件(Web 控制台)。进程名 = 主文件名(无扩展名)。"""
+ base = BASE_DIR
+ web_path = Path(__file__).resolve()
+ entries: list[dict] = []
+
+ for path in sorted(base.glob("tiktok*.py")):
+ if path.is_file() and path.resolve() != web_path:
+ entries.append({"script": path.name, "label": _label_short("tiktok")})
+
+ for path in sorted(base.glob("youtube*.py")):
+ if path.is_file() and path.resolve() != web_path:
+ entries.append({"script": path.name, "label": _label_short("youtube")})
+
+ for path in sorted(base.glob("obs*.sh")):
+ if path.is_file():
+ entries.append({"script": path.name, "label": _label_short("obs")})
+
+ for path in sorted(base.glob("obs*.bat")):
+ if path.is_file():
+ entries.append({"script": path.name, "label": _label_short("obs")})
+
+ entries.append({"script": web_path.name, "label": _label_short("web")})
+
+ return tuple(entries)
+
+
+SCRIPT_ENTRIES = discover_script_entries()
+
+
+def pm2_name_from_script(script: str) -> str:
+ return Path(script).stem
+
+
+def _stem(script: str) -> str:
+ return Path(script).stem
+
+
+def is_youtube_script(script: str) -> bool:
+ return script.endswith(".py") and _stem(script).startswith("youtube")
+
+
+def is_tiktok_script(script: str) -> bool:
+ return script.endswith(".py") and _stem(script).startswith("tiktok")
+
+
+def _build_process_monitor():
+ return tuple(
+ {
+ "pm2": pm2_name_from_script(e["script"]),
+ "script": e["script"],
+ "label": e["label"],
+ }
+ for e in SCRIPT_ENTRIES
+ )
+
+
+PROCESS_MONITOR = _build_process_monitor()
+VALID_PROCESSES = frozenset(p["pm2"] for p in PROCESS_MONITOR)
+
+URL_CONFIG_PM2 = frozenset(
+ pm2_name_from_script(e["script"])
+ for e in SCRIPT_ENTRIES
+ if is_tiktok_script(e["script"]) or is_youtube_script(e["script"])
+)
+
+
+def script_for_pm2(pm2: str) -> Optional[str]:
+ for p in PROCESS_MONITOR:
+ if p["pm2"] == pm2:
+ return p["script"]
+ return None
+
+
+WEB_SCRIPT_NAME = Path(__file__).name
+_builtin_supervisor: Optional[BuiltinSupervisor] = None
+
+
+def get_builtin_supervisor() -> BuiltinSupervisor:
+ global _builtin_supervisor
+ if _builtin_supervisor is None:
+ _builtin_supervisor = BuiltinSupervisor(BASE_DIR, WEB_SCRIPT_NAME, script_for_pm2)
+ return _builtin_supervisor
+
+
+async def control_start(process: str) -> str:
+ if use_builtin():
+ return await get_builtin_supervisor().start(process)
+ return await run_pm2_shell(f"start {process}")
+
+
+async def control_stop(process: str) -> str:
+ if use_builtin():
+ return await get_builtin_supervisor().stop(process)
+ return await run_pm2_shell(f"stop {process}")
+
+
+async def control_restart(process: str) -> str:
+ if use_builtin():
+ return await get_builtin_supervisor().restart(process)
+ return await run_pm2_shell(f"restart {process}")
+
+
+async def control_full_status() -> str:
+ if use_builtin():
+ return await get_builtin_supervisor().full_status_text()
+ return await run_pm2_shell("status", timeout=8.0)
+
+
+async def control_get_process_info(process: str) -> dict:
+ if use_builtin():
+ return await get_builtin_supervisor().get_process_info(process)
+ return await get_process_info_pm2(process, DEFAULT_LOG_DIR, strip_ansi_codes)
+
+
+@app.middleware("http")
+async def web2_token_middleware(request: Request, call_next):
+ """若设置环境变量 WEB2_API_TOKEN,则除 /health 与 OpenAPI 外需携带 Bearer 或 X-API-Token。"""
+ # CORS 预检不带 Authorization;若在外层先于 CORSMiddleware 拦截会缺少 Access-Control-* 头
+ if request.method == "OPTIONS":
+ return await call_next(request)
+ token = os.environ.get("WEB2_API_TOKEN", "").strip()
+ if not token:
+ return await call_next(request)
+ path = request.url.path
+ if path == "/health" or path.startswith("/docs") or path in ("/openapi.json", "/redoc"):
+ return await call_next(request)
+ auth = request.headers.get("authorization") or ""
+ xtok = (request.headers.get("x-api-token") or "").strip()
+ if auth.startswith("Bearer ") and auth[7:].strip() == token:
+ return await call_next(request)
+ if xtok == token:
+ return await call_next(request)
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
+
+
+# 最后注册,保证 CORSMiddleware 在最外层,401 等响应仍带 CORS 头(供 Vite 等跨源调试)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["GET", "POST", "OPTIONS"],
+ allow_headers=["*"],
+)
+
+register_wechat_chatbot_routes(app, BASE_DIR)
+
+
+@app.get("/client/overview")
+async def client_overview():
+ """Electron 工作台:频道列表、微信摘要(EasyTier 节点由主进程 easytier-cli 查询)。"""
+ return build_client_overview(CONFIG_DIR, BASE_DIR)
+
+
+# ---------------- Pro 多路转播(每频道独立 URL 文件 + 去重 + 同步 legacy) ----------------
+
+
+@app.get("/relay_pro/channels")
+async def relay_pro_channels():
+ return list_channels_for_pro(CONFIG_DIR)
+
+
+@app.get("/relay_pro/channel_detail")
+async def relay_pro_channel_detail(channel_id: str = Query(..., description="频道 id,如 ch_0")):
+ d = get_channel_detail(CONFIG_DIR, channel_id)
+ if not d:
+ return JSONResponse({"error": "未找到频道"}, status_code=404)
+ return d
+
+
+@app.get("/relay_pro/url_config")
+async def relay_pro_get_url_config(channel_id: str = Query(...)):
+ return {"content": read_url_config_for_channel(CONFIG_DIR, channel_id)}
+
+
+@app.post("/relay_pro/url_config")
+async def relay_pro_post_url_config(request: Request, channel_id: str = Query(...)):
+ try:
+ data = await request.json()
+ except Exception:
+ return JSONResponse({"error": "无效的 JSON"}, status_code=400)
+ content = data.get("content", "") if isinstance(data, dict) else ""
+ ok, msg = write_url_config_for_channel(CONFIG_DIR, channel_id, content)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"message": msg}
+
+
+@app.post("/relay_pro/channel_key")
+async def relay_pro_save_channel_key(request: Request):
+ try:
+ data = await request.json()
+ except Exception:
+ return JSONResponse({"error": "无效的 JSON"}, status_code=400)
+ if not isinstance(data, dict):
+ return JSONResponse({"error": "无效数据"}, status_code=400)
+ cid = str(data.get("channelId") or data.get("channel_id") or "").strip()
+ key = str(data.get("youtubeKey") or data.get("key") or "")
+ if not cid:
+ return JSONResponse({"error": "缺少 channelId"}, status_code=400)
+ ok, msg = update_channel_key(CONFIG_DIR, cid, key)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"message": msg}
+
+
+@app.post("/relay_pro/sync_active")
+async def relay_pro_sync_active(channel_id: str = Query(...)):
+ ok, msg = sync_channel_to_legacy_files(CONFIG_DIR, channel_id)
+ if not ok:
+ return JSONResponse({"error": msg}, status_code=400)
+ return {"message": msg}
+
+
+@app.get("/relay_pro/live_status")
+async def relay_pro_live_status():
+ yt = first_youtube_pm2_name([{"script": e["script"]} for e in SCRIPT_ENTRIES])
+
+ async def read_tail(path: str, lines: int) -> str:
+ return await read_log_path(path, lines)
+
+ return await build_relay_live_status(
+ CONFIG_DIR,
+ BASE_DIR,
+ control_get_process_info,
+ read_tail,
+ yt,
+ )
+
+
+# ---------------- 工具函数 ----------------
+async def read_log_path(path: str, lines: int) -> str:
+ if not path:
+ return "无日志路径\n"
+ if "/dev/null" in path:
+ return "日志输出被禁用(无日志文件)\n"
+ log_file = Path(path)
+ if not log_file.exists():
+ return f"日志文件不存在: {path}\n"
+ try:
+ with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
+ content_lines = f.readlines()
+ return "".join(content_lines[-lines:]) if content_lines else "无日志内容\n"
+ except Exception as e:
+ return f"读取日志失败 ({path}): {str(e)}\n"
+
+
+# ---------------- 配置路由(youtube.ini,仅 youtube) ----------------
+@app.get("/get_config")
+async def get_config(process: str = Query(..., description="进程名")):
+ sc = script_for_pm2(process)
+ if not sc or not is_youtube_script(sc):
+ return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
+
+ config_file = CONFIG_DIR / "youtube.ini"
+ if not config_file.exists():
+ return {"content": "# youtube.ini 文件不存在,将创建空文件\n"}
+ try:
+ content = config_file.read_text(encoding="utf-8")
+ return {"content": content}
+ except Exception as e:
+ return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
+
+@app.post("/save_config")
+async def save_config(request: Request, process: str = Query(..., description="进程名")):
+ sc = script_for_pm2(process)
+ if not sc or not is_youtube_script(sc):
+ return JSONResponse({"error": "仅 youtube*.py 对应进程支持 youtube.ini 编辑"}, status_code=400)
+
+ try:
+ data = await request.json()
+ content = data.get("content", "")
+ except:
+ return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
+
+ config_file = CONFIG_DIR / "youtube.ini"
+ try:
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ config_file.write_text(content, encoding="utf-8")
+ return {"message": "配置保存成功"}
+ except Exception as e:
+ return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
+
+# ---------------- 配置路由(URL_config.ini,youtube 和 tiktok 共享) ----------------
+@app.get("/get_url_config")
+async def get_url_config(process: str = Query(..., description="进程名")):
+ if process not in URL_CONFIG_PM2:
+ return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
+
+ config_file = CONFIG_DIR / "URL_config.ini"
+ if not config_file.exists():
+ return {"content": "# URL_config.ini 文件不存在,将创建空文件\n"}
+ try:
+ content = config_file.read_text(encoding="utf-8")
+ return {"content": content}
+ except Exception as e:
+ return JSONResponse({"error": f"读取失败: {str(e)}"}, status_code=500)
+
+@app.post("/save_url_config")
+async def save_url_config(request: Request, process: str = Query(..., description="进程名")):
+ if process not in URL_CONFIG_PM2:
+ return JSONResponse({"error": "仅 tiktok*.py / youtube*.py 对应进程支持 URL 配置编辑"}, status_code=400)
+
+ try:
+ data = await request.json()
+ content = data.get("content", "")
+ except:
+ return JSONResponse({"error": "无效的 JSON 数据"}, status_code=400)
+
+ config_file = CONFIG_DIR / "URL_config.ini"
+ try:
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ config_file.write_text(content, encoding="utf-8")
+ return {"message": "配置保存成功"}
+ except Exception as e:
+ return JSONResponse({"error": f"保存失败: {str(e)}"}, status_code=500)
+
+# ---------------- PM2 控制路由 ----------------
+@app.get("/start")
+async def start(process: str = Query(..., description="进程名")):
+ if process not in VALID_PROCESSES:
+ return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
+ output = await control_start(process)
+ return JSONResponse({"output": output})
+
+
+@app.get("/stop")
+async def stop(process: str = Query(..., description="进程名")):
+ if process not in VALID_PROCESSES:
+ return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
+
+ # 第一步:尝试优雅停止
+ ctrl_output = await control_stop(process)
+
+ # 第二步:等待信号传播(通常 1~3 秒)
+ await asyncio.sleep(2.0)
+
+ # 第三步:录制类进程才强杀 ffmpeg(web* 控制台不杀 ffmpeg)
+ if not process.startswith("web"):
+ await force_kill_ffmpeg_cross_platform(process)
+
+ # 第四步:再等一小会儿,确认
+ await asyncio.sleep(1.0)
+
+ return JSONResponse({
+ "message": "已执行 stop" + ("" if process.startswith("web") else " + 强杀 ffmpeg"),
+ "pm2_output": ctrl_output,
+ "note": "如果仍有残留,可多次点击停止或查看下方日志",
+ })
+
+
+@app.get("/restart")
+async def restart(process: str = Query(..., description="进程名")):
+ if process not in VALID_PROCESSES:
+ return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
+ output = await control_restart(process)
+ if not process.startswith("web"):
+ await force_kill_ffmpeg_cross_platform(process)
+ return JSONResponse({"output": output})
+
+
+@app.get("/status")
+async def status(process: str = Query(..., description="进程名")):
+ if process not in VALID_PROCESSES:
+ return JSONResponse({
+ "raw_status": "",
+ "process_status": "invalid",
+ "recent_log": f"无效进程名: {process}",
+ "recent_error": ""
+ })
+
+ full_status_raw = await control_full_status()
+ full_status = strip_ansi_codes(full_status_raw)
+ info = await control_get_process_info(process)
+ recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES)
+ recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES)
+
+ if info["process_status"] == "not_found":
+ recent_log = "进程未运行或未由本机管理器启动。\n"
+ recent_error = ""
+
+ return JSONResponse({
+ "raw_status": full_status,
+ "process_status": info["process_status"],
+ "recent_log": recent_log,
+ "recent_error": recent_error,
+ })
+
+
+@app.get("/health")
+async def health():
+ return {"ok": True}
+
+
+@app.get("/network_status")
+async def network_status():
+ """Google / 抖音 连通与简易网速(供仪表盘)。"""
+ try:
+ return await run_network_dashboard()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+@app.get("/proxy_config")
+async def get_proxy_config():
+ """仅作用于录制子进程的代理(不写 Windows 系统代理)。"""
+ return read_proxy_config()
+
+
+@app.post("/proxy_config")
+async def post_proxy_config(request: Request):
+ try:
+ data = await request.json()
+ except Exception:
+ return JSONResponse({"error": "无效的 JSON"}, status_code=400)
+ if not isinstance(data, dict):
+ return JSONResponse({"error": "无效数据"}, status_code=400)
+ write_proxy_config(
+ {
+ "enabled": bool(data.get("enabled")),
+ "http": str(data.get("http") or ""),
+ "socks": str(data.get("socks") or ""),
+ }
+ )
+ return {"ok": True}
+
+
+@app.get("/system_metrics")
+async def system_metrics():
+ """CPU/内存/磁盘/网卡速率与 ffmpeg 进程摘要。"""
+ try:
+ return system_metrics_snapshot()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+# ---------------- YouTube OAuth(串流密钥) ----------------
+@app.get("/youtube/oauth/start")
+async def youtube_oauth_start():
+ """返回 Google 授权页 URL,前端应用浏览器打开。"""
+ try:
+ url, state = oauth_authorization_url()
+ return {"url": url, "state": state}
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
+
+
+@app.get("/youtube/oauth/callback")
+async def youtube_oauth_callback(code: str = Query(...), state: str = Query(...)):
+ """Google 授权完成后的回调(须与 google_oauth.json 中 redirect_uri 一致)。"""
+ try:
+ oauth_exchange_code(code, state)
+ except Exception as e:
+ err = html.escape(str(e))
+ return HTMLResponse(
+ f"
授权失败 "
+ f""
+ f"
授权失败:{err}
请关闭此页后重试。
",
+ status_code=400,
+ )
+ return HTMLResponse(
+ "
授权成功 "
+ ""
+ "
授权成功,可关闭此页并返回「直播录制助手」。
"
+ "
若浏览器未自动关闭,请手动关闭本标签页。
"
+ ""
+ )
+
+
+@app.get("/youtube/oauth/status")
+async def youtube_oauth_status_route():
+ return youtube_oauth_status_snapshot()
+
+
+@app.post("/youtube/stream/fetch_key")
+async def youtube_stream_fetch_key(process: str = Query(..., description="进程名")):
+ """调用 YouTube Live API 获取串流密钥并写入 config/youtube.ini。"""
+ if process not in VALID_PROCESSES:
+ return JSONResponse({"error": "无效进程名"}, status_code=400)
+ sc = script_for_pm2(process)
+ if not sc or not is_youtube_script(sc):
+ return JSONResponse({"error": "仅 youtube*.py 对应进程可使用此功能"}, status_code=400)
+ try:
+ return fetch_stream_key_and_write_ini()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=500)
+
+
+# ---------------- 进程列表(供前端同步,由 discover_script_entries 自动扫描) ----------------
+@app.get("/process_monitor")
+async def process_monitor():
+ return {
+ "entries": [
+ {"pm2": p["pm2"], "script": p["script"], "label": p["label"]}
+ for p in PROCESS_MONITOR
+ ]
+ }
+
+
+# ---------------- 首页 ----------------
+@app.get("/")
+async def index():
+ return FileResponse(BASE_DIR / "index.html")
\ No newline at end of file
diff --git a/web2.sh b/web2.sh
new file mode 100644
index 0000000..c3abe0b
--- /dev/null
+++ b/web2.sh
@@ -0,0 +1,2 @@
+# Ubuntu:本分支控制台入口(与无「2」分支的 web / youtube.py 等区分)
+uvicorn web2:app --host 0.0.0.0 --port 8001 --reload
diff --git a/web2_client_overview.py b/web2_client_overview.py
new file mode 100644
index 0000000..e0b601f
--- /dev/null
+++ b/web2_client_overview.py
@@ -0,0 +1,255 @@
+"""
+工作台 /client/overview:YouTube 频道摘要、微信绑定摘要(EasyTier 节点数由 Electron 主进程补充)。
+
+多路转播语义:每条线路为「一个抖音直播间 ↔ 一个 YouTube 串流」一对一;
+禁止同一抖音直播间同时向多个 YouTube 频道推流;禁止多条线路共用同一串流密钥(避免串台)。
+"""
+from __future__ import annotations
+
+import configparser
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+from web2_wechat_chatbot import build_client, load_wechat_chatbot_config, load_wechat_state
+
+
+def _mask_key(key: str) -> str:
+ k = (key or "").strip()
+ if not k:
+ return ""
+ if len(k) <= 8:
+ return "****"
+ return f"{k[:4]}…{k[-4:]}"
+
+
+def _short_douyin_hint(url: str) -> str:
+ u = (url or "").strip()
+ if not u:
+ return ""
+ if len(u) <= 56:
+ return u
+ return f"{u[:28]}…{u[-16:]}"
+
+
+def _normalize_douyin_url(url: str) -> str:
+ """用于去重:去空白、小写 host、去尾部斜杠。"""
+ u = (url or "").strip()
+ if not u:
+ return ""
+ u = re.sub(r"#.*$", "", u).strip()
+ return u.rstrip("/").lower()
+
+
+def _normalize_stream_key(key: str) -> str:
+ k = (key or "").strip()
+ if not k:
+ return ""
+ if k.startswith("rtmp://") or k.startswith("rtmps://"):
+ parts = k.rstrip("/").split("/")
+ k = parts[-1] if parts else k
+ return k.strip().lower()
+
+
+def _parse_youtube_ini_key(ini_path: Path) -> str:
+ if not ini_path.exists():
+ return ""
+ cp = configparser.ConfigParser()
+ cp.read(ini_path, encoding="utf-8")
+ if not cp.has_section("youtube"):
+ return ""
+ for name, val in cp.items("youtube"):
+ if name in ("key", "stream_key"):
+ v = (val or "").strip()
+ if v and not v.startswith("#"):
+ return v
+ return ""
+
+
+def _first_douyin_line_from_url_config(config_dir: Path) -> str:
+ """单路 legacy:从 URL_config.ini 取首条抖音链接作展示提示(仍可能多 URL 共单 key,与多路一对一模型不同)。"""
+ p = config_dir / "URL_config.ini"
+ if not p.exists():
+ return ""
+ try:
+ text = p.read_text(encoding="utf-8", errors="ignore")
+ except Exception:
+ return ""
+ for raw in text.splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "douyin" not in line.lower():
+ continue
+ if "," in line:
+ line = line.split(",")[-1].strip()
+ return line.strip()
+ return ""
+
+
+def _parse_youtube_channels_json(path: Path) -> tuple[list[dict[str, str]], list[str]]:
+ """
+ 解析 youtube_channels.json:展示层包含「待填写」线路;仅对同时填写两项的条目做一对一冲突校验。
+ 不完整条目不标为错误,由客户端自动维护文件,用户只需在录制页填写即可。
+ """
+ errors: list[str] = []
+ try:
+ raw = json.loads(path.read_text(encoding="utf-8"))
+ except Exception as e:
+ return [], [f"youtube_channels.json 解析失败: {e}"]
+
+ arr = raw.get("channels") if isinstance(raw, dict) else None
+ if not isinstance(arr, list):
+ return [], ["youtube_channels.json 缺少 channels 数组"]
+
+ parsed: list[tuple[str, str, str]] = []
+ for item in arr:
+ if not isinstance(item, dict):
+ continue
+ name = str(item.get("name") or item.get("id") or "").strip() or "未命名"
+ durl = str(
+ item.get("douyinUrl") or item.get("douyin_url") or item.get("douyin") or item.get("url") or ""
+ ).strip()
+ key = str(item.get("youtubeKey") or item.get("key") or item.get("stream_key") or "").strip()
+ parsed.append((name, durl, key))
+
+ # 仅对「两项均已填写」的条目做冲突与规范化校验
+ strict: list[tuple[str, str, str]] = []
+ for name, durl, key in parsed:
+ if durl and key:
+ strict.append((name, durl, key))
+
+ seen_d: dict[str, str] = {}
+ seen_k: dict[str, str] = {}
+ ok_strict: set[str] = set()
+ for name, durl, key in strict:
+ nd = _normalize_douyin_url(durl)
+ nk = _normalize_stream_key(key)
+ if not nd or not nk:
+ errors.append(f"条目「{name}」地址或密钥无效")
+ continue
+ if nd in seen_d:
+ errors.append(
+ f"禁止重复使用同一抖音直播间绑定多条线路(冲突:「{seen_d[nd]}」与「{name}」)"
+ )
+ continue
+ if nk in seen_k:
+ errors.append(
+ f"禁止多条抖音线路共用同一 YouTube 串流密钥(冲突:「{seen_k[nk]}」与「{name}」)"
+ )
+ continue
+ seen_d[nd] = name
+ seen_k[nk] = name
+ ok_strict.add(name)
+
+ out: list[dict[str, str]] = []
+ for name, durl, key in parsed:
+ if not durl and not key:
+ out.append(
+ {
+ "name": name,
+ "douyinPreview": "(待填写)",
+ "keyPreview": "(待填写)",
+ }
+ )
+ continue
+ if not durl or not key:
+ out.append(
+ {
+ "name": name,
+ "douyinPreview": _short_douyin_hint(durl) if durl else "(待填写)",
+ "keyPreview": _mask_key(key) if key else "(待填写)",
+ }
+ )
+ continue
+ if name in ok_strict:
+ out.append(
+ {
+ "name": name,
+ "douyinPreview": _short_douyin_hint(durl),
+ "keyPreview": _mask_key(key),
+ }
+ )
+ else:
+ out.append(
+ {
+ "name": name,
+ "douyinPreview": _short_douyin_hint(durl) if durl else "(待填写)",
+ "keyPreview": _mask_key(key) if key else "(待填写)",
+ }
+ )
+
+ return out, errors
+
+
+def _youtube_channels_list(config_dir: Path) -> tuple[list[dict[str, str]], list[str], list[str]]:
+ """
+ 返回:(频道行, 校验错误, 说明性备注)。
+ 错误用于重复抖音/重复密钥等;备注用于单路 legacy 提示,不应标红为「错误」。
+ """
+ path = config_dir / "youtube_channels.json"
+ if path.exists():
+ pairs, errs = _parse_youtube_channels_json(path)
+ if pairs:
+ return pairs, errs, []
+ if errs:
+ return [], errs, []
+ return (
+ [],
+ [],
+ [
+ "转播线路文件已就绪,请在客户端「录制」页 Pro 转播中填写抖音地址与 YouTube 密钥,将自动保存。",
+ ],
+ )
+
+ key = _parse_youtube_ini_key(config_dir / "youtube.ini")
+ if not key:
+ return [], [], []
+
+ hint = _first_douyin_line_from_url_config(config_dir)
+ row: dict[str, str] = {
+ "name": "默认(单路)",
+ "keyPreview": _mask_key(key),
+ }
+ if hint:
+ row["douyinPreview"] = _short_douyin_hint(hint)
+ else:
+ row["douyinPreview"] = "(请在 URL_config.ini 填写抖音地址)"
+ notes = [
+ "当前为单路 legacy:youtube.ini + URL_config.ini;多路一对一由客户端自动生成 youtube_channels.json。",
+ ]
+ return [row], [], notes
+
+
+def build_client_overview(config_dir: Path, base_dir: Path) -> dict[str, Any]:
+ channels, relay_errors, relay_notes = _youtube_channels_list(config_dir)
+ cfg = load_wechat_chatbot_config(base_dir)
+ st = load_wechat_state(base_dir)
+ wechat: dict[str, Any] = {
+ "configured": bool(cfg.get("proxyUrl") and cfg.get("apiKey")),
+ "nickName": st.get("nickName"),
+ "wcId": st.get("wcId"),
+ "online": False,
+ "proxyError": None,
+ }
+ if wechat["configured"]:
+ try:
+ c = build_client(cfg)
+ ps = c.get_status()
+ wechat["online"] = bool(ps.get("isLoggedIn")) or bool(ps.get("wcId"))
+ if ps.get("nickName"):
+ wechat["nickName"] = ps.get("nickName")
+ if ps.get("wcId"):
+ wechat["wcId"] = ps.get("wcId")
+ except Exception as e:
+ wechat["proxyError"] = str(e)[:300]
+ return {
+ "youtubeChannelCount": len(channels),
+ "youtubeChannels": channels,
+ "relayPairingPolicy": "one_douyin_one_youtube",
+ "relayValidationErrors": relay_errors,
+ "relayNotes": relay_notes,
+ "wechat": wechat,
+ "easyTierPeersNote": "peer 数量由 Electron 调用 easytier-cli peer list 获取",
+ }
diff --git a/web2_network.py b/web2_network.py
new file mode 100644
index 0000000..dfa18bb
--- /dev/null
+++ b/web2_network.py
@@ -0,0 +1,267 @@
+"""
+网络检测:Google(海外)与抖音(国内)可达性、解析 IP/地区、简易上下行 Mbps。
+说明:上行依赖对端是否接受 POST,失败时返回 null 与原因,属正常现象。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import socket
+import time
+from typing import Any, Optional
+
+import httpx
+
+# ---------- 目标 ----------
+GOOGLE_HOST = "www.google.com"
+GOOGLE_CHECK_URL = "https://www.google.com/generate_204"
+GOOGLE_DOWNLOAD_URL = (
+ "https://www.gstatic.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png"
+)
+
+DOUYIN_HOST = "www.douyin.com"
+DOUYIN_CHECK_URL = "https://www.douyin.com/"
+# 首页 HTML 流足够大,便于采样测下行
+DOUYIN_DOWNLOAD_URL = "https://www.douyin.com/"
+
+OVERSEAS_UPLOAD_URL = "https://httpbin.org/post"
+DOMESTIC_UPLOAD_URLS = (
+ "https://www.douyin.com/",
+ "https://www.163.com/",
+)
+
+IP_API_TEMPLATE = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,message,query"
+
+DOWNLOAD_CAP_BYTES = 600_000
+UPLOAD_BYTES = 256 * 1024
+CONNECT_TIMEOUT = 8.0
+READ_TIMEOUT = 25.0
+
+
+def _is_probably_china_country(code: Optional[str]) -> bool:
+ return (code or "").upper() == "CN"
+
+
+async def _resolve_ipv4(host: str) -> tuple[Optional[str], Optional[str]]:
+ def _sync() -> tuple[Optional[str], Optional[str]]:
+ try:
+ infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
+ except socket.gaierror as e:
+ return None, str(e)
+ for fam, _, _, _, sockaddr in infos:
+ if fam == socket.AF_INET:
+ return sockaddr[0], None
+ for fam, _, _, _, sockaddr in infos:
+ if fam == socket.AF_INET6:
+ return sockaddr[0], None
+ return None, "no address"
+
+ return await asyncio.to_thread(_sync)
+
+
+async def _country_for_ip(client: httpx.AsyncClient, ip: str) -> dict[str, Any]:
+ if not ip:
+ return {"country": None, "country_code": None, "error": "no ip"}
+ try:
+ r = await client.get(IP_API_TEMPLATE.format(ip=ip), timeout=6.0)
+ r.raise_for_status()
+ data = r.json()
+ except Exception as e:
+ return {"country": None, "country_code": None, "error": str(e)}
+ if data.get("status") != "success":
+ return {
+ "country": None,
+ "country_code": None,
+ "error": data.get("message") or "lookup failed",
+ }
+ return {
+ "country": data.get("country"),
+ "country_code": data.get("countryCode"),
+ "error": None,
+ }
+
+
+async def _latency_get(client: httpx.AsyncClient, url: str) -> tuple[bool, float, Optional[str]]:
+ t0 = time.monotonic()
+ try:
+ r = await client.get(url, follow_redirects=True)
+ ok = r.status_code < 400
+ ms = (time.monotonic() - t0) * 1000
+ return ok, ms, None if ok else f"HTTP {r.status_code}"
+ except Exception as e:
+ ms = (time.monotonic() - t0) * 1000
+ return False, ms, str(e)
+
+
+async def _measure_download_mbps(
+ client: httpx.AsyncClient, url: str, cap: int = DOWNLOAD_CAP_BYTES
+) -> tuple[Optional[float], Optional[str], int]:
+ total = 0
+ t0 = time.monotonic()
+ try:
+ async with client.stream("GET", url, follow_redirects=True) as r:
+ r.raise_for_status()
+ async for chunk in r.aiter_bytes():
+ total += len(chunk)
+ if total >= cap:
+ break
+ dt = time.monotonic() - t0
+ if dt < 0.001 or total == 0:
+ return None, "无数据或过快", total
+ mbps = (total * 8) / dt / 1_000_000
+ return round(mbps, 2), None, total
+ except Exception as e:
+ return None, str(e), total
+
+
+async def _measure_upload_mbps(
+ client: httpx.AsyncClient, url: str, size: int = UPLOAD_BYTES
+) -> tuple[Optional[float], Optional[str]]:
+ data = b"\x00" * size
+ t0 = time.monotonic()
+ try:
+ r = await client.post(
+ url,
+ content=data,
+ headers={"Content-Type": "application/octet-stream"},
+ )
+ dt = time.monotonic() - t0
+ if dt < 0.001:
+ return None, "耗时过短"
+ mbps = (size * 8) / dt / 1_000_000
+ return round(mbps, 2), None
+ except Exception as e:
+ return None, str(e)
+
+
+async def _try_domestic_upload(client: httpx.AsyncClient) -> tuple[Optional[float], Optional[str], Optional[str]]:
+ last_err: Optional[str] = None
+ for url in DOMESTIC_UPLOAD_URLS:
+ up, err = await _measure_upload_mbps(client, url)
+ if up is not None:
+ return up, None, url
+ last_err = err
+ return None, last_err, None
+
+
+async def run_network_dashboard() -> dict[str, Any]:
+ """
+ 返回结构供前端仪表盘使用。
+ routes_different:基于解析 IP 的国家码;若 API 失败则给 likely / unknown。
+ """
+ timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT)
+ limits = httpx.Limits(max_keepalive_connections=4, max_connections=8)
+
+ ua = (
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+ )
+ async with httpx.AsyncClient(
+ timeout=timeout,
+ limits=limits,
+ verify=True,
+ headers={"User-Agent": ua},
+ ) as client:
+ g_ip, g_res_err = await _resolve_ipv4(GOOGLE_HOST)
+ d_ip, d_res_err = await _resolve_ipv4(DOUYIN_HOST)
+
+ async def _empty_geo() -> dict[str, Any]:
+ return {"country": None, "country_code": None, "error": "no ip"}
+
+ g_country_task = _country_for_ip(client, g_ip) if g_ip else _empty_geo()
+ d_country_task = _country_for_ip(client, d_ip) if d_ip else _empty_geo()
+
+ (
+ (g_ok, g_ms, g_err),
+ (d_ok, d_ms, d_err),
+ g_geo,
+ d_geo,
+ ) = await asyncio.gather(
+ _latency_get(client, GOOGLE_CHECK_URL),
+ _latency_get(client, DOUYIN_CHECK_URL),
+ g_country_task,
+ d_country_task,
+ )
+
+ if not isinstance(g_geo, dict):
+ g_geo = {"country": None, "country_code": None, "error": None}
+ if not isinstance(d_geo, dict):
+ d_geo = {"country": None, "country_code": None, "error": None}
+
+ g_code = g_geo.get("country_code")
+ d_code = d_geo.get("country_code")
+
+ routes_different: Optional[bool] = None
+ routes_note = ""
+ if g_code and d_code:
+ routes_different = _is_probably_china_country(g_code) != _is_probably_china_country(d_code)
+ routes_note = "依据解析 IP 的国家码:海外站点与国内站点出口不一致" if routes_different else (
+ "依据解析 IP:两国别相同或未区分境内外(若您期望分流,请检查代理/路由)"
+ )
+ else:
+ routes_note = "未能查询到双方 IP 国家,无法严格判断线路是否分流"
+ if g_ip and d_ip and g_ip != d_ip:
+ routes_note += ";两站点解析 IP 不同,通常走不同目标。"
+ if g_res_err:
+ routes_note += f" Google DNS: {g_res_err}"
+ if d_res_err:
+ routes_note += f" 抖音 DNS: {d_res_err}"
+
+ # 测速(并行)
+ od_mbps, od_err, od_bytes = await _measure_download_mbps(client, GOOGLE_DOWNLOAD_URL)
+ dd_mbps, dd_err, dd_bytes = await _measure_download_mbps(client, DOUYIN_DOWNLOAD_URL)
+
+ ou_mbps, ou_err = await _measure_upload_mbps(client, OVERSEAS_UPLOAD_URL)
+ du_mbps, du_err, du_url = await _try_domestic_upload(client)
+
+ return {
+ "google": {
+ "host": GOOGLE_HOST,
+ "reachable": g_ok,
+ "latency_ms": round(g_ms, 1),
+ "error": g_err or g_res_err,
+ "resolved_ip": g_ip,
+ "country": g_geo.get("country"),
+ "country_code": g_code,
+ "geo_error": g_geo.get("error"),
+ },
+ "douyin": {
+ "host": DOUYIN_HOST,
+ "reachable": d_ok,
+ "latency_ms": round(d_ms, 1),
+ "error": d_err or d_res_err,
+ "resolved_ip": d_ip,
+ "country": d_geo.get("country"),
+ "country_code": d_code,
+ "geo_error": d_geo.get("error"),
+ },
+ "routes": {
+ "different": routes_different,
+ "note": routes_note.strip(),
+ },
+ "speed": {
+ "overseas": {
+ "label": "海外(Google / gstatic)",
+ "download_mbps": od_mbps,
+ "download_error": od_err,
+ "download_bytes_sampled": od_bytes,
+ "upload_mbps": ou_mbps,
+ "upload_error": ou_err,
+ "upload_target": OVERSEAS_UPLOAD_URL,
+ },
+ "domestic": {
+ "label": "国内(字节 CDN / 门户)",
+ "download_mbps": dd_mbps,
+ "download_error": dd_err,
+ "download_bytes_sampled": dd_bytes,
+ "upload_mbps": du_mbps,
+ "upload_error": du_err,
+ "upload_target": du_url,
+ },
+ },
+ "meta": {
+ "at": time.time(),
+ "download_cap_bytes": DOWNLOAD_CAP_BYTES,
+ "upload_bytes": UPLOAD_BYTES,
+ },
+ }
diff --git a/web2_proxy_config.py b/web2_proxy_config.py
new file mode 100644
index 0000000..3b59e03
--- /dev/null
+++ b/web2_proxy_config.py
@@ -0,0 +1,96 @@
+"""
+仅给「录制子进程」使用的代理配置(FFmpeg / youtube2 等),不修改 Windows 系统代理。
+用户在本机运行 Clash Verge 等时,通常填 http://127.0.0.1:7890 与 socks5://127.0.0.1:7890。
+"""
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Any
+
+from configparser import ConfigParser
+
+BASE_DIR = Path(__file__).resolve().parent
+PROXY_INI = BASE_DIR / "config" / "ffmpeg_proxy.ini"
+
+
+def _default_ini_text() -> str:
+ return (
+ "[proxy]\n"
+ "# 开启后仅作用于本软件拉起的录制/推流进程,不会改系统代理\n"
+ "enabled = no\n"
+ "# 与 Clash Verge「混合端口」一致即可(示例)\n"
+ "http = http://127.0.0.1:7890\n"
+ "socks = socks5://127.0.0.1:7890\n"
+ )
+
+
+def ensure_proxy_config_file() -> None:
+ PROXY_INI.parent.mkdir(parents=True, exist_ok=True)
+ if not PROXY_INI.exists():
+ PROXY_INI.write_text(_default_ini_text(), encoding="utf-8")
+
+
+def read_proxy_config() -> dict[str, Any]:
+ ensure_proxy_config_file()
+ cp = ConfigParser()
+ cp.read(PROXY_INI, encoding="utf-8")
+ if not cp.has_section("proxy"):
+ cp.add_section("proxy")
+ enabled = cp.get("proxy", "enabled", fallback="no").strip().lower() in (
+ "1",
+ "true",
+ "yes",
+ "on",
+ "是",
+ "开",
+ )
+ http = (cp.get("proxy", "http", fallback="") or "").strip()
+ socks = (cp.get("proxy", "socks", fallback="") or "").strip()
+ return {"enabled": enabled, "http": http, "socks": socks}
+
+
+def write_proxy_config(data: dict[str, Any]) -> None:
+ ensure_proxy_config_file()
+ cp = ConfigParser()
+ cp.read(PROXY_INI, encoding="utf-8")
+ if not cp.has_section("proxy"):
+ cp.add_section("proxy")
+ cp.set("proxy", "enabled", "yes" if data.get("enabled") else "no")
+ cp.set("proxy", "http", str(data.get("http") or "").strip())
+ cp.set("proxy", "socks", str(data.get("socks") or "").strip())
+ with open(PROXY_INI, "w", encoding="utf-8") as f:
+ cp.write(f)
+
+
+def subprocess_env_overlay() -> dict[str, str]:
+ """
+ 合并进子进程 environ 的键值(不含整个 os.environ)。
+ youtube2 / tiktok2 会读 STREAM_HTTP_PROXY / STREAM_SOCKS_PROXY。
+ """
+ cfg = read_proxy_config()
+ out: dict[str, str] = {}
+ if not cfg["enabled"]:
+ return out
+ http = cfg["http"]
+ socks = cfg["socks"]
+ if http:
+ out["STREAM_HTTP_PROXY"] = http
+ out["HTTP_PROXY"] = http
+ out["HTTPS_PROXY"] = http
+ out["http_proxy"] = http
+ out["https_proxy"] = http
+ if socks:
+ out["STREAM_SOCKS_PROXY"] = socks
+ out["ALL_PROXY"] = socks
+ out["all_proxy"] = socks
+ return out
+
+
+def merge_child_env(base: dict[str, str] | None = None) -> dict[str, str]:
+ """供 asyncio subprocess / Popen 使用(完整继承系统环境后再叠加代理)。"""
+ env = dict(os.environ)
+ if base:
+ env.update(base)
+ env.update(subprocess_env_overlay())
+ return env
diff --git a/web2_relay_pro.py b/web2_relay_pro.py
new file mode 100644
index 0000000..d087cf6
--- /dev/null
+++ b/web2_relay_pro.py
@@ -0,0 +1,439 @@
+"""
+Pro 多路转播:每频道独立 URL 配置文件、跨频道抖音地址去重、同步到 legacy youtube.ini + URL_config.ini。
+"""
+from __future__ import annotations
+
+import json
+import re
+import time
+from pathlib import Path
+from typing import Any, Callable, Awaitable
+
+# 与 web2_client_overview 一致的去重键
+def _norm_douyin_url(url: str) -> str:
+ u = (url or "").strip()
+ if not u:
+ return ""
+ u = re.sub(r"#.*$", "", u).strip()
+ return u.rstrip("/").lower()
+
+
+def _norm_stream_key(key: str) -> str:
+ k = (key or "").strip()
+ if not k:
+ return ""
+ if k.startswith("rtmp://") or k.startswith("rtmps://"):
+ parts = k.rstrip("/").split("/")
+ k = parts[-1] if parts else k
+ return k.strip().lower()
+
+
+def relay_pro_dir(config_dir: Path) -> Path:
+ p = config_dir / "relay_pro"
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def _safe_channel_file_id(channel_id: str) -> str:
+ s = re.sub(r"[^a-zA-Z0-9_-]", "_", (channel_id or "").strip())
+ return s or "default"
+
+
+def url_config_path_for_channel(config_dir: Path, channel_id: str) -> Path:
+ return relay_pro_dir(config_dir) / f"url_config.{_safe_channel_file_id(channel_id)}.ini"
+
+
+def channels_json_path(config_dir: Path) -> Path:
+ return config_dir / "youtube_channels.json"
+
+
+def ensure_default_relay_config(config_dir: Path) -> None:
+ """若缺少 youtube_channels.json 或 channels 为空,则写入默认 ch_default 与 url_config(便于独立运行 uvicorn)。"""
+ config_dir = Path(config_dir)
+ config_dir.mkdir(parents=True, exist_ok=True)
+ relay_pro_dir(config_dir)
+ p = channels_json_path(config_dir)
+ need_write = False
+ if not p.exists():
+ need_write = True
+ else:
+ try:
+ raw = json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ need_write = True
+ raw = None
+ else:
+ arr = raw.get("channels") if isinstance(raw, dict) else None
+ if not isinstance(arr, list) or len(arr) == 0:
+ need_write = True
+ if not need_write:
+ return
+ default_doc: dict[str, Any] = {
+ "version": 1,
+ "autoManaged": True,
+ "channels": [
+ {
+ "id": "ch_default",
+ "name": "默认线路",
+ "douyinUrl": "",
+ "youtubeKey": "",
+ }
+ ],
+ }
+ p.write_text(json.dumps(default_doc, ensure_ascii=False, indent=2), encoding="utf-8")
+ uc = url_config_path_for_channel(config_dir, "ch_default")
+ if not uc.exists():
+ uc.write_text(
+ "# 软件已自动创建。每行一个抖音直播间地址,例如:\n# https://live.douyin.com/123456789\n\n",
+ encoding="utf-8",
+ )
+
+
+def load_channels_document(config_dir: Path) -> tuple[dict[str, Any] | None, list[str]]:
+ p = channels_json_path(config_dir)
+ if not p.exists():
+ return None, ["缺少 config/youtube_channels.json,请先添加(见 youtube_channels.example.json)"]
+ try:
+ raw = json.loads(p.read_text(encoding="utf-8"))
+ except Exception as e:
+ return None, [f"youtube_channels.json 解析失败: {e}"]
+ if not isinstance(raw, dict):
+ return None, ["youtube_channels.json 格式错误"]
+ arr = raw.get("channels")
+ if not isinstance(arr, list):
+ return None, ["youtube_channels.json 缺少 channels 数组"]
+ return raw, []
+
+
+def ensure_channel_ids(config_dir: Path, raw: dict[str, Any]) -> bool:
+ """为缺 id 的条目补 ch_0, ch_1 并写回文件。返回是否写回。"""
+ arr = raw.get("channels")
+ if not isinstance(arr, list):
+ return False
+ changed = False
+ for i, item in enumerate(arr):
+ if not isinstance(item, dict):
+ continue
+ cid = str(item.get("id") or "").strip()
+ if not cid:
+ item["id"] = f"ch_{i}"
+ changed = True
+ if changed:
+ channels_json_path(config_dir).write_text(json.dumps(raw, ensure_ascii=False, indent=2), encoding="utf-8")
+ return changed
+
+
+def list_channels_for_pro(config_dir: Path) -> dict[str, Any]:
+ doc, errs = load_channels_document(config_dir)
+ if doc is None:
+ return {"ok": False, "error": errs[0] if errs else "未知错误", "channels": []}
+ ensure_channel_ids(config_dir, doc)
+ doc, _ = load_channels_document(config_dir)
+ assert doc is not None
+ arr = doc.get("channels")
+ out: list[dict[str, Any]] = []
+ if isinstance(arr, list):
+ for item in arr:
+ if not isinstance(item, dict):
+ continue
+ cid = str(item.get("id") or "").strip()
+ name = str(item.get("name") or "").strip() or "未命名"
+ durl = str(item.get("douyinUrl") or item.get("douyin_url") or "").strip()
+ key = str(item.get("youtubeKey") or item.get("key") or "").strip()
+ if not cid:
+ continue
+ kp = _mask_key_preview(key)
+ out.append(
+ {
+ "id": cid,
+ "name": name,
+ "douyinUrl": durl,
+ "keyPreview": kp,
+ }
+ )
+ return {"ok": True, "channels": out}
+
+
+def _mask_key_preview(key: str) -> str:
+ k = (key or "").strip()
+ if not k:
+ return ""
+ if len(k) <= 8:
+ return "****"
+ return f"{k[:4]}…{k[-4:]}"
+
+
+def get_channel_by_id(config_dir: Path, channel_id: str) -> dict[str, Any] | None:
+ doc, _ = load_channels_document(config_dir)
+ if doc is None:
+ return None
+ arr = doc.get("channels")
+ if not isinstance(arr, list):
+ return None
+ for item in arr:
+ if not isinstance(item, dict):
+ continue
+ if str(item.get("id") or "").strip() == channel_id:
+ return item
+ return None
+
+
+def get_channel_detail(config_dir: Path, channel_id: str) -> dict[str, Any] | None:
+ ch = get_channel_by_id(config_dir, channel_id)
+ if not ch:
+ return None
+ return {
+ "id": str(ch.get("id") or "").strip(),
+ "name": str(ch.get("name") or "").strip() or "未命名",
+ "douyinUrl": str(ch.get("douyinUrl") or ch.get("douyin_url") or "").strip(),
+ "youtubeKey": str(ch.get("youtubeKey") or ch.get("key") or "").strip(),
+ }
+
+
+def update_channel_key(config_dir: Path, channel_id: str, new_key: str) -> tuple[bool, str]:
+ doc, errs = load_channels_document(config_dir)
+ if doc is None:
+ return False, errs[0] if errs else "无法读取配置"
+ arr = doc.get("channels")
+ if not isinstance(arr, list):
+ return False, "channels 无效"
+ nk = _norm_stream_key(new_key)
+ if not nk:
+ return False, "密钥不能为空"
+ for item in arr:
+ if not isinstance(item, dict):
+ continue
+ oid = str(item.get("id") or "").strip()
+ if oid == channel_id:
+ continue
+ ok = _norm_stream_key(str(item.get("youtubeKey") or item.get("key") or ""))
+ if ok and ok == nk:
+ return False, "禁止多条线路共用同一 YouTube 串流密钥"
+ for item in arr:
+ if not isinstance(item, dict):
+ continue
+ if str(item.get("id") or "").strip() != channel_id:
+ continue
+ item["youtubeKey"] = new_key.strip()
+ channels_json_path(config_dir).write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8")
+ return True, "已保存"
+ return False, "未找到该频道"
+
+
+def extract_url_lines_from_ini(text: str) -> list[str]:
+ lines: list[str] = []
+ for raw in text.splitlines():
+ t = raw.strip()
+ if not t or t.startswith("#"):
+ continue
+ if "," in t:
+ t = t.split(",")[-1].strip()
+ if "douyin" in t.lower() or "v.douyin.com" in t.lower() or "live.douyin.com" in t.lower():
+ lines.append(t)
+ return lines
+
+
+def collect_normalized_urls_from_all_other_channels(
+ config_dir: Path, exclude_channel_id: str
+) -> dict[str, set[str]]:
+ """channel_id -> set of normalized douyin urls from their url_config file."""
+ doc, _ = load_channels_document(config_dir)
+ out: dict[str, set[str]] = {}
+ if doc is None:
+ return out
+ arr = doc.get("channels")
+ if not isinstance(arr, list):
+ return out
+ for item in arr:
+ if not isinstance(item, dict):
+ continue
+ cid = str(item.get("id") or "").strip()
+ if not cid or cid == exclude_channel_id:
+ continue
+ p = url_config_path_for_channel(config_dir, cid)
+ if not p.exists():
+ out[cid] = set()
+ continue
+ try:
+ text = p.read_text(encoding="utf-8", errors="ignore")
+ except Exception:
+ out[cid] = set()
+ continue
+ s: set[str] = set()
+ for line in extract_url_lines_from_ini(text):
+ nd = _norm_douyin_url(line)
+ if nd:
+ s.add(nd)
+ out[cid] = s
+ return out
+
+
+def validate_url_config_no_cross_duplicate(
+ config_dir: Path, channel_id: str, new_content: str
+) -> list[str]:
+ """保存本频道 URL 列表时,与其它频道的 URL 去重(禁止同一抖音直播间出现在多条线路)。"""
+ errors: list[str] = []
+ mine: set[str] = set()
+ for line in extract_url_lines_from_ini(new_content):
+ nd = _norm_douyin_url(line)
+ if nd:
+ mine.add(nd)
+ others = collect_normalized_urls_from_all_other_channels(config_dir, channel_id)
+ for ocid, oset in others.items():
+ inter = mine & oset
+ if inter:
+ errors.append(
+ f"与频道「{ocid}」的地址列表冲突:以下抖音链接已出现在其它线路(禁止重复):"
+ + ";".join(list(inter)[:5])
+ )
+ return errors
+
+
+def read_url_config_for_channel(config_dir: Path, channel_id: str) -> str:
+ p = url_config_path_for_channel(config_dir, channel_id)
+ if not p.exists():
+ return "# 每行一个抖音直播间地址\n"
+ try:
+ return p.read_text(encoding="utf-8")
+ except Exception:
+ return "# 读取失败\n"
+
+
+def write_url_config_for_channel(config_dir: Path, channel_id: str, content: str) -> tuple[bool, str]:
+ errs = validate_url_config_no_cross_duplicate(config_dir, channel_id, content)
+ if errs:
+ return False, errs[0]
+ p = url_config_path_for_channel(config_dir, channel_id)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(content, encoding="utf-8")
+ return True, "保存成功"
+
+
+def write_youtube_ini_single_key(config_dir: Path, key: str) -> None:
+ """写入单路 youtube.ini([youtube] key=)。"""
+ lines = ["[youtube]", f"key = {key.strip()}", ""]
+ (config_dir / "youtube.ini").write_text("\n".join(lines), encoding="utf-8")
+
+
+def sync_channel_to_legacy_files(config_dir: Path, channel_id: str) -> tuple[bool, str]:
+ """将选中频道的密钥与 URL 列表同步到 youtube.ini + URL_config.ini,供当前单进程 youtube 脚本使用。"""
+ d = get_channel_detail(config_dir, channel_id)
+ if not d:
+ return False, "频道不存在"
+ key = d.get("youtubeKey") or ""
+ if not str(key).strip():
+ return False, "该频道未配置 youtubeKey"
+ url_path = url_config_path_for_channel(config_dir, channel_id)
+ if not url_path.exists():
+ return False, f"缺少该频道的地址文件:{url_path.name}"
+ try:
+ url_body = url_path.read_text(encoding="utf-8")
+ except Exception as e:
+ return False, f"读取地址文件失败: {e}"
+ write_youtube_ini_single_key(config_dir, str(key))
+ (config_dir / "URL_config.ini").write_text(url_body, encoding="utf-8")
+ return True, "已同步到 youtube.ini 与 URL_config.ini"
+
+
+def parse_relay_meta_lines(log_text: str) -> list[dict[str, Any]]:
+ out: list[dict[str, Any]] = []
+ for line in log_text.splitlines():
+ if "[RELAY_META]" not in line:
+ continue
+ idx = line.find("{")
+ if idx < 0:
+ continue
+ try:
+ j = json.loads(line[idx:])
+ if isinstance(j, dict):
+ out.append(j)
+ except Exception:
+ continue
+ return out
+
+
+async def build_relay_live_status(
+ config_dir: Path,
+ base_dir: Path,
+ get_process_info: Callable[[str], Awaitable[dict[str, Any]]],
+ read_log_tail: Callable[[str, int], Awaitable[str]],
+ youtube_pm2_name: str,
+) -> dict[str, Any]:
+ """工作台 Pro:解析日志 RELAY_META + 进程状态,匹配 youtube_channels.json 频道名。"""
+ ch_doc = list_channels_for_pro(config_dir)
+ id_to_name = {c["id"]: c["name"] for c in ch_doc.get("channels") or []}
+ try:
+ info = await get_process_info(youtube_pm2_name)
+ except Exception:
+ info = {"process_status": "unknown", "out_path": "", "err_path": ""}
+ st = str(info.get("process_status") or "unknown")
+ online = st == "online"
+ out_path = str(info.get("out_path") or "")
+ err_path = str(info.get("err_path") or "")
+ recent = ""
+ try:
+ if out_path:
+ recent += await read_log_tail(out_path, 400)
+ if err_path:
+ recent += "\n" + await read_log_tail(err_path, 400)
+ except Exception:
+ recent = recent or ""
+ metas = parse_relay_meta_lines(recent)
+ last = metas[-1] if metas else None
+ rows: list[dict[str, Any]] = []
+ suffix = str(last.get("youtubeKeySuffix") or "").strip().lower() if last else ""
+ matched_id, matched_name = _match_channel_by_key_suffix(config_dir, suffix)
+ if last:
+ ts = last.get("ts")
+ duration_sec: float | None = None
+ if isinstance(ts, (int, float)):
+ duration_sec = max(0.0, time.time() - float(ts))
+ rows.append(
+ {
+ "channelId": matched_id or None,
+ "channelName": matched_name or id_to_name.get(matched_id, "") or "当前推流",
+ "anchor": last.get("anchor"),
+ "douyinHint": last.get("douyinHint"),
+ "streamTitle": last.get("streamTitle"),
+ "youtubeKeySuffix": last.get("youtubeKeySuffix"),
+ "durationSec": duration_sec,
+ "processOnline": online,
+ }
+ )
+ return {
+ "youtubeProcess": youtube_pm2_name,
+ "processStatus": st,
+ "processOnline": online,
+ "relayRows": rows,
+ "lastRelayMeta": last,
+ }
+
+
+def _match_channel_by_key_suffix(config_dir: Path, suffix: str) -> tuple[str, str]:
+ """用 RELAY_META 的 youtubeKeySuffix 与 youtube_channels.json 中密钥比对。"""
+ if not suffix:
+ return "", ""
+ doc, _ = load_channels_document(config_dir)
+ if not doc or not isinstance(doc.get("channels"), list):
+ return "", ""
+ for item in doc["channels"]:
+ if not isinstance(item, dict):
+ continue
+ k = str(item.get("youtubeKey") or item.get("key") or "").strip()
+ if not k:
+ continue
+ if k.startswith("rtmp://") or k.startswith("rtmps://"):
+ parts = k.rstrip("/").split("/")
+ k = parts[-1] if parts else k
+ tail = k[-8:].lower() if len(k) >= 8 else k.lower()
+ if suffix == tail or (len(suffix) >= 4 and tail.endswith(suffix)):
+ return str(item.get("id") or ""), str(item.get("name") or "")
+ return "", ""
+
+
+def first_youtube_pm2_name(script_entries: list[dict[str, Any]]) -> str:
+ for e in script_entries:
+ script = str(e.get("script") or "")
+ if script.endswith(".py") and Path(script).stem.startswith("youtube"):
+ return Path(script).stem
+ return "youtube2"
diff --git a/web2_supervisor.py b/web2_supervisor.py
new file mode 100644
index 0000000..7feff3a
--- /dev/null
+++ b/web2_supervisor.py
@@ -0,0 +1,323 @@
+"""
+进程编排:默认使用内置 Supervisor(Windows / 免 PM2)。
+设置环境变量 WEB2_SUPERVISOR=pm2 时使用 PM2(与旧版 Linux 工作流兼容)。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import shutil
+import sys
+from pathlib import Path
+from typing import Any, Callable, Optional
+
+import psutil
+
+from web2_proxy_config import merge_child_env
+
+
+def use_builtin() -> bool:
+ return os.environ.get("WEB2_SUPERVISOR", "builtin").strip().lower() != "pm2"
+
+
+def strip_ansi_codes(text: str) -> str:
+ return re.sub(r"\x1b\[[0-9;]*m", "", text)
+
+
+class BuiltinSupervisor:
+ """用本地 PID + 日志文件管理子进程,不依赖 Node/PM2。"""
+
+ def __init__(self, base_dir: Path, console_script: str, script_resolver: Callable[[str], Optional[str]]):
+ self.base_dir = base_dir.resolve()
+ self.console_script = console_script
+ self.console_stem = Path(console_script).stem
+ self._script_for = script_resolver
+ self.log_dir = self.base_dir / ".pm2" / "logs"
+ self.log_dir.mkdir(parents=True, exist_ok=True)
+ self.state_path = self.base_dir / ".pm2" / "supervisor_state.json"
+ self._io_lock = asyncio.Lock()
+
+ def _read_state(self) -> dict[str, Any]:
+ if not self.state_path.exists():
+ return {}
+ try:
+ return json.loads(self.state_path.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+ def _write_state(self, state: dict[str, Any]) -> None:
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
+ self.state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
+
+ def _pid_alive(self, pid: int | None) -> bool:
+ if pid is None or pid <= 0:
+ return False
+ try:
+ return psutil.Process(pid).is_running()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ return False
+
+ def _log_paths(self, name: str) -> tuple[Path, Path]:
+ out_p = self.log_dir / f"{name}-out.log"
+ err_p = self.log_dir / f"{name}-error.log"
+ return out_p, err_p
+
+ def _build_cmd(self, script: str) -> Optional[list[str]]:
+ path = self.base_dir / script
+ if not path.is_file():
+ return None
+ ext = path.suffix.lower()
+ if ext == ".py":
+ return [sys.executable, str(path)]
+ if ext == ".bat":
+ comspec = os.environ.get("COMSPEC") or "cmd.exe"
+ return [comspec, "/c", str(path)]
+ if ext == ".sh":
+ bash = shutil.which("bash") or shutil.which("sh")
+ if not bash:
+ return None
+ return [bash, str(path)]
+ return None
+
+ async def start(self, process_name: str) -> str:
+ if process_name == self.console_stem:
+ return "控制台已由本程序托管,无需单独启动。"
+
+ script = self._script_for(process_name)
+ if not script:
+ return f"未知进程名: {process_name}"
+
+ argv = self._build_cmd(script)
+ if argv is None:
+ path = self.base_dir / script
+ if not path.is_file():
+ return f"脚本不存在: {script}"
+ if script.endswith(".sh"):
+ return "未找到 bash/sh,无法运行 .sh(可改用 obs*.bat 或安装 Git for Windows)"
+ return f"无法构建启动命令: {script}"
+
+ async with self._io_lock:
+ state = self._read_state()
+ entry = state.get(process_name, {})
+ pid = entry.get("pid")
+ if self._pid_alive(pid):
+ return f"进程已在运行中(PID {pid})。"
+
+ out_p, err_p = self._log_paths(process_name)
+ out_p.parent.mkdir(parents=True, exist_ok=True)
+ out_f = open(out_p, "ab")
+ err_f = open(err_p, "ab")
+ try:
+ proc = await asyncio.create_subprocess_exec(
+ *argv,
+ cwd=str(self.base_dir),
+ stdout=out_f,
+ stderr=err_f,
+ creationflags=0,
+ env=merge_child_env(),
+ )
+ except Exception as e:
+ out_f.close()
+ err_f.close()
+ return f"启动失败: {e}"
+ out_f.close()
+ err_f.close()
+
+ pid = proc.pid
+ state[process_name] = {
+ "pid": pid,
+ "script": script,
+ "out_log": str(out_p),
+ "err_log": str(err_p),
+ }
+ self._write_state(state)
+ return f"已启动 {script}(PID {pid}),日志见 .pm2/logs"
+
+ async def stop(self, process_name: str) -> str:
+ if process_name == self.console_stem:
+ return "请关闭桌面应用窗口以结束控制台。"
+
+ async with self._io_lock:
+ state = self._read_state()
+ entry = state.get(process_name, {})
+ pid = entry.get("pid")
+
+ if not self._pid_alive(pid):
+ state.pop(process_name, None)
+ self._write_state(state)
+ return "进程未在运行(或已退出)。"
+
+ try:
+ parent = psutil.Process(pid)
+ children = parent.children(recursive=True)
+ for c in children:
+ try:
+ c.terminate()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ try:
+ parent.terminate()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ gone, alive = psutil.wait_procs([parent] + children, timeout=5)
+ for p in alive:
+ try:
+ p.kill()
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ pass
+ except psutil.NoSuchProcess:
+ pass
+
+ state.pop(process_name, None)
+ self._write_state(state)
+ return f"已停止进程(原 PID {pid})。"
+
+ async def restart(self, process_name: str) -> str:
+ if process_name == self.console_stem:
+ return "控制台由桌面应用托管,请关闭窗口后重新打开应用。"
+ out = await self.stop(process_name)
+ await asyncio.sleep(0.5)
+ out2 = await self.start(process_name)
+ return f"{out}\n{out2}"
+
+ async def full_status_text(self) -> str:
+ lines = [
+ "┌──────────────────┬────────────┬────────────────────────────────────────┐",
+ "│ 进程名 │ 状态 │ 说明 │",
+ "├──────────────────┼────────────┼────────────────────────────────────────┤",
+ ]
+ async with self._io_lock:
+ state = self._read_state()
+ lines.append(
+ f"│ {self.console_stem:<16} │ {'online':<10} │ 控制台(本机 uvicorn) │"
+ )
+ for name, entry in sorted(state.items()):
+ pid = entry.get("pid")
+ alive = self._pid_alive(pid)
+ st = "online" if alive else "stopped"
+ scr = (entry.get("script") or "")[:38]
+ lines.append(f"│ {name:<16} │ {st:<10} │ {scr:<38} │")
+ lines.append(
+ "└──────────────────┴────────────┴────────────────────────────────────────┘"
+ )
+ return "\n".join(lines)
+
+ async def get_process_info(self, process_name: str) -> dict[str, Any]:
+ if process_name == self.console_stem:
+ out_p, err_p = self._log_paths(process_name)
+ return {
+ "process_status": "online",
+ "out_path": str(out_p),
+ "err_path": str(err_p),
+ }
+
+ async with self._io_lock:
+ state = self._read_state()
+ entry = state.get(process_name, {})
+ pid = entry.get("pid")
+ out_path = entry.get("out_log")
+ err_path = entry.get("err_log")
+
+ if not out_path:
+ out_path = str(self._log_paths(process_name)[0])
+ if not err_path:
+ err_path = str(self._log_paths(process_name)[1])
+
+ if not self._pid_alive(pid):
+ return {
+ "process_status": "stopped" if entry else "not_found",
+ "out_path": out_path,
+ "err_path": err_path,
+ }
+
+ return {
+ "process_status": "online",
+ "out_path": out_path,
+ "err_path": err_path,
+ }
+
+
+async def run_pm2_shell(cmd: str, timeout: float = 15.0) -> str:
+ process = await asyncio.create_subprocess_shell(
+ f"pm2 {cmd}",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ )
+ try:
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
+ result = (stdout or stderr).decode(errors="ignore").strip()
+ return result if result else "命令执行完成(无输出)"
+ except asyncio.TimeoutError:
+ process.kill()
+ await process.wait()
+ return "ERROR: PM2 命令超时,已强制终止"
+
+
+async def force_kill_ffmpeg_cross_platform(process_name: str) -> None:
+ """按命令行匹配业务进程名,结束相关 ffmpeg。"""
+
+ def _sync_kill() -> None:
+ for proc in psutil.process_iter(["pid", "name", "cmdline"]):
+ try:
+ name = (proc.info.get("name") or "").lower()
+ if "ffmpeg" not in name:
+ continue
+ cmdline = proc.info.get("cmdline") or []
+ flat = " ".join(str(x) for x in cmdline)
+ if process_name and process_name not in flat:
+ continue
+ p = psutil.Process(proc.info["pid"])
+ p.kill()
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
+ continue
+
+ await asyncio.to_thread(_sync_kill)
+ await asyncio.sleep(0.35)
+
+
+async def get_process_info_pm2(
+ process: str,
+ default_log_dir: Path,
+ strip_fn: Callable[[str], str],
+) -> dict[str, Any]:
+ describe_raw = await run_pm2_shell(f"describe {process}", timeout=10.0)
+ describe_clean = strip_fn(describe_raw)
+
+ if any(x in describe_clean.lower() for x in ["no such process", "unknown process", "not found"]) or not describe_clean.strip():
+ return {
+ "process_status": "not_found",
+ "out_path": None,
+ "err_path": None,
+ }
+
+ status = "unknown"
+ out_path = None
+ err_path = None
+
+ for line in describe_raw.splitlines():
+ line_clean = strip_fn(line)
+ if line_clean.count("│") < 2:
+ continue
+ parts = [p.strip() for p in line_clean.split("│") if p.strip()]
+ if len(parts) >= 2:
+ key = parts[0].lower()
+ value = parts[1]
+ if key == "status":
+ status = value.lower()
+ elif key == "out_log_path":
+ out_path = value
+ elif key == "err_log_path":
+ err_path = value
+
+ if not out_path:
+ out_path = str(default_log_dir / f"{process}-out.log")
+ if not err_path:
+ err_path = str(default_log_dir / f"{process}-error.log")
+
+ return {
+ "process_status": status,
+ "out_path": out_path,
+ "err_path": err_path,
+ }
diff --git a/web2_system_metrics.py b/web2_system_metrics.py
new file mode 100644
index 0000000..ad815c2
--- /dev/null
+++ b/web2_system_metrics.py
@@ -0,0 +1,129 @@
+"""
+系统与网卡指标(供仪表盘轮询)。下行/上行为全机网卡合计速率(估算)。
+"""
+from __future__ import annotations
+
+import time
+from typing import Any, Optional
+
+import psutil
+
+_last_net_time: float = 0.0
+_last_net: Optional[dict[str, tuple[int, int]]] = None # name -> (sent, recv)
+
+
+def _net_rates_mbps() -> tuple[Optional[float], Optional[float], dict[str, Any]]:
+ """返回 (上行 Mbps, 下行 Mbps, 原始片段)。"""
+ global _last_net_time, _last_net
+ now = time.monotonic()
+ per = psutil.net_io_counters(pernic=True)
+ cur: dict[str, tuple[int, int]] = {k: (v.bytes_sent, v.bytes_recv) for k, v in per.items()}
+ detail: dict[str, Any] = {}
+ up_mbps = down_mbps = None
+ if _last_net is not None and _last_net_time > 0:
+ dt = now - _last_net_time
+ if dt > 0.05:
+ ds = dr = 0
+ for name, (sent, recv) in cur.items():
+ if name in _last_net:
+ ps0, pr0 = _last_net[name]
+ ds += max(0, sent - ps0)
+ dr += max(0, recv - pr0)
+ up_mbps = round((ds * 8) / dt / 1_000_000, 3)
+ down_mbps = round((dr * 8) / dt / 1_000_000, 3)
+ detail["delta_seconds"] = round(dt, 3)
+ _last_net = cur
+ _last_net_time = now
+ for name, (sent, recv) in cur.items():
+ detail.setdefault("interfaces", {})[name] = {
+ "bytes_sent": sent,
+ "bytes_recv": recv,
+ }
+ return up_mbps, down_mbps, detail
+
+
+def snapshot() -> dict[str, Any]:
+ try:
+ psutil.cpu_percent(interval=None)
+ cpu = psutil.cpu_percent(interval=0.15)
+ except Exception:
+ cpu = None
+ try:
+ vm = psutil.virtual_memory()
+ mem = {
+ "percent": vm.percent,
+ "used": vm.used,
+ "total": vm.total,
+ "available": vm.available,
+ }
+ except Exception:
+ mem = {}
+ try:
+ sm = psutil.swap_memory()
+ swap = {"percent": sm.percent, "used": sm.used, "total": sm.total}
+ except Exception:
+ swap = {}
+
+ disks = []
+ try:
+ for part in psutil.disk_partitions(all=False):
+ try:
+ usage = psutil.disk_usage(part.mountpoint)
+ disks.append(
+ {
+ "device": part.device,
+ "mountpoint": part.mountpoint,
+ "fstype": part.fstype,
+ "percent": usage.percent,
+ "free": usage.free,
+ "total": usage.total,
+ }
+ )
+ except (PermissionError, OSError):
+ continue
+ except Exception:
+ pass
+
+ up_mbps, down_mbps, net_detail = _net_rates_mbps()
+
+ boot = None
+ try:
+ boot = int(psutil.boot_time())
+ except Exception:
+ pass
+
+ ffmpeg_procs = []
+ try:
+ for p in psutil.process_iter(["pid", "name", "memory_info"]):
+ try:
+ name = (p.info.get("name") or "").lower()
+ if "ffmpeg" not in name:
+ continue
+ mi = p.info.get("memory_info")
+ ffmpeg_procs.append(
+ {
+ "pid": p.info["pid"],
+ "name": p.info.get("name"),
+ "rss": mi.rss if mi else 0,
+ }
+ )
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
+ continue
+ except Exception:
+ pass
+
+ return {
+ "ts": time.time(),
+ "cpu_percent": cpu,
+ "cpu_count_logical": psutil.cpu_count(logical=True),
+ "memory": mem,
+ "swap": swap,
+ "disks": disks,
+ "network": {
+ "up_mbps": up_mbps,
+ "down_mbps": down_mbps,
+ "detail": net_detail,
+ },
+ "boot_time_unix": boot,
+ "ffmpeg_processes": ffmpeg_procs[:24],
+ }
diff --git a/web2_wechat_chatbot.py b/web2_wechat_chatbot.py
new file mode 100644
index 0000000..a602dc4
--- /dev/null
+++ b/web2_wechat_chatbot.py
@@ -0,0 +1,292 @@
+"""
+微信 Chatbot 桥接:与 openclaw-wechat 使用的代理 HTTP API 对齐(见 integrations/openclaw-wechat)。
+需自备 apiKey 与 proxyUrl(第三方代理服务),本模块提供扫码登录、状态查询与发消息等 REST,供 Web / CLI / Agent 调用。
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Optional
+
+import httpx
+from fastapi import Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel, Field
+
+CONFIG_NAME = "wechat_chatbot.json"
+STATE_NAME = "wechat_chatbot.state.json"
+
+
+def _config_path(base: Path) -> Path:
+ return base / "config" / CONFIG_NAME
+
+
+def _state_path(base: Path) -> Path:
+ return base / "config" / STATE_NAME
+
+
+def load_wechat_chatbot_config(base_dir: Path) -> dict[str, Any]:
+ p = _config_path(base_dir)
+ if not p.exists():
+ return {}
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def load_wechat_state(base_dir: Path) -> dict[str, Any]:
+ p = _state_path(base_dir)
+ if not p.exists():
+ return {}
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def save_wechat_state(base_dir: Path, state: dict[str, Any]) -> None:
+ p = _state_path(base_dir)
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def mask_api_key(key: str) -> str:
+ k = (key or "").strip()
+ if len(k) <= 8:
+ return "****" if k else ""
+ return f"{k[:4]}…{k[-4:]}"
+
+
+class WechatProxyClient:
+ """与 freestylefly/openclaw-wechat 中 ProxyClient 请求格式一致。"""
+
+ def __init__(
+ self,
+ api_key: str,
+ base_url: str,
+ account_id: str = "default",
+ timeout: float = 60.0,
+ ):
+ if not api_key or not api_key.strip():
+ raise ValueError("缺少 apiKey")
+ if not base_url or not base_url.strip():
+ raise ValueError("缺少 proxyUrl")
+ self.api_key = api_key.strip()
+ self.base_url = base_url.strip().rstrip("/")
+ self.account_id = account_id.strip() or "default"
+ self.timeout = timeout
+
+ def _request(self, endpoint: str, data: Optional[dict[str, Any]] = None) -> Any:
+ url = f"{self.base_url}{endpoint}"
+ headers = {
+ "Content-Type": "application/json",
+ "X-API-Key": self.api_key,
+ "X-Account-ID": self.account_id,
+ }
+ with httpx.Client(timeout=self.timeout) as client:
+ r = client.post(url, headers=headers, json=data)
+ try:
+ result = r.json()
+ except Exception:
+ raise RuntimeError(f"HTTP {r.status_code}: {r.text[:500]}") from None
+
+ if not r.is_success:
+ raise RuntimeError(result.get("error") or result.get("message") or f"HTTP {r.status_code}")
+
+ code = result.get("code")
+ if code in ("1000", "1001", "1002"):
+ d = result.get("data")
+ return d if isinstance(d, (dict, list)) else result
+ if code and str(code) != "1000":
+ raise RuntimeError(result.get("message") or str(result))
+ d = result.get("data")
+ return d if d is not None else result
+
+ def get_status(self) -> dict[str, Any]:
+ out = self._request("/v1/account/status")
+ if not isinstance(out, dict):
+ return {"raw": out}
+ return {
+ "valid": out.get("valid", True),
+ "wcId": out.get("wcId"),
+ "isLoggedIn": out.get("isLoggedIn", False),
+ "nickName": out.get("nickName"),
+ "tier": out.get("tier"),
+ "quota": out.get("quota"),
+ "error": out.get("error"),
+ }
+
+ def get_qr_code(self, device_type: str = "ipad", proxy: str = "10") -> dict[str, str]:
+ out = self._request(
+ "/v1/iPadLogin",
+ {"deviceType": device_type or "ipad", "proxy": proxy or "10"},
+ )
+ if not isinstance(out, dict):
+ raise RuntimeError("代理返回格式异常")
+ w_id = out.get("wId") or out.get("w_id")
+ qr_url = out.get("qrCodeUrl") or out.get("qr_code_url")
+ if not w_id or not qr_url:
+ raise RuntimeError("代理未返回二维码信息")
+ return {"wId": str(w_id), "qrCodeUrl": str(qr_url)}
+
+ def check_login(self, w_id: str) -> dict[str, Any]:
+ out = self._request("/v1/getIPadLoginInfo", {"wId": w_id})
+ if not isinstance(out, dict):
+ return {"status": "waiting"}
+ st = out.get("status")
+ if st == "logged_in":
+ return {
+ "status": "logged_in",
+ "wcId": out.get("wcId"),
+ "nickName": out.get("nickName"),
+ "headUrl": out.get("headUrl"),
+ }
+ if st == "need_verify":
+ return {"status": "need_verify", "verifyUrl": out.get("verifyUrl")}
+ return {"status": "waiting"}
+
+ def send_text(self, target_id: str, content: str) -> dict[str, Any]:
+ out = self._request("/v1/sendText", {"wcId": target_id, "content": content})
+ return out if isinstance(out, dict) else {"result": out}
+
+ def register_webhook(self, webhook_url: str) -> None:
+ self._request("/v1/webhook/register", {"webhookUrl": webhook_url})
+
+
+def build_client(cfg: dict[str, Any]) -> WechatProxyClient:
+ return WechatProxyClient(
+ api_key=str(cfg.get("apiKey") or ""),
+ base_url=str(cfg.get("proxyUrl") or ""),
+ account_id=str(cfg.get("accountId") or "default"),
+ )
+
+
+class WechatConfigBody(BaseModel):
+ apiKey: Optional[str] = None
+ proxyUrl: Optional[str] = None
+ deviceType: Optional[str] = None
+ proxy: Optional[str] = None
+ webhookHost: Optional[str] = None
+ webhookPort: Optional[int] = None
+ webhookPath: Optional[str] = None
+ accountId: Optional[str] = None
+
+
+class WechatSendBody(BaseModel):
+ targetId: str = Field(..., description="接收方 wxid 或群 id")
+ text: str = Field(..., description="文本内容")
+
+
+class WechatWebhookBody(BaseModel):
+ webhookUrl: str
+
+
+def register_wechat_chatbot_routes(app, base_dir: Path) -> None:
+ """在 FastAPI app 上注册 /chatbot/wechat/* 路由。"""
+
+ @app.get("/chatbot/wechat/config")
+ async def wechat_config_get():
+ cfg = load_wechat_chatbot_config(base_dir)
+ st = load_wechat_state(base_dir)
+ key = str(cfg.get("apiKey") or "")
+ return {
+ "configured": bool(cfg.get("proxyUrl") and key),
+ "proxyUrl": cfg.get("proxyUrl") or "",
+ "apiKeyMasked": mask_api_key(key),
+ "deviceType": cfg.get("deviceType") or "ipad",
+ "proxy": cfg.get("proxy") or "10",
+ "webhookHost": cfg.get("webhookHost") or "",
+ "webhookPort": int(cfg.get("webhookPort") or 18790),
+ "webhookPath": cfg.get("webhookPath") or "/webhook/wechat",
+ "accountId": cfg.get("accountId") or "default",
+ "session": {
+ "wcId": st.get("wcId"),
+ "nickName": st.get("nickName"),
+ },
+ }
+
+ @app.post("/chatbot/wechat/config")
+ async def wechat_config_post(body: WechatConfigBody):
+ cfg = load_wechat_chatbot_config(base_dir)
+ d = body.model_dump(exclude_none=True)
+ for k, v in d.items():
+ cfg[k] = v
+ out_path = _config_path(base_dir)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ out_path.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
+ return {"ok": True}
+
+ @app.get("/chatbot/wechat/proxy/status")
+ async def wechat_proxy_status():
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ return c.get_status()
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/qr/start")
+ async def wechat_qr_start():
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ dt = str(cfg.get("deviceType") or "ipad")
+ px = str(cfg.get("proxy") or "10")
+ return c.get_qr_code(device_type=dt, proxy=px)
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/login/check")
+ async def wechat_login_check(request: Request):
+ try:
+ body = await request.json()
+ except Exception:
+ return JSONResponse({"error": "需要 JSON body: { \"wId\": \"...\" }"}, status_code=400)
+ w_id = (body or {}).get("wId") or (body or {}).get("w_id")
+ if not w_id or not isinstance(w_id, str):
+ return JSONResponse({"error": "缺少 wId"}, status_code=400)
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ r = c.check_login(w_id.strip())
+ if r.get("status") == "logged_in" and r.get("wcId"):
+ save_wechat_state(
+ base_dir,
+ {
+ "wcId": r.get("wcId"),
+ "nickName": r.get("nickName"),
+ },
+ )
+ return r
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/send")
+ async def wechat_send(body: WechatSendBody):
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ return c.send_text(body.targetId.strip(), body.text)
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
+
+ @app.post("/chatbot/wechat/webhook/register")
+ async def wechat_webhook_reg(body: WechatWebhookBody):
+ cfg = load_wechat_chatbot_config(base_dir)
+ if not cfg.get("apiKey") or not cfg.get("proxyUrl"):
+ return JSONResponse({"error": "未配置 apiKey 或 proxyUrl"}, status_code=400)
+ try:
+ c = build_client(cfg)
+ c.register_webhook(body.webhookUrl.strip())
+ return {"ok": True}
+ except Exception as e:
+ return JSONResponse({"error": str(e)}, status_code=502)
diff --git a/web2_youtube_oauth.py b/web2_youtube_oauth.py
new file mode 100644
index 0000000..3d0199a
--- /dev/null
+++ b/web2_youtube_oauth.py
@@ -0,0 +1,239 @@
+"""
+YouTube OAuth 2.0 + Live Streaming API:授权后拉取串流密钥并写入 config/youtube.ini。
+需在 Google Cloud Console 为本 OAuth 客户端添加「已授权的重定向 URI」:
+ http://127.0.0.1:8001/youtube/oauth/callback
+(若本地服务端口不是 8001,请同时改 redirect_uri 与 uvicorn 端口。)
+"""
+from __future__ import annotations
+
+import json
+import os
+import time
+from pathlib import Path
+from typing import Any, Optional
+
+from google.auth.transport.requests import Request
+from google.oauth2.credentials import Credentials
+from google_auth_oauthlib.flow import Flow
+from googleapiclient.discovery import build
+from googleapiclient.errors import HttpError
+
+BASE_DIR = Path(__file__).resolve().parent
+CONFIG_DIR = BASE_DIR / "config"
+GOOGLE_OAUTH_JSON = CONFIG_DIR / "google_oauth.json"
+TOKEN_JSON = CONFIG_DIR / "youtube_oauth_token.json"
+YOUTUBE_INI = CONFIG_DIR / "youtube.ini"
+
+# 须与 Google 在换 token 时返回的 scope 集合一致,否则 oauthlib 会报
+# “Scope has changed from … to …”。include_granted_scopes 会合并历史授权,也易触发该错误。
+SCOPES = [
+ "https://www.googleapis.com/auth/youtube",
+ "https://www.googleapis.com/auth/youtube.force-ssl",
+ "https://www.googleapis.com/auth/youtubepartner",
+]
+
+# state -> (Flow, created_ts);回调必须用同一 Flow 实例换 token
+_oauth_pending: dict[str, tuple[Flow, float]] = {}
+STATE_TTL = 600.0
+
+
+def _cleanup_pending() -> None:
+ now = time.time()
+ dead = [s for s, (_, t) in _oauth_pending.items() if now - t > STATE_TTL]
+ for s in dead:
+ _oauth_pending.pop(s, None)
+
+
+def load_client_config_dict() -> tuple[dict[str, Any], str]:
+ if not GOOGLE_OAUTH_JSON.exists():
+ raise FileNotFoundError(
+ "缺少 config/google_oauth.json。请复制 config/google_oauth.example.json 并填入客户端 ID/Secret。"
+ )
+ data = json.loads(GOOGLE_OAUTH_JSON.read_text(encoding="utf-8"))
+ redirect_uri = data.get("redirect_uri") or "http://127.0.0.1:8001/youtube/oauth/callback"
+ if "web" in data:
+ cfg = {"web": data["web"]}
+ elif "installed" in data:
+ inst = data["installed"]
+ cfg = {
+ "web": {
+ "client_id": inst["client_id"],
+ "client_secret": inst["client_secret"],
+ "auth_uri": inst.get("auth_uri", "https://accounts.google.com/o/oauth2/auth"),
+ "token_uri": inst.get("token_uri", "https://oauth2.googleapis.com/token"),
+ "redirect_uris": [redirect_uri],
+ }
+ }
+ else:
+ raise ValueError("google_oauth.json 需包含 web 或 installed 字段")
+ return cfg, redirect_uri
+
+
+def build_flow() -> tuple[Flow, str]:
+ cfg, redirect_uri = load_client_config_dict()
+ flow = Flow.from_client_config(cfg, scopes=SCOPES, redirect_uri=redirect_uri)
+ return flow, redirect_uri
+
+
+def oauth_authorization_url() -> tuple[str, str]:
+ import secrets
+
+ _cleanup_pending()
+ flow, _ = build_flow()
+ state = secrets.token_urlsafe(32)
+ result = flow.authorization_url(
+ access_type="offline",
+ state=state,
+ prompt="consent",
+ )
+ authorization_url = result[0] if isinstance(result, (list, tuple)) else result
+ _oauth_pending[state] = (flow, time.time())
+ return authorization_url, state
+
+
+def oauth_exchange_code(code: str, state: str) -> None:
+ _cleanup_pending()
+ if state not in _oauth_pending:
+ raise ValueError("授权会话无效或已过期,请在本软件中重新点击「Google 授权」。")
+ flow, t0 = _oauth_pending.pop(state)
+ if time.time() - t0 > STATE_TTL:
+ raise ValueError("授权已超时,请重新点击「Google 授权」。")
+ # oauthlib 要求令牌里的 scope 与客户端声明完全一致;Google 常返回多枚 scope 或与声明略有差异
+ _prev_relax = os.environ.get("OAUTHLIB_RELAX_TOKEN_SCOPE")
+ os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
+ try:
+ flow.fetch_token(code=code)
+ finally:
+ if _prev_relax is None:
+ os.environ.pop("OAUTHLIB_RELAX_TOKEN_SCOPE", None)
+ else:
+ os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = _prev_relax
+ creds = flow.credentials
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ TOKEN_JSON.write_text(creds.to_json(), encoding="utf-8")
+
+
+def load_credentials() -> Optional[Credentials]:
+ if not TOKEN_JSON.exists():
+ return None
+ return Credentials.from_authorized_user_file(str(TOKEN_JSON), SCOPES)
+
+
+def ensure_valid_credentials() -> Credentials:
+ creds = load_credentials()
+ if not creds:
+ raise RuntimeError("尚未完成 Google 授权,请先点击「Google 授权」。")
+ if creds.valid:
+ return creds
+ if creds.expired and creds.refresh_token:
+ creds.refresh(Request())
+ TOKEN_JSON.write_text(creds.to_json(), encoding="utf-8")
+ return creds
+ raise RuntimeError("授权已失效且无 refresh_token,请重新授权(勾选同意离线访问)。")
+
+
+def oauth_status() -> dict[str, Any]:
+ creds = load_credentials()
+ if not creds:
+ return {"connected": False, "has_refresh": False}
+ return {
+ "connected": True,
+ "has_refresh": bool(creds.refresh_token),
+ "expired": bool(creds.expired),
+ }
+
+
+def _pick_ingestion(items: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
+ for it in items:
+ cdn = it.get("cdn") or {}
+ ing = cdn.get("ingestionInfo")
+ if ing and ing.get("streamName"):
+ return ing
+ return None
+
+
+def fetch_stream_key_and_write_ini() -> dict[str, Any]:
+ """
+ 列出当前账号的直播流;若无则创建一路,将 streamName 写入 youtube.ini 的 key=。
+ """
+ creds = ensure_valid_credentials()
+ youtube = build("youtube", "v3", credentials=creds, cache_discovery=False)
+ try:
+ resp = youtube.liveStreams().list(part="snippet,cdn,status", mine=True, maxResults=10).execute()
+ except HttpError as e:
+ raise RuntimeError(f"YouTube API 错误: {e}") from e
+
+ items = resp.get("items", [])
+ ing = _pick_ingestion(items)
+
+ if not ing:
+ body = {
+ "snippet": {"title": "直播录制助手"},
+ "cdn": {
+ "ingestionType": "rtmp",
+ "frameRate": "variable",
+ "resolution": "variable",
+ },
+ }
+ try:
+ created = youtube.liveStreams().insert(part="snippet,cdn", body=body).execute()
+ except HttpError as e:
+ raise RuntimeError(f"创建直播流失败: {e}") from e
+ ing = (created.get("cdn") or {}).get("ingestionInfo")
+ if not ing or not ing.get("streamName"):
+ raise RuntimeError("已创建直播流但未返回串流密钥,请到 YouTube 工作室检查。")
+
+ stream_name = str(ing["streamName"])
+ write_youtube_key_to_ini(stream_name)
+
+ rtmp = ing.get("ingestionAddress") or "rtmp://a.rtmp.youtube.com/live2"
+ rtmps = ing.get("rtmpsAddress") or "rtmps://a.rtmp.youtube.com/live2"
+ return {
+ "ok": True,
+ "stream_name": stream_name,
+ "ingestion_address": rtmp,
+ "rtmps_address": rtmps,
+ "message": "已写入 config/youtube.ini 的 key= 项。",
+ }
+
+
+def write_youtube_key_to_ini(stream_key: str) -> None:
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+ if not YOUTUBE_INI.exists():
+ YOUTUBE_INI.write_text(
+ "[youtube]\n"
+ "# 由 Google 授权自动写入,也可手动修改\n"
+ f"key = {stream_key}\n",
+ encoding="utf-8-sig",
+ )
+ return
+
+ raw = YOUTUBE_INI.read_text(encoding="utf-8-sig")
+ lines = raw.splitlines()
+ out: list[str] = []
+ replaced = False
+ for line in lines:
+ stripped = line.lstrip()
+ if stripped.startswith("#") or stripped.startswith(";"):
+ out.append(line)
+ continue
+ if "=" in line:
+ k = line.split("=", 1)[0].strip().lower()
+ if k == "key":
+ indent = line[: len(line) - len(line.lstrip())]
+ out.append(f"{indent}key = {stream_key}")
+ replaced = True
+ continue
+ out.append(line)
+
+ if not replaced:
+ inserted = False
+ final: list[str] = []
+ for line in out:
+ final.append(line)
+ if line.strip().lower() == "[youtube]" and not inserted:
+ final.append(f"key = {stream_key}")
+ inserted = True
+ out = final if inserted else out + ["", f"key = {stream_key}"]
+
+ YOUTUBE_INI.write_text("\n".join(out) + ("\n" if out and not out[-1].endswith("\n") else ""), encoding="utf-8-sig")
diff --git a/wechat_chatbot_cli.py b/wechat_chatbot_cli.py
new file mode 100644
index 0000000..3181aea
--- /dev/null
+++ b/wechat_chatbot_cli.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+"""
+微信 Chatbot HTTP API 的命令行客户端(供脚本与 Agent 调用)。
+环境变量:WEB2_API_BASE(默认 http://127.0.0.1:8001)、WEB2_API_TOKEN(若后端启用鉴权)。
+
+示例:
+ python wechat_chatbot_cli.py config-get --json
+ python wechat_chatbot_cli.py proxy-status --json
+ python wechat_chatbot_cli.py qr-start --json
+ python wechat_chatbot_cli.py login-check --w-id
--json
+ python wechat_chatbot_cli.py send --target wxid_xxx --text "hello" --json
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+import httpx
+
+
+def base_url() -> str:
+ return os.environ.get("WEB2_API_BASE", "http://127.0.0.1:8001").rstrip("/")
+
+
+def headers() -> dict[str, str]:
+ h = {"Content-Type": "application/json"}
+ tok = os.environ.get("WEB2_API_TOKEN", "").strip()
+ if tok:
+ h["Authorization"] = f"Bearer {tok}"
+ return h
+
+
+def out(obj: object, as_json: bool) -> None:
+ if as_json:
+ print(json.dumps(obj, ensure_ascii=False, indent=2))
+ else:
+ print(obj)
+
+
+def main() -> None:
+ p = argparse.ArgumentParser(description="微信 Chatbot API CLI")
+ p.add_argument("--json", action="store_true", help="输出 JSON")
+ sub = p.add_subparsers(dest="cmd", required=True)
+
+ sub.add_parser("config-get", help="GET /chatbot/wechat/config")
+
+ pc = sub.add_parser("config-set", help="POST /chatbot/wechat/config(从 stdin 读 JSON 或文件)")
+ pc.add_argument("--file", "-f", help="JSON 文件路径")
+
+ sub.add_parser("proxy-status", help="GET /chatbot/wechat/proxy/status")
+
+ sub.add_parser("qr-start", help="POST /chatbot/wechat/qr/start")
+
+ pl = sub.add_parser("login-check", help="POST /chatbot/wechat/login/check")
+ pl.add_argument("--w-id", required=True, dest="w_id")
+
+ ps = sub.add_parser("send", help="POST /chatbot/wechat/send")
+ ps.add_argument("--target", required=True, help="对方 wxid 或群 id")
+ ps.add_argument("--text", required=True)
+
+ pw = sub.add_parser("webhook-register", help="POST /chatbot/wechat/webhook/register")
+ pw.add_argument("--url", required=True, help="公网可访问的 webhook 完整 URL")
+
+ args = p.parse_args()
+ bu = base_url()
+ h = headers()
+
+ try:
+ with httpx.Client(timeout=120.0) as client:
+ if args.cmd == "config-get":
+ r = client.get(f"{bu}/chatbot/wechat/config", headers=h)
+ r.raise_for_status()
+ out(r.json(), args.json)
+ elif args.cmd == "config-set":
+ raw = sys.stdin.read()
+ if getattr(args, "file", None):
+ raw = open(args.file, encoding="utf-8").read()
+ body = json.loads(raw)
+ r = client.post(f"{bu}/chatbot/wechat/config", headers=h, json=body)
+ r.raise_for_status()
+ out(r.json(), args.json)
+ elif args.cmd == "proxy-status":
+ r = client.get(f"{bu}/chatbot/wechat/proxy/status", headers=h)
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "qr-start":
+ r = client.post(f"{bu}/chatbot/wechat/qr/start", headers=h)
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "login-check":
+ r = client.post(
+ f"{bu}/chatbot/wechat/login/check",
+ headers=h,
+ json={"wId": args.w_id},
+ )
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "send":
+ r = client.post(
+ f"{bu}/chatbot/wechat/send",
+ headers=h,
+ json={"targetId": args.target, "text": args.text},
+ )
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ elif args.cmd == "webhook-register":
+ r = client.post(
+ f"{bu}/chatbot/wechat/webhook/register",
+ headers=h,
+ json={"webhookUrl": args.url},
+ )
+ try:
+ out(r.json(), args.json)
+ except Exception:
+ print(r.text, file=sys.stderr)
+ sys.exit(1)
+ except httpx.HTTPStatusError as e:
+ print(str(e), file=sys.stderr)
+ try:
+ print(e.response.text, file=sys.stderr)
+ except Exception:
+ pass
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/youtube2.py b/youtube2.py
new file mode 100644
index 0000000..9f39aeb
--- /dev/null
+++ b/youtube2.py
@@ -0,0 +1,2205 @@
+# -*- encoding: utf-8 -*-
+
+"""
+Author: Hmily
+GitHub: https://github.com/ihmily
+Date: 2023-07-17 23:52:05
+Update: 2025-10-23 19:48:05
+Copyright (c) 2023-2025 by Hmily, All Rights Reserved.
+Function: Record live stream video.
+"""
+import asyncio
+import os
+import sys
+import builtins
+import subprocess
+import signal
+import threading
+import time
+import datetime
+import re
+import shutil
+import random
+import uuid
+from pathlib import Path
+import urllib.request
+from urllib.error import URLError, HTTPError
+from typing import Any
+import configparser
+import httpx
+from src import spider, stream
+from src.proxy import ProxyDetector
+from src.utils import logger
+from src import utils
+from backup.msg_push import (
+ dingtalk, xizhi, tg_bot, send_email, bark, ntfy, pushplus
+)
+from ffmpeg_install import (
+ check_ffmpeg, ffmpeg_path, current_env_path
+)
+
+
+
+
+
+version = "v4.0.7"
+platforms = ("\n国内站点:抖音|快手|虎牙|斗鱼|YY|B站|小红书|bigo|blued|网易CC|千度热播|猫耳FM|Look|TwitCasting|百度|微博|"
+ "酷狗|花椒|流星|Acfun|畅聊|映客|音播|知乎|嗨秀|VV星球|17Live|浪Live|漂漂|六间房|乐嗨|花猫|淘宝|京东|咪咕|连接|来秀"
+ "\n海外站点:TikTok|SOOP|PandaTV|WinkTV|FlexTV|PopkonTV|TwitchTV|LiveMe|ShowRoom|CHZZK|Shopee|"
+ "Youtube|Faceit|Picarto")
+
+recording = set()
+error_count = 0
+pre_max_request = 10
+max_request_lock = threading.Lock()
+error_window = []
+error_window_size = 10
+error_threshold = 5
+monitoring = 0
+running_list = []
+url_tuples_list = []
+url_comments = []
+text_no_repeat_url = []
+create_var = locals()
+first_start = True
+exit_recording = False
+need_update_line_list = []
+first_run = True
+not_record_list = []
+start_display_time = datetime.datetime.now()
+global_proxy = False
+recording_time_list = {}
+script_path = os.path.split(os.path.realpath(sys.argv[0]))[0]
+config_file = f'{script_path}/config/config.ini'
+url_config_file = f'{script_path}/config/URL_config.ini'
+backup_dir = f'{script_path}/backup_config'
+text_encoding = 'utf-8-sig'
+rstr = r"[\/\\\:\*\??\"\<\>\|.。,, ~!· ]"
+default_path = f'{script_path}/downloads'
+os.makedirs(default_path, exist_ok=True)
+file_update_lock = threading.Lock()
+os_type = os.name
+clear_command = "cls" if os_type == 'nt' else "clear"
+color_obj = utils.Color()
+os.environ['PATH'] = ffmpeg_path + os.pathsep + current_env_path
+
+
+def signal_handler(_signal, _frame):
+ sys.exit(0)
+
+
+signal.signal(signal.SIGTERM, signal_handler)
+
+
+
+import shutil
+
+
+# 全局变量(只初始化一次)
+_last_lines_count = 0
+
+def _clear_lines(n: int):
+ """向上删除 n 行(不闪烁)"""
+ if n <= 0:
+ return
+ print(f"\033[{n}A\033[K", end="", flush=True) # 上移 + 清行
+
+def display_info() -> None:
+ global start_display_time, _last_lines_count
+ time.sleep(3) # 启动延迟
+
+ while True:
+ try:
+ lines = []
+ now_str = datetime.datetime.now().strftime("%H:%M:%S")
+
+ # 第一行:核心状态
+ lines.append(
+ f"监测 {monitoring} | 并发 {max_request} | "
+ f"代理 {'开' if use_proxy else '关'} | "
+ f"分段 {'开' if split_video_by_time else '关'}{f':{split_time}s' if split_video_by_time else ''} | "
+ f"时间文件 {'开' if create_time_file else '关'} | "
+ f"质量 {video_record_quality} | 格式 {video_save_type} | "
+ f"错误 {error_count} | {now_str}"
+ )
+
+ # 第二行:录制中的直播
+ if recording:
+ no_repeat = list(set(recording))
+ recording_str = f"正在录制 {len(no_repeat)} 个: "
+ details = []
+ now_dt = datetime.datetime.now()
+ for name in no_repeat[:10]: # 最多显示10个,防止爆屏
+ if name not in recording_time_list:
+ continue
+ start_time, quality = recording_time_list[name]
+ duration = str(now_dt - start_time).split('.')[0]
+ details.append(f"{name.split()[-1]}[{quality} {duration}]")
+ recording_str += " | ".join(details)
+ if len(no_repeat) > 10:
+ recording_str += f" ... (+{len(no_repeat)-10})"
+ lines.append(recording_str)
+ else:
+ if monitoring == 0:
+ lines.append("没有正在监测的直播")
+ else:
+ lines.append(f"无录制任务 | 循环间隔 {delay_default}s")
+
+ # 动态清屏 + 重绘(不闪烁!)
+ terminal_width, _ = shutil.get_terminal_size(fallback=(120, 24))
+ display_lines = []
+ for line in lines:
+ if len(line) > terminal_width - 5:
+ line = line[:terminal_width-8] + "..."
+ display_lines.append(line)
+
+ # 删除旧内容 + 打印新内容
+ if _last_lines_count > len(display_lines):
+ _clear_lines(_last_lines_count - 1)
+ print("\r" + display_lines[0])
+ for line in display_lines[1:]:
+ print(line)
+ else:
+ print("\r" + display_lines[0], end="")
+ for line in display_lines[1:]:
+ print(f"\n{line}", end="")
+
+ _last_lines_count = len(display_lines)
+ print(flush=True) # 强制刷新
+
+ time.sleep(5)
+
+ except Exception as e:
+ # 避免崩溃后疯狂打印错误
+ try:
+ print(f"\n[display_info 错误] {e}")
+ except:
+ pass
+ time.sleep(10) # 出错了也别刷屏
+def display_info2() -> None:
+ global start_display_time
+ time.sleep(5)
+ while True:
+ try:
+ sys.stdout.flush()
+ time.sleep(5)
+ if Path(sys.executable).name != 'pythonw.exe':
+ os.system(clear_command)
+ print(f"\r共监测{monitoring}个直播中", end=" | ")
+ print(f"同一时间访问网络的线程数: {max_request}", end=" | ")
+ print(f"是否开启代理录制: {'是' if use_proxy else '否'}", end=" | ")
+ if split_video_by_time:
+ print(f"录制分段开启: {split_time}秒", end=" | ")
+ else:
+ print("录制分段开启: 否", end=" | ")
+ if create_time_file:
+ print("是否生成时间文件: 是", end=" | ")
+ print(f"录制视频质量为: {video_record_quality}", end=" | ")
+ print(f"录制视频格式为: {video_save_type}", end=" | ")
+ print(f"目前瞬时错误数为: {error_count}", end=" | ")
+ now = time.strftime("%H:%M:%S", time.localtime())
+ print(f"当前时间: {now}")
+
+ if len(recording) == 0:
+ time.sleep(5)
+ if monitoring == 0:
+ print("\r没有正在监测和录制的直播")
+ else:
+ print(f"\r没有正在录制的直播 循环监测间隔时间:{delay_default}秒")
+ else:
+ now_time = datetime.datetime.now()
+ print("x" * 60)
+ no_repeat_recording = list(set(recording))
+ print(f"正在录制{len(no_repeat_recording)}个直播: ")
+ for recording_live in no_repeat_recording:
+ rt, qa = recording_time_list[recording_live]
+ have_record_time = now_time - rt
+ print(f"{recording_live}[{qa}] 正在录制中 {str(have_record_time).split('.')[0]}")
+
+ # print('\n本软件已运行:'+str(now_time - start_display_time).split('.')[0])
+ print("x" * 60)
+ start_display_time = now_time
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+
+
+def update_file(file_path: str, old_str: str, new_str: str, start_str: str = None) -> str | None:
+ if old_str == new_str and start_str is None:
+ return old_str
+ with file_update_lock:
+ file_data = []
+ with open(file_path, "r", encoding=text_encoding) as f:
+ try:
+ for text_line in f:
+ if old_str in text_line:
+ text_line = text_line.replace(old_str, new_str)
+ if start_str:
+ text_line = f'{start_str}{text_line}'
+ if text_line not in file_data:
+ file_data.append(text_line)
+ except RuntimeError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ if ini_URL_content:
+ with open(file_path, "w", encoding=text_encoding) as f2:
+ f2.write(ini_URL_content)
+ return old_str
+ if file_data:
+ with open(file_path, "w", encoding=text_encoding) as f:
+ f.write(''.join(file_data))
+ return new_str
+
+
+def delete_line(file_path: str, del_line: str, delete_all: bool = False) -> None:
+ with file_update_lock:
+ with open(file_path, 'r+', encoding=text_encoding) as f:
+ lines = f.readlines()
+ f.seek(0)
+ f.truncate()
+ skip_line = False
+ for txt_line in lines:
+ if del_line in txt_line:
+ if delete_all or not skip_line:
+ skip_line = True
+ continue
+ else:
+ skip_line = False
+ f.write(txt_line)
+
+
+def get_startup_info(system_type: str):
+ if system_type == 'nt':
+ startup_info = subprocess.STARTUPINFO()
+ startup_info.dwFlags |= subprocess.STARTF_USESHOWWINDOW
+ else:
+ startup_info = None
+ return startup_info
+
+
+def segment_video(converts_file_path: str, segment_save_file_path: str, segment_format: str, segment_time: str,
+ is_original_delete: bool = True) -> None:
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ ffmpeg_command = [
+ "ffmpeg",
+ "-i", converts_file_path,
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-map", "0",
+ "-f", "segment",
+ "-segment_time", segment_time,
+ "-segment_format", segment_format,
+ "-reset_timestamps", "1",
+ "-movflags", "+frag_keyframe+empty_moov",
+ segment_save_file_path,
+ ]
+ _output = subprocess.check_output(
+ ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
+ )
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ except subprocess.CalledProcessError as e:
+ logger.error(f'Error occurred during conversion: {e}')
+ except Exception as e:
+ logger.error(f'An unknown error occurred: {e}')
+
+
+def converts_mp4(converts_file_path: str, is_original_delete: bool = True) -> None:
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ if converts_to_h264:
+ color_obj.print_colored("正在转码为MP4格式并重新编码为h264\n", color_obj.YELLOW)
+ ffmpeg_command = [
+ "ffmpeg", "-i", converts_file_path,
+ "-c:v", "libx264",
+ "-preset", "veryfast",
+ "-crf", "23",
+ "-vf", "format=yuv420p",
+ "-c:a", "copy",
+ "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4",
+ ]
+ else:
+ color_obj.print_colored("正在转码为MP4格式\n", color_obj.YELLOW)
+ ffmpeg_command = [
+ "ffmpeg", "-i", converts_file_path,
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "mp4", converts_file_path.rsplit('.', maxsplit=1)[0] + ".mp4",
+ ]
+ _output = subprocess.check_output(
+ ffmpeg_command, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
+ )
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ except subprocess.CalledProcessError as e:
+ logger.error(f'Error occurred during conversion: {e}')
+ except Exception as e:
+ logger.error(f'An unknown error occurred: {e}')
+
+
+def converts_m4a(converts_file_path: str, is_original_delete: bool = True) -> None:
+ try:
+ if os.path.exists(converts_file_path) and os.path.getsize(converts_file_path) > 0:
+ _output = subprocess.check_output([
+ "ffmpeg", "-i", converts_file_path,
+ "-n", "-vn",
+ "-c:a", "aac", "-bsf:a", "aac_adtstoasc", "-ab", "320k",
+ converts_file_path.rsplit('.', maxsplit=1)[0] + ".m4a",
+ ], stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type))
+ if is_original_delete:
+ time.sleep(1)
+ if os.path.exists(converts_file_path):
+ os.remove(converts_file_path)
+ except subprocess.CalledProcessError as e:
+ logger.error(f'Error occurred during conversion: {e}')
+ except Exception as e:
+ logger.error(f'An unknown error occurred: {e}')
+
+
+def generate_subtitles(record_name: str, ass_filename: str, sub_format: str = 'srt') -> None:
+ index_time = 0
+ today = datetime.datetime.now()
+ re_datatime = today.strftime('%Y-%m-%d %H:%M:%S')
+
+ def transform_int_to_time(seconds: int) -> str:
+ m, s = divmod(seconds, 60)
+ h, m = divmod(m, 60)
+ return f"{h:02d}:{m:02d}:{s:02d}"
+
+ while True:
+ index_time += 1
+ txt = str(index_time) + "\n" + transform_int_to_time(index_time) + ',000 --> ' + transform_int_to_time(
+ index_time + 1) + ',000' + "\n" + str(re_datatime) + "\n\n"
+
+ with open(f"{ass_filename}.{sub_format.lower()}", 'a', encoding=text_encoding) as f:
+ f.write(txt)
+
+ if record_name not in recording:
+ return
+ time.sleep(1)
+ today = datetime.datetime.now()
+ re_datatime = today.strftime('%Y-%m-%d %H:%M:%S')
+
+
+def adjust_max_request() -> None:
+ global max_request, error_count, pre_max_request, error_window
+ preset = max_request
+
+ while True:
+ time.sleep(5)
+ with max_request_lock:
+ if error_window:
+ error_rate = sum(error_window) / len(error_window)
+ else:
+ error_rate = 0
+
+ if error_rate > error_threshold:
+ max_request = max(1, max_request - 1)
+ elif error_rate < error_threshold / 2 and max_request < preset:
+ max_request += 1
+ else:
+ pass
+
+ if pre_max_request != max_request:
+ pre_max_request = max_request
+ print(f"\r同一时间访问网络的线程数动态改为 {max_request}")
+
+ error_window.append(error_count)
+ if len(error_window) > error_window_size:
+ error_window.pop(0)
+ error_count = 0
+
+
+def push_message(record_name: str, live_url: str, content: str) -> None:
+ msg_title = push_message_title.strip() or "直播间状态更新通知"
+ push_functions = {
+ '微信': lambda: xizhi(xizhi_api_url, msg_title, content),
+ '钉钉': lambda: dingtalk(dingtalk_api_url, content, dingtalk_phone_num, dingtalk_is_atall),
+ '邮箱': lambda: send_email(
+ email_host, login_email, email_password, sender_email, sender_name,
+ to_email, msg_title, content, smtp_port, open_smtp_ssl
+ ),
+ 'TG': lambda: tg_bot(tg_chat_id, tg_token, content),
+ 'BARK': lambda: bark(
+ bark_msg_api, title=msg_title, content=content, level=bark_msg_level, sound=bark_msg_ring
+ ),
+ 'NTFY': lambda: ntfy(
+ ntfy_api, title=msg_title, content=content, tags=ntfy_tags, action_url=live_url, email=ntfy_email
+ ),
+ 'PUSHPLUS': lambda: pushplus(pushplus_token, msg_title, content),
+ }
+
+ for platform, func in push_functions.items():
+ if platform in live_status_push.upper():
+ try:
+ result = func()
+ print(f'提示信息:已经将[{record_name}]直播状态消息推送至你的{platform},'
+ f' 成功{len(result["success"])}, 失败{len(result["error"])}')
+ except Exception as e:
+ color_obj.print_colored(f"直播消息推送到{platform}失败: {e}", color_obj.RED)
+
+
+def run_script(command: str) -> None:
+ try:
+ process = subprocess.Popen(
+ command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=get_startup_info(os_type)
+ )
+ stdout, stderr = process.communicate()
+ stdout_decoded = stdout.decode('utf-8')
+ stderr_decoded = stderr.decode('utf-8')
+ if stdout_decoded.strip():
+ print(stdout_decoded)
+ if stderr_decoded.strip():
+ print(stderr_decoded)
+ except PermissionError as e:
+ logger.error(e)
+ logger.error('脚本无执行权限!, 若是Linux环境, 请先执行:chmod +x your_script.sh 授予脚本可执行权限')
+ except OSError as e:
+ logger.error(e)
+ logger.error('Please add `#!/bin/bash` at the beginning of your bash script file.')
+
+
+def clear_record_info(record_name: str, record_url: str) -> None:
+ global monitoring
+ recording.discard(record_name)
+ if record_url in url_comments and record_url in running_list:
+ running_list.remove(record_url)
+ monitoring -= 1
+ color_obj.print_colored(f"[{record_name}]已经从录制列表中移除\n", color_obj.YELLOW)
+
+
+def direct_download_stream(source_url: str, save_path: str, record_name: str, live_url: str, platform: str) -> bool:
+ try:
+ with open(save_path, 'wb') as f:
+ client = httpx.Client(timeout=None)
+
+ headers = {}
+ header_params = get_record_headers(platform, live_url)
+ if header_params:
+ key, value = header_params.split(":", 1)
+ headers[key] = value
+
+ with client.stream('GET', source_url, headers=headers, follow_redirects=True) as response:
+ if response.status_code != 200:
+ logger.error(f"请求直播流失败,状态码: {response.status_code}")
+ return False
+
+ downloaded = 0
+ chunk_size = 1024 * 16
+
+ for chunk in response.iter_bytes(chunk_size):
+ if live_url in url_comments or exit_recording:
+ color_obj.print_colored(f"[{record_name}]录制时已被注释或请求停止,下载中断", color_obj.YELLOW)
+ clear_record_info(record_name, live_url)
+ return False
+
+ if chunk:
+ f.write(chunk)
+ downloaded += len(chunk)
+ print()
+ return True
+ except Exception as e:
+ logger.error(f"FLV下载错误: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ return False
+
+
+def check_subprocess(record_name: str, record_url: str, ffmpeg_command: list, save_type: str,
+ script_command: str | None = None) -> bool:
+ save_file_path = ffmpeg_command[-1]
+ process = subprocess.Popen(
+ ffmpeg_command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, startupinfo=get_startup_info(os_type)
+ )
+
+ subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
+ subs_thread_name = f'subs_{Path(subs_file_path).name}'
+ if create_time_file and not split_video_by_time and '音频' not in save_type:
+ create_var[subs_thread_name] = threading.Thread(
+ target=generate_subtitles, args=(record_name, subs_file_path)
+ )
+ create_var[subs_thread_name].daemon = True
+ create_var[subs_thread_name].start()
+
+ while process.poll() is None:
+ if record_url in url_comments or exit_recording:
+ color_obj.print_colored(f"[{record_name}]录制时已被注释,本条线程将会退出", color_obj.YELLOW)
+ clear_record_info(record_name, record_url)
+ # process.terminate()
+ if os.name == 'nt':
+ if process.stdin:
+ process.stdin.write(b'q')
+ process.stdin.close()
+ else:
+ process.send_signal(signal.SIGINT)
+ process.wait()
+ return True
+ time.sleep(1)
+
+ return_code = process.returncode
+ stop_time = time.strftime('%Y-%m-%d %H:%M:%S')
+ if return_code == 0:
+ if converts_to_mp4 and save_type == 'TS':
+ if split_video_by_time:
+ file_paths = utils.get_file_paths(os.path.dirname(save_file_path))
+ prefix = os.path.basename(save_file_path).rsplit('_', maxsplit=1)[0]
+ for path in file_paths:
+ if prefix in path:
+ threading.Thread(target=converts_mp4, args=(path, delete_origin_file)).start()
+ else:
+ threading.Thread(target=converts_mp4, args=(save_file_path, delete_origin_file)).start()
+ print(f"\n{record_name} {stop_time} 直播录制完成\n")
+
+ if script_command:
+ logger.debug("开始执行脚本命令!")
+ if "python" in script_command:
+ params = [
+ f'--record_name "{record_name}"',
+ f'--save_file_path "{save_file_path}"',
+ f'--save_type {save_type}',
+ f'--split_video_by_time {split_video_by_time}',
+ f'--converts_to_mp4 {converts_to_mp4}',
+ ]
+ else:
+ params = [
+ f'"{record_name.split(" ", maxsplit=1)[-1]}"',
+ f'"{save_file_path}"',
+ save_type,
+ f'split_video_by_time:{split_video_by_time}',
+ f'converts_to_mp4:{converts_to_mp4}'
+ ]
+ script_command = script_command.strip() + ' ' + ' '.join(params)
+ run_script(script_command)
+ logger.debug("脚本命令执行结束!")
+
+ else:
+ color_obj.print_colored(f"\n{record_name} {stop_time} 直播录制出错,返回码: {return_code}\n", color_obj.RED)
+
+ recording.discard(record_name)
+ return False
+
+
+def clean_name(input_text):
+ cleaned_name = re.sub(rstr, "_", input_text.strip()).strip('_')
+ cleaned_name = cleaned_name.replace("(", "(").replace(")", ")")
+ if clean_emoji:
+ cleaned_name = utils.remove_emojis(cleaned_name, '_').strip('_')
+ return cleaned_name or '空白昵称'
+
+
+def get_quality_code(qn):
+ QUALITY_MAPPING = {
+ "原画": "OD",
+ "蓝光": "BD",
+ "超清": "UHD",
+ "高清": "HD",
+ "标清": "SD",
+ "流畅": "LD"
+ }
+ return QUALITY_MAPPING.get(qn)
+
+
+def get_record_headers(platform, live_url):
+ live_domain = '/'.join(live_url.split('/')[0:3])
+ record_headers = {
+ 'PandaTV': 'origin:https://www.pandalive.co.kr',
+ 'WinkTV': 'origin:https://www.winktv.co.kr',
+ 'PopkonTV': 'origin:https://www.popkontv.com',
+ 'FlexTV': 'origin:https://www.flextv.co.kr',
+ '千度热播': 'referer:https://qiandurebo.com',
+ '17Live': 'referer:https://17.live/en/live/6302408',
+ '浪Live': 'referer:https://www.lang.live',
+ 'shopee': f'origin:{live_domain}',
+ 'Blued直播': 'referer:https://app.blued.cn'
+ }
+ return record_headers.get(platform)
+
+
+def is_flv_preferred_platform(link):
+ return any(i in link for i in ["douyin", "tiktok"])
+
+
+def select_source_url(link, stream_info):
+ if is_flv_preferred_platform(link):
+ codec = utils.get_query_params(stream_info.get('flv_url'), "codec")
+ if codec and codec[0] == 'h265':
+ logger.warning("FLV is not supported for h265 codec, use HLS source instead")
+ else:
+ return stream_info.get('flv_url')
+
+ return stream_info.get('record_url')
+
+
+def start_record(url_data: tuple, count_variable: int = -1) -> None:
+ global error_count
+
+ while True:
+ try:
+ record_finished = False
+ run_once = False
+ start_pushed = False
+ new_record_url = ''
+ count_time = time.time()
+ retry = 0
+ record_quality_zh, record_url, anchor_name = url_data
+ record_quality = get_quality_code(record_quality_zh)
+ proxy_address = proxy_addr
+ platform = '未知平台'
+ live_domain = '/'.join(record_url.split('/')[0:3])
+
+ if proxy_addr:
+ proxy_address = None
+ for platform in enable_proxy_platform_list:
+ if platform and platform.strip() in record_url:
+ proxy_address = proxy_addr
+ break
+
+ if not proxy_address:
+ if extra_enable_proxy_platform_list:
+ for pt in extra_enable_proxy_platform_list:
+ if pt and pt.strip() in record_url:
+ proxy_address = proxy_addr_bak or None
+
+ # print(f'\r代理地址:{proxy_address}')
+ # print(f'\r全局代理:{global_proxy}')
+ while True:
+ try:
+ port_info = []
+ if record_url.find("douyin.com/") > -1:
+ platform = '抖音直播'
+ with semaphore:
+ if 'v.douyin.com' not in record_url and '/user/' not in record_url:
+ json_data = asyncio.run(spider.get_douyin_web_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=dy_cookie))
+ else:
+ json_data = asyncio.run(spider.get_douyin_app_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=dy_cookie))
+ port_info = asyncio.run(
+ stream.get_douyin_stream_url(json_data, record_quality, proxy_address))
+
+ elif record_url.find("https://www.tiktok.com/") > -1:
+ platform = 'TikTok直播'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_tiktok_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=tiktok_cookie))
+ port_info = asyncio.run(
+ stream.get_tiktok_stream_url(json_data, record_quality, proxy_address))
+ else:
+ logger.error("错误信息: 网络异常,请检查网络是否能正常访问TikTok平台")
+
+ elif record_url.find("https://live.kuaishou.com/") > -1:
+ platform = '快手直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_kuaishou_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=ks_cookie))
+ port_info = asyncio.run(stream.get_kuaishou_stream_url(json_data, record_quality))
+
+ elif record_url.find("https://www.huya.com/") > -1:
+ platform = '虎牙直播'
+ with semaphore:
+ if record_quality not in ['OD', 'BD', 'UHD']:
+ json_data = asyncio.run(spider.get_huya_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=hy_cookie))
+ port_info = asyncio.run(stream.get_huya_stream_url(json_data, record_quality))
+ else:
+ port_info = asyncio.run(spider.get_huya_app_stream_url(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=hy_cookie
+ ))
+
+ elif record_url.find("https://www.douyu.com/") > -1:
+ platform = '斗鱼直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_douyu_info_data(
+ url=record_url, proxy_addr=proxy_address, cookies=douyu_cookie))
+ port_info = asyncio.run(stream.get_douyu_stream_url(
+ json_data, video_quality=record_quality, cookies=douyu_cookie, proxy_addr=proxy_address
+ ))
+
+ elif record_url.find("https://www.yy.com/") > -1:
+ platform = 'YY直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_yy_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=yy_cookie))
+ port_info = asyncio.run(stream.get_yy_stream_url(json_data))
+
+ elif record_url.find("https://live.bilibili.com/") > -1:
+ platform = 'B站直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_bilibili_room_info(
+ url=record_url, proxy_addr=proxy_address, cookies=bili_cookie))
+ port_info = asyncio.run(stream.get_bilibili_stream_url(
+ json_data, video_quality=record_quality, cookies=bili_cookie, proxy_addr=proxy_address))
+
+ elif record_url.find("http://xhslink.com/") > -1 or \
+ record_url.find("https://www.xiaohongshu.com/") > -1:
+ platform = '小红书直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_xhs_stream_url(
+ record_url, proxy_addr=proxy_address, cookies=xhs_cookie))
+ retry += 1
+
+ elif record_url.find("www.bigo.tv/") > -1 or record_url.find("slink.bigovideo.tv/") > -1:
+ platform = 'Bigo直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_bigo_stream_url(
+ record_url, proxy_addr=proxy_address, cookies=bigo_cookie))
+
+ elif record_url.find("https://app.blued.cn/") > -1:
+ platform = 'Blued直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_blued_stream_url(
+ record_url, proxy_addr=proxy_address, cookies=blued_cookie))
+
+ elif record_url.find("sooplive.co.kr/") > -1 or record_url.find("sooplive.com/") > -1:
+ platform = 'SOOP'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_sooplive_stream_data(
+ url=record_url, proxy_addr=proxy_address,
+ cookies=sooplive_cookie,
+ username=sooplive_username,
+ password=sooplive_password
+ ))
+ if json_data and json_data.get('new_cookies'):
+ utils.update_config(
+ config_file, 'Cookie', 'sooplive_cookie', json_data['new_cookies']
+ )
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问SOOP平台")
+
+ elif record_url.find("cc.163.com/") > -1:
+ platform = '网易CC直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_netease_stream_data(
+ url=record_url, cookies=netease_cookie))
+ port_info = asyncio.run(stream.get_netease_stream_url(json_data, record_quality))
+
+ elif record_url.find("qiandurebo.com/") > -1:
+ platform = '千度热播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_qiandurebo_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=qiandurebo_cookie))
+
+ elif record_url.find("www.pandalive.co.kr/") > -1:
+ platform = 'PandaTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_pandatv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=pandatv_cookie
+ ))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问PandaTV直播平台")
+
+ elif record_url.find("fm.missevan.com/") > -1:
+ platform = '猫耳FM直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_maoerfm_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=maoerfm_cookie))
+
+ elif record_url.find("www.winktv.co.kr/") > -1:
+ platform = 'WinkTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_winktv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=winktv_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问WinkTV直播平台")
+
+ elif record_url.find("www.flextv.co.kr/") > -1 or record_url.find("www.ttinglive.com/") > -1:
+ platform = 'FlexTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_flextv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=flextv_cookie,
+ username=flextv_username,
+ password=flextv_password
+ ))
+ if json_data and json_data.get('new_cookies'):
+ utils.update_config(
+ config_file, 'Cookie', 'flextv_cookie', json_data['new_cookies']
+ )
+ if 'play_url_list' in json_data:
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ port_info = json_data
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问FlexTV直播平台")
+
+ elif record_url.find("look.163.com/") > -1:
+ platform = 'Look直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_looklive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=look_cookie
+ ))
+
+ elif record_url.find("www.popkontv.com/") > -1:
+ platform = 'PopkonTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ port_info = asyncio.run(spider.get_popkontv_stream_url(
+ url=record_url,
+ proxy_addr=proxy_address,
+ access_token=popkontv_access_token,
+ username=popkontv_username,
+ password=popkontv_password,
+ partner_code=popkontv_partner_code
+ ))
+ if port_info and port_info.get('new_token'):
+ utils.update_config(
+ file_path=config_file, section='Authorization', key='popkontv_token',
+ new_value=port_info['new_token']
+ )
+
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问PopkonTV直播平台")
+
+ elif record_url.find("twitcasting.tv/") > -1:
+ platform = 'TwitCasting'
+ with semaphore:
+ json_data = asyncio.run(spider.get_twitcasting_stream_url(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=twitcasting_cookie,
+ account_type=twitcasting_account_type,
+ username=twitcasting_username,
+ password=twitcasting_password
+ ))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=False))
+
+ if port_info and port_info.get('new_cookies'):
+ utils.update_config(
+ file_path=config_file, section='Cookie', key='twitcasting_cookie',
+ new_value=port_info['new_cookies']
+ )
+
+ elif record_url.find("live.baidu.com/") > -1:
+ platform = '百度直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_baidu_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=baidu_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality))
+
+ elif record_url.find("weibo.com/") > -1:
+ platform = '微博直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_weibo_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=weibo_cookie))
+ port_info = asyncio.run(stream.get_stream_url(
+ json_data, record_quality, hls_extra_key='m3u8_url'))
+
+ elif record_url.find("kugou.com/") > -1:
+ platform = '酷狗直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_kugou_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=kugou_cookie))
+
+ elif record_url.find("www.twitch.tv/") > -1:
+ platform = 'TwitchTV'
+ with semaphore:
+ if global_proxy or proxy_address:
+ json_data = asyncio.run(spider.get_twitchtv_stream_data(
+ url=record_url,
+ proxy_addr=proxy_address,
+ cookies=twitch_cookie
+ ))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问TwitchTV直播平台")
+
+ elif record_url.find("www.liveme.com/") > -1:
+ if global_proxy or proxy_address:
+ platform = 'LiveMe'
+ with semaphore:
+ port_info = asyncio.run(spider.get_liveme_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=liveme_cookie))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问LiveMe直播平台")
+
+ elif record_url.find("www.huajiao.com/") > -1:
+ platform = '花椒直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_huajiao_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=huajiao_cookie))
+
+ elif record_url.find("7u66.com/") > -1:
+ platform = '流星直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_liuxing_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=liuxing_cookie))
+
+ elif record_url.find("showroom-live.com/") > -1:
+ platform = 'ShowRoom'
+ with semaphore:
+ json_data = asyncio.run(spider.get_showroom_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=showroom_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+
+ elif record_url.find("live.acfun.cn/") > -1 or record_url.find("m.acfun.cn/") > -1:
+ platform = 'Acfun'
+ with semaphore:
+ json_data = asyncio.run(spider.get_acfun_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=acfun_cookie))
+ port_info = asyncio.run(stream.get_stream_url(
+ json_data, record_quality, url_type='flv', flv_extra_key='url'))
+
+ elif record_url.find("live.tlclw.com/") > -1:
+ platform = '畅聊直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_changliao_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=changliao_cookie))
+
+ elif record_url.find("ybw1666.com/") > -1:
+ platform = '音播直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_yinbo_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=yinbo_cookie))
+
+ elif record_url.find("www.inke.cn/") > -1:
+ platform = '映客直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_yingke_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=yingke_cookie))
+
+ elif record_url.find("www.zhihu.com/") > -1:
+ platform = '知乎直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_zhihu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=zhihu_cookie))
+
+ elif record_url.find("chzzk.naver.com/") > -1:
+ platform = 'CHZZK'
+ with semaphore:
+ json_data = asyncio.run(spider.get_chzzk_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=chzzk_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+
+ elif record_url.find("www.haixiutv.com/") > -1:
+ platform = '嗨秀直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_haixiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=haixiu_cookie))
+
+ elif record_url.find("vvxqiu.com/") > -1:
+ platform = 'VV星球'
+ with semaphore:
+ port_info = asyncio.run(spider.get_vvxqiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=vvxqiu_cookie))
+
+ elif record_url.find("17.live/") > -1:
+ platform = '17Live'
+ with semaphore:
+ port_info = asyncio.run(spider.get_17live_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=yiqilive_cookie))
+
+ elif record_url.find("www.lang.live/") > -1:
+ platform = '浪Live'
+ with semaphore:
+ port_info = asyncio.run(spider.get_langlive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=langlive_cookie))
+
+ elif record_url.find("m.pp.weimipopo.com/") > -1:
+ platform = '漂漂直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_pplive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=pplive_cookie))
+
+ elif record_url.find(".6.cn/") > -1:
+ platform = '六间房直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_6room_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=six_room_cookie))
+
+ elif record_url.find("lehaitv.com/") > -1:
+ platform = '乐嗨直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_haixiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=lehaitv_cookie))
+
+ elif record_url.find("h.catshow168.com/") > -1:
+ platform = '花猫直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_pplive_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=huamao_cookie))
+
+ elif record_url.find("live.shopee") > -1 or record_url.find("shp.ee/") > -1:
+ platform = 'shopee'
+ with semaphore:
+ port_info = asyncio.run(spider.get_shopee_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=shopee_cookie))
+ if port_info.get('uid'):
+ new_record_url = record_url.split('?')[0] + '?' + str(port_info['uid'])
+
+ elif record_url.find("www.youtube.com/") > -1 or record_url.find("youtu.be/") > -1:
+ platform = 'Youtube'
+ with semaphore:
+ json_data = asyncio.run(spider.get_youtube_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=youtube_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+
+ elif record_url.find("tb.cn") > -1:
+ platform = '淘宝直播'
+ with semaphore:
+ json_data = asyncio.run(spider.get_taobao_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=taobao_cookie))
+ port_info = asyncio.run(stream.get_stream_url(
+ json_data, record_quality,
+ url_type='all', hls_extra_key='hlsUrl', flv_extra_key='flvUrl'
+ ))
+
+ elif record_url.find("3.cn") > -1 or record_url.find("m.jd.com") > -1:
+ platform = '京东直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_jd_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=jd_cookie))
+
+ elif record_url.find("faceit.com/") > -1:
+ platform = 'faceit'
+ with semaphore:
+ if global_proxy or proxy_address:
+ with semaphore:
+ json_data = asyncio.run(spider.get_faceit_stream_data(
+ url=record_url, proxy_addr=proxy_address, cookies=faceit_cookie))
+ port_info = asyncio.run(stream.get_stream_url(json_data, record_quality, spec=True))
+ else:
+ logger.error("错误信息: 网络异常,请检查本网络是否能正常访问faceit直播平台")
+
+ elif record_url.find("www.miguvideo.com") > -1 or record_url.find("m.miguvideo.com") > -1:
+ platform = '咪咕直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_migu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=migu_cookie))
+
+ elif record_url.find("show.lailianjie.com") > -1:
+ platform = '连接直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_lianjie_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=lianjie_cookie))
+
+ elif record_url.find("www.imkktv.com") > -1:
+ platform = '来秀直播'
+ with semaphore:
+ port_info = asyncio.run(spider.get_laixiu_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=laixiu_cookie))
+
+ elif record_url.find("www.picarto.tv") > -1:
+ platform = 'Picarto'
+ with semaphore:
+ port_info = asyncio.run(spider.get_picarto_stream_url(
+ url=record_url, proxy_addr=proxy_address, cookies=picarto_cookie))
+
+ elif record_url.find(".m3u8") > -1 or record_url.find(".flv") > -1:
+ platform = '自定义录制直播'
+ port_info = {
+ "anchor_name": platform + '_' + str(uuid.uuid4())[:8],
+ "is_live": True,
+ "record_url": record_url,
+ }
+ if '.flv' in record_url:
+ port_info['flv_url'] = record_url
+ else:
+ port_info['m3u8_url'] = record_url
+
+ else:
+ logger.error(f'{record_url} {platform}直播地址')
+ return
+
+ if anchor_name:
+ if '主播:' in anchor_name:
+ anchor_split: list = anchor_name.split('主播:')
+ if len(anchor_split) > 1 and anchor_split[1].strip():
+ anchor_name = anchor_split[1].strip()
+ else:
+ anchor_name = port_info.get("anchor_name", '')
+ else:
+ anchor_name = port_info.get("anchor_name", '')
+
+ if not port_info.get("anchor_name", ''):
+ print(f'序号{count_variable} 网址内容获取失败,进行重试中...获取失败的地址是:{url_data}')
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+ else:
+ anchor_name = clean_name(anchor_name)
+ record_name = f'序号{count_variable} {anchor_name}'
+
+ if record_url in url_comments:
+ print(f"[{anchor_name}]已被注释,本条线程将会退出")
+ clear_record_info(record_name, record_url)
+ return
+
+ if not url_data[-1] and run_once is False:
+ if new_record_url:
+ need_update_line_list.append(
+ f'{record_url}|{new_record_url},主播: {anchor_name.strip()}')
+ not_record_list.append(new_record_url)
+ else:
+ need_update_line_list.append(f'{record_url}|{record_url},主播: {anchor_name.strip()}')
+ run_once = True
+
+ push_at = datetime.datetime.today().strftime('%Y-%m-%d %H:%M:%S')
+ if port_info['is_live'] is False:
+ print(f"\r{record_name} 等待直播... ")
+
+ # 直接退出线程eric
+ # return
+ # 继续循环
+ continue
+
+ if start_pushed:
+ if over_show_push:
+ push_content = "直播间状态更新:[直播间名称] 直播已结束!时间:[时间]"
+ if over_push_message_text:
+ push_content = over_push_message_text
+
+ push_content = (push_content.replace('[直播间名称]', record_name).
+ replace('[时间]', push_at))
+ threading.Thread(
+ target=push_message,
+ args=(record_name, record_url, push_content.replace(r'\n', '\n')),
+ daemon=True
+ ).start()
+ start_pushed = False
+
+ else:
+ content = f"\r{record_name} 正在直播中..."
+ print(content)
+ # 检查是否已有直播在录制
+ if len(recording) > 0 :
+ # 如果已有直播在录制,跳过这次录制(主播在线)
+ # return
+ continue #如果在循环中
+
+ if live_status_push and not start_pushed:
+ if begin_show_push:
+ push_content = "直播间状态更新:[直播间名称] 正在直播中,时间:[时间]"
+ if begin_push_message_text:
+ push_content = begin_push_message_text
+
+ push_content = (push_content.replace('[直播间名称]', record_name).
+ replace('[时间]', push_at))
+ threading.Thread(
+ target=push_message,
+ args=(record_name, record_url, push_content.replace(r'\n', '\n')),
+ daemon=True
+ ).start()
+ start_pushed = True
+
+ if disable_record:
+ time.sleep(push_check_seconds)
+ continue
+
+ real_url = select_source_url(record_url, port_info)
+ full_path = f'{default_path}/{platform}'
+ if real_url:
+ now = datetime.datetime.today().strftime("%Y-%m-%d_%H-%M-%S")
+ live_title = port_info.get('title')
+ title_in_name = ''
+ if live_title:
+ live_title = clean_name(live_title)
+ title_in_name = live_title + '_' if filename_by_title else ''
+
+ try:
+ if len(video_save_path) > 0:
+ if not video_save_path.endswith(('/', '\\')):
+ full_path = f'{video_save_path}/{platform}'
+ else:
+ full_path = f'{video_save_path}{platform}'
+
+ full_path = full_path.replace("\\", '/')
+ if folder_by_author:
+ full_path = f'{full_path}/{anchor_name}'
+ if folder_by_time:
+ full_path = f'{full_path}/{now[:10]}'
+ if folder_by_title and port_info.get('title'):
+ if folder_by_time:
+ full_path = f'{full_path}/{live_title}_{anchor_name}'
+ else:
+ full_path = f'{full_path}/{now[:10]}_{live_title}'
+ if not os.path.exists(full_path):
+ os.makedirs(full_path)
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+
+ if platform != '自定义录制直播':
+ if enable_https_recording and real_url.startswith("http://"):
+ real_url = real_url.replace("http://", "https://")
+
+ http_record_list = ['shopee', "migu"]
+ if platform in http_record_list:
+ real_url = real_url.replace("https://", "http://")
+
+ user_agent = ("Mozilla/5.0 (Linux; Android 11; SAMSUNG SM-G973U) AppleWebKit/537.36 ("
+ "KHTML, like Gecko) SamsungBrowser/14.2 Chrome/87.0.4280.141 Mobile "
+ "Safari/537.36")
+
+ rw_timeout = "15000000"
+ analyzeduration = "20000000"
+ probesize = "10000000"
+ bufsize = "8000k"
+ max_muxing_queue_size = "1024"
+ for pt_host in overseas_platform_host:
+ if pt_host in record_url:
+ rw_timeout = "50000000"
+ analyzeduration = "40000000"
+ probesize = "20000000"
+ bufsize = "15000k"
+ max_muxing_queue_size = "2048"
+ break
+
+ ffmpeg_command = [
+ 'ffmpeg', "-y",
+ "-v", "verbose",
+ "-rw_timeout", rw_timeout,
+ "-loglevel", "error",
+ "-hide_banner",
+ "-user_agent", user_agent,
+ "-protocol_whitelist", "rtmp,crypto,file,http,https,tcp,tls,udp,rtp,httpproxy",
+ "-thread_queue_size", "1024",
+ "-analyzeduration", analyzeduration,
+ "-probesize", probesize,
+ "-fflags", "+discardcorrupt",
+ "-re", "-i", real_url,
+ "-bufsize", bufsize,
+ "-sn", "-dn",
+ "-reconnect_delay_max", "60",
+ "-reconnect_streamed", "-reconnect_at_eof",
+ "-max_muxing_queue_size", max_muxing_queue_size,
+ "-correct_ts_overflow", "1",
+ "-avoid_negative_ts", "1"
+ ]
+
+ headers = get_record_headers(platform, record_url)
+ if headers:
+ ffmpeg_command.insert(11, "-headers")
+ ffmpeg_command.insert(12, headers)
+
+ _ff_proxy = (proxy_address or "").strip() or (
+ os.environ.get("STREAM_HTTP_PROXY") or ""
+ ).strip() or None
+ if _ff_proxy:
+ ffmpeg_command.insert(1, "-http_proxy")
+ ffmpeg_command.insert(2, _ff_proxy)
+
+ recording.add(record_name)
+ start_record_time = datetime.datetime.now()
+ recording_time_list[record_name] = [start_record_time, record_quality_zh]
+ rec_info = f"\r{anchor_name} 准备开始录制视频: {full_path}"
+ if show_url:
+ re_plat = ('WinkTV', 'PandaTV', 'ShowRoom', 'CHZZK', 'Youtube')
+ if platform in re_plat:
+ logger.info(
+ f"{platform} | {anchor_name} | 直播源地址: {port_info.get('m3u8_url')}")
+ else:
+ logger.info(
+ f"{platform} | {anchor_name} | 直播源地址: {real_url}")
+
+ only_flv_record = False
+ only_flv_platform_list = ['shopee', '花椒直播']
+ if platform in only_flv_platform_list:
+ logger.debug(f"提示: {platform} 将强制使用FLV格式录制")
+ only_flv_record = True
+
+ only_audio_record = False
+ only_audio_platform_list = ['猫耳FM直播', 'Look直播']
+ if platform in only_audio_platform_list:
+ only_audio_record = True
+
+ record_save_type = video_save_type
+
+ if is_flv_preferred_platform(record_url) and port_info.get('flv_url'):
+ codec = utils.get_query_params(port_info['flv_url'], "codec")
+ if codec and codec[0] == 'h265':
+ logger.warning("FLV is not supported for h265 codec, use TS format instead")
+ record_save_type = "TS"
+
+ if only_audio_record or any(i in record_save_type for i in ['MP3', 'M4A']):
+ try:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ extension = "mp3" if "m4a" not in record_save_type.lower() else "m4a"
+ name_format = "_%03d" if split_video_by_time else ""
+ save_file_path = (f"{full_path}/{anchor_name}_{title_in_name}{now}"
+ f"{name_format}.{extension}")
+
+ if split_video_by_time:
+ print(f'\r{anchor_name} 准备开始录制音频: {save_file_path}')
+
+ if "MP3" in record_save_type:
+ command = [
+ "-map", "0:a",
+ "-c:a", "libmp3lame",
+ "-ab", "320k",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-reset_timestamps", "1",
+ save_file_path,
+ ]
+ else:
+ command = [
+ "-map", "0:a",
+ "-c:a", "aac",
+ "-bsf:a", "aac_adtstoasc",
+ "-ab", "320k",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", 'mpegts',
+ "-reset_timestamps", "1",
+ save_file_path,
+ ]
+
+ else:
+ if "MP3" in record_save_type:
+ command = [
+ "-map", "0:a",
+ "-c:a", "libmp3lame",
+ "-ab", "320k",
+ save_file_path,
+ ]
+
+ else:
+ command = [
+ "-map", "0:a",
+ "-c:a", "aac",
+ "-bsf:a", "aac_adtstoasc",
+ "-ab", "320k",
+ "-movflags", "+faststart",
+ save_file_path,
+ ]
+
+ ffmpeg_command.extend(command)
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+
+
+ # if only_flv_record:
+ if False:
+
+ logger.info(f"Use Direct Downloader to Download FLV Stream: {record_url}")
+ filename = anchor_name + f'_{title_in_name}' + now + '.flv'
+ save_file_path = f'{full_path}/{filename}'
+ print(f'{rec_info}/{filename}')
+
+ subs_file_path = save_file_path.rsplit('.', maxsplit=1)[0]
+ subs_thread_name = f'subs_{Path(subs_file_path).name}'
+ if create_time_file:
+ create_var[subs_thread_name] = threading.Thread(
+ target=generate_subtitles, args=(record_name, subs_file_path)
+ )
+ create_var[subs_thread_name].daemon = True
+ create_var[subs_thread_name].start()
+
+ try:
+ flv_url = port_info.get('flv_url')
+ if flv_url:
+ recording.add(record_name)
+ start_record_time = datetime.datetime.now()
+ recording_time_list[record_name] = [start_record_time, record_quality_zh]
+
+ download_success = direct_download_stream(
+ flv_url, save_file_path, record_name, record_url, platform
+ )
+
+ if download_success:
+ record_finished = True
+ print(
+ f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制完成\n")
+
+ recording.discard(record_name)
+ else:
+ logger.debug("未找到FLV直播流,跳过录制")
+ except Exception as e:
+ clear_record_info(record_name, record_url)
+ color_obj.print_colored(
+ f"\n{anchor_name} {time.strftime('%Y-%m-%d %H:%M:%S')} 直播录制出错,请检查网络\n",
+ color_obj.RED)
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ elif record_save_type == "FLV":
+ filename = anchor_name + f'_{title_in_name}' + now + ".flv"
+ print(f'{rec_info}/{filename}')
+ save_file_path = full_path + '/' + filename
+
+ try:
+ if split_video_by_time:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.flv"
+ command = [
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-bsf:a", "aac_adtstoasc",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", "flv",
+ "-reset_timestamps", "1",
+ save_file_path
+ ]
+
+ else:
+ command = [
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-bsf:a", "aac_adtstoasc",
+ "-f", "flv",
+ "{path}".format(path=save_file_path),
+ ]
+ ffmpeg_command.extend(command)
+
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ try:
+ if converts_to_mp4:
+ seg_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4"
+ if split_video_by_time:
+ segment_video(
+ save_file_path, seg_file_path,
+ segment_format='mp4', segment_time=split_time,
+ is_original_delete=delete_origin_file
+ )
+ else:
+ threading.Thread(
+ target=converts_mp4,
+ args=(save_file_path, delete_origin_file)
+ ).start()
+
+ else:
+ seg_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.flv"
+ if split_video_by_time:
+ segment_video(
+ save_file_path, seg_file_path,
+ segment_format='flv', segment_time=split_time,
+ is_original_delete=delete_origin_file
+ )
+ except Exception as e:
+ logger.error(f"转码失败: {e} ")
+
+ elif record_save_type == "MKV":
+ filename = anchor_name + f'_{title_in_name}' + now + ".mkv"
+ print(f'{rec_info}/{filename}')
+ save_file_path = full_path + '/' + filename
+
+ try:
+ if split_video_by_time:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mkv"
+ command = [
+ "-flags", "global_header",
+ "-c:v", "copy",
+ "-c:a", "aac",
+ "-map", "0",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", "matroska",
+ "-reset_timestamps", "1",
+ save_file_path,
+ ]
+
+ else:
+ command = [
+ "-flags", "global_header",
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "matroska",
+ "{path}".format(path=save_file_path),
+ ]
+ ffmpeg_command.extend(command)
+
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ elif record_save_type == "MP4":
+ filename = anchor_name + f'_{title_in_name}' + now + ".mp4"
+ print(f'{rec_info}/{filename}')
+ save_file_path = full_path + '/' + filename
+
+ try:
+ if split_video_by_time:
+ now = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())
+ save_file_path = f"{full_path}/{anchor_name}_{title_in_name}{now}_%03d.mp4"
+ command = [
+ "-c:v", "copy",
+ "-c:a", "aac",
+ "-map", "0",
+ "-f", "segment",
+ "-segment_time", split_time,
+ "-segment_format", "mp4",
+ "-reset_timestamps", "1",
+ "-movflags", "+frag_keyframe+empty_moov",
+ save_file_path,
+ ]
+
+ else:
+ command = [
+ "-map", "0",
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-f", "mp4",
+ save_file_path,
+ ]
+
+ ffmpeg_command.extend(command)
+ comment_end = check_subprocess(
+ record_name,
+ record_url,
+ ffmpeg_command,
+ record_save_type,
+ custom_script
+ )
+ if comment_end:
+ return
+
+ except subprocess.CalledProcessError as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ else:
+ import sys
+ sys.path.insert(0, './dist')
+ from douyin_youtube_ffplay import start_douyin_youtube_ffplay
+
+ # 调用(与 douyin_srs_ffplay 调用方式完全一致)
+ start_douyin_youtube_ffplay(
+ title=title_in_name,
+ anchor_name=anchor_name,
+ real_url=real_url,
+ rec_info=rec_info,
+ record_name=record_name,
+ recording=recording,
+ recording_time_list=recording_time_list,
+ running_list=running_list,
+ monitoring=monitoring, # 假设外部 monitoring 是 list,如 [count]
+ clear_record_info=clear_record_info,
+ color_obj=color_obj
+ )
+ count_time = time.time()
+
+
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+
+ num = random.randint(-5, 5) + delay_default
+ if num < 0:
+ num = 0
+ x = num
+
+ if error_count > 20:
+ x = x + 60
+ # x += min(error_count * 2, 30)
+ color_obj.print_colored("\r瞬时错误太多,延迟最多60s", color_obj.YELLOW)
+
+ # 这里是.如果录制结束后,循环时间会暂时变成30s后检测一遍. 这样一定程度上防止主播卡顿造成少录
+ # 当30秒过后检测一遍后. 会回归正常设置的循环秒数
+ if record_finished:
+ count_time_end = time.time() - count_time
+ if count_time_end < 60:
+ x = 30
+ record_finished = False
+
+ else:
+ x = num
+
+ # 这里是正常循环
+ while x:
+ x = x - 1
+ if loop_time:
+ print(f'\r{anchor_name}循环等待{x}秒 ', end="")
+ time.sleep(1)
+ if loop_time:
+ print('\r检测直播间中...', end="")
+ except Exception as e:
+ logger.error(f"错误信息: {e} 发生错误的行数: {e.__traceback__.tb_lineno}")
+ with max_request_lock:
+ error_count += 1
+ error_window.append(1)
+ time.sleep(2)
+
+
+def backup_file(file_path: str, backup_dir_path: str, limit_counts: int = 6) -> None:
+
+ try:
+ if not os.path.exists(backup_dir_path):
+ os.makedirs(backup_dir_path)
+
+ timestamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
+ backup_file_name = os.path.basename(file_path) + '_' + timestamp
+ backup_file_path = os.path.join(backup_dir_path, backup_file_name).replace("\\", "/")
+ shutil.copy2(file_path, backup_file_path)
+
+ files = os.listdir(backup_dir_path)
+ _files = [f for f in files if f.startswith(os.path.basename(file_path))]
+ _files.sort(key=lambda x: os.path.getmtime(os.path.join(backup_dir_path, x)))
+
+ while len(_files) > limit_counts:
+ oldest_file = _files[0]
+ os.remove(os.path.join(backup_dir_path, oldest_file))
+ _files = _files[1:]
+
+ except Exception as e:
+ logger.error(f'\r备份配置文件 {file_path} 失败:{str(e)}')
+
+
+def backup_file_start() -> None:
+ config_md5 = ''
+ url_config_md5 = ''
+
+ while True:
+ try:
+ if os.path.exists(config_file):
+ new_config_md5 = utils.check_md5(config_file)
+ if new_config_md5 != config_md5:
+ backup_file(config_file, backup_dir)
+ config_md5 = new_config_md5
+
+ if os.path.exists(url_config_file):
+ new_url_config_md5 = utils.check_md5(url_config_file)
+ if new_url_config_md5 != url_config_md5:
+ backup_file(url_config_file, backup_dir)
+ url_config_md5 = new_url_config_md5
+ time.sleep(600)
+ except Exception as e:
+ logger.error(f"备份配置文件失败, 错误信息: {e}")
+
+
+def check_ffmpeg_existence() -> bool:
+ try:
+ result = subprocess.run(['ffmpeg', '-version'], check=True, capture_output=True, text=True)
+ if result.returncode == 0:
+ lines = result.stdout.splitlines()
+ version_line = lines[0]
+ built_line = lines[1]
+ print(version_line)
+ print(built_line)
+ except subprocess.CalledProcessError as e:
+ logger.error(e)
+ except FileNotFoundError:
+ pass
+ finally:
+ if check_ffmpeg():
+ time.sleep(1)
+ return True
+ return False
+
+
+# --------------------------初始化程序-------------------------------------
+print("-----------------------------------------------------")
+print("| DouyinLiveRecorder |")
+print("-----------------------------------------------------")
+
+print(f"版本号: {version}")
+print("GitHub: https://github.com/ihmily/DouyinLiveRecorder")
+print(f'支持平台: {platforms}')
+print('.....................................................')
+if not check_ffmpeg_existence():
+ logger.error("缺少ffmpeg无法进行录制,程序退出")
+ sys.exit(1)
+os.makedirs(os.path.dirname(config_file), exist_ok=True)
+t3 = threading.Thread(target=backup_file_start, args=(), daemon=True)
+t3.start()
+utils.remove_duplicate_lines(url_config_file)
+
+
+def read_config_value(config_parser: configparser.RawConfigParser, section: str, option: str, default_value: Any) \
+ -> Any:
+ try:
+
+ config_parser.read(config_file, encoding=text_encoding)
+ if '录制设置' not in config_parser.sections():
+ config_parser.add_section('录制设置')
+ if '推送配置' not in config_parser.sections():
+ config_parser.add_section('推送配置')
+ if 'Cookie' not in config_parser.sections():
+ config_parser.add_section('Cookie')
+ if 'Authorization' not in config_parser.sections():
+ config_parser.add_section('Authorization')
+ if '账号密码' not in config_parser.sections():
+ config_parser.add_section('账号密码')
+ return config_parser.get(section, option)
+ except (configparser.NoSectionError, configparser.NoOptionError):
+ config_parser.set(section, option, str(default_value))
+ with open(config_file, 'w', encoding=text_encoding) as f:
+ config_parser.write(f)
+ return default_value
+
+
+options = {"是": True, "否": False}
+config = configparser.RawConfigParser()
+language = read_config_value(config, '录制设置', 'language(zh_cn/en)', "zh_cn")
+skip_proxy_check = options.get(read_config_value(config, '录制设置', '是否跳过代理检测(是/否)', "否"), False)
+if language and 'en' not in language.lower():
+ from i18n import translated_print
+
+ builtins.print = translated_print
+
+try:
+ if skip_proxy_check:
+ global_proxy = True
+ else:
+ print('系统代理检测中,请耐心等待...')
+ response_g = urllib.request.urlopen("https://www.google.com/", timeout=15)
+ global_proxy = True
+ print('\r全局/规则网络代理已开启√')
+ pd = ProxyDetector()
+ if pd.is_proxy_enabled():
+ proxy_info = pd.get_proxy_info()
+ print("System Proxy: http://{}:{}".format(proxy_info.ip, proxy_info.port))
+except HTTPError as err:
+ print(f"HTTP error occurred: {err.code} - {err.reason}")
+except URLError:
+ color_obj.print_colored("INFO:未检测到全局/规则网络代理,请检查代理配置(若无需录制海外直播请忽略此条提示)",
+ color_obj.YELLOW)
+except Exception as err:
+ print("An unexpected error occurred:", err)
+
+while True:
+
+ try:
+ if not os.path.isfile(config_file):
+ with open(config_file, 'w', encoding=text_encoding) as file:
+ pass
+
+ ini_URL_content = ''
+ if os.path.isfile(url_config_file):
+ with open(url_config_file, 'r', encoding=text_encoding) as file:
+ ini_URL_content = file.read().strip()
+
+ if not ini_URL_content.strip():
+ input_url = input('请输入要录制的主播直播间网址(尽量使用PC网页端的直播间地址):\n')
+ with open(url_config_file, 'w', encoding=text_encoding) as file:
+ file.write(input_url)
+ except OSError as err:
+ logger.error(f"发生 I/O 错误: {err}")
+
+ video_save_path = read_config_value(config, '录制设置', '直播保存路径(不填则默认)', "")
+ folder_by_author = options.get(read_config_value(config, '录制设置', '保存文件夹是否以作者区分', "是"), False)
+ folder_by_time = options.get(read_config_value(config, '录制设置', '保存文件夹是否以时间区分', "否"), False)
+ folder_by_title = options.get(read_config_value(config, '录制设置', '保存文件夹是否以标题区分', "否"), False)
+ filename_by_title = options.get(read_config_value(config, '录制设置', '保存文件名是否包含标题', "否"), False)
+ clean_emoji = options.get(read_config_value(config, '录制设置', '是否去除名称中的表情符号', "是"), True)
+ video_save_type = read_config_value(config, '录制设置', '视频保存格式ts|mkv|flv|mp4|mp3音频|m4a音频', "ts")
+ video_record_quality = read_config_value(config, '录制设置', '原画|超清|高清|标清|流畅', "原画")
+ use_proxy = options.get(read_config_value(config, '录制设置', '是否使用代理ip(是/否)', "是"), False)
+ proxy_addr_bak = read_config_value(config, '录制设置', '代理地址', "")
+ proxy_addr = None if not use_proxy else proxy_addr_bak
+ max_request = int(read_config_value(config, '录制设置', '同一时间访问网络的线程数', 3))
+ semaphore = threading.Semaphore(max_request)
+ delay_default = int(read_config_value(config, '录制设置', '循环时间(秒)', 120))
+ local_delay_default = int(read_config_value(config, '录制设置', '排队读取网址时间(秒)', 0))
+ loop_time = options.get(read_config_value(config, '录制设置', '是否显示循环秒数', "否"), False)
+ show_url = options.get(read_config_value(config, '录制设置', '是否显示直播源地址', "否"), False)
+ split_video_by_time = options.get(read_config_value(config, '录制设置', '分段录制是否开启', "否"), False)
+ enable_https_recording = options.get(read_config_value(config, '录制设置', '是否强制启用https录制', "否"), False)
+ disk_space_limit = float(read_config_value(config, '录制设置', '录制空间剩余阈值(gb)', 1.0))
+ split_time = str(read_config_value(config, '录制设置', '视频分段时间(秒)', 1800))
+ converts_to_mp4 = options.get(read_config_value(config, '录制设置', '录制完成后自动转为mp4格式', "否"), False)
+ converts_to_h264 = options.get(read_config_value(config, '录制设置', 'mp4格式重新编码为h264', "否"), False)
+ delete_origin_file = options.get(read_config_value(config, '录制设置', '追加格式后删除原文件', "否"), False)
+ create_time_file = options.get(read_config_value(config, '录制设置', '生成时间字幕文件', "否"), False)
+ is_run_script = options.get(read_config_value(config, '录制设置', '是否录制完成后执行自定义脚本', "否"), False)
+ custom_script = read_config_value(config, '录制设置', '自定义脚本执行命令', "") if is_run_script else None
+ enable_proxy_platform = read_config_value(
+ config, '录制设置', '使用代理录制的平台(逗号分隔)',
+ 'tiktok, soop, pandalive, winktv, flextv, popkontv, twitch, liveme, showroom, chzzk, shopee, shp, youtu, faceit'
+ )
+ enable_proxy_platform_list = enable_proxy_platform.replace(',', ',').split(',') if enable_proxy_platform else None
+ extra_enable_proxy = read_config_value(config, '录制设置', '额外使用代理录制的平台(逗号分隔)', '')
+ extra_enable_proxy_platform_list = extra_enable_proxy.replace(',', ',').split(',') if extra_enable_proxy else None
+ live_status_push = read_config_value(config, '推送配置', '直播状态推送渠道', "")
+ dingtalk_api_url = read_config_value(config, '推送配置', '钉钉推送接口链接', "")
+ xizhi_api_url = read_config_value(config, '推送配置', '微信推送接口链接', "")
+ bark_msg_api = read_config_value(config, '推送配置', 'bark推送接口链接', "")
+ bark_msg_level = read_config_value(config, '推送配置', 'bark推送中断级别', "active")
+ bark_msg_ring = read_config_value(config, '推送配置', 'bark推送铃声', "bell")
+ dingtalk_phone_num = read_config_value(config, '推送配置', '钉钉通知@对象(填手机号)', "")
+ dingtalk_is_atall = options.get(read_config_value(config, '推送配置', '钉钉通知@全体(是/否)', "否"), False)
+ tg_token = read_config_value(config, '推送配置', 'tgapi令牌', "")
+ tg_chat_id = read_config_value(config, '推送配置', 'tg聊天id(个人或者群组id)', "")
+ email_host = read_config_value(config, '推送配置', 'SMTP邮件服务器', "")
+ open_smtp_ssl = options.get(read_config_value(config, '推送配置', '是否使用SMTP服务SSL加密(是/否)', "是"), True)
+ smtp_port = read_config_value(config, '推送配置', 'SMTP邮件服务器端口', "")
+ login_email = read_config_value(config, '推送配置', '邮箱登录账号', "")
+ email_password = read_config_value(config, '推送配置', '发件人密码(授权码)', "")
+ sender_email = read_config_value(config, '推送配置', '发件人邮箱', "")
+ sender_name = read_config_value(config, '推送配置', '发件人显示昵称', "")
+ to_email = read_config_value(config, '推送配置', '收件人邮箱', "")
+ ntfy_api = read_config_value(config, '推送配置', 'ntfy推送地址', "")
+ ntfy_tags = read_config_value(config, '推送配置', 'ntfy推送标签', "tada")
+ ntfy_email = read_config_value(config, '推送配置', 'ntfy推送邮箱', "")
+ pushplus_token = read_config_value(config, '推送配置', 'pushplus推送token', "")
+ push_message_title = read_config_value(config, '推送配置', '自定义推送标题', "直播间状态更新通知")
+ begin_push_message_text = read_config_value(config, '推送配置', '自定义开播推送内容', "")
+ over_push_message_text = read_config_value(config, '推送配置', '自定义关播推送内容', "")
+ disable_record = options.get(read_config_value(config, '推送配置', '只推送通知不录制(是/否)', "否"), False)
+ push_check_seconds = int(read_config_value(config, '推送配置', '直播推送检测频率(秒)', 1800))
+ begin_show_push = options.get(read_config_value(config, '推送配置', '开播推送开启(是/否)', "是"), True)
+ over_show_push = options.get(read_config_value(config, '推送配置', '关播推送开启(是/否)', "否"), False)
+ sooplive_username = read_config_value(config, '账号密码', 'sooplive账号', '')
+ sooplive_password = read_config_value(config, '账号密码', 'sooplive密码', '')
+ flextv_username = read_config_value(config, '账号密码', 'flextv账号', '')
+ flextv_password = read_config_value(config, '账号密码', 'flextv密码', '')
+ popkontv_username = read_config_value(config, '账号密码', 'popkontv账号', '')
+ popkontv_partner_code = read_config_value(config, '账号密码', 'partner_code', 'P-00001')
+ popkontv_password = read_config_value(config, '账号密码', 'popkontv密码', '')
+ twitcasting_account_type = read_config_value(config, '账号密码', 'twitcasting账号类型', 'normal')
+ twitcasting_username = read_config_value(config, '账号密码', 'twitcasting账号', '')
+ twitcasting_password = read_config_value(config, '账号密码', 'twitcasting密码', '')
+ popkontv_access_token = read_config_value(config, 'Authorization', 'popkontv_token', '')
+ dy_cookie = read_config_value(config, 'Cookie', '抖音cookie', '')
+ ks_cookie = read_config_value(config, 'Cookie', '快手cookie', '')
+ tiktok_cookie = read_config_value(config, 'Cookie', 'tiktok_cookie', '')
+ hy_cookie = read_config_value(config, 'Cookie', '虎牙cookie', '')
+ douyu_cookie = read_config_value(config, 'Cookie', '斗鱼cookie', '')
+ yy_cookie = read_config_value(config, 'Cookie', 'yy_cookie', '')
+ bili_cookie = read_config_value(config, 'Cookie', 'B站cookie', '')
+ xhs_cookie = read_config_value(config, 'Cookie', '小红书cookie', '')
+ bigo_cookie = read_config_value(config, 'Cookie', 'bigo_cookie', '')
+ blued_cookie = read_config_value(config, 'Cookie', 'blued_cookie', '')
+ sooplive_cookie = read_config_value(config, 'Cookie', 'sooplive_cookie', '')
+ netease_cookie = read_config_value(config, 'Cookie', 'netease_cookie', '')
+ qiandurebo_cookie = read_config_value(config, 'Cookie', '千度热播_cookie', '')
+ pandatv_cookie = read_config_value(config, 'Cookie', 'pandatv_cookie', '')
+ maoerfm_cookie = read_config_value(config, 'Cookie', '猫耳fm_cookie', '')
+ winktv_cookie = read_config_value(config, 'Cookie', 'winktv_cookie', '')
+ flextv_cookie = read_config_value(config, 'Cookie', 'flextv_cookie', '')
+ look_cookie = read_config_value(config, 'Cookie', 'look_cookie', '')
+ twitcasting_cookie = read_config_value(config, 'Cookie', 'twitcasting_cookie', '')
+ baidu_cookie = read_config_value(config, 'Cookie', 'baidu_cookie', '')
+ weibo_cookie = read_config_value(config, 'Cookie', 'weibo_cookie', '')
+ kugou_cookie = read_config_value(config, 'Cookie', 'kugou_cookie', '')
+ twitch_cookie = read_config_value(config, 'Cookie', 'twitch_cookie', '')
+ liveme_cookie = read_config_value(config, 'Cookie', 'liveme_cookie', '')
+ huajiao_cookie = read_config_value(config, 'Cookie', 'huajiao_cookie', '')
+ liuxing_cookie = read_config_value(config, 'Cookie', 'liuxing_cookie', '')
+ showroom_cookie = read_config_value(config, 'Cookie', 'showroom_cookie', '')
+ acfun_cookie = read_config_value(config, 'Cookie', 'acfun_cookie', '')
+ changliao_cookie = read_config_value(config, 'Cookie', 'changliao_cookie', '')
+ yinbo_cookie = read_config_value(config, 'Cookie', 'yinbo_cookie', '')
+ yingke_cookie = read_config_value(config, 'Cookie', 'yingke_cookie', '')
+ zhihu_cookie = read_config_value(config, 'Cookie', 'zhihu_cookie', '')
+ chzzk_cookie = read_config_value(config, 'Cookie', 'chzzk_cookie', '')
+ haixiu_cookie = read_config_value(config, 'Cookie', 'haixiu_cookie', '')
+ vvxqiu_cookie = read_config_value(config, 'Cookie', 'vvxqiu_cookie', '')
+ yiqilive_cookie = read_config_value(config, 'Cookie', '17live_cookie', '')
+ langlive_cookie = read_config_value(config, 'Cookie', 'langlive_cookie', '')
+ pplive_cookie = read_config_value(config, 'Cookie', 'pplive_cookie', '')
+ six_room_cookie = read_config_value(config, 'Cookie', '6room_cookie', '')
+ lehaitv_cookie = read_config_value(config, 'Cookie', 'lehaitv_cookie', '')
+ huamao_cookie = read_config_value(config, 'Cookie', 'huamao_cookie', '')
+ shopee_cookie = read_config_value(config, 'Cookie', 'shopee_cookie', '')
+ youtube_cookie = read_config_value(config, 'Cookie', 'youtube_cookie', '')
+ taobao_cookie = read_config_value(config, 'Cookie', 'taobao_cookie', '')
+ jd_cookie = read_config_value(config, 'Cookie', 'jd_cookie', '')
+ faceit_cookie = read_config_value(config, 'Cookie', 'faceit_cookie', '')
+ migu_cookie = read_config_value(config, 'Cookie', 'migu_cookie', '')
+ lianjie_cookie = read_config_value(config, 'Cookie', 'lianjie_cookie', '')
+ laixiu_cookie = read_config_value(config, 'Cookie', 'laixiu_cookie', '')
+ picarto_cookie = read_config_value(config, 'Cookie', 'picarto_cookie', '')
+
+ video_save_type_list = ("FLV", "MKV", "TS", "MP4", "MP3音频", "M4A音频", "MP3", "M4A")
+ if video_save_type and video_save_type.upper() in video_save_type_list:
+ video_save_type = video_save_type.upper()
+ else:
+ video_save_type = "TS"
+
+ check_path = video_save_path or default_path
+ if utils.check_disk_capacity(check_path, show=first_run) < disk_space_limit:
+ exit_recording = True
+ if not recording:
+ logger.warning(f"Disk space remaining is below {disk_space_limit} GB. "
+ f"Exiting program due to the disk space limit being reached.")
+ sys.exit(-1)
+
+
+ def contains_url(string: str) -> bool:
+ pattern = r"(https?://)?(www\.)?[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(:\d+)?(/.*)?"
+ return re.search(pattern, string) is not None
+
+
+ try:
+ url_comments, line_list, url_line_list = [[] for _ in range(3)]
+ with (open(url_config_file, "r", encoding=text_encoding, errors='ignore') as file):
+ for origin_line in file:
+ if origin_line in line_list:
+ delete_line(url_config_file, origin_line)
+ line_list.append(origin_line)
+ line = origin_line.strip()
+ if len(line) < 18:
+ continue
+
+ line_spilt = line.split('主播: ')
+ if len(line_spilt) > 2:
+ line = update_file(url_config_file, line, f'{line_spilt[0]}主播: {line_spilt[-1]}')
+
+ is_comment_line = line.startswith("#")
+ if is_comment_line:
+ line = line.lstrip('#')
+
+ if re.search('[,,]', line):
+ split_line = re.split('[,,]', line)
+ else:
+ split_line = [line, '']
+
+ if len(split_line) == 1:
+ url = split_line[0]
+ quality, name = [video_record_quality, '']
+ elif len(split_line) == 2:
+ if contains_url(split_line[0]):
+ quality = video_record_quality
+ url, name = split_line
+ else:
+ quality, url = split_line
+ name = ''
+ else:
+ quality, url, name = split_line
+
+ if quality not in ("原画", "蓝光", "超清", "高清", "标清", "流畅"):
+ quality = '原画'
+
+ if url not in url_line_list:
+ url_line_list.append(url)
+ else:
+ delete_line(url_config_file, origin_line)
+
+ url = 'https://' + url if '://' not in url else url
+ url_host = url.split('/')[2]
+
+ platform_host = [
+ 'live.douyin.com',
+ 'v.douyin.com',
+ 'www.douyin.com',
+ 'live.kuaishou.com',
+ 'www.huya.com',
+ 'www.douyu.com',
+ 'www.yy.com',
+ 'live.bilibili.com',
+ 'www.redelight.cn',
+ 'www.xiaohongshu.com',
+ 'xhslink.com',
+ 'www.bigo.tv',
+ 'slink.bigovideo.tv',
+ 'app.blued.cn',
+ 'cc.163.com',
+ 'qiandurebo.com',
+ 'fm.missevan.com',
+ 'look.163.com',
+ 'twitcasting.tv',
+ 'live.baidu.com',
+ 'weibo.com',
+ 'fanxing.kugou.com',
+ 'fanxing2.kugou.com',
+ 'mfanxing.kugou.com',
+ 'www.huajiao.com',
+ 'www.7u66.com',
+ 'wap.7u66.com',
+ 'live.acfun.cn',
+ 'm.acfun.cn',
+ 'live.tlclw.com',
+ 'wap.tlclw.com',
+ 'live.ybw1666.com',
+ 'wap.ybw1666.com',
+ 'www.inke.cn',
+ 'www.zhihu.com',
+ 'www.haixiutv.com',
+ "h5webcdnp.vvxqiu.com",
+ "17.live",
+ 'www.lang.live',
+ "m.pp.weimipopo.com",
+ "v.6.cn",
+ "m.6.cn",
+ 'www.lehaitv.com',
+ 'h.catshow168.com',
+ 'e.tb.cn',
+ 'huodong.m.taobao.com',
+ '3.cn',
+ 'eco.m.jd.com',
+ 'www.miguvideo.com',
+ 'm.miguvideo.com',
+ 'show.lailianjie.com',
+ 'www.imkktv.com',
+ 'www.picarto.tv'
+ ]
+ overseas_platform_host = [
+ 'www.tiktok.com',
+ 'play.sooplive.co.kr',
+ 'm.sooplive.co.kr',
+ 'www.sooplive.com',
+ 'm.sooplive.com',
+ 'www.pandalive.co.kr',
+ 'www.winktv.co.kr',
+ 'www.flextv.co.kr',
+ 'www.ttinglive.com',
+ 'www.popkontv.com',
+ 'www.twitch.tv',
+ 'www.liveme.com',
+ 'www.showroom-live.com',
+ 'chzzk.naver.com',
+ 'm.chzzk.naver.com',
+ 'live.shopee.',
+ '.shp.ee',
+ 'www.youtube.com',
+ 'youtu.be',
+ 'www.faceit.com'
+ ]
+
+ platform_host.extend(overseas_platform_host)
+ clean_url_host_list = (
+ "live.douyin.com",
+ "live.bilibili.com",
+ "www.huajiao.com",
+ "www.zhihu.com",
+ "www.huya.com",
+ "chzzk.naver.com",
+ "www.liveme.com",
+ "www.haixiutv.com",
+ "v.6.cn",
+ "m.6.cn",
+ 'www.lehaitv.com'
+ )
+
+ if 'live.shopee.' in url_host or '.shp.ee' in url_host:
+ url_host = 'live.shopee.' if 'live.shopee.' in url_host else '.shp.ee'
+
+ if url_host in platform_host or any(ext in url for ext in (".flv", ".m3u8")):
+ if url_host in clean_url_host_list:
+ url = update_file(url_config_file, old_str=url, new_str=url.split('?')[0])
+
+ if 'xiaohongshu' in url:
+ host_id = re.search('&host_id=(.*?)(?=&|$)', url)
+ if host_id:
+ new_url = url.split('?')[0] + f'?host_id={host_id.group(1)}'
+ url = update_file(url_config_file, old_str=url, new_str=new_url)
+
+ url_comments = [i for i in url_comments if url not in i]
+ if is_comment_line:
+ url_comments.append(url)
+ else:
+ new_line = (quality, url, name)
+ url_tuples_list.append(new_line)
+ else:
+ if not origin_line.startswith('#'):
+ color_obj.print_colored(f"\r{origin_line.strip()} 本行包含未知链接.此条跳过", color_obj.YELLOW)
+ update_file(url_config_file, old_str=origin_line, new_str=origin_line, start_str='#')
+
+ while len(need_update_line_list):
+ a = need_update_line_list.pop()
+ replace_words = a.split('|')
+ if replace_words[0] != replace_words[1]:
+ if replace_words[1].startswith("#"):
+ start_with = '#'
+ new_word = replace_words[1][1:]
+ else:
+ start_with = None
+ new_word = replace_words[1]
+ update_file(url_config_file, old_str=replace_words[0], new_str=new_word, start_str=start_with)
+
+ text_no_repeat_url = list(set(url_tuples_list))
+
+ if len(text_no_repeat_url) > 0:
+ for url_tuple in text_no_repeat_url:
+ monitoring = len(running_list)
+
+ if url_tuple[1] in not_record_list:
+ continue
+ if url_tuple[1] not in running_list:
+ print(f"\r{'新增' if not first_start else '传入'}地址: {url_tuple[1]}")
+ monitoring += 1
+ args = [url_tuple, monitoring]
+ create_var[f'thread_{monitoring}'] = threading.Thread(target=start_record, args=args)
+ create_var[f'thread_{monitoring}'].daemon = True
+ create_var[f'thread_{monitoring}'].start()
+ running_list.append(url_tuple[1])
+ time.sleep(local_delay_default)
+
+
+
+
+ url_tuples_list = []
+ first_start = False
+
+
+
+ except Exception as err:
+ logger.error(f"错误信息: {err} 发生错误的行数: {err.__traceback__.tb_lineno}")
+
+ if first_run:
+ t = threading.Thread(target=display_info, args=(), daemon=True)
+ t.start()
+ t2 = threading.Thread(target=adjust_max_request, args=(), daemon=True)
+ t2.start()
+ first_run = False
+
+ time.sleep(3)
\ No newline at end of file